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.
270 lines
11 KiB
JavaScript
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");
|
|
}
|
|
}
|