Replaced the GDP tax with national sales and tariff taxes
This commit is contained in:
@@ -1287,6 +1287,8 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.central-bank-rate { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
|
||||
.central-bank-rate label { color: var(--muted); }
|
||||
.central-bank-rate input { width: 90px; }
|
||||
.tax-table .tax-input { width: 52px; }
|
||||
.tax-table .tax-apply { padding: 2px 8px; }
|
||||
.subhead { margin-top: 10px; color: var(--muted); font-weight: 600; }
|
||||
|
||||
.diplomacy-table { width: 100%; border-collapse: collapse; }
|
||||
|
||||
@@ -358,6 +358,7 @@
|
||||
<div id="economy-summary" class="modal-body"></div>
|
||||
<div id="economy-consumption" class="modal-body hidden"></div>
|
||||
<div id="economy-currency" class="modal-body hidden"></div>
|
||||
<div id="economy-taxes" class="modal-body hidden"></div>
|
||||
<div id="economy-central-bank" class="modal-body hidden"></div>
|
||||
<div id="economy-rates" class="modal-body hidden"></div>
|
||||
</div>
|
||||
|
||||
@@ -93,6 +93,8 @@ export const panelMethods = {
|
||||
this.sendOrder({ type: "revoke_policy", policy });
|
||||
this.nationModal.onSetInterestRate = (rate) =>
|
||||
this.sendOrder({ type: "set_interest_rate", rate });
|
||||
this.nationModal.onSetTaxRate = (kind, rate, resource) =>
|
||||
this.sendOrder({ type: "set_tax_rate", kind, rate, resource });
|
||||
|
||||
$("#unit-attack")
|
||||
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
|
||||
@@ -1641,8 +1643,8 @@ export const panelMethods = {
|
||||
shortage: cityStats.shortage || {},
|
||||
// The region's reserves, their value and its daily consumption.
|
||||
resources: cityStats.resources || null,
|
||||
// The region's tax base and approval price an upgrade's expected income.
|
||||
base: cityStats.base || 0,
|
||||
// The trade taxes the region generated over the last day.
|
||||
taxes: cityStats.taxes || null,
|
||||
// Construction technology shortens the displayed build times.
|
||||
constructionSpeed: stats.constructionSpeed || 0,
|
||||
taxMultiplier: this._taxMultiplier(cityStats.approval),
|
||||
@@ -1684,8 +1686,7 @@ export const panelMethods = {
|
||||
stats.budget || 0,
|
||||
this._cityBuildings.get(this.selectedCityId) || {},
|
||||
stats.gdp || 0,
|
||||
this._queuedBuildingLevels(this.selectedCityId),
|
||||
{ base: cityStats.base || 0, approval: cityStats.approval }
|
||||
this._queuedBuildingLevels(this.selectedCityId)
|
||||
);
|
||||
this.cityModal.renderBudget(this._cityBudget(this.selectedCityId));
|
||||
this.cityModal.renderEconomy(
|
||||
@@ -1781,6 +1782,11 @@ export const panelMethods = {
|
||||
privateCash: stats.privateCash || 0,
|
||||
// Where the viewer's own currency sits: treasury, regions, foreign banks.
|
||||
currencyHoldings: stats.currencyHoldings || null,
|
||||
// The viewer's trade-tax rates and the day's take, for the Taxes sub-tab.
|
||||
taxes: stats.taxes || null,
|
||||
takeTax: stats.taxTake || { sales: 0, export: 0, import: 0 },
|
||||
// The world commodity price move of the last day, for the central bank.
|
||||
commodityInflation: stats.commodityInflation || 0,
|
||||
cities: (this.snapshot && this.snapshot.cityStats) || [],
|
||||
};
|
||||
},
|
||||
@@ -1795,6 +1801,8 @@ export const panelMethods = {
|
||||
market: (this.snapshot && this.snapshot.resourceMarket) || [],
|
||||
trade: (this.snapshot && this.snapshot.resourceTrade) || [],
|
||||
civs: this.civilisations,
|
||||
// The clock, so the table can reset its day-over-day arrow baseline.
|
||||
totalHours: (this.snapshot && this.snapshot.totalHours) || 0,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
+12
-22
@@ -31,31 +31,18 @@ export function renderBudget($list, breakdown = {}, { expanded = new Set(), onTo
|
||||
const $body = $("<tbody></tbody>");
|
||||
|
||||
$body.append(sectionRow("Income"));
|
||||
$body.append(
|
||||
moneyRow(
|
||||
"Tax income",
|
||||
income.total,
|
||||
"income",
|
||||
`Collecting ${(income.rate * 100).toFixed(3)}% of GDP per hour — ${(
|
||||
income.rate *
|
||||
365 *
|
||||
24 *
|
||||
100
|
||||
).toFixed(0)}% a year`
|
||||
)
|
||||
);
|
||||
$body.append(moneyRow("Trade taxes", income.total, "income", null));
|
||||
if (incomeSources.length === 0) {
|
||||
$body.append(detailRow("No bonuses", 0, "income-detail"));
|
||||
$body.append(detailRow("No taxes collected", 0, "income-detail"));
|
||||
}
|
||||
for (const source of incomeSources) {
|
||||
const where = source.cityName ? ` in ${source.cityName}` : "";
|
||||
$body.append(
|
||||
detailRow(
|
||||
`${source.label}${where} (${SOURCE_LABELS[source.kind] || source.kind}) ${percent(source.effect)}`,
|
||||
source.amount,
|
||||
"income-detail"
|
||||
)
|
||||
);
|
||||
// Tax sources carry an amount but no percentage effect; older bonus sources
|
||||
// still name their kind and effect.
|
||||
const detail = source.effect === undefined
|
||||
? `${source.label}${where}`
|
||||
: `${source.label}${where} (${SOURCE_LABELS[source.kind] || source.kind}) ${percent(source.effect)}`;
|
||||
$body.append(detailRow(detail, source.amount, "income-detail"));
|
||||
}
|
||||
|
||||
$body.append(sectionRow("Expenses"));
|
||||
@@ -134,7 +121,10 @@ export function renderMonthlyBudget($list, report, { expanded = new Set(), onTog
|
||||
const $body = $("<tbody></tbody>");
|
||||
|
||||
$body.append(sectionRow("Income"));
|
||||
$body.append(monthRow("Tax income", report.income, "income", null));
|
||||
const taxes = report.taxes || { sales: 0, export: 0, import: 0 };
|
||||
$body.append(monthRow("National sales tax", taxes.sales, "income", null));
|
||||
$body.append(monthRow("Export tariffs", taxes.export, "income", null));
|
||||
$body.append(monthRow("Import tariffs", taxes.import, "income", null));
|
||||
if (report.sales > 0) {
|
||||
$body.append(monthRow("Resource sales", report.sales, "income-detail", null));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// city's production queue, so the train tab shows both kinds of order.
|
||||
|
||||
import { groupDigits, hours } from "../../../shared/text_format.js";
|
||||
import { ECONOMY, BUILDINGS, EFFECT_BUDGET_INCOME, effectValue, resourceById, RESOURCE_IDS } from "../../../shared/data.js";
|
||||
import { ECONOMY, BUILDINGS, resourceById, RESOURCE_IDS } from "../../../shared/data.js";
|
||||
import { constructionResourceCost, formatResourceAmount } from "../../../shared/resources.js";
|
||||
import {
|
||||
buildingBuildCost,
|
||||
@@ -93,10 +93,7 @@ export class CityModal {
|
||||
this.buildingRows = (view.buildings || []).map((proto, index) =>
|
||||
this._makeBuildingRow(proto, index)
|
||||
);
|
||||
this.refreshBuildings(view.budget || 0, view.levels || {}, this.gdp, {}, {
|
||||
base: view.base || 0,
|
||||
approval: view.approval,
|
||||
});
|
||||
this.refreshBuildings(view.budget || 0, view.levels || {}, this.gdp, {});
|
||||
this.$budgetSub.text(`Region net +${currencySymbol()}0/h`);
|
||||
this.renderBudget(null);
|
||||
this.renderPeople(
|
||||
@@ -474,32 +471,7 @@ export class CityModal {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The upgrade button's tooltip: how much the next level is expected to add
|
||||
// to the region's hourly tax, given its GDP base and the people's approval.
|
||||
_setBuildTooltip($button, proto, level, nextLevel, region) {
|
||||
if (!$button || !$button.length) return;
|
||||
const base = region ? region.base || 0 : 0;
|
||||
const approval = region && region.approval !== undefined && region.approval !== null
|
||||
? region.approval
|
||||
: 1;
|
||||
let delta = 0;
|
||||
for (const effect of proto.effects) {
|
||||
if (effect.stat !== EFFECT_BUDGET_INCOME) continue;
|
||||
delta += effectValue(effect, nextLevel) - effectValue(effect, level);
|
||||
}
|
||||
const amount = base * approval * delta;
|
||||
if (!amount || Math.abs(amount) < 0.5) {
|
||||
$button.removeAttr("title");
|
||||
return;
|
||||
}
|
||||
const sign = amount >= 0 ? "+" : "−";
|
||||
$button.attr(
|
||||
"title",
|
||||
`Expected budget income ${sign}${currencySymbol()}${groupDigits(Math.round(Math.abs(amount)))}/h`
|
||||
);
|
||||
}
|
||||
|
||||
refreshBuildings(budget, levels, gdp = 0, queued = {}, region = null) {
|
||||
refreshBuildings(budget, levels, gdp = 0, queued = {}) {
|
||||
this._levels = levels || {};
|
||||
this.$buildingsBudget.text(`Budget: ${currencySymbol()}${groupDigits(Math.round(budget))}`);
|
||||
for (const row of this.buildingRows) {
|
||||
@@ -522,13 +494,11 @@ export class CityModal {
|
||||
row.$upgrade
|
||||
.text(`Upgrade ${currencySymbol()}${groupDigits(this._quotedCost(cost, row.proto, nextLevel))}`)
|
||||
.prop("disabled", !!requirement);
|
||||
this._setBuildTooltip(row.$upgrade, row.proto, level, nextLevel + 1, region);
|
||||
if (row.$free) {
|
||||
// Free does not ignore placement rules (a port still needs the sea).
|
||||
row.$free
|
||||
.text(`Free & instant — ${row.proto.name}`)
|
||||
.prop("disabled", !!requirement);
|
||||
this._setBuildTooltip(row.$free, row.proto, level, nextLevel + 1, region);
|
||||
}
|
||||
if (level > 0) {
|
||||
// Downgrading returns nothing, so the button names no price.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Shared formatting helpers for the modal panels.
|
||||
|
||||
import { groupDigits } from "../../../shared/text_format.js";
|
||||
import { policyById } from "../../../shared/data.js";
|
||||
import { policyById, resourceById } from "../../../shared/data.js";
|
||||
import { marketPriceIndex, resourceMarketValue } from "../../../shared/resources.js";
|
||||
import { currencySymbol } from "../currency.js";
|
||||
|
||||
@@ -32,8 +32,6 @@ export function formatEffect(effect, level) {
|
||||
switch (effect.stat) {
|
||||
case "production":
|
||||
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% production`;
|
||||
case "budget_income":
|
||||
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% tax income`;
|
||||
case "research":
|
||||
return `${value >= 0 ? "+" : ""}${value.toFixed(2)} research/h`;
|
||||
case "culture":
|
||||
@@ -140,6 +138,14 @@ export function describeNews(entry, civs) {
|
||||
: `${adjective(entry.civ)} scientists have discovered ${entry.name}`;
|
||||
case "join":
|
||||
return `${entry.player} took control of ${name(entry.civ)}`;
|
||||
case "tariff": {
|
||||
const rate = `${(entry.rate * 100).toFixed(1)}%`;
|
||||
const resource = entry.resource
|
||||
? (resourceById(entry.resource) ? resourceById(entry.resource).name : entry.resource)
|
||||
: "all resources";
|
||||
const kind = entry.kind === "export" ? "export tariff" : "import tariff";
|
||||
return `${adjective(entry.civ)} leaders set the ${kind} on ${resource} to ${rate}`;
|
||||
}
|
||||
default:
|
||||
return entry.type;
|
||||
}
|
||||
|
||||
+172
-35
@@ -20,7 +20,9 @@ import { renderMonthlyBudget } from "./budget.js";
|
||||
import { effectsText, money, quotedMoney } from "./format.js";
|
||||
import { bindTabs } from "./tabs.js";
|
||||
import { ethnicityChart } from "../ui/ethnicity.js";
|
||||
import { trendArrow } from "../ui/trend.js";
|
||||
import { currencySymbol } from "../currency.js";
|
||||
import { HOURS_PER_DAY } from "../../../shared/game_state/constants.js";
|
||||
|
||||
export class NationModal {
|
||||
constructor() {
|
||||
@@ -37,6 +39,7 @@ export class NationModal {
|
||||
this.$economySummary = $("#economy-summary");
|
||||
this.$economyConsumption = $("#economy-consumption");
|
||||
this.$economyCurrency = $("#economy-currency");
|
||||
this.$economyTaxes = $("#economy-taxes");
|
||||
this.$economyCentralBank = $("#economy-central-bank");
|
||||
this.$economyRates = $("#economy-rates");
|
||||
// Which Economy sub-tab is open: the private sector, the trade table, the
|
||||
@@ -45,12 +48,18 @@ export class NationModal {
|
||||
// The central-bank interest input is built once and only its value updated,
|
||||
// so typing is not interrupted by the snapshot refresh.
|
||||
this._centralBankBuilt = false;
|
||||
// The tax controls are likewise built once; only their values refresh.
|
||||
this._taxesBuilt = false;
|
||||
this.$resourcesSub = $("#resources-sub");
|
||||
this.$resourcesList = $("#resources-list");
|
||||
this.$resourceTradeList = $("#resource-trade-list");
|
||||
// Which Resources sub-tab is open: the nation's own stores and trade, or
|
||||
// the world ranking of each commodity's major exporter and importer.
|
||||
this.resourceView = "stores";
|
||||
// Day-over-day baseline and the latest values for the Resources tab arrows.
|
||||
this._resourceDay = null;
|
||||
this._resourceBaseline = null;
|
||||
this._resourceLast = null;
|
||||
this.$diplomacySub = $("#diplomacy-sub");
|
||||
this.$diplomacyList = $("#diplomacy-list");
|
||||
this.$citiesSub = $("#cities-sub");
|
||||
@@ -96,6 +105,7 @@ export class NationModal {
|
||||
this.onPolicy = () => {};
|
||||
this.onRevokePolicy = () => {};
|
||||
this.onSetInterestRate = () => {};
|
||||
this.onSetTaxRate = () => {};
|
||||
this.onConfirm = () => {};
|
||||
this.$modal.find(".modal-close").on("click", () => this.close());
|
||||
bindTabs(this.$modal, "tab-");
|
||||
@@ -211,6 +221,7 @@ export class NationModal {
|
||||
{ id: "summary", label: "Private sector" },
|
||||
{ id: "consumption", label: "Consumption & trade" },
|
||||
{ id: "currency", label: currency ? `${currency.symbol} ${currency.name}` : "Currency" },
|
||||
{ id: "taxes", label: "Taxes" },
|
||||
{ id: "centralbank", label: "Central bank" },
|
||||
{ id: "rates", label: "Exchange rates" },
|
||||
];
|
||||
@@ -236,6 +247,7 @@ export class NationModal {
|
||||
this.$economyConsumption.toggleClass("hidden", view !== "consumption");
|
||||
}
|
||||
if (this.$economyCurrency) this.$economyCurrency.toggleClass("hidden", view !== "currency");
|
||||
if (this.$economyTaxes) this.$economyTaxes.toggleClass("hidden", view !== "taxes");
|
||||
if (this.$economyCentralBank) {
|
||||
this.$economyCentralBank.toggleClass("hidden", view !== "centralbank");
|
||||
}
|
||||
@@ -248,6 +260,7 @@ export class NationModal {
|
||||
this.money = money || this.money || {};
|
||||
if (this.economyView === "consumption") this._renderConsumption();
|
||||
else if (this.economyView === "currency") this._renderCurrencyHoldings(this.money);
|
||||
else if (this.economyView === "taxes") this._renderTaxes(this.money);
|
||||
else if (this.economyView === "centralbank") this._renderCentralBank(this.money);
|
||||
else if (this.economyView === "rates") this._renderExchangeRates(this.money);
|
||||
else this._renderPrivateSector(this.money);
|
||||
@@ -482,15 +495,117 @@ export class NationModal {
|
||||
this.$economySummary.append($table);
|
||||
}
|
||||
|
||||
// The trade taxes: three rates, each a national default that may be
|
||||
// overridden per resource. There is no GDP tax, so these are the government's
|
||||
// whole revenue.
|
||||
_renderTaxes(money) {
|
||||
if (!this.$economyTaxes || !this.$economyTaxes.length) return;
|
||||
const taxes = (money && money.taxes) || {};
|
||||
const take = (money && money.takeTax) || { sales: 0, export: 0, import: 0 };
|
||||
if (this.$economySub && this.$economySub.length) {
|
||||
const total = (take.sales || 0) + (take.export || 0) + (take.import || 0);
|
||||
this.$economySub.text(
|
||||
`Trade taxes · collected ${currencySymbol()}${groupDigits(Math.round(total))} today`
|
||||
);
|
||||
}
|
||||
if (!this._taxesBuilt) {
|
||||
this.$economyTaxes.empty();
|
||||
this.$economyTaxes.append(
|
||||
$("<div class='desc'></div>").text(
|
||||
"The government lives on trade. A national sales tax falls on every purchase " +
|
||||
"a region makes from another region at home; an export tariff on what foreign " +
|
||||
"regions buy from your regions; an import tariff on the national currency your " +
|
||||
"regions spend buying foreign goods. A resource box left blank uses the default."
|
||||
)
|
||||
);
|
||||
const kinds = [
|
||||
{ id: "sales", label: "Sales tax" },
|
||||
{ id: "export", label: "Export tariff" },
|
||||
{ id: "import", label: "Import tariff" },
|
||||
];
|
||||
const $table = $("<table class='tax-table economy-table'></table>");
|
||||
const $head = $("<tr></tr>");
|
||||
$head.append($("<th></th>").text("Tax"));
|
||||
$head.append($("<th class='numeric'></th>").text("Default"));
|
||||
for (const proto of RESOURCES) {
|
||||
$head.append($("<th class='numeric'></th>").text(proto.name));
|
||||
}
|
||||
$head.append($("<th></th>").text(""));
|
||||
$table.append($("<thead></thead>").append($head));
|
||||
const $body = $("<tbody></tbody>");
|
||||
for (const kind of kinds) {
|
||||
const $tr = $("<tr></tr>");
|
||||
$tr.append($("<td class='name'></td>").text(kind.label));
|
||||
$tr.append(
|
||||
$("<td class='numeric'></td>").append(
|
||||
$("<input type='number' min='0' max='100' step='1'>")
|
||||
.addClass("tax-input")
|
||||
.attr("data-kind", kind.id)
|
||||
)
|
||||
);
|
||||
for (const proto of RESOURCES) {
|
||||
$tr.append(
|
||||
$("<td class='numeric'></td>").append(
|
||||
$("<input type='number' min='0' max='100' step='1'>")
|
||||
.addClass("tax-input tax-override")
|
||||
.attr("data-kind", kind.id)
|
||||
.attr("data-resource", proto.id)
|
||||
)
|
||||
);
|
||||
}
|
||||
const $apply = $("<button type='button' class='aero tax-apply'></button>").text("Set");
|
||||
$apply.on("click", () => this._applyTaxRow(kind.id));
|
||||
$tr.append($("<td></td>").append($apply));
|
||||
$body.append($tr);
|
||||
}
|
||||
$table.append($body);
|
||||
this.$economyTaxes.append($table);
|
||||
this._taxesBuilt = true;
|
||||
}
|
||||
// Refresh the boxes, leaving the one being typed in alone.
|
||||
const active = document.activeElement;
|
||||
this.$economyTaxes.find(".tax-input").each((_, element) => {
|
||||
if (element === active) return;
|
||||
const $input = $(element);
|
||||
const kind = $input.data("kind");
|
||||
const resource = $input.data("resource");
|
||||
const entry = taxes[kind] || { rate: 0, resources: {} };
|
||||
const value = resource ? entry.resources[resource] : entry.rate;
|
||||
$input.val(value === undefined || value === null ? "" : (value * 100).toFixed(2));
|
||||
});
|
||||
}
|
||||
|
||||
// Sends every box on one tax row that has a value, the default and each
|
||||
// per-resource override.
|
||||
_applyTaxRow(kind) {
|
||||
const $row = this.$economyTaxes.find(`.tax-input[data-kind="${kind}"]`).closest("tr");
|
||||
const send = ($input, resource) => {
|
||||
const raw = $input.val();
|
||||
if (raw === "" || raw === null || raw === undefined) return;
|
||||
const percent = Number(raw);
|
||||
if (!Number.isFinite(percent) || percent < 0) return;
|
||||
this.onSetTaxRate(kind, percent / 100, resource || null);
|
||||
};
|
||||
send($row.find(".tax-input").not(".tax-override"));
|
||||
$row.find(".tax-override").each((_, element) => {
|
||||
const $input = $(element);
|
||||
send($input, $input.attr("data-resource"));
|
||||
});
|
||||
}
|
||||
|
||||
// The central bank: its interest rate, which compounds into every region's
|
||||
// private cash, and the foreign reserves it has accumulated.
|
||||
_renderCentralBank(money) {
|
||||
if (!this.$economyCentralBank || !this.$economyCentralBank.length) return;
|
||||
const bank = money.centralBank || { rate: 0, reserves: [] };
|
||||
// The consumption-weighted move in world commodity prices over the last day.
|
||||
const inflation = (money && money.commodityInflation) || 0;
|
||||
const inflationText = `${inflation >= 0 ? "+" : "−"}${(Math.abs(inflation) * 100).toFixed(2)}%/day`;
|
||||
if (this.$economySub && this.$economySub.length) {
|
||||
this.$economySub.text(
|
||||
`${(money.currency && money.currency.name) || "Central bank"} · ` +
|
||||
`Interest rate ${(bank.rate * 100).toFixed(2)}%/year`
|
||||
`Interest rate ${(bank.rate * 100).toFixed(2)}%/year · ` +
|
||||
`Commodity inflation ${inflationText}`
|
||||
);
|
||||
}
|
||||
if (!this._centralBankBuilt) {
|
||||
@@ -517,6 +632,7 @@ export class NationModal {
|
||||
"overdraft here too."
|
||||
)
|
||||
);
|
||||
this.$economyCentralBank.append($("<div class='central-bank-inflation'></div>"));
|
||||
this.$economyCentralBank.append($("<div class='central-bank-loans'></div>"));
|
||||
const $reserves = $("<div class='central-bank-reserves'></div>");
|
||||
this.$economyCentralBank.append($reserves);
|
||||
@@ -526,6 +642,10 @@ export class NationModal {
|
||||
if ($input.length && document.activeElement !== $input.get(0)) {
|
||||
$input.val((bank.rate * 100).toFixed(2));
|
||||
}
|
||||
this.$economyCentralBank.find(".central-bank-inflation").text(
|
||||
`Commodity inflation: ${inflationText} — the price of the goods the world ` +
|
||||
"consumes, weighted by how much of each it uses."
|
||||
);
|
||||
this.$economyCentralBank.find(".central-bank-loans").text(
|
||||
`Outstanding loans: ${currencySymbol()}${groupDigits(Math.round(bank.loans || 0))}`
|
||||
);
|
||||
@@ -643,7 +763,7 @@ export class NationModal {
|
||||
this.$citiesSub.text(
|
||||
rows.length === 0
|
||||
? "No cities."
|
||||
: `${rows.length} region${rows.length === 1 ? "" : "s"} collecting taxes independently.`
|
||||
: `${rows.length} region${rows.length === 1 ? "" : "s"} paying trade taxes.`
|
||||
);
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
@@ -655,7 +775,7 @@ export class NationModal {
|
||||
{ key: "name", label: "City" },
|
||||
{ key: "population", label: "Population", numeric: true },
|
||||
{ key: "gdp", label: "GDP", numeric: true },
|
||||
{ key: "income", label: "Tax / h", numeric: true },
|
||||
{ key: "income", label: "Taxes / h", numeric: true },
|
||||
];
|
||||
const $table = $("<table class='diplomacy-table budget-table'></table>");
|
||||
const $head = $("<thead></thead>");
|
||||
@@ -721,6 +841,25 @@ export class NationModal {
|
||||
_renderResourceStores(info, summary) {
|
||||
const market = new Map(((info && info.market) || []).map((entry) => [entry.id, entry]));
|
||||
const trade = summary.trade || { imports: {}, exports: {} };
|
||||
// Day-over-day arrows: when the game day rolls, the values last drawn become
|
||||
// the baseline every cell compares against. Prices move with a lower
|
||||
// threshold, so a ten-percent jump doubles its arrow.
|
||||
const day = info && info.totalHours !== undefined
|
||||
? Math.floor(info.totalHours / HOURS_PER_DAY)
|
||||
: 0;
|
||||
if (this._resourceDay !== day) {
|
||||
this._resourceBaseline = this._resourceLast || null;
|
||||
this._resourceDay = day;
|
||||
}
|
||||
const baseline = this._resourceBaseline || {};
|
||||
const values = {};
|
||||
const cell = ($tr, key, value, text, options = {}) => {
|
||||
values[key] = value;
|
||||
const $td = $("<td class='numeric'></td>").text(text);
|
||||
const arrow = trendArrow(value, baseline[key], options);
|
||||
if (arrow) $td.append(arrow);
|
||||
$tr.append($td);
|
||||
};
|
||||
const $table = $("<table class='diplomacy-table budget-table resources-table'></table>");
|
||||
const columns = [
|
||||
{ label: "Resource" },
|
||||
@@ -744,22 +883,20 @@ export class NationModal {
|
||||
const $body = $("<tbody></tbody>");
|
||||
for (const proto of RESOURCES) {
|
||||
const energy = proto.id === "energy";
|
||||
const id = proto.id;
|
||||
const stats = market.get(proto.id) || {};
|
||||
const stored = energy ? "—" : formatResourceAmount(proto.id, summary.stock[proto.id] || 0);
|
||||
const consumed = energy
|
||||
? formatResourceAmount("energy", (summary.consumed && summary.consumed.energy) || 0)
|
||||
: formatResourceAmount(proto.id, (summary.consumed && summary.consumed[proto.id]) || 0);
|
||||
const made = energy
|
||||
? formatResourceAmount("energy", summary.energy ? summary.energy.supply : 0)
|
||||
: formatResourceAmount(proto.id, summary.production[proto.id] || 0);
|
||||
const exported = formatResourceAmount(
|
||||
proto.id,
|
||||
(trade.exports && trade.exports[proto.id]) || 0
|
||||
);
|
||||
const imported = formatResourceAmount(
|
||||
proto.id,
|
||||
(trade.imports && trade.imports[proto.id]) || 0
|
||||
);
|
||||
const storedValue = energy ? null : (summary.stock[id] || 0);
|
||||
const consumedValue = energy
|
||||
? ((summary.consumed && summary.consumed.energy) || 0)
|
||||
: ((summary.consumed && summary.consumed[id]) || 0);
|
||||
const madeValue = energy
|
||||
? (summary.energy ? summary.energy.supply : 0)
|
||||
: (summary.production[id] || 0);
|
||||
const exportedValue = (trade.exports && trade.exports[id]) || 0;
|
||||
const importedValue = (trade.imports && trade.imports[id]) || 0;
|
||||
const priceValue = stats.price || 0;
|
||||
const supplyValue = Math.max(0, stats.supply || 0);
|
||||
const demandValue = Math.max(0, stats.demand || 0);
|
||||
const $tr = $("<tr></tr>");
|
||||
const $name = $("<td class='name'></td>");
|
||||
$name.append(
|
||||
@@ -769,24 +906,23 @@ export class NationModal {
|
||||
);
|
||||
$name.append($("<span></span>").text(` ${proto.name}`));
|
||||
$tr.append($name);
|
||||
$tr.append($("<td class='numeric'></td>").text(stored));
|
||||
$tr.append($("<td class='numeric'></td>").text(consumed));
|
||||
$tr.append($("<td class='numeric'></td>").text(made));
|
||||
$tr.append($("<td class='numeric'></td>").text(exported));
|
||||
$tr.append($("<td class='numeric'></td>").text(imported));
|
||||
$tr.append($("<td class='numeric'></td>").text(this._resourcePriceText(proto, stats.price)));
|
||||
$tr.append(
|
||||
$("<td class='numeric'></td>").text(
|
||||
formatResourceAmount(proto.id, Math.max(0, stats.supply || 0))
|
||||
)
|
||||
);
|
||||
$tr.append(
|
||||
$("<td class='numeric'></td>").text(
|
||||
formatResourceAmount(proto.id, Math.max(0, stats.demand || 0))
|
||||
)
|
||||
);
|
||||
if (energy) {
|
||||
$tr.append($("<td class='numeric'></td>").text("—"));
|
||||
} else {
|
||||
cell($tr, `${id}:stored`, storedValue, formatResourceAmount(id, storedValue));
|
||||
}
|
||||
cell($tr, `${id}:consumed`, consumedValue, formatResourceAmount(id, consumedValue));
|
||||
cell($tr, `${id}:made`, madeValue, formatResourceAmount(id, madeValue));
|
||||
cell($tr, `${id}:exported`, exportedValue, formatResourceAmount(id, exportedValue));
|
||||
cell($tr, `${id}:imported`, importedValue, formatResourceAmount(id, importedValue));
|
||||
cell($tr, `${id}:price`, priceValue, this._resourcePriceText(proto, priceValue), {
|
||||
doubleAt: 0.1,
|
||||
});
|
||||
cell($tr, `${id}:supply`, supplyValue, formatResourceAmount(id, supplyValue));
|
||||
cell($tr, `${id}:demand`, demandValue, formatResourceAmount(id, demandValue));
|
||||
$body.append($tr);
|
||||
}
|
||||
this._resourceLast = values;
|
||||
$table.append($head, $body);
|
||||
this.$resourcesList.append($table);
|
||||
this.$resourcesList.append(
|
||||
@@ -794,7 +930,8 @@ export class NationModal {
|
||||
"Consumption is the people's needs plus the materials buildings, units and works " +
|
||||
"wear out. Exports and imports count yesterday's deliveries between nations. " +
|
||||
"Prices drift with the world's supply and demand, and a region buys from its own " +
|
||||
"stores first, then from reachable home regions, then from foreign ones."
|
||||
"stores first, then from reachable home regions, then from foreign ones. The " +
|
||||
"arrows compare each figure with its value the previous day."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// A small change marker for a value, matching the HUD's summary arrows: a bold
|
||||
// green up arrow, a red down arrow, doubled once a big move happened, or an
|
||||
// equals sign when nothing moved. Returns null when there is no baseline, so
|
||||
// callers can append it only when there is something to compare against.
|
||||
|
||||
export function trendArrow(current, previous, { doubleAt = null, equalEpsilon = 0.5 } = {}) {
|
||||
if (current === undefined || current === null) return null;
|
||||
if (previous === undefined || previous === null) return null;
|
||||
const diff = current - previous;
|
||||
const $arrow = $("<span class='stat-arrow'></span>");
|
||||
if (Math.abs(diff) <= equalEpsilon) {
|
||||
$arrow.text("=").addClass("equal");
|
||||
} else {
|
||||
const rising = diff > 0;
|
||||
const double = doubleAt !== null && previous !== 0 &&
|
||||
Math.abs(diff) / Math.abs(previous) > doubleAt;
|
||||
$arrow
|
||||
.text(rising ? (double ? "▲▲" : "▲") : (double ? "▼▼" : "▼"))
|
||||
.addClass(rising ? "up" : "down");
|
||||
}
|
||||
return $arrow;
|
||||
}
|
||||
@@ -219,6 +219,10 @@ export class GameServer extends EventEmitter {
|
||||
return this._handleRevokePolicy(civ, order);
|
||||
case "set_interest_rate":
|
||||
return this.state.requestSetInterestRate(civ, Number(order.rate));
|
||||
case "set_tax_rate":
|
||||
return this.state.requestSetTaxRate(
|
||||
civ, order.kind, Number(order.rate), order.resource || null
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// with the level, with no level cap; upkeep is 1% of that cost, so it follows
|
||||
// the level too.
|
||||
|
||||
import { EFFECT_PRODUCTION, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
|
||||
import { EFFECT_PRODUCTION, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
|
||||
|
||||
// Buildings whose benefit is a rule rather than a stat effect. Kept as ids so
|
||||
// the simulation and the UI can name them without magic strings.
|
||||
@@ -32,16 +32,6 @@ export const BUILDINGS = [
|
||||
effects: [effect(EFFECT_PRODUCTION, 0.04, 0.75)],
|
||||
icon: "icon_market.svg",
|
||||
},
|
||||
{
|
||||
id: "bank",
|
||||
name: "Bank",
|
||||
description: "Channels private wealth into the state coffers. Raises the tax collected from the city's region.",
|
||||
baseCost: 1_000_000,
|
||||
costAlpha: 1.3,
|
||||
refundFraction: 0.5,
|
||||
effects: [effect(EFFECT_BUDGET_INCOME, 0.05, 0.7)],
|
||||
icon: "icon_bank.svg",
|
||||
},
|
||||
{
|
||||
id: "barracks",
|
||||
name: "Barracks",
|
||||
@@ -132,7 +122,7 @@ export const BUILDINGS = [
|
||||
baseCost: 20_000_000,
|
||||
costAlpha: 1.7,
|
||||
refundFraction: 0.5,
|
||||
effects: [effect(EFFECT_BUDGET_INCOME, 0.08, 0.7)],
|
||||
effects: [],
|
||||
// Every person in the city's region produces this much more, for each sea
|
||||
// tile next to the city, per port level.
|
||||
mechanic: "port_production",
|
||||
|
||||
+15
-12
@@ -2,11 +2,21 @@
|
||||
// both use to derive production, training time, budget income and population
|
||||
// growth.
|
||||
|
||||
import { HOURS_PER_YEAR } from "../game_clock.js";
|
||||
|
||||
// Nations collect this share of their GDP as tax over a whole year; the hourly
|
||||
// rate below is what the per-tick economy actually uses.
|
||||
export const ANNUAL_TAX_RATE = 0.25;
|
||||
// Trade taxes are the government's only revenue; there is no GDP-based tax.
|
||||
// Every region buying from another region inside the country pays a national
|
||||
// sales tax on top of the price. A foreign buyer pays an export tariff to the
|
||||
// seller's government, and a region buying from abroad pays an import tariff to
|
||||
// its own government on the national currency it spent acquiring the foreign
|
||||
// currency. Rates are per nation and may be overridden per resource.
|
||||
export const TAXES = {
|
||||
defaultSales: 0.2,
|
||||
defaultExport: 0.05,
|
||||
defaultImport: 0.05,
|
||||
// The highest rate a player may set, per kind and resource.
|
||||
maxRate: 1.0,
|
||||
// The three tax kinds, in the order the panels list them.
|
||||
kinds: ["sales", "export", "import"],
|
||||
};
|
||||
|
||||
// The buckets the monthly budget report groups a nation's spending into. Each
|
||||
// row carries a signed cash movement (positive is money into the treasury) and,
|
||||
@@ -58,9 +68,6 @@ export const ECONOMY = {
|
||||
capitalTileProductionMultiplier: 2.5,
|
||||
citySurroundingProductionBonus: 0.3,
|
||||
gdpPerProductionPoint: 100_000,
|
||||
// 25% of GDP per year, spread evenly over the hours of the year.
|
||||
taxRatePerYear: ANNUAL_TAX_RATE,
|
||||
budgetGdpRate: ANNUAL_TAX_RATE / HOURS_PER_YEAR,
|
||||
// A nation opens the game on 1 January 2000 with a tenth of its starting GDP.
|
||||
startingBudgetFraction: 0.1,
|
||||
startingBudget: 1_000_000_000,
|
||||
@@ -81,10 +88,6 @@ export const ECONOMY = {
|
||||
return cost / capacity;
|
||||
},
|
||||
|
||||
budgetIncome(gdp, hours = 1) {
|
||||
return gdp * this.budgetGdpRate * hours;
|
||||
},
|
||||
|
||||
populationGrowthFactor(hoursPerYear, hours = 1) {
|
||||
if (this.basePopulationGrowthRate <= 0 || hoursPerYear <= 0 || hours <= 0) return 0;
|
||||
return Math.pow(1 + this.basePopulationGrowthRate, hours / hoursPerYear) - 1;
|
||||
|
||||
Vendored
-1
@@ -6,7 +6,6 @@
|
||||
export const EFFECT_PRODUCTION = "production";
|
||||
export const EFFECT_RESEARCH = "research";
|
||||
export const EFFECT_CULTURE = "culture";
|
||||
export const EFFECT_BUDGET_INCOME = "budget_income";
|
||||
// Repeatable technology effects. Each is a fraction that stacks with every
|
||||
// further level researched.
|
||||
export const EFFECT_INFANTRY_ATTACK = "infantry_attack";
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// Forms of government a civilisation may adopt. Each grants a flat modifier to
|
||||
// the stats it favours.
|
||||
|
||||
import { EFFECT_PRODUCTION, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
|
||||
import { EFFECT_PRODUCTION, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
|
||||
|
||||
export const GOVERNMENTS = [
|
||||
{
|
||||
id: "republic",
|
||||
name: "Republic",
|
||||
description: "Rule by elected representatives. A balanced government that spreads its support across the economy, the treasury and the sciences.",
|
||||
description: "Rule by elected representatives. A balanced government that spreads its support across the economy, trade and the sciences.",
|
||||
baseCost: 0,
|
||||
costGdpFraction: 0.02,
|
||||
effects: [effect(EFFECT_PRODUCTION, 0.05, 1.0), effect(EFFECT_BUDGET_INCOME, 0.1, 1.0), effect(EFFECT_RESEARCH, 1.0, 1.0)],
|
||||
effects: [effect(EFFECT_PRODUCTION, 0.05, 1.0), effect(EFFECT_RESEARCH, 1.0, 1.0)],
|
||||
},
|
||||
{
|
||||
id: "monarchy",
|
||||
|
||||
@@ -16,3 +16,6 @@ export const NEWS_POLICY = "policy";
|
||||
export const NEWS_TECHNOLOGY = "technology";
|
||||
// A player took control of a nation. Recorded by the server, not the model.
|
||||
export const NEWS_JOIN = "join";
|
||||
// A government changed an import or export tariff. The involved trading
|
||||
// parties see it in the news feed.
|
||||
export const NEWS_TARIFF = "tariff";
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
import {
|
||||
EFFECT_PRODUCTION,
|
||||
EFFECT_BUDGET_INCOME,
|
||||
EFFECT_RESEARCH,
|
||||
EFFECT_CULTURE,
|
||||
EFFECT_INFANTRY_ATTACK,
|
||||
@@ -51,11 +50,11 @@ export const TECHNOLOGIES = [
|
||||
{
|
||||
id: "renewable_energy",
|
||||
name: "Renewable Energy",
|
||||
description: "Clean generation cuts waste and strengthens the public finances.",
|
||||
description: "Clean generation cuts waste and lifts national production.",
|
||||
theme: "economy",
|
||||
cost: 80,
|
||||
prerequisites: ["scientific_method"],
|
||||
effects: [effect(EFFECT_PRODUCTION, 0.03, 1.0), effect(EFFECT_BUDGET_INCOME, 0.05, 1.0)],
|
||||
effects: [effect(EFFECT_PRODUCTION, 0.03, 1.0)],
|
||||
},
|
||||
{
|
||||
id: "quantum_computing",
|
||||
|
||||
@@ -49,6 +49,7 @@ import { warfareMethods } from "./game_state/warfare.js";
|
||||
import { resourceMethods } from "./game_state/resources.js";
|
||||
import { budgetMethods } from "./game_state/budget.js";
|
||||
import { moneyMethods } from "./game_state/money.js";
|
||||
import { taxMethods } from "./game_state/taxes.js";
|
||||
|
||||
export {
|
||||
MAX_NEWS,
|
||||
@@ -288,6 +289,7 @@ export class GameState {
|
||||
this._placeSettlements();
|
||||
this._initResources();
|
||||
this._initMoney();
|
||||
this._initTaxes();
|
||||
this._refreshStatuses();
|
||||
this._revealAll();
|
||||
this._refreshVisibility();
|
||||
@@ -412,5 +414,6 @@ Object.assign(
|
||||
warfareMethods,
|
||||
resourceMethods,
|
||||
budgetMethods,
|
||||
moneyMethods
|
||||
moneyMethods,
|
||||
taxMethods
|
||||
);
|
||||
|
||||
@@ -20,7 +20,9 @@ import { MONTH_NAMES } from "../game_clock.js";
|
||||
export const BUDGET_HISTORY_MONTHS = 24;
|
||||
|
||||
function emptyBudgetLedger() {
|
||||
const cash = { income: 0, sell: 0 };
|
||||
// Tax income is booked under the kind of trade tax that raised it; `income` is
|
||||
// kept as the running total the report reads.
|
||||
const cash = { income: 0, sell: 0, taxSales: 0, taxExport: 0, taxImport: 0 };
|
||||
const economic = {};
|
||||
for (const category of BUDGET_EXPENSE_CATEGORIES) {
|
||||
cash[category.id] = 0;
|
||||
@@ -172,7 +174,12 @@ export const budgetMethods = {
|
||||
sold: Math.max(0, resourceSell[id] || 0),
|
||||
}))
|
||||
.filter((entry) => entry.bought > 0 || entry.sold > 0);
|
||||
const income = cash.income || 0;
|
||||
const taxes = {
|
||||
sales: cash.taxSales || 0,
|
||||
export: cash.taxExport || 0,
|
||||
import: cash.taxImport || 0,
|
||||
};
|
||||
const income = taxes.sales + taxes.export + taxes.import;
|
||||
const sales = cash.sell || 0;
|
||||
const year = Math.floor(monthIndex / 12);
|
||||
const month = monthIndex % 12;
|
||||
@@ -181,6 +188,7 @@ export const budgetMethods = {
|
||||
isCurrent,
|
||||
label: `${MONTH_NAMES[month]} ${year}`,
|
||||
income,
|
||||
taxes,
|
||||
sales,
|
||||
expenses,
|
||||
resourceTrade,
|
||||
|
||||
+48
-112
@@ -5,7 +5,6 @@ import { ECONOMY } from "../data/economy.js";
|
||||
import { RESOURCE_RULES, RESOURCE_IDS } from "../data/resources.js";
|
||||
import {
|
||||
EFFECT_PRODUCTION,
|
||||
EFFECT_BUDGET_INCOME,
|
||||
EFFECT_RESEARCH,
|
||||
EFFECT_CULTURE,
|
||||
EFFECT_CONSTRUCTION_SPEED,
|
||||
@@ -307,9 +306,10 @@ export const economyMethods = {
|
||||
return city.buildings[protoIndex] || 0;
|
||||
},
|
||||
|
||||
// Population, GDP and the tax one city's region yields per hour. The tax is
|
||||
// collected by the region alone: a city's income modifiers never apply to
|
||||
// another city's land, only government and research reach every region.
|
||||
// Population, GDP and the people's approval of one city's region. There is no
|
||||
// GDP tax any more, so a region's economy carries no income of its own; what
|
||||
// it contributes to the state is the trade tax it generates, read from the
|
||||
// tax ledger.
|
||||
getCityEconomy(city) {
|
||||
// While a snapshot is being assembled, every caller sees one figure: the
|
||||
// city stats and the viewer's budget breakdown both need it for the same
|
||||
@@ -333,66 +333,58 @@ export const economyMethods = {
|
||||
const productionScale = this._productionBalance.get(city.civ) || 1;
|
||||
population *= populationScale;
|
||||
gdp *= populationScale * productionScale;
|
||||
const rate = ECONOMY.budgetGdpRate;
|
||||
const base = gdp * rate;
|
||||
const cityMods = this.getCityBuildingModifiers(city);
|
||||
const national = this.getNationalModifiers(city.civ);
|
||||
const modifier =
|
||||
(cityMods[EFFECT_BUDGET_INCOME] || 0) + (national[EFFECT_BUDGET_INCOME] || 0);
|
||||
// A region whose people have turned against the government barely pays its
|
||||
// taxes, so approval scales the collected income after every other bonus.
|
||||
const approval = this.taxApprovalMultiplier(city);
|
||||
const result = {
|
||||
population,
|
||||
gdp,
|
||||
base,
|
||||
modifier,
|
||||
approval,
|
||||
income: base * (1 + modifier) * approval,
|
||||
approval: this.taxApprovalMultiplier(city),
|
||||
};
|
||||
if (this._cityEconomyCacheActive) this._cityEconomyCache.set(city.id, result);
|
||||
return result;
|
||||
},
|
||||
|
||||
// The tax the whole nation collects in one hour, summed over its regions.
|
||||
// The tax the whole nation collects in one hour, the day's take spread over
|
||||
// its 24 hours.
|
||||
getCivHourlyIncome(civ) {
|
||||
let total = 0;
|
||||
for (const city of this.cities) {
|
||||
if (city.civ !== civ) continue;
|
||||
total += this.getCityEconomy(city).income;
|
||||
}
|
||||
return total;
|
||||
const take = this.getTaxTake(civ);
|
||||
return (take.sales + take.export + take.import) / HOURS_PER_DAY;
|
||||
},
|
||||
|
||||
// Income and upkeep with the source behind every figure, so the UI can show
|
||||
// which buildings, government or technologies shape the budget. Amounts are
|
||||
// per in-game hour, matching the rest of the economy figures. Income is
|
||||
// aggregated from the regions; buildings are grouped by type with a per-city
|
||||
// breakdown the budget table can expand.
|
||||
// The three trade taxes a nation collected over the last day, as hourly
|
||||
// averages, in the order the budget panel lists them.
|
||||
_taxIncomeSources(civ, cityName = null) {
|
||||
const take = this.getTaxTake(civ);
|
||||
const sources = [
|
||||
{ label: "National sales tax", kind: "tax", amount: take.sales / HOURS_PER_DAY },
|
||||
{ label: "Export tariffs", kind: "tax", amount: take.export / HOURS_PER_DAY },
|
||||
{ label: "Import tariffs", kind: "tax", amount: take.import / HOURS_PER_DAY },
|
||||
];
|
||||
for (const source of sources) if (cityName) source.cityName = cityName;
|
||||
return sources.filter((source) => source.amount > 0);
|
||||
},
|
||||
|
||||
// Income and upkeep with the source behind every figure. The income side is
|
||||
// the trade taxes the nation collected over the last day, as hourly averages;
|
||||
// there is no GDP tax. Upkeep is grouped by type with a per-city breakdown the
|
||||
// budget table can expand.
|
||||
budgetBreakdown(civ) {
|
||||
const rate = ECONOMY.budgetGdpRate;
|
||||
const regions = [];
|
||||
for (const city of this.cities) {
|
||||
if (city.civ !== civ) continue;
|
||||
const economy = this.getCityEconomy(city);
|
||||
const buildingModifier = this.getCityBuildingModifiers(city)[EFFECT_BUDGET_INCOME] || 0;
|
||||
const tax = this.getCityTaxTake(city);
|
||||
regions.push({
|
||||
cityId: city.id,
|
||||
cityName: city.name,
|
||||
isCapital: city.isCapital,
|
||||
population: economy.population,
|
||||
gdp: economy.gdp,
|
||||
base: economy.base,
|
||||
buildingModifier,
|
||||
modifier: economy.base > 0 ? economy.income / economy.base - 1 : 0,
|
||||
approval: economy.approval,
|
||||
income: economy.income,
|
||||
taxes: tax,
|
||||
income: (tax.sales + tax.export + tax.import) / HOURS_PER_DAY,
|
||||
});
|
||||
}
|
||||
const base = regions.reduce((sum, region) => sum + region.base, 0);
|
||||
const income = regions.reduce((sum, region) => sum + region.income, 0);
|
||||
const modifier = base > 0 ? income / base - 1 : 0;
|
||||
const incomeSources = this._incomeSources(civ, regions, base);
|
||||
const incomeSources = this._sortSources(this._taxIncomeSources(civ));
|
||||
const groups = this._buildingUpkeepGroups(civ);
|
||||
const buildingSources = this._sortSources(
|
||||
groups.map((group) => ({ label: group.label, kind: "building", amount: group.amount }))
|
||||
@@ -416,7 +408,7 @@ export const economyMethods = {
|
||||
.map((id) => ({ id, bought: buy[id] || 0, sold: sell[id] || 0 }))
|
||||
.filter((entry) => entry.bought > 0 || entry.sold > 0);
|
||||
return {
|
||||
income: { rate, base, modifier, total: income, sources: incomeSources, regions },
|
||||
income: { rate: 0, base: 0, modifier: 0, total: income, sources: incomeSources, regions },
|
||||
buildingUpkeep: { total: buildingUpkeep, sources: buildingSources, groups },
|
||||
unitUpkeep: { total: unitUpkeep, sources: unitSources, groups: unitSources },
|
||||
campaignUpkeep: { total: campaignUpkeep, sources: campaignSources, groups: campaignSources },
|
||||
@@ -432,28 +424,27 @@ export const economyMethods = {
|
||||
},
|
||||
|
||||
// The economy of one city's region, shaped like `budgetBreakdown` so the UI
|
||||
// renders both with one table: the tax the region collects with its building,
|
||||
// government and technology sources, and the upkeep of the city's own
|
||||
// buildings. National unit upkeep is not attributed to a region, so it is
|
||||
// left out and the net is the region's own income minus its buildings.
|
||||
// renders both with one table: the trade taxes the region generated, and the
|
||||
// upkeep of the city's own buildings. National unit upkeep is not attributed
|
||||
// to a region, so it is left out and the net is what the region contributes to
|
||||
// the state minus what its buildings cost it.
|
||||
// `economy`, when supplied, is the already-computed `getCityEconomy` result,
|
||||
// so a caller that needs both does not derive the region twice.
|
||||
cityBudgetBreakdown(city, economy = null) {
|
||||
if (!economy) economy = this.getCityEconomy(city);
|
||||
const rate = ECONOMY.budgetGdpRate;
|
||||
const tax = this.getCityTaxTake(city);
|
||||
const income = (tax.sales + tax.export + tax.import) / HOURS_PER_DAY;
|
||||
const region = {
|
||||
cityId: city.id,
|
||||
cityName: city.name,
|
||||
isCapital: city.isCapital,
|
||||
population: economy.population,
|
||||
gdp: economy.gdp,
|
||||
base: economy.base,
|
||||
buildingModifier: this.getCityBuildingModifiers(city)[EFFECT_BUDGET_INCOME] || 0,
|
||||
modifier: economy.base > 0 ? economy.income / economy.base - 1 : 0,
|
||||
approval: economy.approval,
|
||||
income: economy.income,
|
||||
taxes: tax,
|
||||
income,
|
||||
};
|
||||
const incomeSources = this._incomeSources(city.civ, [region], region.base);
|
||||
const incomeSources = this._sortSources(this._taxIncomeSources(city.civ, city.name));
|
||||
const groups = this._cityBuildingUpkeepGroups(city);
|
||||
const buildingSources = this._sortSources(
|
||||
groups.map((group) => ({ label: group.label, kind: "building", amount: group.amount }))
|
||||
@@ -461,16 +452,16 @@ export const economyMethods = {
|
||||
const buildingUpkeep = groups.reduce((sum, group) => sum + group.amount, 0);
|
||||
return {
|
||||
income: {
|
||||
rate,
|
||||
base: region.base,
|
||||
modifier: region.modifier,
|
||||
total: region.income,
|
||||
rate: 0,
|
||||
base: 0,
|
||||
modifier: 0,
|
||||
total: income,
|
||||
sources: incomeSources,
|
||||
regions: [region],
|
||||
},
|
||||
buildingUpkeep: { total: buildingUpkeep, sources: buildingSources, groups },
|
||||
unitUpkeep: { total: 0, sources: [], groups: [] },
|
||||
net: region.income - buildingUpkeep,
|
||||
net: income - buildingUpkeep,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -497,58 +488,6 @@ export const economyMethods = {
|
||||
return groups.sort((a, b) => b.amount - a.amount);
|
||||
},
|
||||
|
||||
// Every building, government and researched technology that shifts budget
|
||||
// income. A building's bonus is measured against the region that hosts it,
|
||||
// while government and research are measured against the whole nation.
|
||||
_incomeSources(civ, regions, base) {
|
||||
const sources = new Map();
|
||||
const add = (key, label, kind, effect, amount, region) => {
|
||||
if (effect === 0) return;
|
||||
const entry = sources.get(key) || {
|
||||
label,
|
||||
kind,
|
||||
effect: 0,
|
||||
amount: 0,
|
||||
cityId: region ? region.cityId : null,
|
||||
cityName: region ? region.cityName : null,
|
||||
};
|
||||
entry.effect += effect;
|
||||
entry.amount += amount;
|
||||
sources.set(key, entry);
|
||||
};
|
||||
for (const region of regions) {
|
||||
const city = this._cityById.get(region.cityId);
|
||||
if (!city) continue;
|
||||
for (const indexStr of Object.keys(city.buildings)) {
|
||||
const proto = this.protoBuildings[Number(indexStr)];
|
||||
const level = city.buildings[indexStr];
|
||||
for (const e of proto.effects) {
|
||||
if (e.stat !== EFFECT_BUDGET_INCOME) continue;
|
||||
const effect = effectAt(e, level);
|
||||
add(`building:${proto.name}:${city.id}`, proto.name, "building", effect, region.base * effect, region);
|
||||
}
|
||||
}
|
||||
}
|
||||
const government = this.governments[this.getGovernment(civ)];
|
||||
if (government) {
|
||||
for (const e of government.effects) {
|
||||
if (e.stat !== EFFECT_BUDGET_INCOME) continue;
|
||||
const effect = effectAt(e, 1);
|
||||
add(`government:${government.name}`, government.name, "government", effect, base * effect);
|
||||
}
|
||||
}
|
||||
const researched = this.researched.get(civ) || new Set();
|
||||
for (const index of researched) {
|
||||
const tech = this.technologies[index];
|
||||
for (const e of tech.effects) {
|
||||
if (e.stat !== EFFECT_BUDGET_INCOME) continue;
|
||||
const effect = effectAt(e, 1);
|
||||
add(`technology:${tech.name}`, tech.name, "technology", effect, base * effect);
|
||||
}
|
||||
}
|
||||
return this._sortSources(Array.from(sources.values()));
|
||||
},
|
||||
|
||||
// Upkeep of every building the nation keeps, grouped by building type and
|
||||
// carrying the per-city breakdown the budget table expands.
|
||||
_buildingUpkeepGroups(civ) {
|
||||
@@ -959,14 +898,11 @@ export const economyMethods = {
|
||||
|
||||
_tickEconomy() {
|
||||
const hours = HOURS_PER_DAY;
|
||||
// There is no GDP tax to collect: the government's trade taxes are booked as
|
||||
// the trades happen. All this tick still does is pay the day's propaganda
|
||||
// spending back out to the private sector.
|
||||
for (const civ of Array.from(this.budgets.keys())) {
|
||||
const income = this.getCivHourlyIncome(civ) * hours;
|
||||
const upkeep = this.getPlayerUpkeep(civ) * hours;
|
||||
// Tax is collected from the private sector, not minted, then the day's
|
||||
// propaganda spending is paid back out to it.
|
||||
this._chargeRegions(civ, income);
|
||||
this.budgets.set(civ, this.getBudget(civ) + income);
|
||||
this._recordBudgetCash(civ, "income", income);
|
||||
if (upkeep > 0) this._spendBudget(civ, upkeep, "campaign");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -250,8 +250,8 @@ export const moneyMethods = {
|
||||
// currency: first from what it already holds, then from that nation's central
|
||||
// bank, paying with its own national currency (borrowing from its own bank if
|
||||
// it is short). The target's central bank keeps the buyer's currency as a
|
||||
// foreign reserve. Returns the amount of the target currency that ends up
|
||||
// handed to the seller.
|
||||
// foreign reserve. Returns the amount of the target currency acquired, plus
|
||||
// the buyer currency actually spent on it (the held portion is free).
|
||||
//
|
||||
// `charge` overrides who pays the national-currency cost: a region does by
|
||||
// default, but a government buys with the treasury. `useHeld` is false for the
|
||||
@@ -267,8 +267,13 @@ export const moneyMethods = {
|
||||
const heldTarget = basket ? basket.get(target.code) || 0 : 0;
|
||||
const fromHeld = Math.min(heldTarget, needTarget);
|
||||
const remaining = needTarget - fromHeld;
|
||||
// The buyer currency actually spent acquiring the foreign currency. The
|
||||
// held portion costs nothing, so an import tariff can fall only on what was
|
||||
// really exchanged.
|
||||
let spent = 0;
|
||||
if (remaining > 0) {
|
||||
const costBuyer = remaining * vTarget / vBuyer;
|
||||
spent = costBuyer;
|
||||
// The buyer pays with its national currency, borrowing the rest from its
|
||||
// own central bank if it is short; the seller's bank holds that currency
|
||||
// as a reserve and issues its own, taking on the matching debt so no money
|
||||
@@ -279,7 +284,7 @@ export const moneyMethods = {
|
||||
this._addCentralBankDebt(targetCiv, target.code, remaining);
|
||||
}
|
||||
if (fromHeld > 0) basket.set(target.code, heldTarget - fromHeld);
|
||||
return { code: target.code, amount: fromHeld + remaining };
|
||||
return { code: target.code, amount: fromHeld + remaining, spent };
|
||||
},
|
||||
|
||||
// --------------------------------------------------- demand/rates --
|
||||
|
||||
@@ -441,6 +441,33 @@ export const resourceMethods = {
|
||||
this.resourcePrices.set(id, value);
|
||||
},
|
||||
|
||||
// The price a resource carried at the last daily update, for the day-on-day
|
||||
// arrows. Falls back to today's price before the first update.
|
||||
getResourcePrevPrice(id) {
|
||||
const previous = this._prevResourcePrices ? this._prevResourcePrices.get(id) : undefined;
|
||||
return previous === undefined ? this.getResourcePrice(id) : previous;
|
||||
},
|
||||
|
||||
// The consumption-weighted move in the world's commodity prices over the last
|
||||
// day, a positive fraction when they rose overall. Each commodity is weighted
|
||||
// by what the world consumed of it, valued at today's price, so a dear
|
||||
// commodity the world uses little of does not swamp the index.
|
||||
_recordCommodityInflation(market, previousPrices) {
|
||||
this._prevResourcePrices = previousPrices;
|
||||
let now = 0;
|
||||
let before = 0;
|
||||
for (const id of RESOURCE_IDS) {
|
||||
const stats = market.get(id);
|
||||
const weight = Math.max(0, stats ? stats.demand : 0);
|
||||
if (weight <= 0) continue;
|
||||
const price = this.getResourcePrice(id);
|
||||
const previous = previousPrices.get(id) || price;
|
||||
now += weight * price;
|
||||
before += weight * previous;
|
||||
}
|
||||
this.commodityInflation = before > 0 ? now / before - 1 : 0;
|
||||
},
|
||||
|
||||
// The storage a tile has, or null when it is neither a city nor a resource
|
||||
// building. Used both by the simulation and the delivery graph.
|
||||
_resourceNodeAt(coords) {
|
||||
@@ -1152,6 +1179,7 @@ export const resourceMethods = {
|
||||
this.resourceTrade = new Map();
|
||||
this.resourceExternal = new Map();
|
||||
this._resourceShortages = new Map();
|
||||
this._resetTaxLedger();
|
||||
const market = new Map();
|
||||
for (const id of RESOURCE_IDS) market.set(id, { supply: 0, demand: 0, price: this.getResourcePrice(id) });
|
||||
this.resourceMarketStats = market;
|
||||
@@ -1254,7 +1282,11 @@ export const resourceMethods = {
|
||||
this._civilianUpkeepShortage(civ, 1 - paid);
|
||||
}
|
||||
|
||||
// Once a day the world's imbalance sets the next price.
|
||||
// Once a day the world's imbalance sets the next price. The prices it
|
||||
// replaces are kept, so the panel can show each commodity's day-on-day move
|
||||
// and the consumption-weighted inflation it adds up to.
|
||||
const previousPrices = new Map();
|
||||
for (const id of RESOURCE_IDS) previousPrices.set(id, this.getResourcePrice(id));
|
||||
for (const id of RESOURCE_IDS) {
|
||||
const stats = market.get(id);
|
||||
const price = nextMarketPrice(
|
||||
@@ -1266,6 +1298,7 @@ export const resourceMethods = {
|
||||
this.setResourcePrice(id, price);
|
||||
stats.price = price;
|
||||
}
|
||||
this._recordCommodityInflation(market, previousPrices);
|
||||
|
||||
this._applyResourceShortages(ledger);
|
||||
this._pruneResourceStock();
|
||||
@@ -1546,17 +1579,47 @@ export const resourceMethods = {
|
||||
// neighbour in the shared national currency, a foreigner after a trip to
|
||||
// its central bank. Freight rides with the goods.
|
||||
if (foreign) {
|
||||
const acquired = this._regionAcquireCurrency(city, supplier.civ, value, charge, !payer);
|
||||
// The seller's government charges the foreign buyer an export tariff,
|
||||
// in the seller's currency, on top of the goods.
|
||||
const exportTax = value * this.getTaxRate(supplier.civ, "export", id);
|
||||
const acquired = this._regionAcquireCurrency(
|
||||
city, supplier.civ, value + exportTax, charge, !payer
|
||||
);
|
||||
if (!acquired) continue;
|
||||
this._paySupplier(supplier, acquired.code, acquired.amount);
|
||||
const total = value + exportTax;
|
||||
const sellerShare = total > 0 ? value / total : 1;
|
||||
const sellerAmount = acquired.amount * sellerShare;
|
||||
this._paySupplier(supplier, acquired.code, sellerAmount);
|
||||
if (acquired.amount > sellerAmount) {
|
||||
const sellerCity = supplier.city || this._regionCityNear(supplier.civ, supplier.coords);
|
||||
this._recordTax(
|
||||
supplier.civ, "export", id, acquired.amount - sellerAmount, sellerCity
|
||||
);
|
||||
}
|
||||
// The buyer's own government taxes the national currency it had to spend
|
||||
// to buy the foreign currency (nothing held means nothing spent).
|
||||
if (!payer && acquired.spent > 0) {
|
||||
const importRate = this.getTaxRate(city.civ, "import", id);
|
||||
if (importRate > 0) {
|
||||
const importTax = acquired.spent * importRate;
|
||||
charge(importTax);
|
||||
this._recordTax(city.civ, "import", id, importTax, city);
|
||||
}
|
||||
}
|
||||
this._recordResourceTrade(city.civ, supplier.civ, id, bought);
|
||||
this._recordExternalBuy(city.civ, id, bought, value / vBuyer);
|
||||
this._recordExternalSell(supplier.civ, id, bought, value / this.currencyValue(supplier.civ));
|
||||
this._recordCurrencyDemand(supplier.civ, value);
|
||||
this._recordCurrencyDemand(city.civ, -value);
|
||||
} else {
|
||||
charge(value / vBuyer);
|
||||
this._paySupplier(supplier, buyerCode, value / vBuyer);
|
||||
// A region buying from another region of its own country pays a sales
|
||||
// tax on top of the price; the seller still gets the full value, and the
|
||||
// government keeps the tax. Government purchases are not taxed.
|
||||
const homeCost = value / vBuyer;
|
||||
const salesTax = payer ? 0 : homeCost * this.getTaxRate(city.civ, "sales", id);
|
||||
charge(homeCost + salesTax);
|
||||
this._paySupplier(supplier, buyerCode, homeCost);
|
||||
if (salesTax > 0) this._recordTax(city.civ, "sales", id, salesTax, city);
|
||||
}
|
||||
this._nodeClaims.set(
|
||||
key(supplier.coords.x, supplier.coords.y),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { OPINION } from "../data/politics.js";
|
||||
import { EFFECT_RAIL_SPEED, EFFECT_CONSTRUCTION_SPEED, EFFECT_RESEARCH } from "../data/effects.js";
|
||||
import { RESOURCE_IDS } from "../data/resources.js";
|
||||
import { serializeSet } from "./helpers.js";
|
||||
import { HOURS_PER_DAY } from "./constants.js";
|
||||
|
||||
export const serializationMethods = {
|
||||
snapshot(viewerCiv = -1) {
|
||||
@@ -153,6 +154,9 @@ export const serializationMethods = {
|
||||
breakdown: null,
|
||||
currencyHoldings: null,
|
||||
resourceAlerts: [],
|
||||
taxes: null,
|
||||
taxTake: { sales: 0, export: 0, import: 0 },
|
||||
commodityInflation: 0,
|
||||
};
|
||||
}
|
||||
const headline = this._civHeadline(viewerCiv);
|
||||
@@ -199,9 +203,16 @@ export const serializationMethods = {
|
||||
governmentDebt: this.getGovernmentDebt(viewerCiv),
|
||||
// How far the headline figures have grown, against January 2000 in the
|
||||
// first year and year-on-year afterwards.
|
||||
growth: this._growthRates(viewerCiv),
|
||||
};
|
||||
},
|
||||
growth: this._growthRates(viewerCiv),
|
||||
// The nation's trade-tax rates (with per-resource overrides) and what it
|
||||
// collected over the last day, for the Taxes sub-tab.
|
||||
taxes: this.getTaxConfigView(viewerCiv),
|
||||
taxTake: this.getTaxTake(viewerCiv),
|
||||
// The world commodity price move of the last day, weighted by what the
|
||||
// world consumed, for the central bank's inflation indicator.
|
||||
commodityInflation: this.commodityInflation || 0,
|
||||
};
|
||||
},
|
||||
|
||||
// The monthly budget reports, compacted for the wire: category ids replace
|
||||
// the labels the client already knows from the data catalogue.
|
||||
@@ -210,6 +221,7 @@ export const serializationMethods = {
|
||||
monthIndex: report.monthIndex,
|
||||
isCurrent: report.isCurrent,
|
||||
income: report.income,
|
||||
taxes: report.taxes,
|
||||
sales: report.sales,
|
||||
totalCash: report.totalCash,
|
||||
totalEconomic: report.totalEconomic,
|
||||
@@ -409,6 +421,7 @@ export const serializationMethods = {
|
||||
_serializeCityStats() {
|
||||
return this.cities.map((city) => {
|
||||
const economy = this.getCityEconomy(city);
|
||||
const taxes = this.getCityTaxTake(city);
|
||||
return {
|
||||
id: city.id,
|
||||
civ: city.civ,
|
||||
@@ -416,10 +429,11 @@ export const serializationMethods = {
|
||||
isCapital: city.isCapital,
|
||||
population: Math.round(economy.population),
|
||||
gdp: economy.gdp,
|
||||
base: economy.base,
|
||||
modifier: economy.base > 0 ? economy.income / economy.base - 1 : 0,
|
||||
approval: economy.approval,
|
||||
income: economy.income,
|
||||
// The trade taxes the region generated over the last day, in its
|
||||
// currency, and their hourly average.
|
||||
taxes,
|
||||
income: (taxes.sales + taxes.export + taxes.import) / HOURS_PER_DAY,
|
||||
// The region's own income and building upkeep, for the city budget tab.
|
||||
budget: this.cityBudgetBreakdown(city, economy),
|
||||
// The private-sector cash the region holds, a basket of currencies, and
|
||||
@@ -601,6 +615,8 @@ export const serializationMethods = {
|
||||
return {
|
||||
id,
|
||||
price: this.getResourcePrice(id),
|
||||
// Yesterday's price, for the day-on-day arrows and the inflation index.
|
||||
prevPrice: this.getResourcePrevPrice(id),
|
||||
supply: stats ? stats.supply : 0,
|
||||
demand: stats ? stats.demand : 0,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// Trade taxes: the government's only revenue. There is no GDP tax, so the
|
||||
// state lives on what crosses its borders and what its regions trade among
|
||||
// themselves:
|
||||
//
|
||||
// - a national sales tax on every purchase a region makes from another region
|
||||
// of the same country, paid by the buyer on top of the price;
|
||||
// - an export tariff a foreign buyer pays to the seller's government, in the
|
||||
// seller's currency, on top of what the seller receives;
|
||||
// - an import tariff a region pays its own government on the national
|
||||
// currency it spends acquiring a foreign currency for a purchase.
|
||||
//
|
||||
// Rates are set per nation, with an optional per-resource override, so a
|
||||
// government can protect or tax one commodity differently from the rest.
|
||||
|
||||
import { RESOURCE_IDS, TAXES } from "../data.js";
|
||||
import { NEWS_TARIFF } from "../data/relations.js";
|
||||
|
||||
function defaultTaxConfig() {
|
||||
return {
|
||||
sales: TAXES.defaultSales,
|
||||
export: TAXES.defaultExport,
|
||||
import: TAXES.defaultImport,
|
||||
// Per-resource overrides, keyed resource id -> { sales?, export?, import? }.
|
||||
overrides: {},
|
||||
};
|
||||
}
|
||||
|
||||
export const taxMethods = {
|
||||
_initTaxes() {
|
||||
this.taxRates = new Map();
|
||||
// The current day's tax take per nation, in treasury units, reset by the
|
||||
// resource tick alongside the other daily ledgers.
|
||||
this.taxLedger = new Map();
|
||||
// The current day's tax take per region that generated it, so the region
|
||||
// tables can show what each contributes.
|
||||
this.cityTaxLedger = new Map();
|
||||
// The world commodity price index move of the last day, a fraction.
|
||||
this.commodityInflation = 0;
|
||||
this._prevResourcePrices = null;
|
||||
},
|
||||
|
||||
// A nation's live tax config, created with the defaults on first read.
|
||||
getTaxConfig(civ) {
|
||||
if (civ < 0) return null;
|
||||
let config = this.taxRates ? this.taxRates.get(civ) : null;
|
||||
if (!config) {
|
||||
config = defaultTaxConfig();
|
||||
if (!this.taxRates) this.taxRates = new Map();
|
||||
this.taxRates.set(civ, config);
|
||||
}
|
||||
return config;
|
||||
},
|
||||
|
||||
// The rate a nation charges for one tax kind, on one resource: the resource
|
||||
// override when it has one, otherwise the nation's default rate.
|
||||
getTaxRate(civ, kind, resource = null) {
|
||||
const config = this.getTaxConfig(civ);
|
||||
if (!config) return 0;
|
||||
const override = resource && config.overrides[resource]
|
||||
? config.overrides[resource][kind]
|
||||
: undefined;
|
||||
return override === undefined ? (config[kind] || 0) : override;
|
||||
},
|
||||
|
||||
// Sets a nation's default rate for a kind, or a single resource's override.
|
||||
// Returns the value applied.
|
||||
setTaxRate(civ, kind, rate, resource = null) {
|
||||
if (civ < 0 || !TAXES.kinds.includes(kind)) return null;
|
||||
const value = Math.max(0, Math.min(TAXES.maxRate, Number(rate) || 0));
|
||||
const config = this.getTaxConfig(civ);
|
||||
if (resource) {
|
||||
const entry = config.overrides[resource] || (config.overrides[resource] = {});
|
||||
entry[kind] = value;
|
||||
} else {
|
||||
config[kind] = value;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
||||
// A player changes a tax. Changing an import or export tariff notifies the
|
||||
// other nations; the sales tax is purely domestic and passes quietly.
|
||||
requestSetTaxRate(civ, kind, rate, resource = null) {
|
||||
if (civ < 0 || civ >= this.civilisations.length) return false;
|
||||
const value = Number(rate);
|
||||
if (!Number.isFinite(value) || value < 0) return false;
|
||||
if (!TAXES.kinds.includes(kind)) return false;
|
||||
if (resource !== null && !RESOURCE_IDS.includes(resource)) return false;
|
||||
const before = this.getTaxRate(civ, kind, resource);
|
||||
const applied = this.setTaxRate(civ, kind, value, resource);
|
||||
if (applied === null) return false;
|
||||
// A no-op apply (the box was left unchanged) is not news.
|
||||
if (applied !== before && (kind === "import" || kind === "export")) {
|
||||
this._addNews({ type: NEWS_TARIFF, civ, kind, rate: applied, resource: resource || null });
|
||||
}
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
// The viewer's tax config, as plain data: each kind's default rate and the
|
||||
// resource overrides that differ from it.
|
||||
getTaxConfigView(civ) {
|
||||
const config = this.getTaxConfig(civ);
|
||||
const result = {};
|
||||
for (const kind of TAXES.kinds) {
|
||||
const resources = {};
|
||||
for (const id of RESOURCE_IDS) {
|
||||
const override = config.overrides[id] ? config.overrides[id][kind] : undefined;
|
||||
if (override !== undefined) resources[id] = override;
|
||||
}
|
||||
result[kind] = { rate: config[kind] || 0, resources };
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
// Books a tax payment into the treasury and the day's ledgers. `city` is the
|
||||
// region that generated it (the buyer, or the seller for an export tariff).
|
||||
_recordTax(civ, kind, resource, amount, city = null) {
|
||||
if (civ < 0 || !(amount > 0)) return;
|
||||
this.budgets.set(civ, this.getBudget(civ) + amount);
|
||||
const category = kind === "sales" ? "taxSales" : kind === "export" ? "taxExport" : "taxImport";
|
||||
this._recordBudgetCash(civ, category, amount);
|
||||
if (!this.taxLedger) this.taxLedger = new Map();
|
||||
let day = this.taxLedger.get(civ);
|
||||
if (!day) {
|
||||
day = { sales: 0, export: 0, import: 0 };
|
||||
this.taxLedger.set(civ, day);
|
||||
}
|
||||
day[kind] += amount;
|
||||
if (city) {
|
||||
if (!this.cityTaxLedger) this.cityTaxLedger = new Map();
|
||||
let region = this.cityTaxLedger.get(city.id);
|
||||
if (!region) {
|
||||
region = { cityId: city.id, sales: 0, export: 0, import: 0 };
|
||||
this.cityTaxLedger.set(city.id, region);
|
||||
}
|
||||
region[kind] += amount;
|
||||
}
|
||||
},
|
||||
|
||||
// The taxes one nation collected over the last day, in treasury units.
|
||||
getTaxTake(civ) {
|
||||
const day = this.taxLedger ? this.taxLedger.get(civ) : null;
|
||||
return {
|
||||
sales: day ? day.sales : 0,
|
||||
export: day ? day.export : 0,
|
||||
import: day ? day.import : 0,
|
||||
};
|
||||
},
|
||||
|
||||
// The taxes one region generated over the last day, in treasury units.
|
||||
getCityTaxTake(city) {
|
||||
const region = this.cityTaxLedger ? this.cityTaxLedger.get(city.id) : null;
|
||||
return {
|
||||
sales: region ? region.sales : 0,
|
||||
export: region ? region.export : 0,
|
||||
import: region ? region.import : 0,
|
||||
};
|
||||
},
|
||||
|
||||
// Clears the daily tax ledgers, called from the resource day tick.
|
||||
_resetTaxLedger() {
|
||||
this.taxLedger = new Map();
|
||||
this.cityTaxLedger = new Map();
|
||||
},
|
||||
};
|
||||
@@ -18,7 +18,7 @@ export class BudgetTest extends TestCase {
|
||||
const state = smallState();
|
||||
state._spendBudget(0, 1_000, "training");
|
||||
state._spendBudget(0, 4_000, "construction");
|
||||
state._recordBudgetCash(0, "income", 9_000);
|
||||
state._recordTax(0, "sales", "steel", 9_000);
|
||||
const report = state.budgetMonthReports(0).at(-1);
|
||||
const byId = new Map(report.expenses.map((entry) => [entry.id, entry]));
|
||||
this.assertApprox(byId.get("training").cash, 1_000);
|
||||
|
||||
+3
-2
@@ -130,9 +130,10 @@ export class DataTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
test_market_and_bank_are_cheap_starter_buildings() {
|
||||
test_market_is_a_cheap_starter_building() {
|
||||
this.assertEqual(buildingById("market").baseCost, 500_000);
|
||||
this.assertEqual(buildingById("bank").baseCost, 1_000_000);
|
||||
// The tax-raising Bank is gone: the state lives on trade taxes alone.
|
||||
this.assertEqual(buildingById("bank"), null);
|
||||
}
|
||||
|
||||
test_new_buildings_declare_their_mechanics() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { ECONOMY } from "../shared/data.js";
|
||||
import { ECONOMY, TAXES } from "../shared/data.js";
|
||||
import { HOURS_PER_YEAR } from "../shared/game_state.js";
|
||||
|
||||
function config(overrides = {}) {
|
||||
@@ -44,10 +44,10 @@ export class EconomyTest extends TestCase {
|
||||
this.assertEqual(config({ gdpPerProductionPoint: 0 }).trainingHours(1000, 50), 0);
|
||||
}
|
||||
|
||||
test_budget_income_scales_with_hours() {
|
||||
const cfg = config({ budgetGdpRate: 0.001 });
|
||||
this.assertApprox(cfg.budgetIncome(1000), 1);
|
||||
this.assertApprox(cfg.budgetIncome(1000, 24), 24);
|
||||
test_trade_tax_defaults() {
|
||||
this.assertApprox(TAXES.defaultSales, 0.2);
|
||||
this.assertApprox(TAXES.defaultExport, 0.05);
|
||||
this.assertApprox(TAXES.defaultImport, 0.05);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ export class GameScreenTest extends TestCase {
|
||||
const cityStats = state.snapshot(0).cityStats.find((c) => c.id === capital.id);
|
||||
this.assertNotNull(cityStats.budget, "the city stats carry a region budget");
|
||||
const text = env.$("#city-budget-list").text();
|
||||
this.assertTrue(text.includes("Tax income"), "region income is listed");
|
||||
this.assertTrue(text.includes("Trade taxes"), "region tax is listed");
|
||||
this.assertTrue(text.includes("TOTAL"), "the region net is totalled");
|
||||
screen.leave();
|
||||
} finally {
|
||||
@@ -1774,33 +1774,6 @@ export class GameScreenTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_build_tooltip_forecasts_the_income_gain() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1.0e15);
|
||||
screen.onState(state.snapshot(0));
|
||||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||||
screen.map.onCitySelected(capital.id);
|
||||
env.$("#city-manage").click();
|
||||
env.$("#modal-city .tab[data-tab='buildings']").click();
|
||||
const bank = state.protoBuildings.findIndex((b) => b.id === "bank");
|
||||
const $upgrade = env.$("#city-buildings-list .row-card")
|
||||
.eq(bank)
|
||||
.find("button")
|
||||
.first();
|
||||
this.assertTrue(
|
||||
($upgrade.attr("title") || "").includes("Expected budget income"),
|
||||
"the upgrade button forecasts the budget gain"
|
||||
);
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_stat_tooltips_show_the_long_run_growth() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, cityOf, gdpBuilding, grantBuilding } from "./framework/helpers.js";
|
||||
import { key } from "../shared/hex.js";
|
||||
import { ECONOMY, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, RESOURCE_RULES } from "../shared/data.js";
|
||||
import { EFFECT_RESEARCH, RESOURCE_RULES } from "../shared/data.js";
|
||||
import { tileFoodOutput } from "../shared/resources.js";
|
||||
import { DAYS_PER_YEAR } from "../shared/game_state/constants.js";
|
||||
|
||||
@@ -328,35 +328,21 @@ export class GameStateBuildingsTest extends TestCase {
|
||||
breakdown.unitUpkeep.total - breakdown.campaignUpkeep.total -
|
||||
breakdown.transportUpkeep.total - breakdown.tileUpkeep.total;
|
||||
this.assertApprox(breakdown.net, expected_net, 1e-6);
|
||||
const income = state.getPlayerGdp(0) * ECONOMY.budgetGdpRate * (1 + breakdown.income.modifier);
|
||||
this.assertApprox(breakdown.income.total, income, 1e-6);
|
||||
// Income is the trade tax collected over the day, as an hourly average.
|
||||
this.assertApprox(breakdown.income.total, state.getCivHourlyIncome(0), 1e-6);
|
||||
}
|
||||
|
||||
test_budget_breakdown_names_income_sources() {
|
||||
test_budget_breakdown_names_tax_sources() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const effect = { stat: EFFECT_BUDGET_INCOME, baseValue: 0.05, exponent: 1.0 };
|
||||
const index = this.addBuilding(
|
||||
state,
|
||||
gdpBuilding({ baseCost: 0, effects: [effect] })
|
||||
);
|
||||
const city = cityOf(state, 0);
|
||||
this.assertTrue(state.requestBuild(city.id, index));
|
||||
state._recordTax(0, "sales", "steel", 240, city);
|
||||
const breakdown = state.viewerStats(0).breakdown;
|
||||
const source = breakdown.income.sources.find(
|
||||
(s) => s.label === "Test market" && s.cityId === city.id
|
||||
);
|
||||
this.assertTrue(source, "the building appears as an income source");
|
||||
this.assertEqual(source.kind, "building");
|
||||
this.assertApprox(source.effect, 0.05);
|
||||
// A building's bonus is measured against its own region, not the nation.
|
||||
const region = breakdown.income.regions.find((r) => r.cityId === city.id);
|
||||
this.assertApprox(source.amount, region.base * 0.05);
|
||||
this.assertApprox(
|
||||
breakdown.income.modifier,
|
||||
(breakdown.income.total - breakdown.income.base) / breakdown.income.base
|
||||
);
|
||||
this.assertApprox(breakdown.income.total, breakdown.income.base * (1 + breakdown.income.modifier));
|
||||
const source = breakdown.income.sources.find((s) => s.label === "National sales tax");
|
||||
this.assertTrue(source, "the sales tax appears as an income source");
|
||||
this.assertEqual(source.kind, "tax");
|
||||
this.assertApprox(source.amount, 10);
|
||||
this.assertApprox(breakdown.income.total, 10);
|
||||
}
|
||||
|
||||
test_snapshot_exposes_buildings_and_resources() {
|
||||
|
||||
+78
-14
@@ -576,19 +576,23 @@ export class NationModalTest extends TestCase {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const modal = new NationModal();
|
||||
const month = (overrides) => ({
|
||||
monthIndex: 24000,
|
||||
isCurrent: true,
|
||||
label: "January 2000",
|
||||
income: 0,
|
||||
sales: 0,
|
||||
totalCash: 0,
|
||||
totalEconomic: 0,
|
||||
net: 0,
|
||||
expenses: [],
|
||||
resourceTrade: [],
|
||||
...overrides,
|
||||
});
|
||||
const month = (overrides) => {
|
||||
const income = overrides.income || 0;
|
||||
return {
|
||||
monthIndex: 24000,
|
||||
isCurrent: true,
|
||||
label: "January 2000",
|
||||
income,
|
||||
taxes: { sales: income, export: 0, import: 0 },
|
||||
sales: 0,
|
||||
totalCash: 0,
|
||||
totalEconomic: 0,
|
||||
net: 0,
|
||||
expenses: [],
|
||||
resourceTrade: [],
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
const finances = {
|
||||
months: [
|
||||
month({
|
||||
@@ -625,7 +629,8 @@ export class NationModalTest extends TestCase {
|
||||
this.assertSize(env.$("#budget-list table.budget-table"), 1);
|
||||
|
||||
const text = env.$("#budget-list").text();
|
||||
this.assertTrue(text.includes("+¤1 035"), "the month's tax income is shown");
|
||||
this.assertTrue(text.includes("National sales tax"), "the month's taxes are shown");
|
||||
this.assertTrue(text.includes("+¤1 035"), "the tax take is shown");
|
||||
this.assertTrue(text.includes("Resource sales"), "resource sales are shown");
|
||||
this.assertTrue(text.includes("Building upkeep"), "a building category is listed");
|
||||
this.assertTrue(text.includes("−¤700"), "the rightmost column is the market value");
|
||||
@@ -817,6 +822,14 @@ export class NationModalTest extends TestCase {
|
||||
loans: 987654,
|
||||
},
|
||||
privateCash: 5000,
|
||||
// The trade-tax rates, the day's take and the commodity inflation.
|
||||
taxes: {
|
||||
sales: { rate: 0.2, resources: { steel: 0.1 } },
|
||||
export: { rate: 0.05, resources: {} },
|
||||
import: { rate: 0.05, resources: {} },
|
||||
},
|
||||
takeTax: { sales: 1200, export: 300, import: 100 },
|
||||
commodityInflation: 0.0123,
|
||||
cities: [{ id: 1, civ: 0, name: "Paris", cashValue: 5000 }],
|
||||
currencyHoldings: {
|
||||
code: "FRF",
|
||||
@@ -858,10 +871,28 @@ export class NationModalTest extends TestCase {
|
||||
this.assertTrue(summary.includes("Paris"), "the region is listed");
|
||||
this.assertTrue(summary.includes("5 000"), "its private cash is shown");
|
||||
|
||||
env.$("#economy-subtabs .subtab[data-view=taxes]").click();
|
||||
const taxesText = env.$("#economy-taxes").text();
|
||||
this.assertTrue(taxesText.includes("Sales tax"), "the sales tax is listed");
|
||||
this.assertTrue(taxesText.includes("Export tariff"), "the export tariff is listed");
|
||||
this.assertTrue(taxesText.includes("Import tariff"), "the import tariff is listed");
|
||||
this.assertEqual(
|
||||
env.$("#economy-taxes .tax-input[data-kind=sales]").first().val(), "20.00",
|
||||
"the default sales rate is shown"
|
||||
);
|
||||
this.assertEqual(
|
||||
env.$("#economy-taxes .tax-input[data-kind=sales][data-resource='steel']").val(), "10.00",
|
||||
"the per-resource override is shown"
|
||||
);
|
||||
this.assertTrue(
|
||||
env.$("#economy-sub").text().includes("1 600"), "the day's total take is shown"
|
||||
);
|
||||
|
||||
env.$("#economy-subtabs .subtab[data-view=centralbank]").click();
|
||||
const bank = env.$("#economy-central-bank").text();
|
||||
this.assertTrue(bank.includes("British Pound"), "the foreign reserve is listed");
|
||||
this.assertTrue(bank.includes("987 654"), "the outstanding loans are listed");
|
||||
this.assertTrue(bank.includes("Commodity inflation"), "the inflation indicator is shown");
|
||||
this.assertTrue(env.$("#cb-rate").length === 1, "the rate input is present");
|
||||
|
||||
env.$("#economy-subtabs .subtab[data-view=rates]").click();
|
||||
@@ -891,6 +922,39 @@ export class NationModalTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
export class ResourceTrendTest extends TestCase {
|
||||
async test_resources_tab_marks_day_on_day_changes() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const modal = new NationModal();
|
||||
const info = (price, totalHours) => ({
|
||||
totalHours,
|
||||
summary: {
|
||||
stock: { food: 100, steel: 100, luxury: 100, hightech: 0 },
|
||||
consumed: { food: 10, steel: 10, luxury: 10, hightech: 0, energy: 100 },
|
||||
production: { food: 10, steel: 10, luxury: 10, hightech: 0 },
|
||||
energy: { need: 100, supply: 100 },
|
||||
trade: { imports: {}, exports: {} },
|
||||
},
|
||||
market: [{ id: "food", price, prevPrice: price, supply: 1000, demand: 1000 }],
|
||||
trade: [],
|
||||
civs: [{ name: "France" }],
|
||||
});
|
||||
modal._renderResources(info(100, 0));
|
||||
this.assertEqual(
|
||||
env.$("#resources-list .stat-arrow").length, 0,
|
||||
"the first day has no baseline to compare against"
|
||||
);
|
||||
modal._renderResources(info(120, 24));
|
||||
this.assertTrue(
|
||||
env.$("#resources-list .stat-arrow").length > 0, "the second day marks the changes"
|
||||
);
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfirmModalTest extends TestCase {
|
||||
async test_confirm_runs_the_callback_and_cancel_does_not() {
|
||||
const env = await setupDom();
|
||||
|
||||
@@ -229,21 +229,19 @@ export class PoliticsModelTest extends TestCase {
|
||||
this.assertLess(after, before, "the newly held, hostile people pull approval down");
|
||||
}
|
||||
|
||||
test_low_approval_slows_building_and_taxes() {
|
||||
test_low_approval_slows_building() {
|
||||
const state = smallState();
|
||||
const city = state.cities.find((c) => c.civ === 0);
|
||||
const baseline = state.getCityEconomy(city).income;
|
||||
for (let eth = 0; eth < state.civilisations.length; eth++) {
|
||||
state._shiftGovOpinion(eth, 0, -0.5);
|
||||
}
|
||||
const approval = state.getCityApproval(city);
|
||||
this.assertLess(approval, APPROVAL.rebellionThreshold, "the region turned against the government");
|
||||
this.assertGreater(state.constructionApprovalMultiplier(city), 1, "building slows down");
|
||||
this.assertLess(state.getCityEconomy(city).income, baseline, "the tax take drops");
|
||||
this.assertLess(
|
||||
state.taxApprovalMultiplier(city),
|
||||
1,
|
||||
"the tax multiplier is penalised"
|
||||
"the approval multiplier is penalised"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
TERRAIN_TILES,
|
||||
MAP_CONFIG,
|
||||
ECONOMY,
|
||||
TAXES,
|
||||
BATTLE,
|
||||
effectValue,
|
||||
pillageSteps,
|
||||
@@ -201,7 +202,7 @@ export class CatalogueSplitTest extends TestCase {
|
||||
this.assertNotEmpty(TECHNOLOGIES);
|
||||
this.assertNotEmpty(TERRAIN_TILES);
|
||||
this.assertEqual(typeof MAP_CONFIG.mapSize.x, "number");
|
||||
this.assertEqual(typeof ECONOMY.budgetGdpRate, "number");
|
||||
this.assertEqual(typeof TAXES.defaultSales, "number");
|
||||
this.assertEqual(typeof BATTLE.recoveryPerDay, "number");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, cityOf } from "./framework/helpers.js";
|
||||
import { key } from "../shared/hex.js";
|
||||
import { TAXES } from "../shared/data.js";
|
||||
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
|
||||
|
||||
// Trade taxes are the government's only revenue, so these lock in who pays
|
||||
// what: a region pays a sales tax on home purchases, a foreign buyer pays an
|
||||
// export tariff to the seller's government, and a region pays an import tariff
|
||||
// on the national currency it spends buying a foreign currency.
|
||||
|
||||
// A buyer with a road to its land neighbours, so crafted suppliers are
|
||||
// reachable over the transport network.
|
||||
function reachableBuyer(state) {
|
||||
const buyer = cityOf(state, 0);
|
||||
const land = state.topology.neighbours(buyer.coords.x, buyer.coords.y)
|
||||
.filter((n) => state._isLand(n));
|
||||
for (const n of land) state.roads.add(key(n.x, n.y));
|
||||
return { buyer, land };
|
||||
}
|
||||
|
||||
export class TaxesTest extends TestCase {
|
||||
test_rates_default_and_override_per_resource() {
|
||||
const state = smallState();
|
||||
this.assertApprox(state.getTaxRate(0, "sales"), TAXES.defaultSales);
|
||||
this.assertApprox(state.getTaxRate(0, "export"), TAXES.defaultExport);
|
||||
this.assertApprox(state.getTaxRate(0, "import"), TAXES.defaultImport);
|
||||
state.setTaxRate(0, "sales", 0.3);
|
||||
this.assertApprox(state.getTaxRate(0, "sales"), 0.3);
|
||||
this.assertApprox(state.getTaxRate(0, "sales", "food"), 0.3);
|
||||
state.setTaxRate(0, "sales", 0.1, "steel");
|
||||
this.assertApprox(state.getTaxRate(0, "sales", "steel"), 0.1);
|
||||
this.assertApprox(state.getTaxRate(0, "sales", "food"), 0.3);
|
||||
// A change is clamped to the catalogue ceiling.
|
||||
state.setTaxRate(0, "export", 5);
|
||||
this.assertApprox(state.getTaxRate(0, "export"), TAXES.maxRate);
|
||||
}
|
||||
|
||||
test_a_sales_tax_falls_on_a_home_purchase() {
|
||||
const state = smallState();
|
||||
const { buyer, land } = reachableBuyer(state);
|
||||
const homeNode = { coords: land[0], civ: 0, stock: { food: 1e9 }, kind: "building" };
|
||||
const buyerStock = state.getCityResourceStock(buyer);
|
||||
buyerStock.food = 0;
|
||||
const treasuryBefore = state.getBudget(0);
|
||||
const bought = state._procureFromNeighbours(buyer, "food", 1000, buyerStock, [homeNode]);
|
||||
this.assertGreater(bought, 0, "the home supplier sold food");
|
||||
const tax = state.getTaxTake(0).sales;
|
||||
this.assertGreater(tax, 0, "the sales tax was charged");
|
||||
this.assertApprox(state.getBudget(0) - treasuryBefore, tax, 1e-6, "the tax reached the treasury");
|
||||
}
|
||||
|
||||
test_an_export_tariff_falls_on_a_foreign_buyer() {
|
||||
const state = smallState();
|
||||
const { buyer, land } = reachableBuyer(state);
|
||||
const foreignNode = { coords: land[0], civ: 1, stock: { food: 1e9 }, kind: "building" };
|
||||
const buyerStock = state.getCityResourceStock(buyer);
|
||||
buyerStock.food = 0;
|
||||
const treasuryBefore = state.getBudget(1);
|
||||
const bought = state._procureFromNeighbours(buyer, "food", 1000, buyerStock, [foreignNode]);
|
||||
this.assertGreater(bought, 0, "the foreign region sold food");
|
||||
const tax = state.getTaxTake(1).export;
|
||||
this.assertGreater(tax, 0, "the export tariff was charged");
|
||||
this.assertApprox(
|
||||
state.getBudget(1) - treasuryBefore, tax, 1e-6, "the tariff reached the seller's treasury"
|
||||
);
|
||||
this.assertEqual(state.getTaxTake(1).sales, 0, "no sales tax crosses a border");
|
||||
}
|
||||
|
||||
test_an_import_tariff_falls_on_the_buyer_currency_spent() {
|
||||
const state = smallState();
|
||||
const { buyer, land } = reachableBuyer(state);
|
||||
const foreignNode = { coords: land[0], civ: 1, stock: { food: 1e9 }, kind: "building" };
|
||||
const buyerStock = state.getCityResourceStock(buyer);
|
||||
buyerStock.food = 0;
|
||||
const treasuryBefore = state.getBudget(0);
|
||||
const bought = state._procureFromNeighbours(buyer, "food", 1000, buyerStock, [foreignNode]);
|
||||
this.assertGreater(bought, 0, "the foreign region sold food");
|
||||
const tax = state.getTaxTake(0).import;
|
||||
this.assertGreater(tax, 0, "the import tariff was charged");
|
||||
this.assertApprox(
|
||||
state.getBudget(0) - treasuryBefore, tax, 1e-6, "the tariff reached the buyer's treasury"
|
||||
);
|
||||
}
|
||||
|
||||
test_holding_the_foreign_currency_avoids_the_import_tariff() {
|
||||
const state = smallState();
|
||||
const { buyer, land } = reachableBuyer(state);
|
||||
const foreignNode = { coords: land[0], civ: 1, stock: { food: 1e9 }, kind: "building" };
|
||||
const buyerStock = state.getCityResourceStock(buyer);
|
||||
buyerStock.food = 0;
|
||||
// Fill the buyer's basket with the seller's currency: nothing of the buyer's
|
||||
// own currency need be spent, so nothing is taxed.
|
||||
const code = state.currencyOf(1).code;
|
||||
state.getRegionCash(buyer).set(code, 1e12);
|
||||
const bought = state._procureFromNeighbours(buyer, "food", 1000, buyerStock, [foreignNode]);
|
||||
this.assertGreater(bought, 0, "the foreign region sold food");
|
||||
this.assertApprox(state.getTaxTake(0).import, 0, 1e-6, "no currency was spent, no tariff");
|
||||
}
|
||||
|
||||
test_only_tariff_changes_are_news() {
|
||||
const state = smallState();
|
||||
const before = state.news.length;
|
||||
state.requestSetTaxRate(0, "sales", 0.3);
|
||||
state.requestSetTaxRate(0, "import", 0.08);
|
||||
state.requestSetTaxRate(0, "export", 0.07, "steel");
|
||||
const news = state.news.slice(before);
|
||||
this.assertSize(news.filter((entry) => entry.type === "tariff"), 2, "two tariff notices");
|
||||
const kinds = news.map((entry) => entry.kind).sort();
|
||||
this.assertEqual(kinds.join(","), "export,import");
|
||||
}
|
||||
|
||||
test_commodity_inflation_is_weighted_by_consumption() {
|
||||
const state = smallState();
|
||||
const previous = new Map();
|
||||
for (const id of state.resourcePrices.keys()) {
|
||||
previous.set(id, state.getResourcePrice(id));
|
||||
}
|
||||
// Steel is consumed (weight 100) and doubles; nothing else is demanded.
|
||||
const market = new Map();
|
||||
for (const id of previous.keys()) market.set(id, { id, demand: 0 });
|
||||
market.get("steel").demand = 100;
|
||||
state.setResourcePrice("steel", state.getResourcePrice("steel") * 2);
|
||||
state._recordCommodityInflation(market, previous);
|
||||
this.assertApprox(state.commodityInflation, 1.0, 1e-9, "a doubling reads as 100%");
|
||||
}
|
||||
|
||||
test_viewer_stats_carry_taxes_and_inflation() {
|
||||
const state = smallState();
|
||||
state.setTaxRate(0, "sales", 0.25);
|
||||
state._recordTax(0, "sales", "steel", 480);
|
||||
const stats = state.viewerStats(0);
|
||||
this.assertApprox(stats.taxes.sales.rate, 0.25);
|
||||
this.assertApprox(stats.taxTake.sales, 480);
|
||||
this.assertEqual(typeof stats.commodityInflation, "number");
|
||||
for (let h = 0; h < HOURS_PER_DAY; h++) state.advanceHour();
|
||||
const market = state.snapshot(0).resourceMarket;
|
||||
this.assertTrue(market.every((entry) => typeof entry.prevPrice === "number"),
|
||||
"every market row ships yesterday's price");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user