Files
Battle-for-Tismo/tests/simple_taxes_test.js
T
adrien d5305da449 Gave the world a simple economy: one pool per nation and a global market
The simple economy is now the default and the old simulation is frozen as the hard model, selectable per game. A nation keeps one resource pool available in every region, fed by public production works rather than private per-region stores, and construction and training begin at once, paced by the region's spare power instead of gathering materials over days.

Money is euro only: a per-inhabitant daily tax funds a treasury spent in a global market, where buy and sell lots trade against the world stock and move its price. The hard economy keeps its per-nation currencies, central banks and exchange rates.

Simple games skip the price-settling warmup and economic migration; the snapshot carries the economy model, the market and the pool figures the client draws. Added the matching test suites and the design briefs under docs/simple_economy.
2026-09-25 14:58:49 +02:00

270 lines
11 KiB
JavaScript

// Money and taxes in the simple economy (05-money-and-taxes.md): one euro for
// every nation, a fixed opening treasury, a single daily approval tax, no
// internal money use and propaganda funded by culture instead of the treasury.
// The hard economy keeps its currencies, trade taxes and money campaigns, so
// those paths are pinned here too.
import { TestCase } from "./framework/test_case.js";
import { simpleState, smallState, cityOf, smallConfig, SEED } from "./framework/helpers.js";
import { GameServer } from "../server/game_server.js";
import { SIMPLE_ECONOMY, PROPAGANDA } from "../shared/data.js";
function fakeNetwork() {
return { getPeerIds: () => [], send: () => {} };
}
function configuredServer(economyModel) {
const server = new GameServer(fakeNetwork());
server.configureGame({
seed: SEED,
player_civ: 0,
civilisations: ["france", "britain"],
mapConfig: smallConfig(),
economyModel,
});
server.peerCiv.set(1, 0);
return server;
}
// The day's approval tax for one nation, computed from the live figures the
// collection itself reads, so the two can be compared exactly.
function expectedApprovalTax(state, civ) {
let total = 0;
for (const city of state.cities) {
if (city.civ !== civ) continue;
const population = state.getCityPopulation(city);
const approval = Math.max(0, Math.min(1, state.getCityApproval(city)));
const factor = Math.min(
SIMPLE_ECONOMY.approvalTaxFactor,
approval * SIMPLE_ECONOMY.approvalTaxFactor
);
total += population * SIMPLE_ECONOMY.taxPerInhabitant * factor * state.taxIncomeModifier(civ);
}
return total;
}
export class SimpleTaxesTest extends TestCase {
test_a_simple_game_quotes_everything_in_euros() {
const state = simpleState();
for (const civ of [0, 1]) {
this.assertEqual(state.currencyOf(civ).code, "EUR", `civ ${civ} uses the euro`);
this.assertEqual(state.currencyOf(civ).symbol, "€", `civ ${civ} shows the euro sign`);
this.assertEqual(state.currencyValue(civ), 1, `civ ${civ} is at par`);
}
this.assertEqual(state.exchangeRate(0, 1), 1, "there is no exchange rate");
this.assertEqual(state.currencyValueOf("USD"), 1, "every code is at par");
const book = state._serializeCurrencies();
for (const entry of book) {
this.assertEqual(entry.code, "EUR", "the currency book lists only euros");
this.assertEqual(entry.value, 1, "and always at par");
}
this.assertEqual(
state.viewerStats(0).currency.code,
"EUR",
"the viewer's money quote is the euro"
);
}
test_no_central_bank_or_private_cash_is_seeded() {
const state = simpleState();
this.assertEqual(state.regionCash.size, 0, "regions hold no money");
for (const civ of [0, 1]) {
this.assertEqual(state.getCentralBankRate(civ), 0, "there is no interest rate");
this.assertSize(state.getCentralBankReserves(civ), 0, "the bank holds no reserves");
this.assertEqual(state.getPrivateSectorCash(civ), 0, "the private sector holds no money");
}
}
test_the_simple_tick_runs_no_fx_or_central_bank_work() {
const state = simpleState();
state.applyStartingScenario();
for (let hour = 0; hour < 48; hour++) state.advanceHour();
for (const civ of [0, 1]) {
this.assertEqual(state.currencyValue(civ), 1, "the currency never floats");
this.assertEqual(state.getCentralBankRate(civ), 0, "the bank never lends");
}
this.assertEqual(state.regionDebt.size, 0, "no region borrows");
}
test_a_simple_nation_opens_with_the_fixed_treasury() {
const state = simpleState();
for (let civ = 0; civ < state.civilisations.length; civ++) {
this.assertEqual(
state.getBudget(civ),
SIMPLE_ECONOMY.startingTreasury,
`civ ${civ}'s opening treasury`
);
}
}
test_the_daily_tax_is_population_times_one_hundred_times_double_approval() {
const state = simpleState();
state.applyStartingScenario();
const before = state.getBudget(0);
const expected = expectedApprovalTax(state, 0);
this.assertGreater(expected, 0, "the fixture has people who pay tax");
// Collect in isolation so a day's drifting opinions cannot move the figure
// between the expectation and the collection.
state._resetTaxLedger();
state._tickApprovalTaxes();
this.assertApprox(state.getBudget(0) - before, expected, 1e-6, "the treasury gained the tax");
this.assertApprox(state.getTaxTake(0).income, expected, 1e-6, "and the ledger recorded it");
}
test_the_tax_ledger_reports_one_income_source() {
const state = simpleState();
state.applyStartingScenario();
state._resetTaxLedger();
state._tickApprovalTaxes();
const take = state.getTaxTake(0);
this.assertEqual(take.sales, 0, "no sales tax exists");
this.assertEqual(take.export, 0, "no export tariff exists");
this.assertEqual(take.import, 0, "no import tariff exists");
const income = take.income;
this.assertGreater(income, 0, "the day's income was booked");
this.assertApprox(state.getCivHourlyIncome(0), income / 24, 1e-6, "income is the day over 24");
const breakdown = state.budgetBreakdown(0);
this.assertApprox(breakdown.income.total, income / 24, 1e-6, "the breakdown matches");
this.assertEqual(breakdown.income.sources.length, 1, "there is exactly one income line");
this.assertEqual(breakdown.income.sources[0].label, "Tax income", "labelled as income");
const city = cityOf(state, 0);
const region = state.cityBudgetBreakdown(city);
this.assertApprox(region.income.total, state.getCityTaxTake(city).income / 24, 1e-6, "per region");
}
test_the_treasury_grows_a_day_at_a_time() {
const state = simpleState();
state.applyStartingScenario();
const before = state.getBudget(0);
for (let hour = 0; hour < 24; hour++) state.advanceHour();
const after = state.getBudget(0);
this.assertGreater(after, before, "a full day added tax revenue");
this.assertApprox(after - before, state.getTaxTake(0).income, 1e-6, "exactly the day's take");
}
test_the_simple_game_refuses_tax_and_interest_controls() {
const state = simpleState();
this.assertFalse(state.requestSetTaxRate(0, "sales", 0.5), "the sales rate is fixed");
this.assertFalse(state.requestSetTaxRate(0, "import", 0.5), "the import rate is fixed");
this.assertFalse(state.requestSetInterestRate(0, 0.1), "there is no interest rate to set");
const view = state.getTaxConfigView(0);
for (const kind of ["sales", "export", "import"]) {
this.assertEqual(view[kind].rate, 0, `the ${kind} rate is zero`);
this.assertSize(Object.keys(view[kind].resources), 0, `no ${kind} overrides exist`);
}
}
test_the_server_refuses_tax_and_interest_orders_in_a_simple_game() {
const server = configuredServer("simple");
this.assertFalse(
server.handleOrder(1, { type: "set_tax_rate", kind: "sales", rate: 0.3 }),
"set_tax_rate is refused"
);
this.assertFalse(
server.handleOrder(1, { type: "set_interest_rate", rate: 0.1 }),
"set_interest_rate is refused"
);
}
test_the_hard_server_still_accepts_tax_and_interest_orders() {
const server = configuredServer("hard");
this.assertTrue(
server.handleOrder(1, { type: "set_tax_rate", kind: "sales", rate: 0.3 }),
"the hard model keeps its tax controls"
);
this.assertTrue(
server.handleOrder(1, { type: "set_interest_rate", rate: 0.1 }),
"and its central bank"
);
}
test_construction_and_training_do_not_touch_the_treasury() {
const state = simpleState();
state.applyStartingScenario();
const pool = state.getResourcePool(0);
pool.steel = 1e9;
pool.hightech = 1e6;
const city = cityOf(state, 0);
const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks");
const infantry = state.protoUnits.findIndex((p) => p.id === "modern_infantry");
const before = state.getBudget(0);
this.assertTrue(state.requestBuild(city.id, barracks), "a building is ordered");
this.assertTrue(state.requestTrain(city.id, infantry), "a unit is ordered");
// Stay short of the day boundary, so the only thing that could move the
// treasury in the window is the construction itself.
for (let hour = 0; hour < 23; hour++) state.advanceHour();
this.assertApprox(state.getBudget(0), before, 1e-6, "the treasury only moved by tax, none here");
this.assertEqual(state.getPlayerUpkeep(0), 0, "there is no money upkeep");
}
test_no_trade_tax_is_booked_in_simple_mode() {
const state = simpleState();
const before = state.getBudget(0);
state._recordTax(0, "sales", "steel", 1000, cityOf(state, 0));
this.assertApprox(state.getBudget(0), before, 1e-6, "no trade tax reaches the treasury");
this.assertEqual(state.getTaxTake(0).sales, 0, "and none is booked in the ledger");
}
test_propaganda_costs_culture_not_the_treasury() {
const state = simpleState();
state.applyStartingScenario();
this.assertEqual(state.getPlayerUpkeep(0), 0, "propaganda is not a money upkeep");
this.assertTrue(
state.requestCampaign(0, 1, "government", 1, 1),
"a campaign starts"
);
const campaign = state.campaigns[state.campaigns.length - 1];
this.assertEqual(
campaign.hourlyCost,
SIMPLE_ECONOMY.campaign.defaultCulturePerHour,
"the campaign spends the simple culture rate"
);
this.assertEqual(state._campaignSpendFactor(campaign), 1, "at the default it lands at full strength");
state.culture.set(0, 1_000_000);
const budgetBefore = state.getBudget(0);
const output = state.getCultureOutput(0) * 24;
for (let hour = 0; hour < 24; hour++) state.advanceHour();
this.assertApprox(
state.getBudget(0) - budgetBefore,
state.getTaxTake(0).income,
1e-6,
"the treasury moved only by tax, never by the campaign"
);
this.assertApprox(
state.getCulture(0),
1_000_000 + output - SIMPLE_ECONOMY.campaign.defaultCulturePerHour * 24,
1e-6,
"the day's culture paid for the campaign"
);
}
test_an_unfunded_campaign_lapses() {
const state = simpleState();
state.applyStartingScenario();
this.assertTrue(state.requestCampaign(0, 1, "government", 1, 1));
state.culture.set(0, 0);
for (let hour = 0; hour < 24; hour++) state.advanceHour();
this.assertSize(state.campaigns.filter((campaign) => campaign.civ === 0), 0, "it lapsed");
this.assertEqual(state.getCulture(0), 0, "and took no more culture than the nation held");
}
test_the_hard_economy_keeps_money_taxes_and_money_campaigns() {
const state = smallState();
this.assertTrue(state.requestSetTaxRate(0, "sales", 0.3), "hard tax rates still move");
this.assertApprox(state.getTaxConfigView(0).sales.rate, 0.3, 1e-9, "the rate is kept");
this.assertEqual(state.getTaxTake(0).income || 0, 0, "hard still books the trade-tax kinds");
this.assertEqual(
state._campaignDefaultCost(),
PROPAGANDA.defaultHourlyCost,
"a hard campaign is priced in money"
);
this.assertTrue(state.requestCampaign(0, 1, "government", 1, 1));
this.assertEqual(
state.getCampaignUpkeep(0),
PROPAGANDA.defaultHourlyCost,
"and its money upkeep is charged"
);
this.assertGreater(state.getPlayerUpkeep(0), 0, "which the hard budget reports");
}
}