").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");
+ }
+}