Replaced the GDP tax with national sales and tariff taxes

This commit is contained in:
2026-09-22 07:21:07 +02:00
parent 91dfedf7ec
commit b6287d7cf0
31 changed files with 830 additions and 336 deletions
+2
View File
@@ -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; }
+1
View File
@@ -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>
+12 -4
View File
@@ -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
View File
@@ -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 -33
View File
@@ -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.
+9 -3
View File
@@ -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
View File
@@ -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."
)
);
}
+22
View File
@@ -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;
}
+4
View File
@@ -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 -12
View File
@@ -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
View File
@@ -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;
-1
View File
@@ -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";
+3 -3
View File
@@ -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",
+3
View File
@@ -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";
+2 -3
View File
@@ -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",
+4 -1
View File
@@ -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
);
+10 -2
View File
@@ -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
View File
@@ -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");
}
},
+8 -3
View File
@@ -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 --
+68 -5
View File
@@ -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),
+22 -6
View File
@@ -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,
};
+165
View File
@@ -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();
},
};
+1 -1
View File
@@ -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
View File
@@ -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() {
+5 -5
View File
@@ -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);
}
}
+1 -28
View File
@@ -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 {
+10 -24
View File
@@ -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
View File
@@ -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();
+2 -4
View File
@@ -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"
);
}
+2 -1
View File
@@ -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");
}
+141
View File
@@ -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");
}
}