diff --git a/client/css/style.css b/client/css/style.css index 748238d..5b3be64 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -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; } diff --git a/client/index.html b/client/index.html index 083806f..a9bf023 100644 --- a/client/index.html +++ b/client/index.html @@ -358,6 +358,7 @@ + diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js index 7555664..489aa80 100644 --- a/client/js/game_screen/panels.js +++ b/client/js/game_screen/panels.js @@ -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, }; }, diff --git a/client/js/modals/budget.js b/client/js/modals/budget.js index b031768..042b867 100644 --- a/client/js/modals/budget.js +++ b/client/js/modals/budget.js @@ -31,31 +31,18 @@ export function renderBudget($list, breakdown = {}, { expanded = new Set(), onTo const $body = $(""); $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 = $(""); $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)); } diff --git a/client/js/modals/city.js b/client/js/modals/city.js index 7848029..49146e9 100644 --- a/client/js/modals/city.js +++ b/client/js/modals/city.js @@ -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. diff --git a/client/js/modals/format.js b/client/js/modals/format.js index 74771d4..3296e49 100644 --- a/client/js/modals/format.js +++ b/client/js/modals/format.js @@ -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; } diff --git a/client/js/modals/nation.js b/client/js/modals/nation.js index 07a0f29..5988658 100644 --- a/client/js/modals/nation.js +++ b/client/js/modals/nation.js @@ -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( + $("
").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 = $("
"); + const $head = $(""); + $head.append($("").text("Tax")); + $head.append($("").text("Default")); + for (const proto of RESOURCES) { + $head.append($("").text(proto.name)); + } + $head.append($("").text("")); + $table.append($("").append($head)); + const $body = $(""); + for (const kind of kinds) { + const $tr = $(""); + $tr.append($("").text(kind.label)); + $tr.append( + $("").append( + $("") + .addClass("tax-input") + .attr("data-kind", kind.id) + ) + ); + for (const proto of RESOURCES) { + $tr.append( + $("").append( + $("") + .addClass("tax-input tax-override") + .attr("data-kind", kind.id) + .attr("data-resource", proto.id) + ) + ); + } + const $apply = $("").text("Set"); + $apply.on("click", () => this._applyTaxRow(kind.id)); + $tr.append($("").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($("
")); this.$economyCentralBank.append($("
")); const $reserves = $("
"); 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 = $("
"); const $head = $(""); @@ -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 = $("").text(text); + const arrow = trendArrow(value, baseline[key], options); + if (arrow) $td.append(arrow); + $tr.append($td); + }; const $table = $("
"); const columns = [ { label: "Resource" }, @@ -744,22 +883,20 @@ export class NationModal { const $body = $(""); 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 = $(""); const $name = $(""); $name.append( @@ -769,24 +906,23 @@ export class NationModal { ); $name.append($("").text(` ${proto.name}`)); $tr.append($name); - $tr.append($("").text(stored)); - $tr.append($("").text(consumed)); - $tr.append($("").text(made)); - $tr.append($("").text(exported)); - $tr.append($("").text(imported)); - $tr.append($("").text(this._resourcePriceText(proto, stats.price))); - $tr.append( - $("").text( - formatResourceAmount(proto.id, Math.max(0, stats.supply || 0)) - ) - ); - $tr.append( - $("").text( - formatResourceAmount(proto.id, Math.max(0, stats.demand || 0)) - ) - ); + if (energy) { + $tr.append($("").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." ) ); } diff --git a/client/js/ui/trend.js b/client/js/ui/trend.js new file mode 100644 index 0000000..25c9aef --- /dev/null +++ b/client/js/ui/trend.js @@ -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 = $(""); + 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; +} diff --git a/server/game_server.js b/server/game_server.js index b730943..4671cd1 100644 --- a/server/game_server.js +++ b/server/game_server.js @@ -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; } diff --git a/shared/data/buildings.js b/shared/data/buildings.js index 2ead9b1..3bee19d 100644 --- a/shared/data/buildings.js +++ b/shared/data/buildings.js @@ -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", diff --git a/shared/data/economy.js b/shared/data/economy.js index bee9817..535e4cc 100644 --- a/shared/data/economy.js +++ b/shared/data/economy.js @@ -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; diff --git a/shared/data/effects.js b/shared/data/effects.js index f5ba9b5..faf3b40 100644 --- a/shared/data/effects.js +++ b/shared/data/effects.js @@ -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"; diff --git a/shared/data/governments.js b/shared/data/governments.js index e2118fa..840cc2d 100644 --- a/shared/data/governments.js +++ b/shared/data/governments.js @@ -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", diff --git a/shared/data/relations.js b/shared/data/relations.js index fd33d69..6eac2d7 100644 --- a/shared/data/relations.js +++ b/shared/data/relations.js @@ -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"; diff --git a/shared/data/technologies.js b/shared/data/technologies.js index e91b401..fd6568f 100644 --- a/shared/data/technologies.js +++ b/shared/data/technologies.js @@ -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", diff --git a/shared/game_state.js b/shared/game_state.js index 27be18a..85b51de 100644 --- a/shared/game_state.js +++ b/shared/game_state.js @@ -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 ); diff --git a/shared/game_state/budget.js b/shared/game_state/budget.js index 5212066..960a630 100644 --- a/shared/game_state/budget.js +++ b/shared/game_state/budget.js @@ -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, diff --git a/shared/game_state/economy.js b/shared/game_state/economy.js index eeb9a51..aa0eb71 100644 --- a/shared/game_state/economy.js +++ b/shared/game_state/economy.js @@ -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"); } }, diff --git a/shared/game_state/money.js b/shared/game_state/money.js index 71e784e..c9961d5 100644 --- a/shared/game_state/money.js +++ b/shared/game_state/money.js @@ -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 -- diff --git a/shared/game_state/resources.js b/shared/game_state/resources.js index f50bb50..4c6c076 100644 --- a/shared/game_state/resources.js +++ b/shared/game_state/resources.js @@ -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), diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js index 99a0bf6..5cc438e 100644 --- a/shared/game_state/serialization.js +++ b/shared/game_state/serialization.js @@ -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, }; diff --git a/shared/game_state/taxes.js b/shared/game_state/taxes.js new file mode 100644 index 0000000..13e9f36 --- /dev/null +++ b/shared/game_state/taxes.js @@ -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(); + }, +}; diff --git a/tests/budget_test.js b/tests/budget_test.js index be31007..c98cbd0 100644 --- a/tests/budget_test.js +++ b/tests/budget_test.js @@ -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); diff --git a/tests/data_test.js b/tests/data_test.js index 49e45ce..0f42646 100644 --- a/tests/data_test.js +++ b/tests/data_test.js @@ -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() { diff --git a/tests/economy_test.js b/tests/economy_test.js index e8579e4..b1d68a9 100644 --- a/tests/economy_test.js +++ b/tests/economy_test.js @@ -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); } } diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js index 5118387..3886c52 100644 --- a/tests/game_screen_test.js +++ b/tests/game_screen_test.js @@ -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 { diff --git a/tests/game_state_buildings_test.js b/tests/game_state_buildings_test.js index e0db3eb..73e625b 100644 --- a/tests/game_state_buildings_test.js +++ b/tests/game_state_buildings_test.js @@ -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() { diff --git a/tests/modals_test.js b/tests/modals_test.js index 6c6515f..d844154 100644 --- a/tests/modals_test.js +++ b/tests/modals_test.js @@ -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(); diff --git a/tests/politics_test.js b/tests/politics_test.js index d4c468d..4676d99 100644 --- a/tests/politics_test.js +++ b/tests/politics_test.js @@ -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" ); } diff --git a/tests/regression_test.js b/tests/regression_test.js index a4adf25..30ff611 100644 --- a/tests/regression_test.js +++ b/tests/regression_test.js @@ -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"); } diff --git a/tests/taxes_test.js b/tests/taxes_test.js new file mode 100644 index 0000000..2d5a7f9 --- /dev/null +++ b/tests/taxes_test.js @@ -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"); + } +}