From d5305da449c270d3c5be948dd6e02c587a74c35d Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Fri, 25 Sep 2026 14:58:49 +0200 Subject: [PATCH] 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. --- DESIGN.md | 1 + client/css/style.css | 6 + client/js/currency.js | 11 +- client/js/devlog_data.js | 12 +- client/js/game_screen.js | 4 + client/js/game_screen/panels.js | 27 +- client/js/modals/city.js | 101 +- client/js/modals/nation.js | 201 ++- .../01-decoupling-hard-economy.md | 83 ++ .../02-resource-pool-and-production.md | 96 ++ docs/simple_economy/03-construction.md | 101 ++ docs/simple_economy/04-warmup.md | 47 + docs/simple_economy/05-money-and-taxes.md | 102 ++ docs/simple_economy/06-global-market.md | 99 ++ docs/simple_economy/07-procurement.md | 78 ++ docs/simple_economy/08-migrations.md | 81 ++ docs/simple_economy/09-ui.md | 101 ++ docs/simple_economy/10-snapshot-and-orders.md | 82 ++ docs/simple_economy/11-tests.md | 119 ++ docs/simple_economy/README.md | 124 ++ server/game_server.js | 31 +- server/server.js | 41 +- shared/data/effects.js | 4 + shared/data/index.js | 1 + shared/data/simple_economy.js | 106 ++ shared/game_state.js | 23 +- shared/game_state/budget.js | 10 +- shared/game_state/economy.js | 39 +- shared/game_state/politics.js | 27 +- shared/game_state/resources.js | 56 +- shared/game_state/serialization.js | 16 +- shared/game_state/simple_economy.js | 1131 +++++++++++++++++ tests/economy_model_test.js | 140 ++ tests/framework/helpers.js | 18 +- tests/politics_test.js | 62 +- tests/simple_construction_test.js | 326 +++++ tests/simple_market_test.js | 246 ++++ tests/simple_migration_test.js | 131 ++ tests/simple_pool_test.js | 120 ++ tests/simple_procurement_test.js | 321 +++++ tests/simple_snapshot_test.js | 203 +++ tests/simple_taxes_test.js | 269 ++++ tests/simple_ui_test.js | 198 +++ tests/testing_mode_test.js | 3 + 44 files changed, 4929 insertions(+), 69 deletions(-) create mode 100644 docs/simple_economy/01-decoupling-hard-economy.md create mode 100644 docs/simple_economy/02-resource-pool-and-production.md create mode 100644 docs/simple_economy/03-construction.md create mode 100644 docs/simple_economy/04-warmup.md create mode 100644 docs/simple_economy/05-money-and-taxes.md create mode 100644 docs/simple_economy/06-global-market.md create mode 100644 docs/simple_economy/07-procurement.md create mode 100644 docs/simple_economy/08-migrations.md create mode 100644 docs/simple_economy/09-ui.md create mode 100644 docs/simple_economy/10-snapshot-and-orders.md create mode 100644 docs/simple_economy/11-tests.md create mode 100644 docs/simple_economy/README.md create mode 100644 shared/data/simple_economy.js create mode 100644 shared/game_state/simple_economy.js create mode 100644 tests/economy_model_test.js create mode 100644 tests/simple_construction_test.js create mode 100644 tests/simple_market_test.js create mode 100644 tests/simple_migration_test.js create mode 100644 tests/simple_pool_test.js create mode 100644 tests/simple_procurement_test.js create mode 100644 tests/simple_snapshot_test.js create mode 100644 tests/simple_taxes_test.js create mode 100644 tests/simple_ui_test.js diff --git a/DESIGN.md b/DESIGN.md index 47d1c4b..799b393 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -8,6 +8,7 @@ context on purpose. | --- | --- | | `ICONS.md` | Replacing the placeholder "dummy icon" SVGs with real art. | | `SIMPLIFICATIONS.md` | The `[~]` roadmap items: deliberate simplifications worth revisiting. | +| `docs/simple_economy/` | The simple economy replacement, one implementation prompt per module. | `ROADMAP.md` remains the authoritative task list; these docs add the *why* and the design space around the open items. diff --git a/client/css/style.css b/client/css/style.css index c052705..3b24723 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -1719,6 +1719,12 @@ input:focus, select:focus { border-color: var(--accent); } .economy-table td.clickable:hover { color: var(--accent); } .resource-demand td { padding-top: 2px; padding-bottom: 6px; } .demand-line { font-size: 12px; color: var(--muted); padding: 1px 0 1px 14px; } +/* The simple economy's global market: one row per good with three buy lots and + three sell lots, small enough to sit in the table. */ +.market-table td.name { white-space: nowrap; } +.market-table td.market-actions { white-space: nowrap; } +.market-lots { display: inline-flex; gap: 4px; } +.market-table .market-lot { padding: 2px 6px; font-size: 11px; min-width: 0; } .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; } diff --git a/client/js/currency.js b/client/js/currency.js index 0d902a8..28b9ff3 100644 --- a/client/js/currency.js +++ b/client/js/currency.js @@ -10,6 +10,10 @@ let symbol = "¤"; let currencies = []; let rates = []; let viewer = -1; +// True when the snapshot's nations all share one currency (the simple economy's +// euro). There is then nothing to convert: every figure is already in the same +// money, so foreign figures carry no parenthetical equivalent. +let singleCurrency = false; export function setCurrency(currency) { if (currency && currency.symbol) symbol = currency.symbol; @@ -21,6 +25,8 @@ export function setCurrencyBook(list, exchangeRates, viewerCiv) { currencies = Array.isArray(list) ? list : []; rates = Array.isArray(exchangeRates) ? exchangeRates : []; viewer = Number.isInteger(viewerCiv) ? viewerCiv : -1; + const codes = new Set(currencies.map((entry) => entry && entry.code).filter(Boolean)); + singleCurrency = currencies.length > 0 && codes.size === 1; } export function currencySymbol() { @@ -44,13 +50,16 @@ export function viewerCurrencyCiv() { } export function currencyFor(civ) { + // One shared currency (the euro) means there is nothing to re-quote: callers + // treat every nation as already in the viewer's money. + if (singleCurrency) return null; return currencies[civ] || null; } // How many units of `civ`'s currency one unit of the viewer's buys, at the // snapshot's instant rate. One for our own currency, or when it is unknown. export function rateToNational(civ) { - if (civ === viewer) return 1; + if (singleCurrency || civ === viewer) return 1; const entry = rates[civ]; if (entry && Number.isFinite(entry.rate)) return entry.rate; const mine = currencies[viewer]; diff --git a/client/js/devlog_data.js b/client/js/devlog_data.js index 4a247b3..24924ff 100644 --- a/client/js/devlog_data.js +++ b/client/js/devlog_data.js @@ -1,6 +1,12 @@ // Generated by scripts/generate-devlog.js from `git log`; do not edit. // The pre-commit hook refreshes it so the main menu shows the latest commits. export const DEVLOG = [ + { + "hash": "49613f2", + "date": "2026-09-24", + "subject": "Made training gather materials, fixed the trade graph, and banked money gifts", + "body": "Unit orders now gather steel and high-tech over the days before training, exactly as buildings do, and clicking a queued order reveals what it is gathering, its reserved budget and the soldiers it will draw.\n\nThe trade graph cached a producer-less node list under a stamp that already reflected the producers' stores, so every seeded mine stayed invisible to buyers and rare-good prices settled at a huge multiple of base; the storage nodes are now re-read after production.\n\nMoney gifts draw the giver's central-bank reserves in the denomination first, buy the rest from the issuing bank at the market rate, convert into the receiver's currency, and cannot buy from a bank their nation is at war with.\n\nPruned the finished design briefs (intelligence, stacks, air movement, politics performance, economy balance, training UI and FIXME) and refreshed the index and cross-references." + }, { "hash": "afb23df", "date": "2026-09-24", @@ -54,11 +60,5 @@ export const DEVLOG = [ "date": "2026-09-23", "subject": "Made producers pay their own way and settled the world to steady prices", "body": "- The monthly budget breaks each expense into the resource bought, its quantity and the average price paid: tile improvements already did, and now every upkeep row (buildings, units, works, transport) does too.\n- Private producers settle their daily wear from their own cash and borrow the shortfall from the central bank, so their cash never goes negative.\n- Producers fund their own construction and upgrades on the same credit; the treasury pays only for public works, roads and railways, and no private purchase is filed as a public tile-improvement expense.\n- The server settles the world until every commodity holds within 10% over a trailing seven days instead of a fixed 150, capped by --warmup (0 skips it).\n- The shipped month report carries its label again, so the budget header and pager name the month." - }, - { - "hash": "31683be", - "date": "2026-09-23", - "subject": "Rolled the commodity index over ninety days and gave the market an equilibrium", - "body": "The central bank now charts one point a day for a rolling ninety days, shipped rebased to 100, with alternating month columns named once along the bottom so no two dates collide; short games leave future days blank and the axis floor is pinned at 0 with the 100 baseline labelled. The market no longer clamps a price to 0.5-1.8x base: the day's supply and demand set an equilibrium (base * demand/supply) and the price eases toward it, so a sustained shortage lifts a good well past its old band while a glut cheapens it, and the price settles instead of compounding to infinity. The nation modal holds each scroll position across a snapshot rebuild so the foreign reserves below the fold stay put." } ]; diff --git a/client/js/game_screen.js b/client/js/game_screen.js index 2941536..6109551 100644 --- a/client/js/game_screen.js +++ b/client/js/game_screen.js @@ -30,6 +30,9 @@ export class GameScreen { // Whether the free, instant testing buttons are offered: true for a test // game and for a page served from the local machine. this.testing = !!(config && config.testing); + // Which economy model the game runs ("simple" or "hard"), taken from the + // snapshot so the view can render the matching UI. + this.economyModel = "simple"; this.civilisations = []; this.protoUnits = PROTO_UNITS; this.selectedUnitIds = []; @@ -190,6 +193,7 @@ export class GameScreen { state.viewer ); this.testing = !!(state.testing || (this.config && this.config.testing)); + this.economyModel = state.economyModel || (this.config && this.config.economyModel) || "simple"; this.civilisations = state.civs.map( (c) => CIVILISATIONS.find((full) => full.id === c.id) || { id: c.id, name: c.name, primaryColour: "#888" } ); diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js index de75359..a2672d0 100644 --- a/client/js/game_screen/panels.js +++ b/client/js/game_screen/panels.js @@ -116,6 +116,10 @@ export const panelMethods = { this.sendOrder({ type: "set_interest_rate", rate }); this.nationModal.onSetTaxRate = (kind, rate, resource) => this.sendOrder({ type: "set_tax_rate", kind, rate, resource }); + // The simple economy's market: one lot of one good, bought or sold at the + // world price. The server re-validates the lot, the funds and the stock. + this.nationModal.onMarketTrade = (resource, side, lot) => + this.sendOrder({ type: "market_trade", resource, side, lot }); this.nationModal.onProposeTreaty = (type, to, payload) => this.sendOrder({ type: "propose_treaty", treaty: type, to, payload }); this.nationModal.onRespondTreaty = (proposal, accept) => @@ -2323,6 +2327,9 @@ export const panelMethods = { taxMultiplier: this._taxMultiplier(cityStats.approval), // Today's market prices, so build costs are quoted at current rates. prices: marketPricesFrom(this.snapshot), + // Which economy model the panel renders for: the simple model drops the + // materials phase and shows the region's spare power instead. + economyModel: this.economyModel, }; }, @@ -2337,12 +2344,16 @@ export const panelMethods = { return; } const stats = this.snapshot.viewerStats || {}; + const cityStats = this._cityStats(this.selectedCityId); const queue = this._trainingQueue(this.selectedCityId); this.cityModal.renderQueue(queue); - this._updateCityProgress(queue); - const cityStats = this._cityStats(this.selectedCityId); + // The simple economy builds off the region's spare power, so the progress + // line names it; the hard economy has no such figure and ignores the field. + this.cityModal.energyAvailable = (cityStats.resources && cityStats.resources.energy) || 0; this.cityModal.constructionSpeed = stats.constructionSpeed || 0; + this.cityModal.economyModel = this.economyModel; this.cityModal.prices = marketPricesFrom(this.snapshot); + this._updateCityProgress(queue); const city = this._city(this.selectedCityId); if (city) { this.cityModal.refreshTrainList(this._trainableUnits(city)); @@ -2494,12 +2505,19 @@ export const panelMethods = { resourceInflation: (this.snapshot && this.snapshot.resourceInflation) || [], totalHours: (this.snapshot && this.snapshot.totalHours) || 0, cities: (this.snapshot && this.snapshot.cityStats) || [], + // The economy model the Economy tab renders for: the simple model keeps + // the euro and drops the taxes, central bank and FX sub-tabs. + economyModel: this.economyModel, + // The treasury, so the simple summary can show it beside the tax income. + budget: stats.budget || 0, }; }, // Everything the Resources tab needs: the nation's own stores, needs and // trade from the viewer stats, plus the world market's prices, the world's - // supply and demand, and every nation's import/export ledger. + // supply and demand, and every nation's import/export ledger. In the simple + // economy the same block feeds the market table, so it also carries the + // economy model, the treasury the buy controls draw on, and the pool figures. _resourcesInfo() { const stats = (this.snapshot && this.snapshot.viewerStats) || {}; return { @@ -2509,6 +2527,9 @@ export const panelMethods = { civs: this.civilisations, // The clock, so the table can reset its day-over-day arrow baseline. totalHours: (this.snapshot && this.snapshot.totalHours) || 0, + economyModel: this.economyModel, + // The treasury a market purchase is paid from. + budget: stats.budget || 0, }; }, diff --git a/client/js/modals/city.js b/client/js/modals/city.js index d583225..e988eb8 100644 --- a/client/js/modals/city.js +++ b/client/js/modals/city.js @@ -53,6 +53,11 @@ export class CityModal { this.coastal = false; // Construction-technology bonus, which shortens every displayed duration. this.constructionSpeed = 0; + // Which economy model the panel renders for, and -- in the simple model -- + // the region's spare power, which sets the build speed and is named on the + // progress line. + this.economyModel = "hard"; + this.energyAvailable = 0; // Local test game: each order also offers a free, instant button. this.testing = false; // Today's market prices, so build costs are quoted at current rates. @@ -105,6 +110,8 @@ export class CityModal { this.gdp = view.gdp || 0; this.coastal = !!view.coastal; this.constructionSpeed = view.constructionSpeed || 0; + this.economyModel = view.economyModel || this.economyModel || "hard"; + this.energyAvailable = view.energyAvailable || 0; this.testing = !!view.testing; this.prices = view.prices || {}; this.unitProtos = view.unitProtos || {}; @@ -230,6 +237,12 @@ export class CityModal { renderResources(resources) { if (!this.$resourcesList || !this.$resourcesList.length) return; this.$resourcesList.empty(); + // The simple economy has one national pool rather than per-region stores, + // so this tab shows the pool and the region's own spare power instead. + if (this.economyModel === "simple") { + this._renderSimpleResources(resources || {}); + return; + } const info = resources || {}; const stock = info.stock || {}; const consumption = info.consumption || {}; @@ -307,6 +320,83 @@ export class CityModal { this.$resourcesList.append($table); } + // The simple economy's region Resources view: what the nation's one pool + // holds, what this region draws each day, and the region's own spare power, + // which sets how fast it builds. There are no per-region stores. + _renderSimpleResources(info) { + const stock = info.stock || {}; + const consumption = info.consumption || {}; + const available = info.energy || 0; + const demand = info.demand || {}; + if (this.$resourcesSub && this.$resourcesSub.length) { + this.$resourcesSub.text( + `Region power available ${formatResourceAmount("energy", available)}/day` + ); + } + this.$resourcesList.append( + $("
").text( + "Your nation keeps one resource pool, available in every region, so this region " + + "holds no separate stores. Construction draws materials from the pool and runs " + + "on the region's spare power. Energy is a flow, not a store." + ) + ); + const $table = $("
"); + const $head = $("").append( + $("") + .append($("").text("Resource")) + .append($("").text("National pool / available")) + .append($("").text("Drawn / day")) + ); + const $body = $(""); + for (const id of RESOURCE_IDS) { + const proto = resourceById(id); + const name = proto ? proto.name : id; + const energy = id === "energy"; + const held = energy ? available : (stock[id] || 0); + const consumers = (demand[id] || []).slice().sort((a, b) => b.amount - a.amount); + const open = this.expandedResources.has(id); + const row = $(""); + if (consumers.length > 0) { + const $name = $(""); + $name.append($("").text(open ? "▾" : "▸")); + $name.append(document.createTextNode(` ${name}`)); + $name.on("click", () => { + if (this.expandedResources.has(id)) this.expandedResources.delete(id); + else this.expandedResources.add(id); + this.renderResources(info); + }); + row.append($name); + } else { + row.append($("").text(name)); + } + row.append( + $("").text(formatResourceAmount(id, held)) + ); + row.append( + $("").text(formatResourceAmount(id, consumption[id] || 0)) + ); + $body.append(row); + if (open) { + const $detail = $(""); + const $cell = $(""); + for (const consumer of consumers) { + const where = consumer.coords + ? ` (${consumer.coords[0]}, ${consumer.coords[1]})` + : ""; + $cell.append( + $("
").text( + `${consumer.name}${where} · ${formatResourceAmount(id, consumer.amount)}/day` + ) + ); + } + $detail.append($cell); + $body.append($detail); + } + } + $table.append($head, $body); + this.$resourcesList.append($table); + } + // The region's private-sector cash: a basket of currencies it holds. Regions // are the game's private economic agents, so this is their own money, not the // government treasury. @@ -616,15 +706,22 @@ export class CityModal { return population || RESOURCE_RULES.unitPopulation; } + // The simple economy builds on the region's spare power, so the progress line + // names it. The hard economy has no such figure and shows nothing extra. + _speedNote() { + if (this.economyModel !== "simple" || !(this.energyAvailable > 0)) return ""; + return ` · ${formatResourceAmount("energy", this.energyAvailable)}/day spare power`; + } + beginTraining(name, totalHours, elapsedHours = 0, verb = "Training") { - this.$trainLabel.text(`${verb} ${name}…`); + this.$trainLabel.text(`${verb} ${name}…${this._speedNote()}`); this.$trainProgress.css("width", `${Math.min(100, (elapsedHours / Math.max(totalHours, 0.0001)) * 100)}%`); this.$trainStatus.removeClass("hidden"); } setTrainingProgress(name, elapsedHours, totalHours, verb = "Training") { const remaining = Math.max(0, Math.ceil(totalHours - elapsedHours)); - this.$trainLabel.text(`${verb} ${name}… (${remaining} h left)`); + this.$trainLabel.text(`${verb} ${name}… (${remaining} h left)${this._speedNote()}`); this.$trainProgress.css("width", `${Math.min(100, (elapsedHours / Math.max(totalHours, 0.0001)) * 100)}%`); } diff --git a/client/js/modals/nation.js b/client/js/modals/nation.js index 90f05ee..4c0ecd8 100644 --- a/client/js/modals/nation.js +++ b/client/js/modals/nation.js @@ -14,6 +14,7 @@ import { RESOURCES, RESOURCE_IDS, STORABLE_RESOURCE_IDS, + MARKET_LOTS, resourceById, PRICE_INDEX_WINDOW_DAYS, TREATIES, @@ -28,7 +29,7 @@ import { buildCard, researchNode, setCardMeta } from "../ui/card.js"; import { resourceNode, valueNode } from "../ui/value.js"; import { lineChart } from "../ui/line_chart.js"; import { trendArrow } from "../ui/trend.js"; -import { currencySymbol, currencyFor, rateToNational } from "../currency.js"; +import { currencySymbol, currencyFor, rateToNational, formatMoney } from "../currency.js"; import { HOURS_PER_DAY } from "../../../shared/game_state/constants.js"; import { MONTH_NAMES, unixTime } from "../../../shared/game_clock.js"; @@ -68,8 +69,12 @@ export class NationModal { // The tax controls are likewise built once; only their values refresh. this._taxesBuilt = false; this.$resourcesSub = $("#resources-sub"); + this.$resourcesTabs = $("#resources-subtabs"); this.$resourcesList = $("#resources-list"); this.$resourceTradeList = $("#resource-trade-list"); + // Which economy model the game runs; the simple model turns the Resources + // tab into the global market and drops the hard money sub-tabs. + this.economyModel = "hard"; // 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"; @@ -138,6 +143,8 @@ export class NationModal { this.onRevokePolicy = () => {}; this.onSetInterestRate = () => {}; this.onSetTaxRate = () => {}; + // The simple economy's global market: buy or sell a lot of one good. + this.onMarketTrade = () => {}; this.onConfirm = () => {}; this.$modal.find(".modal-close").on("click", () => this.close()); bindTabs(this.$modal, "tab-"); @@ -177,6 +184,13 @@ export class NationModal { this.intelligence = intelligence; this.resources = resourcesInfo; this.money = moneyInfo; + // The snapshot names the economy model; a direct caller that omits it keeps + // the hard rendering (the default before the simple economy existed). + this.economyModel = + (resourcesInfo && resourcesInfo.economyModel) || + (moneyInfo && moneyInfo.economyModel) || + this.economyModel || + "hard"; // Rebuild the rows from scratch: reopening the panel must not stack a // second set of buttons on top of the previous one, or the stale rows keep // their old labels and disabled state and appear unresponsive. @@ -228,11 +242,19 @@ export class NationModal { // The Resources tab's sub-tabs: the nation's own stores and trade, and the // world ranking of each commodity's major exporters and importers. Both views - // are rendered together; switching only shows and hides them. + // are rendered together; switching only shows and hides them. The simple + // economy has neither: its Resources tab is one global market, so the sub-tab + // bar is hidden and only the market list is drawn. _buildResourceSubtabs() { - const $tabs = $("#resources-subtabs"); + const $tabs = this.$resourcesTabs || $("#resources-subtabs"); if (!$tabs.length) return; $tabs.empty(); + if (this.economyModel === "simple") { + $tabs.addClass("hidden"); + this.resourceView = "stores"; + return; + } + $tabs.removeClass("hidden"); const views = [ { id: "stores", label: "Stores & trade" }, { id: "leaders", label: "Major traders" }, @@ -262,6 +284,16 @@ export class NationModal { _buildEconomySubtabs() { if (!this.$economySubtabs || !this.$economySubtabs.length) return; this.$economySubtabs.empty(); + // The simple economy has one currency, no central bank and no FX, and its + // only money movement is the market: nothing is left to split into + // sub-tabs, so the bar is hidden and the one summary is drawn. + if (this.economyModel === "simple") { + this.$economySubtabs.addClass("hidden"); + this.economyView = "summary"; + this._setEconomyView("summary"); + return; + } + this.$economySubtabs.removeClass("hidden"); const currency = (this.money && this.money.currency) || null; const views = [ { id: "summary", label: "Private sector" }, @@ -304,6 +336,10 @@ export class NationModal { _renderEconomy(money) { this.money = money || this.money || {}; + if (this.economyModel === "simple") { + this._renderSimpleEconomy(this.money); + return; + } if (this.economyView === "consumption") this._renderConsumption(); else if (this.economyView === "currency") this._renderCurrencyHoldings(this.money); else if (this.economyView === "taxes") this._renderTaxes(this.money); @@ -312,6 +348,47 @@ export class NationModal { else this._renderPrivateSector(this.money); } + // The simple economy's one Economy view: the euro treasury and the single + // daily tax income, with the day's income sources. There is no central bank, + // no FX and no private-sector cash to report. + _renderSimpleEconomy(money) { + if (!this.$economySummary || !this.$economySummary.length) return; + const treasury = money.budget !== undefined ? money.budget : this._treasury; + const take = (money && money.takeTax) || {}; + const income = take.income || 0; + if (this.$economySub && this.$economySub.length) { + this.$economySub.text( + `Treasury ${formatMoney(treasury)} · tax income ${formatMoney(income)}/day` + ); + } + this.$economySummary.empty(); + this.$economySummary.append( + $("
").text( + "Money is used only in the global market. Taxes are collected each day from " + + "every inhabitant, and construction and upkeep draw on your resource pool " + + "instead of the treasury." + ) + ); + const $table = $("
"); + $table.append( + $("").append( + $("") + .append($("").text("Item")) + .append($("").text("Per day")) + ) + ); + const $body = $("").append( + $("") + .append($("").text("Tax income")) + .append($("").text(`+${formatMoney(income)}`)), + $("") + .append($("").text("Treasury")) + .append($("").text(formatMoney(treasury))) + ); + $table.append($body); + this.$economySummary.append($table); + } + // The day's resource consumption and the trade that crossed the nation's // border: what each neighbour and the world market sold us, and what we sold // them, with the money it moved. @@ -1089,6 +1166,14 @@ export class NationModal { _renderResources(info) { if (!this.$resourcesList || !this.$resourcesList.length) return; this.$resourcesList.empty(); + // The simple economy's Resources tab is a global market: one row per + // storable good with the pool, the world stock, the price and Buy/Sell + // lots. There is no per-region store table and no major-traders ranking. + if (this.economyModel === "simple") { + this._renderMarket(info || {}); + if (this.$resourceTradeList) this.$resourceTradeList.empty().addClass("hidden"); + return; + } const summary = info && info.summary; if (this.$resourcesSub && this.$resourcesSub.length) { this.$resourcesSub.text( @@ -1105,6 +1190,111 @@ export class NationModal { this._setResourceView(this.resourceView); } + // The simple economy's market: the viewer's pool, the world stock and price + // of each storable good, and the three buy/sell lots. Everything is priced + // from the one world stock, so a purchase the treasury cannot afford or a + // sale the pool cannot cover is disabled rather than sent to be refused. + _renderMarket(info) { + const summary = info.summary || {}; + const pool = summary.stock || {}; + const market = new Map((info.market || []).map((entry) => [entry.id, entry])); + const budget = info.budget || 0; + if (this.$resourcesSub && this.$resourcesSub.length) { + this.$resourcesSub.text( + `Treasury ${formatMoney(budget)} · your pool is available in every region` + ); + } + this.$resourcesList.append( + $("
").text( + "The global market is shared by every nation: buying drains the world stock and " + + "raises the price, selling refills it and lowers the price. Goods go into your " + + "national pool, which every region can draw on." + ) + ); + const $table = $("
"); + const $head = $("").append( + $("") + .append($("").text("Resource")) + .append($("").text("In pool")) + .append($("").text("World stock")) + .append($("").text("Price")) + .append($("").text("Buy")) + .append($("").text("Sell")) + ); + const $body = $(""); + for (const proto of RESOURCES) { + if (proto.id === "energy") continue; + const id = proto.id; + const stats = market.get(id) || {}; + const held = pool[id] || 0; + const stock = stats.stock; + const price = stats.price || 0; + const $row = $(""); + const $name = $(""); + $name.append( + $("") + .attr("src", `assets/${proto.icon}`) + .attr("alt", proto.name) + ); + $name.append($("").text(` ${proto.name}`)); + $row.append($name); + $row.append( + $("").text(formatResourceAmount(id, held)) + ); + $row.append( + $("").text( + stock === null || stock === undefined ? "—" : formatResourceAmount(id, stock) + ) + ); + $row.append( + $("").append(this._resourcePriceNode(proto, price)) + ); + $row.append($("").append( + this._marketControls(id, "buy", held, price, budget, stock) + )); + $row.append($("").append( + this._marketControls(id, "sell", held, price, budget, stock) + )); + $body.append($row); + } + $table.append($head, $body); + this.$resourcesList.append($table); + this.$resourcesList.append( + $("
").text( + "Selling takes goods from your pool; buying adds to it. A lot the treasury " + + "cannot afford or the pool cannot cover is disabled." + ) + ); + } + + // The Buy or Sell buttons for one good, one per lot. The cost/revenue is + // checked against the treasury and the pool up front so a disabled button + // explains itself instead of sending an order the server would refuse. + _marketControls(id, side, held, price, budget, stock) { + const $group = $(""); + for (const lot of MARKET_LOTS) { + const buying = side === "buy"; + const cost = lot * price; + const affordable = buying + ? budget + 1e-9 >= cost && stock !== null && stock !== undefined && stock + 1e-9 >= lot + : held + 1e-9 >= lot; + const $button = $("") + .text(compact(lot)) + .prop("disabled", !affordable); + $button.attr( + "title", + buying + ? `Buy ${groupDigits(lot)} for ${formatMoney(cost)}` + : `Sell ${groupDigits(lot)} for ${formatMoney(cost)}` + ); + if (affordable) { + $button.on("click", () => this.onMarketTrade(id, side, lot)); + } + $group.append($button); + } + return $group; + } + _renderResourceStores(info, summary) { const market = new Map(((info && info.market) || []).map((entry) => [entry.id, entry])); const trade = summary.trade || { imports: {}, exports: {} }; @@ -1392,7 +1582,10 @@ export class NationModal { .map((_, el) => ({ el, top: el.scrollTop })) .get(); if (techInfo) this.techInfo = techInfo; - if (resourcesInfo) this.resources = resourcesInfo; + if (resourcesInfo) { + this.resources = resourcesInfo; + if (resourcesInfo.economyModel) this.economyModel = resourcesInfo.economyModel; + } this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number)); if (diplomacy !== null) this.diplomacy = diplomacy; if (conflicts !== null) this.conflicts = conflicts || []; diff --git a/docs/simple_economy/01-decoupling-hard-economy.md b/docs/simple_economy/01-decoupling-hard-economy.md new file mode 100644 index 0000000..9765fac --- /dev/null +++ b/docs/simple_economy/01-decoupling-hard-economy.md @@ -0,0 +1,83 @@ +# 01 — Decouple the economy model (freeze "hard economy") + +**Depends on:** nothing. **Do this first**; every other prompt assumes it. + +## Goal + +Introduce an explicit, per-game economy model so the current model can be kept +untouched as the **hard economy** while a new **simple economy** becomes the +default. Nothing about the current simulation should change when the hard model +is selected. + +## Current behaviour + +- There is one implicit model. `GameState.configure(civilisations, seed)` in + `shared/game_state.js` builds the world and the rich resource economy. +- The server passes setup through `GameServer.configureGame(setup)` + (`server/game_server.js`), which also runs the warmup. `setups` are assembled + in `server/server.js` from CLI flags. +- `testing` is the only mode flag and is shipped in the snapshot. + +## Target behaviour + +- Add `economyModel` to `GameState`, defaulting to `"simple"`, accepting + `"simple"` or `"hard"`. Reject/coerce anything else to `"simple"`. +- `GameState.configure(civs, seed, options)` takes `{ economyModel }` (keep the + existing two-argument call working; `options` optional). Set + `this.economyModel` before building the world, because world/scenario seeding + differs per model. +- Ship `economyModel` in the snapshot alongside `testing` so joiners know which + UI to render (`shared/game_state/serialization.js`). +- Server CLI: add `--economy simple|hard` (env `ECONOMY`), default `simple`, + parsed in `server/server.js` and forwarded in the `configureGame` setup + object. Keep `--warmup` working for hard; see `04-warmup.md`. +- Add a model-specific override mechanism. Recommended: a new + `shared/game_state/simple_economy.js` exporting `simpleEconomyMethods`. In + `configure()`, when the model is simple, `Object.assign(this, simpleEconomyMethods)` + so the instance shadows the prototype methods it needs to replace. The hard + model needs no override module — it is the existing mixins unchanged. + - Instance-level assignment is safe because the model is fixed for the life of + a game and the client does not run a live `GameState`. + - Name each override after the method it replaces so the diff is obvious. +- Add a helper `isSimpleEconomy()` (or `this.economyModel === "simple"`) and use + it at every branch rather than scattering string comparisons. + +## Files + +- `shared/game_state.js` — constructor field, `configure()` options, override + assignment, `isSimpleEconomy()`. +- `shared/game_state/simple_economy.js` — **new**, the override mixin (empty at + first; later prompts add methods). +- `shared/game_state/serialization.js` — ship `economyModel`. +- `server/server.js` — CLI flag and help text. +- `server/game_server.js` — pass `economyModel` into `configure`, keep the warmup + path intact. +- `shared/data/economy.js` (or a new `shared/data/simple_economy.js`) — constants + for the simple model, namespaced so they cannot collide with `ECONOMY`, + `MONEY` and `RESOURCE_*`. +- `client/js/game_screen.js` / `client/js/net.js` — store the flag from the + snapshot for the view (no visible change yet). +- `tests/economy_model_test.js` — **new**. + +## Acceptance criteria + +- `new GameState()` defaults to `"simple"`. +- A game configured with `{ economyModel: "hard" }` behaves byte-for-byte like + today: the existing suites (`resources_test.js`, `money_test.js`, + `industry_test.js`, `construction_site_test.js`, `taxes_test.js`, ...) still + pass unchanged for the hard model. +- A game configured with `{ economyModel: "simple" }` is constructible and runs + its daily ticks without throwing, even before the other modules land. To keep + it runnable, the initial `simpleEconomyMethods` may be empty. +- The snapshot carries `economyModel`; the server default is `"simple"`. +- `node server/server.js --economy hard` still runs the hard game. + +## Notes / decisions + +- Decide whether hard is selectable only by CLI/admin or also by a lobby option. + Default recommendation: CLI/launch option only. +- Do not rename existing symbols (`resources.js`, `money.js`, `taxes.js`) — they + *are* the hard model. "Hard economy" is a label, not a new directory. +- If a branch is needed inside an existing method, prefer an early + `if (this.isSimpleEconomy()) return this._simpleX(...)` delegating to the + override module over editing the hard path in place. diff --git a/docs/simple_economy/02-resource-pool-and-production.md b/docs/simple_economy/02-resource-pool-and-production.md new file mode 100644 index 0000000..d22f733 --- /dev/null +++ b/docs/simple_economy/02-resource-pool-and-production.md @@ -0,0 +1,96 @@ +# 02 — Global resource pool and public production + +**Depends on:** `01-decoupling-hard-economy.md`. + +## Goal + +In the simple economy there is **one pool of resources per player, available +everywhere**. Every production building is **public**: its output goes straight +into the player's pool. There is no per-region storage, no transport, no trade +graph, and no private building agents. + +## Current behaviour (hard economy) + +- `shared/game_state/resources.js` seeds a per-tile store in `resourceStock` + (`_seedCityResources`, `_ensureResourceStock`, `getCityResourceStock`) for + cities and resource-building tiles. Four goods are storable; energy is a flow. +- Production is local: tiles and resource buildings feed `resourceStock`, and + regions buy from neighbours or the global market through `_procureFrom + Neighbours`, `_nearbySuppliers`, `_resourceNodes`, `_resourceNodeAt`, all + bounded by `RESOURCE_TRADE_RADIUS` and the delivery/transport graph. +- Production buildings are private agents (`shared/game_state/industry.js`): + `buildingAgents` (cash, debt, sales, upgrades), `_creditBuildingCash`, + `_chargeBuildingAccount`, `_payGridProducers`, `getPrivateBuildingCash`. +- `getRegionResourceInfo(city)` (`resources.js`) is the per-region view used by + the scenario seeding, the UI and (indirectly) the warmup. + +## Target behaviour + +- Add `resourcePools: Map` where `Stock` holds the four storable + goods (`steel`, `food`, `luxury`, `hightech`) in canonical units. **Decision + to settle first: is energy pooled too?** The construction rule in + `03-construction.md` needs *regional* energy, and energy is currently a + non-storable flow, so the recommendation is: **keep energy a per-region flow** + and pool only the four storable goods. Record the decision in the file header + when implemented. +- Every player starts with a pool. Sizing is a scenario decision + (`scenario.js`); for the simple economy, seed each pool with a comfortable + working stock rather than a year of per-region consumption. A starting figure + belongs in the simple-economy data module, not inline. +- All production buildings are public. Their output adds to the owner's pool. + Retire the private-agent accounting in simple mode: no `buildingAgents` cash, + debt, sales window or demand-driven upgrade. Keep them working for hard. +- No gathering phase. Any consumer (upkeep, construction, training, combat, + repairs, food) draws from the pool immediately. `03-` and `07-` cover the + draws; this module only needs the pool exists and production fills it. +- `getCityResourceStock(city)` in simple mode should either be removed from + callers or return a read-only view of the owner's pool, so the UI and any + surviving consumer keeps working. Prefer an explicit `getResourcePool(civ)` + and migrate callers; keep the old name only if it is widely used. +- `getRegionResourceInfo` should be replaced in simple mode by a pool-based + region view (production, consumption, energy). The scenario and UI both read + it. + +## Files + +- `shared/game_state/resources.js` — `_initResources`, `_seedCityResources`, + `getCityResourceStock`, `getRegionResourceInfo`, production application + (`_tickResources` production pass). Prefer overrides in the simple module over + editing hard paths. +- `shared/game_state/industry.js` — gate `buildingAgents` and the upgrade + machinery behind `!isSimpleEconomy()`, or override `_tickIndustry`, + `getProductionAgents`, `getPrivateBuildingCash/Debt` and + `resourceBuildingOutputAt` call sites in the simple module. +- `shared/game_state/simple_economy.js` — add the pool, seeding, production and + the consumers' draw helpers. +- `shared/game_state/scenario.js` — seed pools for simple instead of the + per-region year of reserves (`_seedStartingReserves`). +- `shared/game_state/serialization.js` — see `10-snapshot-and-orders.md`. + +## Acceptance criteria + +- A simple game has exactly one pool per civ; no per-tile `resourceStock` is + read for procurement. +- Public producers add to the pool; the totals shown for a player are the pool + contents. +- Ordering a build/train in a simple game does not start a materials phase and + does not consult `_nearbySuppliers`, `_resourceNodes`, the trade radius or the + transport graph. +- The hard game is unchanged. + +## Notes / decisions + +- **Energy pooling.** Recommended: not pooled (regional flow), because + `03-construction.md` keys construction speed to the region's available energy. + Confirm before implementing. +- **Food synthesis / famine.** Food is currently synthesised from energy in hard + mode (`foodSynthesisQuadratic`) and famine is per-region. Decide whether the + simple pool keeps famine (pool empty → hungry) and whether synthesis stays. + Recommendation: keep a global famine check when the pool's food is zero, and + keep energy→food synthesis if it is cheap to retain; otherwise drop synthesis + and let the pool be the only buffer. +- **Middlemen.** `MIDDLEMAN` and `_procureFromNeighbours` belong to hard; they + are unreachable in simple mode. +- Keep the daily ledger (`resourceSpend`, consumption) for the UI even though + there is no bidding; a global pool still has production and consumption to + report. diff --git a/docs/simple_economy/03-construction.md b/docs/simple_economy/03-construction.md new file mode 100644 index 0000000..c61ab92 --- /dev/null +++ b/docs/simple_economy/03-construction.md @@ -0,0 +1,101 @@ +# 03 — Construction: immediate start, energy-driven speed + +**Depends on:** `01-`, `02-`. + +## Goal + +In the simple economy, building **starts immediately** and its **construction +speed is proportional to the energy available in the region**, with modifiers. +Every region must start with one power plant. + +## Current behaviour (hard economy) + +- `requestBuild` / `requestTrain` (`shared/game_state/orders.js`) pay the money + cost, reserve a material budget, push a queue entry in the `"materials"` + phase, and gather steel/high-tech over days via `_gatherBuildEntry` before + `"construction"` begins. +- Construction time is gated and scaled off GDP: + `ECONOMY.productionCapacity(this.getPlayerGdp(civ))` and + `ECONOMY.buildingHours(gdp, cost)` in `shared/data/economy.js`, times + `constructionApprovalMultiplier(city)` and `constructionSpeedMultiplier(civ)`. +- Tile improvements become construction sites (`shared/game_state/sites.js`): + `_openConstructionSite`, `_tickConstructionSites`, phases `materials` then + `construction`, buying energy day by day (`_buySiteEnergy`). +- Starting cities/buildings are laid out in `scenario.js` + (`_seedStartingBuildings`) and the generated world in + `shared/game_state/world.js`. + +## Target behaviour + +- **No materials phase in simple mode.** An order enters the construction/ + training phase immediately. If a material bill exists, draw it from the + player's pool at order time (`02-`), or make simple-mode builds cost no + materials. Recommendation: **draw from the pool up front**, so ordering still + consumes resources and cannot be spammed, but there is no multi-day gather and + no stall. +- **Construction capacity comes from regional energy, not GDP.** Define a + region energy figure — e.g. `regionAvailableEnergy(city)` = the connected + region's power production that is not already committed — and make the build + speed proportional to it. Keep the existing shape: `totalHours = base * + speedMultiplier`, where `base` is a cost/energy ratio and the multiplier folds + in approval and the existing technology/modifier bonus + (`constructionSpeedMultiplier`, `EFFECT_CONSTRUCTION_SPEED`). + - Put the tuning constants (`energyPerConstructionHour`, a minimum speed floor, + and how energy maps to hours) in the simple-economy data module. + - Define what happens when a region has no power: the work should be slow or + stalled, not free. A floor avoids a divide-by-zero and a hard lock. +- **Every region starts with one power plant.** Seed one working plant per city + region in the simple scenario so regions open with energy and the speed rule + has a non-zero basis. + - Decide the plant type and placement. Recommendation: one of the + fuel-free/renewable plants (e.g. `solar_power_plant` or `wind_turbines`) on a + valid land tile owned by the region, chosen deterministically, so no fuel + resource is implied. Ensure the tile passes the existing resource-building + placement rules and is connected. +- **Public upgrades.** Production-building upgrades (`industry.js`) are public + works in simple mode and follow the same immediate, energy-driven rule. +- Remove the GDP capacity gate (`productionCapacity`) from simple-mode + construction and training. GDP may still be computed for display only. + +## Files + +- `shared/game_state/orders.js` — `requestBuild`, `requestTrain`, + `requestBuildImprovement`, `_pendingBuildingLevels`. +- `shared/game_state/sites.js` — `_openConstructionSite`, `_kickoffSite`, + `_tickConstructionSites`, `_gatherSiteMaterials`, `_gatherBuildEntry`, + `_buySiteEnergy`, `_siteForecastBudget`. In simple mode a site should open + already in `"construction"` and skip the gather/energy-buy phases. +- `shared/game_state/industry.js` — `_startBuildingUpgrade`, `_buyBuilding + Materials`, `_tickBuildingDecisions`. +- `shared/game_state/economy.js` — `constructionSpeedMultiplier`, and a new + energy-based equivalent; `getPlayerGdp` stays for display. +- `shared/game_state/simple_economy.js` — the overrides. +- `shared/game_state/scenario.js` / `world.js` — the starting plant per region. +- `shared/data/economy.js` or new simple data module — construction constants. +- `tests/construction_site_test.js`, `tests/industry_test.js` — extend for + simple mode; or a new `tests/simple_construction_test.js`. + +## Acceptance criteria + +- In simple mode, ordering a building raises it on the hourly tick with no + `"materials"` phase and no `_nearbySuppliers`/trade-graph lookups. +- Construction time scales with the region's available energy, and a + power-less region builds clearly slower than a powered one (never faster, and + never instantaneous at zero energy). +- Every starting region has at least one working power plant in simple mode. +- Hard economy construction is untouched. + +## Notes / decisions + +- **What "available energy" means.** Options: (a) the region grid's uncommitted + power, (b) the region's gross plant output, (c) a per-region energy balance + already computed in the daily resource tick. Define one and use it on both the + server and (for display) the client. Do not read a value that only exists + mid-tick elsewhere. +- **Concurrency.** Hard mode lets one queue line run at a time. Decide whether + simple mode keeps one line per city or lets every queued item build in + parallel off the same energy pool. Recommendation: keep one line, scaled by + energy, to preserve the queue UI. +- **Money.** `05-money-and-taxes.md` says money is not spent internally in the + simple economy; if that is accepted, the `_spendBudget` calls here must be + dropped in simple mode. diff --git a/docs/simple_economy/04-warmup.md b/docs/simple_economy/04-warmup.md new file mode 100644 index 0000000..b6bd11c --- /dev/null +++ b/docs/simple_economy/04-warmup.md @@ -0,0 +1,47 @@ +# 04 — No warmup in the simple economy + +**Depends on:** `01-`. + +## Goal + +The simple economy needs **no warmup**. The server should open directly on the +starting scenario. + +## Current behaviour (hard economy) + +- `server/server.js` reads `--warmup` (env `WARMUP`, default `150`), logs + "settling the world...", and passes `warmupDays` into the setup object. +- `GameServer.configureGame(setup)` (`server/game_server.js`) calls + `GameState.warmUpToStability({ maxDays })` when `warmupDays > 0`, records + `this.settledDays`, and only then calls `applyStartingScenario()`. +- `GameState.warmUp(days)` and `warmUpToStability` live in + `shared/game_state.js` / `shared/game_state/resources.js`. They settle + commodity prices (`pricesAreSteady`, `nextMarketPrice`) — a hard-economy + concept. + +## Target behaviour + +- When `economyModel === "simple"`, skip the settle entirely: + `settledDays = 0`, no `warmUpToStability` call, and apply the starting + scenario directly. No UI/market state should depend on a settle having run. +- `--warmup` is a hard-economy flag. In simple mode it should be ignored; log a + short note if it was explicitly set (rather than silently dropping it). Keep + the existing default and behaviour for `--economy hard`. +- Do not remove `warmUp`/`warmUpToStability`; they remain the hard economy's + settle and are exercised by existing tests. + +## Files + +- `server/game_server.js` — branch in `configureGame`. +- `server/server.js` — pass `economyModel` in the setup; adjust the startup log + and `--warmup` help text to say it applies to the hard economy. +- `tests/simple_economy_test.js` or the decoupling test — assert a simple game + reports `settledDays === 0` and that no price-settle ran. + +## Acceptance criteria + +- A simple server starts without the "settling..." phase and `settledDays` is + `0` in the state/snapshot (if it is exposed). +- The starting scenario and pool contents are exactly as authored, with nothing + spent by a settle. +- `--economy hard --warmup 0` and the default 150 keep working as before. diff --git a/docs/simple_economy/05-money-and-taxes.md b/docs/simple_economy/05-money-and-taxes.md new file mode 100644 index 0000000..b5defbc --- /dev/null +++ b/docs/simple_economy/05-money-and-taxes.md @@ -0,0 +1,102 @@ +# 05 — Money and taxes (euro only, approval tax) + +**Depends on:** `01-`. + +## Goal + +- One shared currency, the **euro (€)**. No conversion rates. +- Currency is used **only in the market between players** — no internal use. +- Taxes: **1 inhabitant = 100 €/day × (approval × 2) × other modifiers**, where + `approval` is the derived 0–100% figure, not the raw popularity points. +- The **Taxes tab is not needed** in this version. + +## Current behaviour (hard economy) + +- Every civilisation has its own currency (`shared/data/civilisations.js`, + `currency: { code, symbol, name }`). `shared/game_state/money.js` owns + `currencyOf`, `currencyValue`, `exchangeRate`, central banks, reserves, + region cash baskets and `_tickMoney`. +- Government income is **trade taxes only** (`shared/game_state/taxes.js`): + sales/export/import, booked by `_recordTax`; there is no GDP tax. + `getCivHourlyIncome` / `budgetBreakdown` (`economy.js`) read `getTaxTake`. +- Approval is `getCityApproval(city)` / `getCivApproval(civ)` in + `shared/game_state/politics.js` (0..1, population-weighted), with + `taxApprovalMultiplier` used as a collection discount. +- Construction/training spend treasury money (`_spendBudget`, + `_reserveSiteBudget`), and upkeep is materials valued in money. + +## Target behaviour + +- **Euro only.** In simple mode `currencyOf(civ)` returns a single euro + descriptor for every civ and `currencyValue(civ) === 1`; `exchangeRate` + returns 1. Retire the central bank, reserves, FX and region cash baskets from + the simple simulation. Keep them intact for hard. + - Add the euro descriptor in one place (simple data module) and have the + client's `currency.js` show `€` from the snapshot. +- **No internal use of money.** The only money movements in simple mode are: + - tax income into the treasury each day, and + - market purchases/sales (`06-global-market.md`). + Construction, training, upkeep, repairs and combat materials must **not** + touch the treasury in simple mode — they draw from the resource pool. (Confirm + this reading with the user; it follows from "only used in the market".) +- **Daily tax income.** Once per day, for each civ: + `income = Σ_regions population × 100 × (approval × 2) × modifiers`. + - `approval` is the city/region 0..1 approval (the derived figure). Sum per + region so a region's own approval matters, or use the national population + and national approval if that is simpler and agreed. + - "Other modifiers" must be named. Recommendation: a `taxIncomeModifier` + effect (government and/or research), defaulting to 1, applied to the total. + - Book it with `_recordBudgetCash(civ, , amount)` and add to + `budgets`; expose a daily `taxLedger` for the UI so the budget report has an + income source. There is only one kind now ("income"), not sales/export/import. + - Guard the rate against overflow and keep the existing population scaling + (`_populationBalance`) so the headline population and the tax base agree. +- **Remove the tax controls in simple mode.** `requestSetTaxRate` and the + `set_tax_rate` order are hard-only. The client must not render the Taxes tab + (see `09-ui.md`). +- Remove trade-tax booking in simple mode (`_recordTax` branches) since there is + no inter-region trade to tax; the resource economy is pool-based (`07-`). +- `budgetBreakdown` / `cityBudgetBreakdown` / `getCivHourlyIncome` must report the + new single income source in simple mode. + +## Files + +- `shared/game_state/money.js` — euro singleton and the simple-mode tick; or + override the tick in `simple_economy.js` and leave `money.js` hard-only. +- `shared/game_state/taxes.js` — gate trade taxes; add the approval tax (or put + it in the simple module). +- `shared/game_state/economy.js` — `getCivHourlyIncome`, `_taxIncomeSources`, + `budgetBreakdown`, `cityBudgetBreakdown`. +- `shared/game_state/orders.js` / `sites.js` — remove `_spendBudget` / + `_reserveSiteBudget` from the simple paths. +- `shared/game_state/simple_economy.js` — the overrides and the income tick. +- `shared/data/simple_economy.js` (new) — `TAX_PER_INHABITANT = 100`, + `APPROVAL_TAX_FACTOR = 2`, modifier default, euro descriptor. +- `server/game_server.js` — reject `set_tax_rate` / `set_interest_rate` in + simple mode. +- `tests/money_test.js`, `tests/taxes_test.js` — add simple-mode cases or a new + `tests/simple_taxes_test.js`. + +## Acceptance criteria + +- A simple game quotes every money figure in `€`; no exchange-rate or central-bank + code runs in its tick. +- Treasury grows each day by exactly + `population × 100 × approval × 2 × modifiers` for each civ (within rounding). +- `set_tax_rate` is rejected by the server in simple mode. +- Construction/training/upkeep do not debit the treasury in simple mode; the + hard economy keeps its current money flows. + +## Notes / decisions + +- **"approval × 2".** With `approval ∈ [0,1]` that is a 0–2 factor, so a 100% + region pays 200 €/person/day and a 50% region 100 €/person/day. Confirm that is + the intent and clamp the result to `[0, 2]`. +- **Per-region vs national approval.** The spec says "1 inhabitant", which + suggests per-region. Use the per-region derived approval and sum, which is the + faithful reading. +- **"Other modifiers".** Enumerate them before coding (government effect IDs, + research). If none exist, expose a single hook defaulting to 1 so the formula + is easy to extend. +- **Starting treasury.** Pick a simple-mode starting budget; it should not be + the hard `ECONOMY.startingBudgetFraction` of GDP unless that is acceptable. diff --git a/docs/simple_economy/06-global-market.md b/docs/simple_economy/06-global-market.md new file mode 100644 index 0000000..f878920 --- /dev/null +++ b/docs/simple_economy/06-global-market.md @@ -0,0 +1,99 @@ +# 06 — The global market + +**Depends on:** `01-`, `02-`, `05-`. + +## Goal + +In the nation view, the **Resources tab acts as a global market**. Players can +sell and buy resources in fixed lots of **100, 2 000 and 50 000**. The market +starts with a stockpile of **1,000,000 of each resource**, and the price is + +``` +price = initial_price × (available / 1_000_000) ^ -1 +``` + +## Current behaviour (hard economy) + +- `shared/data/resources.js` defines `RESOURCE_MARKET_BASE` (per-unit euro + prices) and `RESOURCE_MARKET` / `nextMarketPrice`, which eases a drifting price + toward a supply/demand equilibrium each day. +- `shared/game_state/resources.js` holds `resourcePrices`, `getResourcePrice`, + `_marketUnitCost`, and the daily market set-up inside `_tickResources` + (supply/demand per resource, `previousPrices`, `commodityInflation`). +- There are **no buy/sell orders** today. The Resources tab + (`client/js/modals/nation.js`) is read-only: stores, consumption, production, + trade, prices. +- The `resourcePrices` figures are used all over the hard simulation (upkeep + valuation, construction quotes, combat). Simple mode must not destabilise them + mid-tick. + +## Target behaviour + +- A **global market** (shared by all players, per the price formula's global + `available`) holds a stock per resource, starting at `1_000_000`. + - `Market.stock[id]` for the storable resources. Energy is non-storable in the + hard model; decide whether the market includes it (see decisions). + - `marketPrice(id) = RESOURCE_MARKET_BASE[id] * Math.pow(stock[id] / 1_000_000, -1)` + (clamp for a zero/very low stock so it cannot explode or go NaN). Cache it + per stock change so the price is one source of truth. +- **Buy**: the player pays `qty × marketPrice(id)` from the treasury, and the + goods go into the player's pool (`02-`); the market stock **decreases** by + `qty`, so the price rises. Reject if the treasury is short. +- **Sell**: the goods leave the player's pool and the treasury is credited + `qty × marketPrice(id)`; the market stock **increases**, so the price falls. + Reject if the pool is short. +- **Lots**: only `100`, `2000`, `50000` per order (both directions). Validate the + lot server-side; never trust the client. +- **Order**: add `market_trade` to `GameServer.handleOrder` + (`server/game_server.js`), e.g. `{ t: "market_trade", side: "buy"|"sell", + resource, lot }`, mapping to `state.requestMarketTrade(civ, resource, side, qty)`. + The request validates civ, resource, side, lot, funds/stock, then mutates the + pool, market stock and treasury atomically and returns a boolean. +- **Prices in simple mode** come from the market formula, not + `nextMarketPrice`. `getResourcePrice(id)` should return `marketPrice(id)` in + simple mode so every existing caller (upkeep valuation, budget quotes) + automatically reads the right figure. Drop the daily supply/demand price drift + and `commodityInflation` from the simple tick. +- Market stock must be part of the snapshot so every client shows the same + price/availability, and versioned (see `10-snapshot-and-orders.md`). + +## Files + +- `shared/data/simple_economy.js` (new) — `MARKET_START_STOCK = 1_000_000`, + `MARKET_LOTS = [100, 2000, 50000]`, price helper. +- `shared/game_state/simple_economy.js` — `_initSimpleMarket`, `marketPrice`, + `getResourcePrice` override, `requestMarketTrade`, `getMarketView`. +- `shared/game_state/resources.js` — the hard `_tickResources` market block must + be skipped in simple mode (override the tick or the price update). +- `server/game_server.js` — the `market_trade` handler. +- `shared/game_state/serialization.js` — market stock/price in the snapshot. +- `client/js/modals/nation.js` — the market UI (see `09-ui.md`, approval gate). +- `tests/simple_market_test.js` — **new**. + +## Acceptance criteria + +- At `stock = 1_000_000`, `marketPrice(id) === RESOURCE_MARKET_BASE[id]`. +- Buying `qty` lowers stock and raises the price; selling raises stock and + lowers the price; the pool and treasury move by exactly `qty` and + `qty × price`. +- Only the three lots are accepted; other quantities are refused. +- The client sees one consistent market price across players. +- Hard economy prices still drift with supply/demand. + +## Notes / decisions + +- **Global vs per-player market.** The formula's `available / 1_000_000` reads + as one shared world stock. The phrase "between players" could instead mean a + player-to-player order book. Recommendation: a single shared global market + pool; it matches the starting stock and formula literally. Confirm. +- **Energy.** If energy stays a regional flow (`02-`), it cannot be bought into a + pool. Recommendation: exclude energy from the buyable market, or give the + simple economy a storable energy commodity. Confirm before building the UI. +- **Food/famine interplay.** Selling all food could starve the nation; decide + whether to warn or block. Recommendation: allow it (the tax/market loop is the + point) and surface a warning. +- **Price flooring.** With the `^-1` formula a near-empty stock sends the price + to infinity. Define a minimum stock (or a max price) so a rounding error + cannot break the market. +- **Currency.** `05-` makes everything euro; the market is the one place money is + spent, which is exactly this module. diff --git a/docs/simple_economy/07-procurement.md b/docs/simple_economy/07-procurement.md new file mode 100644 index 0000000..d274975 --- /dev/null +++ b/docs/simple_economy/07-procurement.md @@ -0,0 +1,78 @@ +# 07 — Procurement from the pool, no bidding + +**Depends on:** `01-`, `02-`. + +## Goal + +All regions procure their resources from their **player's global resource pool**. +There is no bidding, no local sourcing, no distance and no per-region pricing. + +## Current behaviour (hard economy) + +- Procurement is a cost-ordered search across reachable stores and the world + market: + - `_procureFromNeighbours(city, id, amount, haul, nodes, opts)` + - `_nearbySuppliers`, `_resourceNodes`, `_resourceNodeAt`, `_nodeSurplus` + - `_tradeGraph` / `_cityMarketAccess` (the expensive sea-lane flood, cached) + - `_marketUnitCost(id)` = market price + energy freight per unit. +- Consumers call the above from many places: `_payConstructionResources`, + `payCombatResources`, `_gatherSiteMaterials`, `_gatherBuildEntry`, + `_drawFromNearest`, `_buyBuildingMaterials`, plus food/upkeep in + `_tickResources` and `_consumeCityResources`. +- `resourceSpend` records what a nation bought/sold for the budget panel. + +## Target behaviour + +- In simple mode, procurement is a single subtraction from the owner's pool: + `_drawFromPool(civ, id, amount)` returns what was available and decrements the + pool. No ordering beyond resource id, no suppliers, no distance, no freight. +- Replace each consumer's procurement call in simple mode with a pool draw: + - building/training material bills (`03-`), + - upkeep and city consumption in the daily tick, + - combat and repairs, + - anything that currently calls `_procureFromNeighbours` or `_drawFromNearest`. +- **No bidding.** The old `MONEY.bidPremium` / `maxBidMultiplier` behaviour and + `_nearbySuppliers` price escalation must never run in simple mode. If a + consumer used to bid, it now simply takes from the pool; a shortfall is a + shortage (the work stalls or the need goes unmet), never a higher price. +- **No transport bill.** `RESOURCE_RULES.deliveryEnergy`, the trade graph and + the delivery overlay are hard-economy concerns. Do not compute them in simple + mode; skip `_tradeGraph` rebuilds entirely. +- Keep the day's ledger (`resourceSpend`, consumption/production totals) so the + Resources tab and budget can still report flows. Only the *sourcing* changes. +- Prices for any valuation that remains (budget forecasts, combat material + value) are the global market prices from `06-`. + +## Files + +- `shared/game_state/resources.js` — `_procureFromNeighbours`, `_drawFromNearest`, + `_nearbySuppliers`, `_resourceNodes`, `_tradeGraph`, `_cityMarketAccess`, + `_marketUnitCost`, `_tickResources` consumer passes. +- `shared/game_state/sites.js` — `_siteBuy`, `_gatherSiteMaterials`, + `_gatherBuildEntry`, `_siteUnreachableMaterials`. +- `shared/game_state/industry.js` — `_buyBuildingMaterials`, + `_buildingMaterialAvailability`. +- `shared/game_state/simple_economy.js` — `_drawFromPool` and the overrides. +- `tests/simple_procurement_test.js` — **new**. + +## Acceptance criteria + +- Ordering a build/unit in simple mode never calls the trade graph or supplier + search and never charges a transport premium. +- Consumption and production move the player's pool by exact amounts. +- A pool shortfall leaves the need unmet / the work stalled, with no price bid. +- The hard economy procurement path is byte-for-byte unchanged. + +## Notes / decisions + +- **Priority of draws.** If several needs compete in one tick, define an order + (e.g. food before industry before construction) so the simulation is + deterministic. Recommendation: food/upkeep first, then industry inputs, then + construction. +- **Shortage consequence.** In hard mode a shortfall triggers + `shortagePopularityPenalty` and grid throttling. Decide whether a pool + shortfall keeps those penalties. Recommendation: keep the popularity penalty; + drop the per-region grid throttle since energy stays regional and is handled in + `03-`. +- **`_marketUnitCost` for quotes.** Where the UI quotes a delivered price, in + simple mode it is just `marketPrice(id)` from `06-`. diff --git a/docs/simple_economy/08-migrations.md b/docs/simple_economy/08-migrations.md new file mode 100644 index 0000000..f5ce27a --- /dev/null +++ b/docs/simple_economy/08-migrations.md @@ -0,0 +1,81 @@ +# 08 — No economic migrations + +**Depends on:** `01-`. + +## Goal + +The simple economy has **no economic migrations**: people do not move between +regions chasing income/GDP. + +## Current behaviour (hard economy) + +`shared/game_state/politics.js` runs a daily migration tick inside +`_tickPolitics` (`_tickMigration`) that mixes several drivers: + +- **Economic / income-chasing flows.** Destinations are chosen by income ratio + against the origin (`MIGRATION.incomeRatioBase`, `incomeTimePenalty`, + `incomeSharePerDay`, `incomeGapCap`, `incomeMaxSharePerDay`), plus a daily + "equalize" pass over regions (`MIGRATION.equalizePasses`, `equalizeTolerance`, + `minRegionPopulation`). +- **Political / policy flows.** Expelling people, encouraging immigration + (`POLICY_IMMIGRATION`), war-crime expulsion, airport-driven immigration + (`BUILDING_MECHANIC.AIR_IMMIGRATION`), comfort thresholds. +- Natural population growth is separate (`_tickPopulation`). + +`shared/data/politics.js` holds the `MIGRATION` constants. + +## Target behaviour + +- In simple mode, **disable economic migration only**: the income-ratio flows and + the equalize pass must not run, so a region's population changes only through + natural growth and explicit non-economic effects. +- **Decision to settle first — do non-economic flows stay?** + - Option A (recommended): keep political/policy movement (expel, immigration + policy, airports) because it is not "economic"; only strip the income-chasing + and equalize passes. + - Option B: no inter-region migration at all in simple mode (only natural + growth), which is the simplest reading of "no economic migrations" but also + removes political migration. + Confirm with the user before implementing. +- Keep `_tickMigration` and all constants intact for hard; gate the economic + portion with `!isSimpleEconomy()`. +- Keep the migration snapshot fields (`migrations`, `migrationGraph`) valid: in + simple mode with no flows they should be empty rather than `undefined`, so the + client's `setMigrationGraph` does not regress. + +## Files + +- `shared/game_state/politics.js` — `_tickMigration` and the income/equalize + helpers. +- `shared/game_state/simple_economy.js` — an override that runs only the + non-economic part (if Option A) or a no-op population pass (Option B). +- `shared/game_state/serialization.js` — ensure empty `migrations` / + `migrationGraph`. +- `tests/politics_test.js` — keep hard tests; add a simple-mode case asserting no + income-driven flows. + +## Acceptance criteria + +- In simple mode, two regions with very different income do not exchange + people over many ticks. +- Natural population growth still happens. +- The snapshot's migration fields are present and empty (or policy-only), + not missing. +- Hard economy migration tests pass unchanged. + +## Notes / decisions + +- The word "economic" is the key ambiguity; record which option was chosen in + this file once decided. +- **Decision: Option A.** The simple economy retires only the two income-driven + mechanisms: `_migrateForIncome` (the daily income-ratio sweep) and + `_equalizeRegionIncomes` (the start-of-game levelling). Both are overridden to + no-ops in `shared/game_state/simple_economy.js`. Political migration is kept + unchanged (`_tickMigration`: expel, immigration policy, airport traffic), so + the "policy-only" migration snapshot fields are valid and empty only when no + policy or airport is moving anyone. +- No change was needed in `shared/game_state/serialization.js`: `migrations` + and `migrationGraph` are already initialised in `_resetCollections`/ + `_initPolitics` and serialise as empty lists. +- If Option B is chosen, note that the population/ethnicity UI and + `migrationGraph` rendering become inert but must not error. diff --git a/docs/simple_economy/09-ui.md b/docs/simple_economy/09-ui.md new file mode 100644 index 0000000..2d7eb96 --- /dev/null +++ b/docs/simple_economy/09-ui.md @@ -0,0 +1,101 @@ +# 09 — UI changes (approval gate) + +**Depends on:** all simulation modules. **No UI change may be committed until the +user has seen and approved a preview.** + +## Process (mandatory) + +Per `AGENTS.md`: + +1. Build a **minimal standalone HTML example** of the change first — the + isolated element and each of its states, not a whole mocked-up screen. Keep it + small and self-contained: link the stylesheet and copy in only the assets it + needs. A short gallery of one example per visual change is the right size. +2. Open it in the user's Firefox directly: `firefox `. Never render a + screenshot and never just print the path. +3. Wait for approval. Only then wire it into the real client and commit. +4. Do not add the examples to the repository. + +Submit the UI work in small pieces (one element per preview) so each can be +approved independently. + +## Changes to make + +All changes are conditional on the snapshot's `economyModel === "simple"`; the +hard economy keeps its current UI. + +### 1. National Resources tab → global market (`client/js/modals/nation.js`) + +- Replace the read-only "Stores & trade" / "Major traders" content with a + **market table**: per resource, the player's pool amount, the market stock, the + current price, and **Buy / Sell** controls offering the lots `100`, `2 000`, + `50 000`. +- Keep the resource icons/protos so it matches the existing look + (`RESOURCES`, `.resource-icon`). +- Wire each control to send the `market_trade` order (`net.js`). +- Show the treasury (`€`) and the pool clearly; a purchase that the treasury + cannot afford should be disabled or reported. +- Decide whether to keep a separate "stores" summary; the pool replaces + per-region stores, so a single pool row per resource is likely enough. + +Related IDs in `client/index.html`: `#tab-resources`, `#resources-sub`, +`#resources-subtabs`, `#resources-list`. + +### 2. National Economy tab (`client/js/modals/nation.js`) + +- Remove the **Taxes** sub-tab (`#economy-taxes`, `_renderTaxes`, + `_buildEconomySubtabs`). Taxes tab is explicitly not needed. +- Show the euro treasury and the single daily **tax income** line in the budget + summary. +- In simple mode, hide the **Currency**, **Central bank** and **Exchange rates** + sub-tabs (`#economy-currency`, `#economy-central-bank`, `#economy-rates`) since + there is one currency and no FX. Keep "Private sector" only if it still means + something; otherwise reduce the sub-tabs to what remains. +- Ensure money everywhere reads `€` (single symbol), via `currency.js` / + `setCurrency`. + +### 3. City modal (`client/js/modals/city.js`) + +- The city build/train rows must no longer show a **"gathering materials"** + phase in simple mode; show construction/training progress and the + **energy-based speed** instead. +- The city Resources sub-tab should show the region's **energy** and the link to + the player's pool, not per-region stores. + +### 4. Game screen HUD (`client/js/game_screen.js`, `client/js/game_screen/panels.js`) + +- Top bar / budget: show `€` and tax income; no per-civ currency conversion. +- If warmup was surfaced anywhere, drop it for simple. + +### 5. Currency helper (`client/js/currency.js`) + +- With one euro currency, `rateToNational`, `moneyText` and `currencyFor` can be + simplified. Do not break hard mode: keep the multi-currency path when the + snapshot is a hard game. + +## Files + +- `client/index.html` +- `client/js/modals/nation.js` (large; the Resources and Economy tabs) +- `client/js/modals/city.js` +- `client/js/game_screen.js`, `client/js/game_screen/panels.js` +- `client/js/currency.js` +- `client/js/net.js` (send `market_trade`) +- `client/css/` (stylesheet touched by any new market control) + +## Acceptance criteria + +- In a simple game, no Taxes tab, no central-bank/exchange-rate view, no + per-nation currency conversion; the Resources tab is a working market. +- Buying and selling from the UI updates the pool, treasury and price. +- In a hard game the existing UI is unchanged. +- Every visual change has been previewed in Firefox and approved before commit. + +## Notes / decisions + +- **One preview per element.** The market row (with all lot states), the reduced + Economy sub-tabs, and the build-row progress are three separate previews. +- Ask the user whether to keep "Major traders" anywhere; with a single global + market it is probably redundant. +- The client never runs `GameState`; all figures come from the snapshot, so make + sure `10-snapshot-and-orders.md` ships everything these controls need. diff --git a/docs/simple_economy/10-snapshot-and-orders.md b/docs/simple_economy/10-snapshot-and-orders.md new file mode 100644 index 0000000..7c4281f --- /dev/null +++ b/docs/simple_economy/10-snapshot-and-orders.md @@ -0,0 +1,82 @@ +# 10 — Snapshot and orders (wire shape) + +**Depends on:** `01-`; interleave with `02-`, `05-`, `06-`, `07-`. + +## Goal + +Define the snapshot fields and server order handling for the simple economy so +the client can render it and the server can validate it. Keep the snapshot path +cheap, and keep the delta mechanism honest. + +## Current behaviour + +- `shared/game_state/serialization.js` builds the state snapshot and the + per-viewer stats. The market block is around line 726 (`price: + getResourcePrice(id)`); taxes/approval around lines 180–250 and 460–490. +- `server/game_server.js` builds deltas using `DELTA_COLLECTIONS` (top of file), + pairing each large collection with the `versions` key that reports it. A + mismatch re-sends a whole collection on every broadcast. +- Orders are dispatched in `GameServer.handleOrder`; economy orders today are + `set_interest_rate` and `set_tax_rate`. +- The client keeps previous collections until the version moves + (`client/js/game_screen.js`). + +## Target behaviour + +### Snapshot + +- Ship `economyModel` (top level) so the client picks the UI. +- Ship the viewer's **resource pool** (`{ steel, food, luxury, hightech, ... }`) + and, if public, every civ's pool for rankings. Decide visibility: only the + viewer's own pool is economically necessary; others may be omitted for + fairness/perf. +- Ship the **global market**: per resource `stock` (availability) and `price`, + the player's pool amount is separate. This is read every broadcast by open + nation modals, so make it a small, versioned collection. +- Ship the single daily **tax income** / treasury rather than the three trade + taxes. `taxTake` becomes a single `income` figure in simple mode. +- Ensure `migrations` / `migrationGraph` are present and empty (or policy-only) + in simple mode (see `08-`). +- For any new large collection, add a `DELTA_COLLECTIONS` entry **and** bump a + version when it changes. Recommended: `["resourcePools", "resourcePools"]` + and `["market", "market"]`, each with a matching `versions` key. If the pool + is small (four numbers per civ) prefer sending it inline and skip the delta + machinery. + +### Orders + +- Add `market_trade` → `requestMarketTrade(civ, resource, side, qty)` (`06-`). + Validate resource id, side, lot membership, treasury/pool, and mutate + atomically on the server. Never trust `free`. +- Reject `set_tax_rate` and `set_interest_rate` for simple games (return false). +- Keep every hard-mode order working when `economyModel === "hard"`. +- If construction/training no longer cost money (`05-`), the existing order + handlers need no economy-specific validation change beyond delegating to the + simple state. + +## Files + +- `shared/game_state/serialization.js` +- `server/game_server.js` — `DELTA_COLLECTIONS`, `handleOrder`, new handler +- `server/server.js` — setup field (already via `01-`) +- `client/js/game_screen.js` / `client/js/net.js` — consume the new fields +- `tests/simple_snapshot_test.js` — **new** + +## Acceptance criteria + +- A simple snapshot includes `economyModel`, the market (stock+price per + resource) and the viewer's pool; the client can render the market from it with + no extra fields. +- A changed market/pool is shipped at most once until it changes again (delta + works, version key matches). +- `market_trade` is validated server-side; a bad lot/resource/side is refused. +- Hard snapshots are unchanged. + +## Notes / decisions + +- **Pool visibility.** Sending every civ's pool is useful for rankings but not + required; start with the viewer's own and add others only if the UI needs it. +- **Market in the snapshot every broadcast.** The price derives from the stock, + so ship the stock (and the price for convenience). Version both together. +- **Fog.** The pool and market are national, not tile data, so no visibility + filtering beyond the viewer's own nation. diff --git a/docs/simple_economy/11-tests.md b/docs/simple_economy/11-tests.md new file mode 100644 index 0000000..adc2c18 --- /dev/null +++ b/docs/simple_economy/11-tests.md @@ -0,0 +1,119 @@ +# 11 — Test plan + +**Depends on:** all modules. + +## Goal + +Cover the simple economy with focused suites while keeping the hard economy's +existing suites green. During development run only the affected file: + +``` +node tests/run_tests.js --file .js +``` + +Never run the full suite by hand; the commit hook runs it. + +## Conventions + +- Each file extends `TestCase` from `tests/framework/`. Look at + `tests/resources_test.js`, `tests/money_test.js`, `tests/industry_test.js`, + `tests/construction_site_test.js` and `tests/framework/helpers.js` for the + established fixtures. +- Build a small map fixture when possible (the DOM tests use one); force RNG with + `state._random = () => value`. +- Configure simple games explicitly: `game.configure(civs, seed, { economyModel: + "simple" })`, and add a hard counterpart where behaviour must stay unchanged. + +## Suites + +### `tests/economy_model_test.js` (from `01-`) + +- Default `economyModel` is `"simple"`. +- `{ economyModel: "hard" }` and `{ economyModel: "simple" }` both configure and + tick. +- The snapshot reports the model. +- An unknown model coerces to `"simple"`. + +### `tests/simple_pool_test.js` (from `02-`) + +- One pool per civ, seeded; public producer output lands in the pool. +- No per-tile `resourceStock` is read for procurement. +- A build/train order does not enter a materials phase. + +### `tests/simple_construction_test.js` (from `03-`, `04-`) + +- Order enters construction immediately; completes on the hourly tick without a + gather day. +- More regional energy → strictly shorter `totalHours` (within the same map); + zero energy → a finite, slow floor, never instant. +- Every simple starting region has a working power plant. +- A simple game has `settledDays === 0` and no price settle. + +### `tests/simple_taxes_test.js` (from `05-`) + +- Daily income equals `population × 100 × approval × 2 × modifiers` per civ. +- Income uses the derived approval, not raw popularity. +- `set_tax_rate` is refused in simple mode; the single income source shows in the + budget breakdown. +- Construction/training/upkeep do not debit the treasury in simple mode. +- Hard `taxes_test.js` / `money_test.js` still pass. + +### `tests/simple_market_test.js` (from `06-`) + +- At stock `1_000_000`, price equals `RESOURCE_MARKET_BASE`. +- Buy lowers stock and raises price; treasury and pool move exactly. +- Sell raises stock and lowers price. +- Only lots `100/2000/50000` are accepted. +- Insufficient funds/stock is refused without partial mutation. +- A near-empty stock is clamped (no `Infinity`/`NaN`). + +### `tests/simple_procurement_test.js` (from `07-`) + +- Consumption draws move the pool exactly. +- No trade-graph/supplier lookup runs in simple mode. +- A shortfall stalls the work / leaves the need unmet without a bid. + +### `tests/simple_migration_test.js` (from `08-`) + +- Two regions with very different income do not exchange people in simple mode. +- Natural growth still applies. +- Hard `politics_test.js` migration tests pass. + +### `tests/simple_snapshot_test.js` (from `10-`) + +- Simple snapshot carries `economyModel`, the market (stock+price) and the + viewer's pool. +- A market change bumps its version and re-ships; an unchanged market is + omitted. +- `market_trade` validation accepts a valid lot and rejects bad input. + +## Hard-economy regression + +The hard-model suites must pass unchanged: `resources_test.js`, +`money_test.js`, `taxes_test.js`, `industry_test.js`, +`construction_site_test.js`, `politics_test.js`, `growth_test.js`, +`air_test.js`. If a hard path had to be refactored, add a direct hard assertion +rather than relying on the hook alone. + +## Acceptance criteria + +- Each new suite fails before its module is implemented and passes after. +- No hard-economy test is modified to accommodate a simple-economy change + without a note in this file. + +## Implementation notes + +- All suites above are in place. `tests/simple_migration_test.js` is the + dedicated home for the 08 checks; the simple-mode migration cases were + *also* added beside the hard ones in `tests/politics_test.js` (as 08 asked), + so the behaviour is pinned from both there and `migration_test.js`. +- The new migration suite carries a direct hard control + (`test_the_hard_model_still_moves_people_for_income`) so the no-op overrides + are proved to differ from the original model rather than relying on the hook. +- Verified green during development: `economy_model_test`, `simple_pool_test`, + `simple_construction_test`, `simple_taxes_test`, `simple_market_test`, + `simple_procurement_test`, `simple_snapshot_test`, `simple_ui_test`, + `simple_migration_test`, plus the hard regression suites + (`resources_test`, `money_test`, `taxes_test`, `industry_test`, + `construction_site_test`, `politics_test`, `migration_test`, `growth_test`, + `air_test`). diff --git a/docs/simple_economy/README.md b/docs/simple_economy/README.md new file mode 100644 index 0000000..3661c10 --- /dev/null +++ b/docs/simple_economy/README.md @@ -0,0 +1,124 @@ +# Simple economy — implementation prompts + +This directory decomposes the "simple economy" replacement into one +implementable unit per file. Each file is written as a **self-contained prompt**: +a future session should be able to pick up one file and implement it without +re-reading the whole codebase. + +The existing economy is **not deleted**. It is frozen as the **hard economy** +and kept selectable. The **simple economy is the default** from now on. + +--- + +## 1. Shared project context (read this first) + +*Battle for 'Tismo* is an HTML5 port of a turn-of-the-millennium strategy game. +Pure JavaScript, no build step, no runtime dependencies. + +- **Server-authoritative.** `shared/game_state.js` (`GameState`) is the model; + `server/game_server.js` validates orders; the browser renders snapshots and + sends orders back. The client never runs `GameState` against a live game. +- **GameState is mixins.** `shared/game_state.js` composes the modules in + `shared/game_state/` onto the prototype (`Object.assign` of each + `*Methods` export). All state lives on the one instance. +- **Shared logic runs in Node and the browser.** `shared/` must stay + framework-free (no Node built-ins, no DOM). `server/websocket.js` is the only + place Node built-ins are expected. +- **Data** lives in `shared/data/` (barrel: `shared/data.js`); import from the + barrel, edit the file under `shared/data/`. +- **No new libraries.** Everything is hand-written; only the vendored jQuery + under `client/vendor/` is third-party. Indent `.js` with 2 spaces. +- **Tests** live in `tests/_test.js` extending `TestCase` (base in + `tests/framework/`). During development run **one** suite: + `node tests/run_tests.js --file .js`. Never run the full suite by hand; + the commit hook runs it. +- **Snapshot cost matters.** Large collections are delta-shipped via + `DELTA_COLLECTIONS` in `server/game_server.js`, each paired with the + `versions` key that reports it. A new snapshot collection needs an entry and a + version, or it ships on every 10 Hz broadcast. +- **UI changes need approval.** Per `AGENTS.md`, any change that alters what the + player sees must first be shown as a minimal standalone HTML example opened in + the user's Firefox, and only committed once the user has seen and approved it. + See `09-ui.md`. + +### Commands + +- Install once: `npm install`. +- One suite: `node tests/run_tests.js --file .js`. +- Server: `node server/server.js --port 27015 --bind 127.0.0.1`. + +--- + +## 2. The simple economy at a glance + +| Area | Hard economy (today) | Simple economy (target) | +| --- | --- | --- | +| Resources | per-region/tile stores, transport, trade graph | **one global pool per player, available everywhere** | +| Production buildings | private agents with cash/debt/upgrades | **public, output to the player's pool** | +| Building | gathers steel/high-tech over days, then builds | **starts immediately**, speed driven by regional energy | +| Construction capacity | `GDP` → `ECONOMY.productionCapacity` | **energy available in the region**, with modifiers | +| Warmup | settles the world until prices are steady | **none** | +| Currency | one per nation, floating FX, central banks | **euro (€) only, no conversion** | +| Money use | construction, training, internal bills | **market purchases only** | +| Taxes | trade taxes (sales/export/import) | **1 inhabitant = 100 €/day × (approval × 2) × modifiers** | +| Market | read-only reference prices, drifting | **global buy/sell market** in the Resources tab | +| Procurement | bidding across reachable stores + global market | **draw from the player's pool, no bidding** | +| Migration | income-driven people move between regions | **no economic migration** | + +Open questions are called out in each module file; settle them before or while +implementing that module. + +--- + +## 3. Suggested order and dependencies + +Implement in this order. Later modules assume the earlier switch exists. + +1. `01-decoupling-hard-economy.md` — the `economyModel` switch everything hangs + off. **Do this first.** +2. `02-resource-pool-and-production.md` — the pool and public producers. +3. `03-construction.md` — immediate build, energy-driven speed, starting plants. +4. `04-warmup.md` — skip the settle in simple mode. +5. `05-money-and-taxes.md` — euro-only treasury and per-inhabitant income. +6. `06-global-market.md` — the global market and its order. +7. `07-procurement.md` — pool draws instead of bidding. +8. `08-migrations.md` — turn off economic migration. +9. `09-ui.md` — the visible changes (approval gate). +10. `10-snapshot-and-orders.md` — wire protocol and server validation. +11. `11-tests.md` — test plan spanning the above. + +`10` is naturally interleaved with `02`, `05`, `06` and `07`; treat it as the +checklist for the wire shape of whatever those modules add. + +--- + +## 4. Cross-cutting rules for every module + +- Keep the hard economy working when `economyModel === "hard"`. Do not delete + hard code paths; gate them. +- Default `economyModel` is `"simple"` everywhere it is not explicitly set. +- Do not add libraries; do not touch `client/vendor/`. +- Every new snapshot field must be cheap and versioned. +- Add or extend the matching `tests/_test.js`; run only that file. +- No comments unless the surrounding file already uses them heavily; match the + house style (the code is heavily commented on purpose — follow suit for new + modules). +- Commit only when asked; the hook runs the full suite. + +--- + +## 5. Module index + +| File | Module | +| --- | --- | +| `01-decoupling-hard-economy.md` | The economy-model switch; freeze current model as hard. | +| `02-resource-pool-and-production.md` | Global pool per player; public production buildings. | +| `03-construction.md` | Immediate building; speed from regional energy; starting plants. | +| `04-warmup.md` | No warmup in the simple economy. | +| `05-money-and-taxes.md` | Euro only; per-inhabitant approval tax; drop taxes tab. | +| `06-global-market.md` | Global market, lots, price formula, starting stock. | +| `07-procurement.md` | Regions draw from the player's pool; no bidding. | +| `08-migrations.md` | No economic migration. | +| `09-ui.md` | All client changes (approval gate). | +| `10-snapshot-and-orders.md` | Snapshot fields, deltas, server order handling. | +| `11-tests.md` | Test plan and fixtures. | diff --git a/server/game_server.js b/server/game_server.js index 656b5c9..be3b05a 100644 --- a/server/game_server.js +++ b/server/game_server.js @@ -26,6 +26,10 @@ const DELTA_COLLECTIONS = [ ["cityStats", "cityStats"], ["visible", "visible"], ["resourceGraph", "resourceGraph"], + // `resourceMarket` is small but is priced by the simple economy's global + // stock and carries its supply/demand in the hard one, so it re-ships when + // the market version moves rather than on every 10 Hz broadcast. + ["resourceMarket", "market"], ["migrationGraph", "migrationGraph"], ]; @@ -111,7 +115,11 @@ export class GameServer extends EventEmitter { this.testing = !!setup.testing; this.state = new GameState(); if (setup.mapConfig) this.state.mapConfig = setup.mapConfig; - this.state.configure(this._loadCivs(setup.civilisations || []), Number(setup.seed || 0)); + this.state.configure(this._loadCivs(setup.civilisations || []), Number(setup.seed || 0), { + // The model the launcher chose; `configure` coerces an unknown value to + // the "simple" default, so the setup may pass anything. + economyModel: setup.economyModel, + }); this.state.testing = this.testing; this.state.onChanged(() => { this._stateDirty = true; @@ -121,8 +129,10 @@ export class GameServer extends EventEmitter { // cold start. The settle runs until commodity prices hold steady (over a // seven-day window) instead of for a fixed number of days, capped by the // launcher's `warmupDays` so an unsettled world cannot run away. Off when - // the cap is zero. - const warmupDays = Number(setup.warmupDays || 0); + // the cap is zero. This is a hard-economy concept: the simple economy's + // scenario is authored as its opening state, so it skips the settle + // entirely and `settledDays` stays 0 whatever the cap says. + const warmupDays = this.state.isSimpleEconomy() ? 0 : Number(setup.warmupDays || 0); this.settledDays = warmupDays > 0 ? this.state.warmUpToStability({ maxDays: warmupDays }) : 0; @@ -262,11 +272,26 @@ export class GameServer extends EventEmitter { case "revoke_policy": return this._handleRevokePolicy(civ, order); case "set_interest_rate": + // The simple economy has no central bank and no tax controls: its one + // revenue is the per-inhabitant approval tax, so both orders are refused + // before they reach the state. + if (this.state.isSimpleEconomy()) return false; return this.state.requestSetInterestRate(civ, Number(order.rate)); case "set_tax_rate": + if (this.state.isSimpleEconomy()) return false; return this.state.requestSetTaxRate( civ, order.kind, Number(order.rate), order.resource || null ); + case "market_trade": + // The global market is a simple-economy institution: the hard model's + // reference prices are not buyable, so its server has no such order. + if (!this.state.isSimpleEconomy()) return false; + return this.state.requestMarketTrade( + civ, + String(order.resource || ""), + String(order.side || ""), + Number(order.lot) + ); default: return false; } diff --git a/server/server.js b/server/server.js index 514008f..21cb5a5 100644 --- a/server/server.js +++ b/server/server.js @@ -1,7 +1,8 @@ // Node entry point: serves the client and shared modules as static files and // hosts the authoritative game over a WebSocket. Run with: // -// node server/server.js [--port 27015] [--bind 127.0.0.1] +// node server/server.js [--port 27015] [--bind 127.0.0.1] [--economy simple|hard] +// [--warmup days (hard economy only)] // // The browser client connects to ws(s):///ws on the same origin. The // wiring is exposed through startServer() so the test suite can boot the same @@ -14,6 +15,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { WebSocketServer } from "./websocket.js"; import { GameServer } from "./game_server.js"; import { SECONDS_PER_HOUR } from "../shared/game_clock.js"; +import { DEFAULT_ECONOMY_MODEL, normalizeEconomyModel } from "../shared/data.js"; const ROOT = normalize(join(dirname(fileURLToPath(import.meta.url)), "..")); @@ -112,6 +114,11 @@ export function startServer({ civilisations = [], mapConfig = null, warmupDays = 0, + // Whether `warmupDays` was asked for explicitly (the launcher's flag or the + // `WARMUP` env var rather than a default), so the simple model can say it is + // ignoring the request instead of dropping it in silence. + warmupExplicit = false, + economyModel = "simple", } = {}) { const httpServer = createServer(serveStatic); const wsServer = new WebSocketServer(httpServer, { path: "/ws" }); @@ -128,13 +135,21 @@ export function startServer({ }; const gameServer = new GameServer(network); - if (warmupDays > 0) { + // The settle is a hard-economy concept; the simple model opens on its authored + // scenario. Resolve the model here the same way `configure` will, so the log + // matches the game that is actually built. + const simpleEconomy = normalizeEconomyModel(economyModel) === DEFAULT_ECONOMY_MODEL; + if (simpleEconomy) { + if (warmupExplicit) { + log("the simple economy needs no warm-up; ignoring --warmup."); + } + } else if (warmupDays > 0) { log(`settling the world until commodity prices hold steady (up to ${warmupDays} days)...`); } const settledDays = gameServer.configureGame({ - seed, player_civ: 0, civilisations, testing, mapConfig, warmupDays, + seed, player_civ: 0, civilisations, testing, mapConfig, warmupDays, economyModel, }); - if (warmupDays > 0) log(`world settled at day ${settledDays}`); + if (!simpleEconomy && warmupDays > 0) log(`world settled at day ${settledDays}`); function broadcastPlayers() { const players = {}; @@ -303,6 +318,13 @@ function argValue(name, fallback) { } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + // Settle the world before serving, so the hard game opens with its economy + // established. The settle stops early once commodity prices hold steady over + // a seven-day window, and this value caps the days it may run. `--warmup 0` + // skips the settle. A `null` means the operator asked for it explicitly + // (flag or `WARMUP`), which the simple model reports and ignores; the plain + // default 150 is not "explicit". + const warmupArg = argValue("--warmup", process.env.WARMUP ?? null); startServer({ port: Number(argValue("--port", process.env.PORT || 27015)), bind: argValue("--bind", process.env.BIND || "127.0.0.1"), @@ -310,10 +332,11 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) // Free, instant orders for local play; remote peers still have their `free` // flag stripped in the order handler. testing: process.argv.includes("--testing"), - // Settle the world before serving, so the game opens with its economy - // established. The settle stops early once commodity prices hold steady - // over a seven-day window, and this value caps the days it may run. - // `--warmup 0` skips the settle. - warmupDays: Number(argValue("--warmup", process.env.WARMUP || 150)) | 0, + warmupDays: warmupArg == null ? 150 : Number(warmupArg) | 0, + warmupExplicit: warmupArg != null, + // Which economy model to run: "simple" (the default) or "hard", the + // original per-region simulation. `configure` coerces an unknown value to + // the default. + economyModel: argValue("--economy", process.env.ECONOMY || "simple"), }); } diff --git a/shared/data/effects.js b/shared/data/effects.js index 0316bc4..dcc9f14 100644 --- a/shared/data/effects.js +++ b/shared/data/effects.js @@ -14,6 +14,10 @@ export const EFFECT_ARTILLERY_ATTACK = "artillery_attack"; export const EFFECT_MISSILE_ATTACK = "missile_attack"; export const EFFECT_CONSTRUCTION_SPEED = "construction_speed"; export const EFFECT_RAIL_SPEED = "rail_speed"; +// Scales the simple economy's per-inhabitant daily tax. No government, building +// or technology grants one yet, so the factor is 1; it exists so the tax formula +// has a single hook a later policy can move. +export const EFFECT_TAX_INCOME = "tax_income"; // Resource efficiency: the national bonus to a commodity's output per unit of // energy input. One technology per commodity raises its own stat, and the diff --git a/shared/data/index.js b/shared/data/index.js index bca15fe..b8dfb67 100644 --- a/shared/data/index.js +++ b/shared/data/index.js @@ -12,6 +12,7 @@ export * from "./technologies.js"; export * from "./statuses.js"; export * from "./combat.js"; export * from "./economy.js"; +export * from "./simple_economy.js"; export * from "./politics.js"; export * from "./intelligence.js"; export * from "./map_config.js"; diff --git a/shared/data/simple_economy.js b/shared/data/simple_economy.js new file mode 100644 index 0000000..68379df --- /dev/null +++ b/shared/data/simple_economy.js @@ -0,0 +1,106 @@ +// Simple-economy figures, in their own namespace so they cannot collide with +// the hard economy's ECONOMY, MONEY and RESOURCE_* constants. The simple model +// is the default from now on; the rich per-region simulation is kept intact as +// the "hard" model and selected explicitly. + +// The two models a game may run, and the one used when none is given. +export const ECONOMY_MODELS = ["simple", "hard"]; +export const DEFAULT_ECONOMY_MODEL = "simple"; + +export const SIMPLE_ECONOMY = { + // Tax: every inhabitant pays this many euros a day, scaled by twice the + // region's approval (a 0..2 factor) and any tax-income modifiers. + taxPerInhabitant: 100, + approvalTaxFactor: 2, + // The one shared currency: there is no conversion, every nation quotes prices + // in euros. + euro: { code: "EUR", symbol: "€", name: "Euro" }, + // The fixed euro reserve every nation opens with. Money buys only market + // goods in the simple economy, so this is day-one buying power rather than a + // working balance the state spends internally. + startingTreasury: 1_000_000_000, + // Propaganda is the one thing culture is spent on: a running campaign drains + // culture at its hourly rate (the opinion shift scales with that rate just as + // the hard economy's money spend did), and lapses when the nation can no + // longer fund it. The hard economy's figures are money, so the simple model + // keeps its own, sized near a culture building's hourly output. + campaign: { + defaultCulturePerHour: 8, + maxCulturePerHour: 40, + }, + // The comfortable working stock every nation's single resource pool opens + // with. Unlike the hard economy's year of per-region reserves this is a fixed + // figure, not derated from consumption: with one pool per player there is + // nowhere for a surplus region to hoard, so the opening stock only has to + // bridge the first works coming online. Energy is not pooled -- it stays a + // per-region flow (see `03-construction.md`) -- so only the four storable + // goods appear here. + startingPool: { + steel: 1_000_000, + food: 500_000, + luxury: 500_000, + hightech: 20_000, + }, + // Construction in the simple economy starts the moment it is ordered: there + // is no materials-gathering phase, and the speed comes from the spare power + // of the region the work stands in rather than from national GDP. + construction: { + // The cost/energy exchange at the heart of simple construction: one euro of + // build cost needs this many kWh a day of spare regional power to be raised + // in one hour. A work's base hours are `cost * energyPerConstructionHour / + // max(available, minEnergy)`, then the building/training ratio below and the + // usual approval and technology multipliers. A tighter grid raises the same + // work slower, the opposite of the hard economy's richer-nation-builds-faster. + energyPerConstructionHour: 3_000, + // The least spare power a region is credited with, so a blacked-out region + // still builds -- slowly -- instead of dividing by zero or finishing at + // once. A tenth of a solar plant's daily output. + minEnergy: 30_000_000, + // The hard economy builds ten times faster than it trains a unit of the same + // cost; the simple economy keeps that ratio. + buildingTimeMultiplier: 0.1, + // No work finishes in under an hour, so even a surging grid cannot make a + // build instant. + minHours: 1, + // The fuel-free plants, in preference order, used to guarantee every opening + // region a power plant. All three are land buildings with no fuel bill, so + // the simple economy never implies a fuel resource. + seedPlantIds: ["solar_power_plant", "wind_turbines", "geothermal_plant"], + }, +}; + +// The global market every simple-economy nation buys and sells from. One shared +// world stock per storable good opens at this figure, and the price is the base +// price scaled by how far the stock has fallen from it: half the stock doubles +// the price, a glut cheapens it. Buying drains the stock (raising the price), +// selling refills it (lowering it), so the two sides of the trade price +// themselves. A single shared stock, not a per-player order book, matches the +// formula and the opening figure. +export const MARKET_START_STOCK = 1_000_000; + +// The only quantities a market order may name, in canonical units. The client +// offers exactly these three buttons and the server re-validates the lot. +export const MARKET_LOTS = [100, 2_000, 50_000]; + +// The stock a price is never allowed to read below, so an emptied market cannot +// send the price to infinity (or to NaN on a zero). One thousandth of the +// opening stock caps the dearest a good can get at a thousand times its base. +export const MARKET_MIN_STOCK = 1_000; + +// The market price of one good at a given world stock: +// `basePrice × (stock / MARKET_START_STOCK) ^ -1`, clamped so a low or broken +// stock stays finite. This is the single source of truth the tick, the order +// handler and the snapshot all price a good through. +export function marketPriceFor(basePrice, stock) { + const available = Number.isFinite(stock) && stock > MARKET_MIN_STOCK + ? stock + : MARKET_MIN_STOCK; + return basePrice * Math.pow(available / MARKET_START_STOCK, -1); +} + +// Coerces a stored or configured model name to a known one. An unknown value +// falls back to the default rather than throwing, so a typo or a stale save +// cannot stop a game from starting. +export function normalizeEconomyModel(value) { + return ECONOMY_MODELS.includes(value) ? value : DEFAULT_ECONOMY_MODEL; +} diff --git a/shared/game_state.js b/shared/game_state.js index 9f591ac..5641491 100644 --- a/shared/game_state.js +++ b/shared/game_state.js @@ -18,6 +18,8 @@ import { GOVERNMENTS, TECHNOLOGIES, MAP_CONFIG, + DEFAULT_ECONOMY_MODEL, + normalizeEconomyModel, } from "./data.js"; import { MAX_NEWS, @@ -47,6 +49,7 @@ import { politicsMethods } from "./game_state/politics.js"; import { orderMethods } from "./game_state/orders.js"; import { visibilityMethods } from "./game_state/visibility.js"; import { serializationMethods } from "./game_state/serialization.js"; +import { simpleEconomyMethods } from "./game_state/simple_economy.js"; import { monthlyMethods } from "./game_state/monthly.js"; import { warfareMethods } from "./game_state/warfare.js"; import { resourceMethods } from "./game_state/resources.js"; @@ -94,6 +97,10 @@ export class GameState { // Set by the server for a local test game: construction and training may be // issued for free and instantly. Never trusted from a remote client. this.testing = false; + // Which economy model the game runs: "simple" (the default) or "hard", the + // original per-region resource simulation. Fixed for the life of a game and + // shipped in the snapshot so the client renders the matching UI. + this.economyModel = DEFAULT_ECONOMY_MODEL; this._generator = null; this._pathfinder = new HexPathfinder(); this._minMovementCost = 1.0; @@ -336,7 +343,21 @@ export class GameState { return this._configured; } - configure(civs, worldSeed = 0) { + // Whether this game runs the simple economy. Branch on this rather than + // comparing `economyModel` to a string at each call site. + isSimpleEconomy() { + return this.economyModel === DEFAULT_ECONOMY_MODEL; + } + + configure(civs, worldSeed = 0, options = {}) { + // The model is fixed before the world is built, because world and scenario + // seeding differ per model. Unknown values coerce to the default. Assigning + // the simple overrides as own properties shadows the hard mixins on the + // prototype; dropping any previous ones first makes a reconfigure from + // simple back to hard fall through to the untouched prototype again. + this.economyModel = normalizeEconomyModel(options.economyModel); + for (const name of Object.keys(simpleEconomyMethods)) delete this[name]; + if (this.isSimpleEconomy()) Object.assign(this, simpleEconomyMethods); this.civilisations = civs.slice(); // The map seeds one small island per nation (up to the map's island // budget), and those islands are the first capitals, so generation has to diff --git a/shared/game_state/budget.js b/shared/game_state/budget.js index ee4c5de..3ffa93e 100644 --- a/shared/game_state/budget.js +++ b/shared/game_state/budget.js @@ -20,9 +20,10 @@ import { MONTH_NAMES } from "../game_clock.js"; export const BUDGET_HISTORY_MONTHS = 24; function emptyBudgetLedger() { - // 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 }; + // Tax income is booked under the kind of trade tax that raised it -- or the + // simple economy's single `taxIncome` category; `income` is kept as the + // running total the report reads. + const cash = { income: 0, sell: 0, taxIncome: 0, taxSales: 0, taxExport: 0, taxImport: 0 }; const economic = {}; for (const category of BUDGET_EXPENSE_CATEGORIES) { cash[category.id] = 0; @@ -194,11 +195,12 @@ export const budgetMethods = { })) .filter((entry) => entry.bought > 0 || entry.sold > 0); const taxes = { + income: cash.taxIncome || 0, sales: cash.taxSales || 0, export: cash.taxExport || 0, import: cash.taxImport || 0, }; - const income = taxes.sales + taxes.export + taxes.import; + const income = taxes.income + taxes.sales + taxes.export + taxes.import; const sales = cash.sell || 0; const year = Math.floor(monthIndex / 12); const month = monthIndex % 12; diff --git a/shared/game_state/economy.js b/shared/game_state/economy.js index 0235e0d..6dc5344 100644 --- a/shared/game_state/economy.js +++ b/shared/game_state/economy.js @@ -400,21 +400,36 @@ export const economyMethods = { // The tax the whole nation collects in one hour, the day's take spread over // its 24 hours. getCivHourlyIncome(civ) { - const take = this.getTaxTake(civ); - return (take.sales + take.export + take.import) / HOURS_PER_DAY; + return this._taxIncome(this.getTaxTake(civ)) / HOURS_PER_DAY; }, - // The three trade taxes a nation collected over the last day, as hourly - // averages, in the order the budget panel lists them. + // The money a nation's tax ledger holds, in whichever shape its model uses: + // the simple economy's single "income" figure, or the hard economy's three + // trade taxes. One helper keeps the two call shapes from drifting. + _taxIncome(take) { + return (take.income || 0) + (take.sales || 0) + (take.export || 0) + (take.import || 0); + }, + + // The tax sources a nation collected over the last day, as hourly averages, + // in the order the budget panel lists them. The simple economy has one + // ("Tax income"); the hard economy has its three trade taxes. _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 }, - ]; + const sources = []; + if (take.income) { + sources.push({ label: "Tax income", kind: "tax", amount: take.income / HOURS_PER_DAY }); + } + if (take.sales) { + sources.push({ label: "National sales tax", kind: "tax", amount: take.sales / HOURS_PER_DAY }); + } + if (take.export) { + sources.push({ label: "Export tariffs", kind: "tax", amount: take.export / HOURS_PER_DAY }); + } + if (take.import) { + sources.push({ 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); + return sources; }, // Income and upkeep with the source behind every figure. The income side is @@ -435,7 +450,7 @@ export const economyMethods = { gdp: economy.gdp, approval: economy.approval, taxes: tax, - income: (tax.sales + tax.export + tax.import) / HOURS_PER_DAY, + income: this._taxIncome(tax) / HOURS_PER_DAY, }); } const income = regions.reduce((sum, region) => sum + region.income, 0); @@ -501,7 +516,7 @@ export const economyMethods = { cityBudgetBreakdown(city, economy = null) { if (!economy) economy = this.getCityEconomy(city); const tax = this.getCityTaxTake(city); - const income = (tax.sales + tax.export + tax.import) / HOURS_PER_DAY; + const income = this._taxIncome(tax) / HOURS_PER_DAY; const region = { cityId: city.id, cityName: city.name, diff --git a/shared/game_state/politics.js b/shared/game_state/politics.js index dcee332..0dbb29e 100644 --- a/shared/game_state/politics.js +++ b/shared/game_state/politics.js @@ -485,7 +485,7 @@ export const politicsMethods = { target, direction: dir, startedHours: this.totalHours, - hourlyCost: PROPAGANDA.defaultHourlyCost, + hourlyCost: this._campaignDefaultCost(), dailyDelta: PROPAGANDA.dailyDelta, }); this._touchPolitics(); @@ -503,7 +503,7 @@ export const politicsMethods = { const value = Number(amount); if (!Number.isFinite(value) || value < 0) return false; if (value <= 0) return this.requestCancelCampaign(civ, campaignId); - campaign.hourlyCost = Math.min(value, PROPAGANDA.maxHourlyCost); + campaign.hourlyCost = Math.min(value, this._campaignMaxCost()); this._touchPolitics(); this._emitChanged(); return true; @@ -552,6 +552,25 @@ export const politicsMethods = { return [{ label: "Propaganda campaigns", kind: "campaign", amount: total, count }]; }, + // How hard a campaign lands relative to its default spend. The hard economy + // prices a campaign in money; the simple economy overrides the two cost hooks + // to price it in culture, so the opinion shift tracks whichever currency + // funds it. + _campaignSpendFactor(campaign) { + const base = this._campaignDefaultCost(); + return base > 0 ? campaign.hourlyCost / base : 0; + }, + + // The hourly spend a new campaign opens at, and the ceiling it may be raised + // to. The hard model's figures are money; the simple model overrides these. + _campaignDefaultCost() { + return PROPAGANDA.defaultHourlyCost; + }, + + _campaignMaxCost() { + return PROPAGANDA.maxHourlyCost; + }, + // Campaigns run for as long as they are left in place, moving opinion a // little every day. Spending above the default amplifies the shift and // spending below it dampens it. The shift also shrinks as the target opinion @@ -560,9 +579,7 @@ export const politicsMethods = { _tickCampaigns() { if (this.campaigns.length === 0) return; for (const campaign of this.campaigns) { - const spendFactor = PROPAGANDA.defaultHourlyCost > 0 - ? campaign.hourlyCost / PROPAGANDA.defaultHourlyCost - : 0; + const spendFactor = this._campaignSpendFactor(campaign); const delta = campaign.direction * campaign.dailyDelta * diff --git a/shared/game_state/resources.js b/shared/game_state/resources.js index 242dfa7..4cc5957 100644 --- a/shared/game_state/resources.js +++ b/shared/game_state/resources.js @@ -120,6 +120,10 @@ export const resourceMethods = { // The regions whose food store has run dry and cannot feed their people. this._famine = new Set(); this._resourceVersion = 0; + // Bumped whenever the market listing could have changed, so the delta + // snapshot re-ships it only then. The hard tick moves it every day; a + // simple-economy trade bumps it itself. + this._marketVersion = 0; // What each nation imported and exported over the last simulated day, by // resource, from cross-nation deliveries only. this.resourceTrade = new Map(); @@ -311,6 +315,13 @@ export const resourceMethods = { for (const claim of claims) { this._layStartingIndustry(rng, components, foodEnergy, touchesNetwork, claim); } + // The simple economy keys construction speed to a region's spare power, so + // every region must open with at least one plant. The seeding above only + // covers grids the converters put in deficit; this fills the gaps. The + // override is absent in the hard model, so the guard keeps hard untouched. + if (this.isSimpleEconomy() && this._seedSimpleRegionPlants) { + this._seedSimpleRegionPlants(); + } this._clearTileGdpCache(); }, @@ -498,6 +509,27 @@ export const resourceMethods = { return previous === undefined ? this.getResourcePrice(id) : previous; }, + // Moves every commodity's price a share of the way toward the equilibrium the + // day's world-wide supply and demand imply, and records the inflation the + // move adds up to. The simple economy overrides this to a no-op: its prices + // are derived from the global market stock, not from a day's imbalance. + _updateMarketPrices(market) { + 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( + RESOURCE_MARKET_BASE[id], + this.getResourcePrice(id), + stats.supply, + stats.demand + ); + this.setResourcePrice(id, price); + stats.price = price; + } + this._recordCommodityInflation(market, previousPrices); + }, + // 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 @@ -1524,6 +1556,10 @@ export const resourceMethods = { // shortfall never grew. this._foodProduced = new Map(); this._resetTaxLedger(); + // The simple economy has no trade to tax: its one revenue is the day's + // per-inhabitant approval tax, collected into the fresh ledger. The override + // is absent in the hard model, so the guard keeps hard untouched. + if (this.isSimpleEconomy() && this._tickApprovalTaxes) this._tickApprovalTaxes(); // The transport network cannot move while the day's trades run, so the many // sourcing passes below share one set of graph walks and port-access checks. // The walks themselves outlive the day (see `_tradeGraph`); only the @@ -1738,21 +1774,11 @@ export const resourceMethods = { // 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( - RESOURCE_MARKET_BASE[id], - this.getResourcePrice(id), - stats.supply, - stats.demand - ); - this.setResourcePrice(id, price); - stats.price = price; - } - this._recordCommodityInflation(market, previousPrices); + // and the consumption-weighted inflation it adds up to. The simple economy + // replaces this with a no-op, so its market listing only re-ships when a + // trade moves the stock. + this._marketVersion += 1; + this._updateMarketPrices(market); // Today's prices join the central bank's daily index series. this._sampleDailyResourcePrices(); diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js index ec1f586..64ae19b 100644 --- a/shared/game_state/serialization.js +++ b/shared/game_state/serialization.js @@ -26,6 +26,9 @@ export const serializationMethods = { mapConfig: this.mapConfig, // A local test game where the client offers free, instant orders. testing: !!this.testing, + // Which economy model the game runs ("simple" or "hard"), so a joiner + // renders the matching UI. + economyModel: this.economyModel, totalHours: this.totalHours, civs: this.civilisations.map((c) => ({ id: c.id, name: c.name })), protos: this.protoUnits.map((p) => ({ id: p.id, name: p.name })), @@ -138,6 +141,7 @@ export const serializationMethods = { visible: this._visibleVersion, productionBaseline: this._productionBaselineVersion, resourceGraph: this._resourceVersion, + market: this._marketVersion || 0, migrationGraph: this._migrationFlowVersion, }, }; @@ -478,6 +482,11 @@ export const serializationMethods = { return this.cities.map((city) => { const economy = this.getCityEconomy(city); const taxes = this.getCityTaxTake(city); + // The simple economy has one national pool rather than per-region stores, + // so the region figure its Resources tab needs is its spare power, which + // drives the build clock. The hard economy reads its own stores instead. + const resources = this.getRegionResourceInfo(city); + if (this.isSimpleEconomy()) resources.energy = this.regionAvailableEnergy(city); return { id: city.id, civ: city.civ, @@ -503,7 +512,7 @@ export const serializationMethods = { famine: this.isFamine(city), // The region's reserves, their market value and the private sector's // daily consumption, for the Resources tab. - resources: this.getRegionResourceInfo(city), + resources, // The ethnicity of the region's people, for the circle graph. ethnicMakeup: this.getCityEthnicMakeup(city), }; @@ -721,6 +730,10 @@ export const serializationMethods = { _serializeResourceMarket() { return RESOURCE_IDS.map((id) => { const stats = this.resourceMarketStats ? this.resourceMarketStats.get(id) : null; + // The simple economy's price is derived from a shared world stock; the + // hard economy has no stock, so the field is null there. Energy is a flow + // and is never stocked. + const stock = this.marketStock ? this.marketStock.get(id) : undefined; return { id, price: this.getResourcePrice(id), @@ -728,6 +741,7 @@ export const serializationMethods = { prevPrice: this.getResourcePrevPrice(id), supply: stats ? stats.supply : 0, demand: stats ? stats.demand : 0, + stock: stock === undefined ? null : stock, }; }); }, diff --git a/shared/game_state/simple_economy.js b/shared/game_state/simple_economy.js new file mode 100644 index 0000000..9eb9285 --- /dev/null +++ b/shared/game_state/simple_economy.js @@ -0,0 +1,1131 @@ +// Simple-economy overrides for GameState. When a game runs the simple model, +// `configure()` assigns these methods onto the instance, so they shadow the +// hard-economy mixins on the prototype without changing them. The hard model +// needs no override module: it is the existing mixins untouched. +// +// Instance-level assignment is safe because the economy model is fixed for the +// life of a game and the browser never runs a live GameState. Name every +// override after the method it replaces, so the diff is obvious. +// +// Decision (02-resource-pool-and-production.md): every nation keeps **one +// resource pool** holding the four storable goods, available in every region. +// Energy is deliberately **not** pooled: it stays a per-region flow so +// `03-construction.md` can key construction speed to the region's available +// power. Production buildings are public -- their output lands in the owner's +// pool -- and the private-agent accounting (cash, debt, sales, upgrades) is +// retired by overriding the agent hooks to no-ops. The daily resource tick +// itself is the hard one, re-pointed at the pool: `_ensureResourceStock` now +// returns the owner's pool, `getCityResourceStock` hands out that same pool, +// and the sourcing searches are overridden to find nothing, so every draw is a +// single subtraction from the pool. +// +// Decision (03-construction.md): an order starts building at once. Its whole +// steel/high-tech bill is drawn from the pool the moment it is placed -- a bill +// the pool cannot cover is refused rather than gathered over days -- and there +// is no materials phase, no energy-buy phase and no trade-graph lookup. The +// construction or training clock is set from the spare power of the region the +// work stands in: `regionAvailableEnergy` is the region's plant output less what +// its cities, buildings and converters already draw, floored so a blacked-out +// region still builds slowly instead of dividing by zero. Money is not spent +// internally on construction (the single tax and the market cover that in +// `05-`/`06-`), so the treasury gates nothing. Every opening region is guaranteed +// at least one plant -- a fuel-free one where the hard seeding left it without -- +// so the speed rule never starts at zero. +// +// The private demand-driven upgrade stays retired (02-); `_startBuildingUpgrade` +// is overridden so that if an upgrade is ever undertaken it is a public, +// immediate, energy-driven work rather than an agent-funded one. +// +// Decision (07-procurement.md): every consumer sources from the one pool. The +// sourcing search is emptied (`_nearbySuppliers`/`_resourceNodes` find nothing, +// `_procureFromNeighbours` returns zero) and the draw helpers +// (`_drawFromNearest`/`_drawFromCities`) are re-pointed at the pool, so upkeep, +// city consumption, combat, repairs and unit supply all take a single +// subtraction -- no bidding, no distance, no freight, never a higher price for a +// shortfall. The construction-site and queued-build gather passes are overridden +// to the pool too, so even a stray call from the hard daily tick cannot rebuild +// the trade graph. The day's ledger (`resourceSpend`, consumption/production +// totals) is still filled by the hard tick; only the sourcing changed. + +import { key, parseKey } from "../hex.js"; +import { + RESOURCE_IDS, + STORABLE_RESOURCE_IDS, + RESOURCE_MARKET_BASE, + SIMPLE_ECONOMY, + MARKET_START_STOCK, + MARKET_LOTS, + marketPriceFor, + EFFECT_TAX_INCOME, + TAXES, + tileImprovementById, + isResourceTileBuilding, + resourceBuildingUpgradeMoneyCost, +} from "../data.js"; +import { buildingBuildCost } from "../rules.js"; +import { + cityEnergyPerDay, + foodNeedPerDay, + steelNeedPerDay, + luxuryNeedPerDay, + tileFoodOutput, + tileImprovementResourceCost, + protoUpkeep, +} from "../resources.js"; +import { TRAINING_QUEUE_LIMIT, TILE_IMPROVEMENT_HP, HOURS_PER_DAY } from "./constants.js"; + +// A fresh pool of the four storable goods. Mirrors the hard economy's +// `emptyStock`; the simple model never owns per-tile stock. +function emptyPool() { + return { steel: 0, food: 0, luxury: 0, hightech: 0 }; +} + +// The bill a construction site or queue entry reports. Shaped like the hard +// one's `emptyResources` so the snapshot and the panels keep their fields. +function emptySiteResources() { + return { steel: 0, hightech: 0, energy: 0 }; +} + +export const simpleEconomyMethods = { + // -------------------------------------------------------------- pool -- + + // Builds the pool collection and the market series exactly as the hard + // initialiser does, but seeds one pool per nation instead of a store for + // every city and resource tile. No `_seedCityResources` call: there is no + // per-tile stock in the simple economy. + _initResources() { + this.resourcePools = new Map(); + // Kept as an empty map so the hard methods that guard on `resourceStock` + // (the tick, unit supply) still run and the ones that iterate it find + // nothing to do. Nothing is ever stored here in simple mode. + this.resourceStock = new Map(); + this.resourcePrices = new Map(); + this.resourceMarketStats = new Map(); + for (const id of RESOURCE_IDS) { + this.resourcePrices.set(id, RESOURCE_MARKET_BASE[id]); + this.resourceMarketStats.set(id, { supply: 0, demand: 0, price: RESOURCE_MARKET_BASE[id] }); + } + this.resourceMonthlyPrices = new Map(); + for (const id of RESOURCE_IDS) this.resourceMonthlyPrices.set(id, [RESOURCE_MARKET_BASE[id]]); + this.resourceDailyPrices = new Map(); + for (const id of RESOURCE_IDS) this.resourceDailyPrices.set(id, [RESOURCE_MARKET_BASE[id]]); + this.resourceLinks = []; + this._resourceShortages = new Map(); + this._resourceGraphCache = null; + this._relayStock = new Map(); + this._famine = new Set(); + this._resourceVersion = 0; + this.resourceTrade = new Map(); + this.resourceExternal = new Map(); + this._resourceAlerts = new Map(); + this.resourceSpend = new Map(); + this.cityRepairQueue = new Map(); + this.tileRepairDebt = new Map(); + for (let civ = 0; civ < this.civilisations.length; civ++) { + const pool = this._ensurePool(civ); + for (const id of STORABLE_RESOURCE_IDS) pool[id] = SIMPLE_ECONOMY.startingPool[id] || 0; + } + this._initSimpleMarket(); + }, + + // The one pool every region of a nation draws on, created on first use. + _ensurePool(civ) { + if (!this.resourcePools) this.resourcePools = new Map(); + let pool = this.resourcePools.get(civ); + if (!pool) { + pool = emptyPool(); + this.resourcePools.set(civ, pool); + } + return pool; + }, + + // A nation's whole resource pool. Callers that used to ask a city for its + // store now ask the owner for the pool. + getResourcePool(civ) { + return this._ensurePool(civ); + }, + + // Takes up to `amount` of one good out of a nation's pool and returns what + // was actually drawn: the single procurement primitive of the simple + // economy. Never returns negative and never bids a price up. + _drawFromPool(civ, id, amount) { + const pool = this._ensurePool(civ); + const available = Math.max(0, pool[id] || 0); + const take = Math.min(available, Math.max(0, amount)); + if (take > 0) pool[id] = available - take; + return take; + }, + + // The public producers write here. The hard tick adds each work's output to + // `_ensureResourceStock(k)`, so mapping that onto the owner's pool is all it + // takes for every producer -- food from the land, materials from the + // converters -- to feed the shared pool. + _ensureResourceStock(k) { + const coords = parseKey(k); + const city = this.cityAt(coords); + let civ = city ? city.civ : this.tileImprovementOwner.get(k); + if (civ === undefined || civ === null || civ < 0) civ = this.civAt(coords); + if (civ === undefined || civ === null || civ < 0) return emptyPool(); + return this._ensurePool(civ); + }, + + // The read every surviving consumer makes: a city's stock is its owner's + // pool, so a region has exactly what the nation holds. + getCityResourceStock(city) { + return this._ensurePool(city.civ); + }, + + // ------------------------------------------------- global market (06) -- + + // The one world stock of each storable good, opening full. A single shared + // stock, not a per-player order book: every nation trades against the same + // figures, so the price one player moves is the price the next one pays. + // Energy is not traded -- it stays a per-region flow -- so only the four + // storable goods are stocked. + _initSimpleMarket() { + this.marketStock = new Map(); + for (const id of STORABLE_RESOURCE_IDS) this.marketStock.set(id, MARKET_START_STOCK); + this._marketVersion = (this._marketVersion || 0) + 1; + }, + + // The world price of a good straight from the shared stock. A good with no + // stock (energy) keeps its base price. Computed rather than memoised: it is + // one Map lookup and a pow, and deriving it keeps the stock the single source + // of truth, so no stale cache can lag a trade. + marketPrice(id) { + const stock = this.marketStock ? this.marketStock.get(id) : undefined; + if (stock === undefined) return RESOURCE_MARKET_BASE[id] || 1; + return marketPriceFor(RESOURCE_MARKET_BASE[id], stock); + }, + + // Every surviving caller -- upkeep valuation, construction quotes, food + // synthesis, the panels -- reads the market price in the simple economy, so + // one override keeps them all in step with the stock. Closes the loop with + // the daily tick: a trade that drains the stock raises what the next order + // costs and what the stores are worth. + getResourcePrice(id) { + return this.marketPrice(id); + }, + + // The simple market block of the daily tick. Prices come from the world + // stock, not from the day's supply and demand, so the hard drift has nothing + // to do: the stock only moves when a player trades. + _updateMarketPrices(_market) {}, + + // A nation's share of the market, for its Resources panel: the world stock + // and price of each buyable good, how much of it the nation holds, and the + // lots it may trade in. + getMarketView(civ) { + const pool = this._ensurePool(civ); + return STORABLE_RESOURCE_IDS.map((id) => ({ + id, + price: this.marketPrice(id), + stock: this.marketStock ? (this.marketStock.get(id) || 0) : 0, + pool: pool[id] || 0, + lots: MARKET_LOTS.slice(), + })); + }, + + // Buys or sells one lot of one good at the world price. Atomic: every check + // runs before any figure moves, so a refused trade leaves the treasury, the + // pool and the stock exactly as they were, and there is no overdraft to hide + // a shortfall. `qty` must be one of the three lots; the server re-validates + // whatever the client sends. + requestMarketTrade(civ, resource, side, qty) { + if (!this._validCiv(civ)) return false; + if (side !== "buy" && side !== "sell") return false; + if (!STORABLE_RESOURCE_IDS.includes(resource)) return false; + const amount = Number(qty); + if (!MARKET_LOTS.includes(amount)) return false; + if (!this.marketStock || !this.marketStock.has(resource)) return false; + const price = this.marketPrice(resource); + if (!(price > 0) || !Number.isFinite(price)) return false; + const pool = this._ensurePool(civ); + const stock = this.marketStock.get(resource) || 0; + const treasury = this.getBudget(civ); + if (side === "buy") { + const cost = amount * price; + // The world must hold the goods and the nation must hold the money; a + // near miss is refused whole rather than part-filled. + if (stock + 1e-9 < amount) return false; + if (treasury + 1e-9 < cost) return false; + this.budgets.set(civ, treasury - cost); + pool[resource] = (pool[resource] || 0) + amount; + this.marketStock.set(resource, stock - amount); + this._recordBudgetCash(civ, "provisioning", -cost); + this._recordBudgetResource(civ, resource, cost, 0); + this._recordBudgetCategoryResource(civ, "provisioning", resource, cost, "market", amount); + } else { + if ((pool[resource] || 0) + 1e-9 < amount) return false; + const revenue = amount * price; + pool[resource] = (pool[resource] || 0) - amount; + this.marketStock.set(resource, stock + amount); + this.budgets.set(civ, treasury + revenue); + this._recordBudgetCash(civ, "sell", revenue); + this._recordBudgetResource(civ, resource, 0, revenue); + } + this._marketVersion += 1; + this._emitChanged(); + return true; + }, + + // ------------------------------------------------- money and taxes (05) -- + + // A simple nation keeps one treasury in one currency. The central bank, its + // reserves, the FX desk and the private-sector region cash baskets are all + // hard-economy machinery: the simple model still creates the collection + // shapes so the serialisers can walk them, but seeds no money anywhere except + // the fixed opening treasury (`_initBudgets`), so every basket reads empty. + _initMoney() { + this.regionCash = new Map(); + this.regionDebt = new Map(); + this.centralBankDebt = new Map(); + this.centralBankRate = new Map(); + this.centralBankReserves = new Map(); + this.currencyValues = new Map(); + this._currencyDemand = new Map(); + this._currencyCivMap = null; + this._fxSubsidySpent = new Map(); + for (let civ = 0; civ < this.civilisations.length; civ++) { + this.centralBankRate.set(civ, 0); + this.centralBankReserves.set(civ, new Map()); + this.centralBankDebt.set(civ, new Map()); + this.currencyValues.set(civ, 1); + this._currencyDemand.set(civ, 0); + } + }, + + // A nation opens with a fixed euro reserve, not the hard model's tenth of + // GDP: money buys only market goods, so the opening figure is day-one buying + // power rather than a working balance. + _initBudgets() { + for (let i = 0; i < this.civilisations.length; i++) { + this.budgets.set(i, SIMPLE_ECONOMY.startingTreasury); + } + }, + + // The one currency: every nation's prices are quoted in euros, at par. + currencyOf(_civ) { + return SIMPLE_ECONOMY.euro; + }, + + currencyValue(_civ) { + return 1; + }, + + currencyValueOf(_code) { + return 1; + }, + + exchangeRate(_fromCiv, _toCiv) { + return 1; + }, + + // No central bank work runs in the simple tick: nothing borrows, no currency + // floats with demand and no region repatriates foreign cash. + _tickMoney() {}, + + requestSetInterestRate(_civ, _rate) { + return false; + }, + + // ------------------------------------------------------ taxes (05) -- + + // The day's one revenue: every inhabitant pays `taxPerInhabitant`, scaled by + // twice their region's derived approval (a 0..2 factor, clamped) and the + // nation's tax-income modifier. Booked once per day, after the daily ledger + // reset, so the tax ledger always holds the last full day's take. + _tickApprovalTaxes() { + const factorCap = SIMPLE_ECONOMY.approvalTaxFactor; + for (let civ = 0; civ < this.civilisations.length; civ++) { + const modifier = this.taxIncomeModifier(civ); + let total = 0; + for (const city of this.cities) { + if (city.civ !== civ) continue; + const population = Math.max(0, this.getCityPopulation(city)); + const approval = Math.max(0, Math.min(1, this.getCityApproval(city))); + const factor = Math.min(factorCap, approval * factorCap); + const income = population * SIMPLE_ECONOMY.taxPerInhabitant * factor * modifier; + if (!(income > 0) || !Number.isFinite(income)) continue; + total += income; + this._bookCityApprovalTax(city, income); + } + if (!(total > 0)) continue; + this.budgets.set(civ, this.getBudget(civ) + total); + this._recordBudgetCash(civ, "taxIncome", total); + let day = this.taxLedger.get(civ); + if (!day) { + day = { income: 0 }; + this.taxLedger.set(civ, day); + } + day.income += total; + } + }, + + // The slice of the day's tax a single region raised, for the region tables. + _bookCityApprovalTax(city, income) { + let entry = this.cityTaxLedger.get(city.id); + if (!entry) { + entry = { cityId: city.id, income: 0 }; + this.cityTaxLedger.set(city.id, entry); + } + entry.income += income; + }, + + // The one modifier on the approval tax. No government, building or + // technology grants EFFECT_TAX_INCOME yet, so this is 1; the hook is here so + // a later policy can move it. + taxIncomeModifier(civ) { + const bonus = this.getCivModifiers(civ)[EFFECT_TAX_INCOME] || 0; + return Math.max(0, 1 + bonus); + }, + + // The taxes a nation collected over the last day: one "income" figure, in + // place of the hard economy's sales/export/import kinds. + getTaxTake(civ) { + const day = this.taxLedger ? this.taxLedger.get(civ) : null; + return { sales: 0, export: 0, import: 0, income: day ? day.income : 0 }; + }, + + getCityTaxTake(city) { + const region = this.cityTaxLedger ? this.cityTaxLedger.get(city.id) : null; + return { sales: 0, export: 0, import: 0, income: region ? region.income : 0 }; + }, + + // There is no inter-region trade to tax and no player-set rate: the one tax + // is the approval tax above, so a trade-tax booking or a tax control is a + // hard-only concern. + _recordTax(_civ, _kind, _resource, _amount, _city) {}, + + requestSetTaxRate(_civ, _kind, _rate, _resource) { + return false; + }, + + // The client renders the tax shape it knows, so it is returned with zeroed + // rates and no overrides rather than omitted; the tab itself is hidden in + // `09-`. The server refuses any attempt to move a rate. + getTaxConfigView(_civ) { + const view = {}; + for (const kind of TAXES.kinds) view[kind] = { rate: 0, resources: {} }; + return view; + }, + + // ------------------------------------------------- no internal money -- + + // Money never moves inside the state in the simple economy, so no spend can + // touch the treasury. Construction, training, upkeep and repair draw from the + // resource pool; the only money movements are the daily tax in and the market + // in `06-`. + _spendBudget(_civ, _amount, _category) { + return true; + }, + + // The hard upkeep figure is money plus the market value of materials. Money + // upkeep is retired, so only the material figure a caller adds remains. + getPlayerUpkeep(_civ) { + return 0; + }, + + _campaignUpkeepSources(_civ) { + return []; + }, + + // Propaganda is paid for in culture: a campaign drains its hourly rate from + // the national culture stock each day. A nation that cannot fund a full day + // loses its campaigns rather than running them on credit, which the hard + // economy's treasury overdraft would have allowed. + _tickEconomy() { + for (let civ = 0; civ < this.civilisations.length; civ++) { + const upkeep = this.getCampaignUpkeep(civ) * HOURS_PER_DAY; + if (!(upkeep > 0)) continue; + const culture = this.getCulture(civ); + if (culture >= upkeep) { + this.culture.set(civ, culture - upkeep); + continue; + } + this.culture.set(civ, 0); + this.campaigns = this.campaigns.filter((campaign) => campaign.civ !== civ); + this._touchPolitics(); + } + }, + + // The two campaign cost hooks, retargeted from money to culture. The opinion + // shift still scales with spend because `_campaignSpendFactor` reads these. + _campaignDefaultCost() { + return SIMPLE_ECONOMY.campaign.defaultCulturePerHour; + }, + + _campaignMaxCost() { + return SIMPLE_ECONOMY.campaign.maxCulturePerHour; + }, + + // --------------------------------------------------- public production -- + + // Production buildings are public works, not private agents: the simple + // economy never creates an agent for one. This gates the agent creation in + // `_seedResourceBuildings` and the private branches of the site/money code. + _isProductionAgent(_proto) { + return false; + }, + + // A public converter runs at its nameplate output. The hard model throttles + // one that cannot cover its power bill; the simple model is not run for a + // private profit, so only grid power (above) limits it. + _converterProductionScale(_k, _proto, _agent) { + return 1; + }, + + _converterProfitable(_k, _proto, _agent) { + return true; + }, + + // Retire the private-agent money hooks: no agent cash, no debt, no central + // bank loan. They are no-ops rather than absent so the hard callers that + // reach them keep running. + _creditBuildingCash(_k, _code, _amount) {}, + _chargeBuildingCash(_k, _code, _cost) {}, + _chargeBuildingAccount(_k, _amount) {}, + getPrivateBuildingCash(_civ) { + return 0; + }, + getPrivateBuildingDebt(_civ) { + return 0; + }, + getProductionAgents(_civ) { + return []; + }, + + // No private upgrades: the demand-driven reinvestment of the hard model has + // no owner to serve. + _tickIndustry() {}, + + // ------------------------------------------------------- procurement -- + + // Every good in the simple economy lives in the pool, so there is nothing to + // search for. These overrides empty the sourcing search so no trade graph, + // trade radius or supplier bid can run, and point the draws at the pool. + _nearbySuppliers() { + return []; + }, + + _resourceNodes() { + return []; + }, + + // With no per-tile stores there is no route to price: the trade graph is + // empty. Its only callers (`_nearbySuppliers`, `_resourceCandidateEdges`) are + // already retired above, but returning early keeps the walk unreachable. + _tradeGraph() { + return []; + }, + + // The hard consumers call this to buy a shortfall after exhausting the city + // store. In simple mode the store *is* the pool, so the goods are already + // gone or they never existed: returning zero means a shortfall simply stalls + // the work instead of triggering a bid. + _procureFromNeighbours(_city, _id, _amount, _cityStore, _nodes, _options) { + return 0; + }, + + // The materials a government or region draws are pulled straight from the + // pool. Kept to the hard signatures so construction, combat, repairs and + // unit supply all land here unchanged. + _drawFromNearest(civ, _coords, wanted, _payer = null) { + return { + steel: this._drawFromPool(civ, "steel", wanted.steel || 0), + hightech: this._drawFromPool(civ, "hightech", wanted.hightech || 0), + }; + }, + + // Returns what is still missing, like the hard version, but the source is + // the pool rather than the nation's city stores. + _drawFromCities(civ, id, amount, _payer = null) { + const drawn = this._drawFromPool(civ, id, amount); + return Math.max(0, amount - drawn); + }, + + // With no freight and no currency conversion, a good is worth its world + // price. The hard model folds in a nominal two-tile haul; the simple model + // quotes the market directly. + _marketUnitCost(id) { + return this.getResourcePrice(id); + }, + + // The hard model pays a region for material drawn from its store and lets the + // treasury carry the bill. The simple model has neither private region cash + // nor internal money: the good comes out of the pool and nothing is settled. + _payRegionForMaterial(_city, _id, _amount, _payer) {}, + + // A tile work's daily gather is a hard-economy phase: a simple site opened + // already building with its whole bill paid. Kept to the hard signature and + // drawn from the pool best-effort, so a stray call still finishes rather than + // stalling on a supplier that does not exist. Energy is a regional flow and is + // never pooled, so it is never drawn here. + _gatherSiteMaterials(site) { + if (site.phase !== "materials") return; + for (const id of ["steel", "hightech"]) { + const missing = Math.max(0, (site.needed[id] || 0) - (site.bought[id] || 0)); + if (!(missing > 0)) continue; + site.bought[id] = (site.bought[id] || 0) + this._drawFromPool(site.civ, id, missing); + } + const stillMissing = ["steel", "hightech"].some( + (id) => (site.needed[id] || 0) - (site.bought[id] || 0) > 1e-9 + ); + site.stalled = stillMissing; + if (!stillMissing) { + site.phase = "construction"; + site.elapsedHours = 0; + this._emitChanged(); + } + }, + + // A site's materials come from the owner's pool, not a city store, and no + // budget is charged: the pool already paid at order time. Energy is a flow. + _siteBuy(site, id, amount) { + if (id === "energy") return 0; + return this._drawFromPool(site.civ, id, amount); + }, + + // A queued building or unit starts constructing at once, so its gather pass + // draws the whole remaining bill from the pool in one go and flips the entry. + _gatherBuildEntry(city, entry) { + for (const id of ["steel", "hightech"]) { + const missing = Math.max(0, (entry.needed[id] || 0) - (entry.bought[id] || 0)); + if (!(missing > 0)) continue; + entry.bought[id] = (entry.bought[id] || 0) + this._drawFromPool(city.civ, id, missing); + } + const stillMissing = ["steel", "hightech"].some( + (id) => (entry.needed[id] || 0) - (entry.bought[id] || 0) > 1e-9 + ); + if (!stillMissing) entry.phase = "construction"; + entry.stalled = stillMissing; + return !stillMissing; + }, + + // The pool is available everywhere, so a work's material is never stranded. + _siteUnreachableMaterials(_site) { + return []; + }, + + // A public upgrade's materials are the owner's pool, checked whole so an + // underfunded upgrade never starts half-paid. + _buyBuildingMaterials(_k, _coords, civ, materials) { + const steel = Math.max(0, materials.steel || 0); + const hightech = Math.max(0, materials.hightech || 0); + return this._payPoolMaterials(civ, { steel, hightech }); + }, + + // The owner's pool is the whole availability for an upgrade's material. + _buildingMaterialAvailability(civ, _coords, id) { + return this._ensurePool(civ)[id] || 0; + }, + + // ------------------------------------------------------------ energy -- + + // No regional power trading: energy is a per-region flow in the simple + // economy, so each grid balances what its own plants make against what its + // own cities and industry draw. The hard tick reads `imports` when it folds + // the day's balance, so they are zeroed rather than left undefined. + _tradeGridPower(ledger) { + for (const component of ledger.values()) { + component.imports = 0; + component.exports = 0; + component.importCost = 0; + } + }, + + // Power is not bought or sold for money in the simple model; the physical + // balance the ledger already computed is all that matters. Consumers are + // billed in `05-money-and-taxes.md`'s single tax, not per kWh. + _settleGridPower(_ledger) {}, + + // ------------------------------------------------------ scenario seed -- + + // The hard scenario fills every region with a year of its own consumption. + // The simple scenario seeds the one pool each nation opens with; the warm-up + // may have spent the initial seed before the scenario is applied, so it is + // reset here to the fixed working stock. + _seedStartingReserves() { + for (let civ = 0; civ < this.civilisations.length; civ++) { + const pool = this._ensurePool(civ); + for (const id of STORABLE_RESOURCE_IDS) pool[id] = SIMPLE_ECONOMY.startingPool[id] || 0; + } + }, + + // ------------------------------------------------------- migration (08) -- + + // Decision (08-migrations.md): only *economic* migration is retired. The + // income-chasing flows and the start-of-game equalize pass are the two + // mechanisms that move people between regions for money, so they are the two + // no-ops below. Political migration -- expelling a people, an immigration + // policy, airport traffic -- is not economic and stays, exactly as in the hard + // model, so `_tickMigration` is left untouched. The snapshot fields + // (`migrations`, `migrationGraph`) keep their shape: the hard tick still + // fills them, and with no policies and no airports they simply open empty + // rather than missing. + // + // People leaving a poor region for a rich one: finds nothing to do. + _migrateForIncome() {}, + + // The opening levelling the hard model runs when the world is placed, which + // redistributes each nation's people so every region starts at the same + // income per head. Without it a simple game keeps the scenario's raw spread; + // population follows natural growth and politics, not GDP. + _equalizeRegionIncomes() {}, + + // ---------------------------------------------------------- summary -- + + // What the nation holds, needs, makes and consumes. The stock is the pool + // once, not the pool summed once per city as the hard summary would do; the + // production is the public works' output plus the land's harvest. + getCivResourceSummary(civ) { + const pool = this._ensurePool(civ); + const stock = { + steel: pool.steel || 0, + food: pool.food || 0, + luxury: pool.luxury || 0, + hightech: pool.hightech || 0, + }; + const need = emptyPool(); + let energyNeed = 0; + for (const city of this.cities) { + if (city.civ !== civ) continue; + const economy = this.getCityEconomy(city); + const population = economy.population; + need.food += foodNeedPerDay(population); + need.steel += steelNeedPerDay(population); + need.luxury += luxuryNeedPerDay(population); + energyNeed += cityEnergyPerDay(economy.gdp); + } + const production = emptyPool(); + let energySupply = 0; + for (const [k, id] of this.tileImprovements) { + if (this.tileImprovementOwner.get(k) !== civ) continue; + const proto = tileImprovementById(id); + if (!isResourceTileBuilding(proto)) continue; + if (proto.resource === "energy") { + energySupply += this.resourceBuildingOutputAt(k, proto); + } else { + production[proto.resource] += this.resourceBuildingOutputAt(k, proto); + } + } + const foodMultipliers = new Map(); + for (const coords of this._territoryByCiv.get(civ) || []) { + const k = key(coords.x, coords.y); + if ((this.tilePopulation.get(k) || 0) <= 0) continue; + const city = this.regionCityAt(coords); + let multiplier = 1; + if (city) { + multiplier = foodMultipliers.get(city.id); + if (multiplier === undefined) { + multiplier = this._regionFoodMultiplier(city); + foodMultipliers.set(city.id, multiplier); + } + } + production.food += tileFoodOutput(this.tiles[k]) * multiplier; + } + const upkeep = this.getCivUpkeepResources(civ); + const consumed = { + food: need.food + (upkeep.food || 0), + steel: need.steel + (upkeep.steel || 0), + luxury: need.luxury + (upkeep.luxury || 0), + hightech: upkeep.hightech || 0, + energy: energyNeed + (upkeep.energy || 0), + }; + return { + stock, + need, + production, + energy: { need: energyNeed, supply: energySupply }, + upkeep, + consumed, + trade: this._civResourceTrade(civ), + external: this._civExternalTrade(civ), + prices: Object.fromEntries(RESOURCE_IDS.map((id) => [id, this.getResourcePrice(id)])), + }; + }, + + // The delivery graph is a hard-economy overlay: there are no routes to draw + // when every region shares one pool. Returning an empty graph also keeps the + // snapshot off the trade-graph walk entirely. + _serializeResourceGraph(_viewerCiv) { + return { nodes: [], links: [], edges: [] }; + }, + + // ------------------------------------------------- construction (03) -- + + // The spare power of a city's region, in kWh a day: what its plants make less + // what its cities, buildings and converters already draw. Region-based rather + // than grid-based, because the simple economy has no regional power market; a + // tile outside every region reads zero. Answers the construction-speed + // question and (in `09-`) explains it to the player. + // + // The city Resources tab and the build/site clocks all read this, and the + // snapshot does so for every city on every 10 Hz broadcast, so the region walk + // is memoised against the versions that can move a power balance: the region's + // tiles (territory/regions), its works (improvements/warfare), its buildings + // and its people (modifiers/population). + regionAvailableEnergy(city) { + if (!city) return 0; + const stamp = `${this._territoryVersion}:${this._regionVersion}:` + + `${this._improvementVersion}:${this._tileImprovementVersion}:` + + `${this._modifiersVersion}:${this._populationVersion}:${this._gdpEpoch}`; + if (!this._regionEnergyCache) this._regionEnergyCache = new Map(); + const cached = this._regionEnergyCache.get(city.id); + if (cached && cached.stamp === stamp) return cached.value; + const value = this._computeRegionAvailableEnergy(city); + this._regionEnergyCache.set(city.id, { stamp, value }); + return value; + }, + + _computeRegionAvailableEnergy(city) { + // A city's own people and its buildings are the first draw on its region. + let committed = cityEnergyPerDay(this.getCityEconomy(city).gdp); + for (const [index, level] of Object.entries(city.buildings || {})) { + if (level <= 0) continue; + const proto = this.protoBuildings[Number(index)]; + if (proto) committed += this.buildingUpkeepResources(proto, level).energy; + } + let supply = 0; + for (const coords of this.regionTiles(city)) { + const k = key(coords.x, coords.y); + const id = this.tileImprovements.get(k); + if (!id) continue; + const proto = tileImprovementById(id); + if (!proto) continue; + if (isResourceTileBuilding(proto)) { + if (proto.resource === "energy") { + supply += this.resourceBuildingOutputAt(k, proto); + } else { + committed += this.resourceBuildingInputAt(k, proto, "energyPerDay"); + } + // A plant's own fuel is a draw on the grid, as in the hard ledger. + committed += this.resourceBuildingInputAt(k, proto, "fuelEnergyPerDay"); + } + // Every work's operating power rides on the grid, as in the hard ledger. + committed += protoUpkeep(proto, proto.buildCost || 0).energy; + } + return Math.max(0, supply - committed); + }, + + // The base hours a work of `cost` takes in `city`'s region at today's spare + // power. `kind` is "unit" for training, anything else for a building, tile + // work or upgrade -- which the hard economy runs ten times quicker. The energy + // is floored so a blacked-out region still builds, slowly. + _simpleConstructionHours(city, cost, kind) { + const energy = Math.max( + this.regionAvailableEnergy(city), + SIMPLE_ECONOMY.construction.minEnergy + ); + const base = Math.max(0, cost) * + SIMPLE_ECONOMY.construction.energyPerConstructionHour / energy; + const scaled = kind === "unit" + ? base + : base * SIMPLE_ECONOMY.construction.buildingTimeMultiplier; + return Math.max(SIMPLE_ECONOMY.construction.minHours, scaled); + }, + + // Pays a construction bill out of the nation's pool. Atomic: a bill the pool + // cannot cover is refused whole, so an order can never start half-funded. + _payPoolMaterials(civ, materials) { + const steel = Math.max(0, materials.steel || 0); + const hightech = Math.max(0, materials.hightech || 0); + const pool = this._ensurePool(civ); + if ((pool.steel || 0) + 1e-9 < steel) return false; + if ((pool.hightech || 0) + 1e-9 < hightech) return false; + pool.steel = (pool.steel || 0) - steel; + pool.hightech = (pool.hightech || 0) - hightech; + return true; + }, + + // Draws a tile work's bill from the pool best-effort. A tile already carries + // at most one work, so a partial draw is a slow-down the player sees, not a + // way to spam cheap works. + _paySiteMaterials(civ, materials) { + this._drawFromPool(civ, "steel", Math.max(0, materials.steel || 0)); + this._drawFromPool(civ, "hightech", Math.max(0, materials.hightech || 0)); + }, + + // Opens a site already under construction: the whole bill is paid from the + // pool now, the clock is set from the region's spare power, and there is + // nothing to gather, haul or buy each day. The hard economy's reserved + // budget, locked prices and haul route are all left out. + _openConstructionSite({ + civ, coords, proto, kind = "build", targetLevel = 0, buildCost, level = 0, + }) { + const k = key(coords.x, coords.y); + const materials = kind === "upgrade" + ? this.constructionResourceCost(proto, level, buildCost) + : tileImprovementResourceCost(proto, buildCost); + this._paySiteMaterials(civ, materials); + const city = this.cityAt(coords) || this.regionCityAt(coords) || this._nearestCity(civ, coords); + const cost = buildCost !== null && buildCost !== undefined + ? buildCost + : ((proto && proto.buildCost) || 0); + const site = { + coords: { x: coords.x, y: coords.y }, + id: proto.id, + civ, + kind, + targetLevel, + private: false, + phase: "construction", + budget: 0, + prices: {}, + needed: emptySiteResources(), + bought: emptySiteResources(), + elapsedHours: 0, + totalHours: Math.max(1, + this._simpleConstructionHours(city, cost, "building") * + this.constructionSpeedMultiplier(civ)), + cityId: null, + path: [], + stalled: false, + budgetRaises: 0, + lastBuyDay: -1, + }; + this.constructionSites.set(k, site); + this._emitChanged(); + return site; + }, + + // A simple site has nothing to gather or buy; it is already building. + _kickoffSite(_site) {}, + + // Simple construction is paid from the material pool, not the treasury, so no + // money is reserved for a site. Kept as a no-op so the hard callers that reach + // it in simple mode move nothing. + _reserveSiteBudget(_civ, _amount, _category) { + return 0; + }, + + // ------------------------------------------------------ orders (03) -- + + // Queues one unit. Its material bill is drawn from the pool up front and it + // starts training at once: no money, no production-capacity gate, no gather + // phase. The clock comes from the city's spare power, scaled by approval and + // the usual construction-speed research, exactly as the hard order is. + requestTrain(cityId, protoIndex, free = false) { + const city = this.findCity(cityId); + if (!city) return false; + if (protoIndex < 0 || protoIndex >= this.protoUnits.length) return false; + const proto = this.protoUnits[protoIndex]; + if (proto.requiresBuilding && !this.hasCityBuilding(city, proto.requiresBuilding)) return false; + if (proto.requiresTechnology && !this.hasTechnology(city.civ, proto.requiresTechnology)) return false; + if ( + Array.isArray(proto.requiresTechnologies) && + !proto.requiresTechnologies.every((id) => this.hasTechnology(city.civ, id)) + ) { + return false; + } + if (free) { + this._spawnTrainedUnit(city, proto); + this._visibilityDirty = true; + this._emitChanged(); + return true; + } + const queue = this.training.get(cityId); + if (queue && queue.length >= TRAINING_QUEUE_LIMIT) return false; + if (proto.cost <= 0) { + this._spawnTrainedUnit(city, proto); + this._visibilityDirty = true; + this._emitChanged(); + return true; + } + const materials = this.constructionResourceCost(proto, 0, proto.cost); + if (!this._payPoolMaterials(city.civ, materials)) return false; + const entry = { + kind: "unit", + protoIndex, + phase: "construction", + budget: 0, + budgetCategory: "training", + prices: {}, + needed: emptySiteResources(), + bought: emptySiteResources(), + elapsedHours: 0, + totalHours: Math.max(1, + this._simpleConstructionHours(city, proto.cost, "unit") * + this.constructionApprovalMultiplier(city) * + this.constructionSpeedMultiplier(city.civ)), + lastGatherDay: -1, + }; + if (queue) queue.push(entry); + else this.training.set(cityId, [entry]); + this._emitChanged(); + return true; + }, + + // Queues one level of a city building, paid from the pool and started at + // once, with the level priced off whatever the queue will leave behind. + requestBuild(cityId, protoIndex, free = false) { + const city = this.findCity(cityId); + if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false; + const proto = this.protoBuildings[protoIndex]; + if (proto.coastal && !this.isCoastalCity(city)) return false; + if (proto.requiresBuilding && !this.hasCityBuilding(city, proto.requiresBuilding)) return false; + if (proto.requiresTechnology && !this.hasTechnology(city.civ, proto.requiresTechnology)) return false; + if (free) { + const level = + this.getCityBuildingLevel(city, protoIndex) + + this._pendingBuildingLevels(cityId, protoIndex); + city.buildings[protoIndex] = level + 1; + this._clearTileGdpCache(); + this._touchModifiers(); + this._emitChanged(); + return true; + } + const queue = this.training.get(cityId); + if (queue && queue.length >= TRAINING_QUEUE_LIMIT) return false; + const level = + this.getCityBuildingLevel(city, protoIndex) + + this._pendingBuildingLevels(cityId, protoIndex); + const cost = buildingBuildCost(proto, level); + if (cost <= 0) { + city.buildings[protoIndex] = level + 1; + this._clearTileGdpCache(); + this._touchModifiers(); + this._emitChanged(); + return true; + } + const materials = this.constructionResourceCost(proto, level, cost); + if (!this._payPoolMaterials(city.civ, materials)) return false; + const entry = { + kind: "building", + protoIndex, + level, + phase: "construction", + budget: 0, + prices: {}, + needed: emptySiteResources(), + bought: emptySiteResources(), + elapsedHours: 0, + totalHours: Math.max(1, + this._simpleConstructionHours(city, cost, "building") * + this.constructionApprovalMultiplier(city) * + this.constructionSpeedMultiplier(city.civ)), + lastGatherDay: -1, + }; + if (queue) queue.push(entry); + else this.training.set(cityId, [entry]); + this._emitChanged(); + return true; + }, + + // Lays a military or resource tile improvement. Like a city order it pays the + // pool at once and starts building, with the clock from the region's spare + // power; there is no GDP-capacity gate, and a producer is public (it never + // opens a private agent). + requestBuildTileImprovement(civ, coords, id) { + if (!this._validCiv(civ) || !coords) return false; + if (!Number.isFinite(coords.x) || !Number.isFinite(coords.y)) return false; + const proto = tileImprovementById(id); + if (!proto) return false; + const k = key(coords.x, coords.y); + const tile = this.tiles[k]; + if (!tile || tile.terrainClass !== "Land") return false; + if (this.civAt(coords) !== civ) return false; + if (this.tileImprovements.has(k) || this.constructionSiteAt(coords)) return false; + if (proto.coastal && !this.isCoastalCoords(coords)) return false; + if (proto.resource && this.cityAt(coords)) return false; + if (proto.requiresTechnology && !this.hasTechnology(civ, proto.requiresTechnology)) return false; + this._openConstructionSite({ + civ, coords, proto, kind: "build", buildCost: proto.buildCost, + }); + this._emitChanged(); + return true; + }, + + // A public upgrade of a production building: immediate and energy-driven like + // any other simple work, funded by the pool, never by a private agent. The + // daily demand-driven decision that would call this stays retired (02-). + _startBuildingUpgrade(k, proto, agent) { + const owner = this.tileImprovementOwner.get(k); + if (owner === undefined || owner < 0) return false; + if (this.constructionSites && this.constructionSites.has(k)) return false; + const level = agent.level; + const coords = parseKey(k); + this._openConstructionSite({ + civ: owner, + coords, + proto, + kind: "upgrade", + targetLevel: level + 1, + buildCost: resourceBuildingUpgradeMoneyCost(proto, level), + level, + }); + this._tileImprovementVersion += 1; + this._emitChanged(); + return true; + }, + + // --------------------------------------------------- region plant seed -- + + // Gives every city region that opened without a plant one fuel-free producer, + // placed deterministically on a valid owned land tile, so the energy-driven + // construction rule always starts from a non-zero basis. + _seedSimpleRegionPlants() { + const ids = SIMPLE_ECONOMY.construction.seedPlantIds; + let added = false; + for (const city of this.cities) { + if (this._regionHasPowerPlant(city)) continue; + const coords = this._pickSimplePlantTile(city); + if (!coords) continue; + const proto = ids + .map((id) => tileImprovementById(id)) + .find((candidate) => candidate && !candidate.coastal); + if (!proto) continue; + const k = key(coords.x, coords.y); + this.tileImprovements.set(k, proto.id); + this.tileImprovementOwner.set(k, city.civ); + this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[proto.id] || 1000); + // A plant beside the network gets a one-tile road spur, so it is reachable + // exactly as a hard-seeded work is. + if (!this.roads.has(k) && !this.railways.has(k) && this._touchesNetwork(coords)) { + this.roads.add(k); + } + added = true; + } + // The seeded plants change what each region can spare; drop the memoised + // power balances so the next read rebuilds them. + if (added) { + this._improvementVersion += 1; + this._regionEnergyCache = null; + } + }, + + _regionHasPowerPlant(city) { + for (const coords of this.regionTiles(city)) { + const id = this.tileImprovements.get(key(coords.x, coords.y)); + if (!id) continue; + const proto = tileImprovementById(id); + if (isResourceTileBuilding(proto) && proto.resource === "energy") return true; + } + return false; + }, + + // Whether a tile sits beside a road or railway. + _touchesNetwork(coords) { + for (const neighbour of this._neighbours(coords)) { + const nk = key(neighbour.x, neighbour.y); + if (this.roads.has(nk) || this.railways.has(nk)) return true; + } + return false; + }, + + // The first owned land tile in the region with room for a plant. Iterated in + // `regionTiles` order, which is deterministic for a given world, so a replay + // places the same plants. + _pickSimplePlantTile(city) { + for (const coords of this.regionTiles(city)) { + if (this.cityAt(coords)) continue; + if (!this._isLand(coords)) continue; + if (this.civAt(coords) !== city.civ) continue; + const k = key(coords.x, coords.y); + if (this.tileImprovements.has(k)) continue; + if (this.constructionSiteAt(coords)) continue; + return coords; + } + return null; + }, +}; diff --git a/tests/economy_model_test.js b/tests/economy_model_test.js new file mode 100644 index 0000000..4b18ec9 --- /dev/null +++ b/tests/economy_model_test.js @@ -0,0 +1,140 @@ +// The economy-model switch introduced by 01-decoupling-hard-economy.md: the +// simple model is the default, the hard model is the original simulation kept +// selectable, and the snapshot tells the client which one is running. + +import { TestCase } from "./framework/test_case.js"; +import { GameState } from "../shared/game_state.js"; +import { GameServer } from "../server/game_server.js"; +import { smallConfig, withServer, SEED } from "./framework/helpers.js"; +import { CIVILISATIONS } from "../shared/data.js"; + +function civs() { + return ["france", "britain"].map((id) => CIVILISATIONS.find((c) => c.id === id)); +} + +// A configured state on the small fixture map. `options` is passed straight to +// `configure`, so a test can pin the model or leave it out entirely. +function stateWithModel(options) { + const state = new GameState(); + state.mapConfig = smallConfig(); + state.configure(civs(), SEED, options); + return state; +} + +function makeServer(setup = {}) { + const network = { getPeerIds: () => [], send: () => {} }; + const server = new GameServer(network); + server.configureGame({ + seed: SEED, + player_civ: 0, + civilisations: ["france", "britain"], + mapConfig: smallConfig(), + ...setup, + }); + return server; +} + +export class EconomyModelTest extends TestCase { + test_a_new_state_defaults_to_the_simple_economy() { + const state = new GameState(); + this.assertEqual(state.economyModel, "simple", "the constructor defaults to simple"); + this.assertTrue(state.isSimpleEconomy(), "and reports it"); + } + + test_configure_without_options_defaults_to_simple() { + const state = new GameState(); + state.mapConfig = smallConfig(); + // The two-argument call is the existing one and must keep working. + state.configure(civs(), SEED); + this.assertEqual(state.economyModel, "simple"); + this.assertTrue(state.isSimpleEconomy()); + } + + test_configure_accepts_each_known_model_verbatim() { + this.assertEqual(stateWithModel({ economyModel: "simple" }).economyModel, "simple"); + const hard = stateWithModel({ economyModel: "hard" }); + this.assertEqual(hard.economyModel, "hard"); + this.assertFalse(hard.isSimpleEconomy()); + } + + test_unknown_models_coerce_to_simple() { + for (const value of ["", "SIMPLE", "rich", null, 42, {}]) { + const state = stateWithModel({ economyModel: value }); + this.assertEqual(state.economyModel, "simple", `coerced from ${JSON.stringify(value)}`); + this.assertTrue(state.isSimpleEconomy(), "and is treated as simple"); + } + } + + test_the_snapshot_carries_the_model() { + this.assertEqual(stateWithModel({}).snapshot(0).economyModel, "simple"); + this.assertEqual(stateWithModel({ economyModel: "hard" }).snapshot(0).economyModel, "hard"); + } + + test_a_simple_game_runs_a_day_without_throwing() { + const state = stateWithModel({ economyModel: "simple" }); + for (let hour = 0; hour < 24; hour++) state.advanceHour(); + this.assertEqual(state.totalHours, 24, "the day advanced"); + } + + test_a_hard_game_runs_a_day_without_throwing() { + const state = stateWithModel({ economyModel: "hard" }); + for (let hour = 0; hour < 24; hour++) state.advanceHour(); + this.assertEqual(state.totalHours, 24, "the day advanced"); + } + + test_the_server_defaults_to_simple() { + this.assertEqual(makeServer().state.economyModel, "simple"); + } + + test_the_server_accepts_the_hard_model() { + this.assertEqual(makeServer({ economyModel: "hard" }).state.economyModel, "hard"); + } + + test_the_server_coerces_an_unknown_model() { + this.assertEqual(makeServer({ economyModel: "bogus" }).state.economyModel, "simple"); + } + + async test_start_server_forwards_the_model() { + await withServer(async (port, server) => { + this.assertEqual(server.gameServer.state.economyModel, "hard"); + }, { economyModel: "hard" }); + } + + // 04-warmup.md: the simple model never announces or runs a settle, and says + // so when `--warmup` was asked for explicitly; the hard model still settles. + async test_start_server_skips_the_settle_for_simple() { + const lines = []; + await withServer(async (port, server) => { + this.assertEqual(server.gameServer.settledDays, 0, "no settle ran"); + this.assertEqual(server.gameServer.state.totalHours, 0, "the clock never advanced"); + }, { + economyModel: "simple", + warmupDays: 150, + warmupExplicit: true, + log: (message) => lines.push(message), + }); + this.assertFalse( + lines.some((line) => /settling the world/.test(line)), + "no settle was announced" + ); + this.assertTrue( + lines.some((line) => /needs no warm-up/.test(line)), + "the ignored flag is reported rather than dropped in silence" + ); + } + + async test_start_server_still_settles_for_hard() { + const lines = []; + await withServer(async (port, server) => { + this.assertGreater(server.gameServer.settledDays, 0, "the hard settle ran"); + }, { + economyModel: "hard", + warmupDays: 2, + log: (message) => lines.push(message), + }); + this.assertTrue( + lines.some((line) => /settling the world/.test(line)), + "the settle is announced" + ); + } +} diff --git a/tests/framework/helpers.js b/tests/framework/helpers.js index 93c4238..c661aaf 100644 --- a/tests/framework/helpers.js +++ b/tests/framework/helpers.js @@ -50,11 +50,25 @@ export function smallConfig(overrides = {}) { }; } +// The historical fixtures run the **hard** economy explicitly. The model +// defaults to simple everywhere, but these suites pin the original per-region +// simulation so their assertions stay valid; simple-economy tests use +// `simpleState` below. export function smallState(civIds = ["france", "britain"], seed = SEED, overrides = {}) { const state = new GameState(); state.mapConfig = smallConfig(overrides); const civs = civIds.map((id) => CIVILISATIONS.find((c) => c.id === id)); - state.configure(civs, seed); + state.configure(civs, seed, { economyModel: "hard" }); + return state; +} + +// A configured state on the small fixture map running the simple economy: one +// resource pool per nation and public production buildings. +export function simpleState(civIds = ["france", "britain"], seed = SEED, overrides = {}) { + const state = new GameState(); + state.mapConfig = smallConfig(overrides); + const civs = civIds.map((id) => CIVILISATIONS.find((c) => c.id === id)); + state.configure(civs, seed, { economyModel: "simple" }); return state; } @@ -63,7 +77,7 @@ export function smallState(civIds = ["france", "britain"], seed = SEED, override export function defaultState(civIds = ["france", "britain"], seed = SEED) { const state = new GameState(); const civs = civIds.map((id) => CIVILISATIONS.find((c) => c.id === id)); - state.configure(civs, seed); + state.configure(civs, seed, { economyModel: "hard" }); return state; } diff --git a/tests/politics_test.js b/tests/politics_test.js index ba0109c..b341f46 100644 --- a/tests/politics_test.js +++ b/tests/politics_test.js @@ -1,5 +1,6 @@ import { TestCase } from "./framework/test_case.js"; -import { smallState, grantBuilding } from "./framework/helpers.js"; +import { smallState, simpleState, grantBuilding } from "./framework/helpers.js"; +import { key } from "../shared/hex.js"; import { OPINION, APPROVAL, @@ -486,6 +487,65 @@ export class PoliticsModelTest extends TestCase { this.assertGreater(arriving.inbound.get(1) || 0, 0, "people arrived"); } + // 08-migrations: the simple economy keeps political flows but retires the + // economic ones, so a region far richer than another draws nobody, however + // long the world runs. + test_simple_economy_has_no_income_migration() { + const state = simpleState(); + const cities = state.cities.filter((city) => city.civ === 0); + this.assertGreaterOrEqual(cities.length, 2, "two regions to compare"); + // One sparse (and so rich) region, one packed and poor: the hard sweep would + // send people from the poor one to the rich one. + for (const coords of state.regionTiles(cities[0])) { + state.tilePopulation.set(key(coords.x, coords.y), 1_000); + } + for (const coords of state.regionTiles(cities[1])) { + state.tilePopulation.set(key(coords.x, coords.y), 5_000_000); + } + state._clearTileGdpCache(); + const before = state.cities.map((city) => state.regionPopulation(city)); + for (let day = 0; day < 30; day++) state._tickPolitics(); + const after = state.cities.map((city) => state.regionPopulation(city)); + for (let i = 0; i < before.length; i++) { + this.assertApprox(after[i], before[i], 1e-6, "no income-driven movement"); + } + } + + // Option A keeps political migration in the simple economy: the income sweep + // is a no-op, but an expelling nation still drives a people across the border. + test_simple_economy_keeps_political_migration() { + const state = simpleState(); + const border = borderPair(state, 0, 1); + this.assertNotNull(border, "the two nations share a border"); + state._setTileEthnicFractions(border.from, new Map([[0, 0.6], [1, 0.4]])); + state._setTileEthnicFractions(border.to, new Map([[1, 0.9], [0, 0.1]])); + this.assertTrue(state.requestPolicy(0, POLICY_EXPEL, 1)); + state._tickMigration(); + const leaving = state.migrations.get(0); + this.assertNotNull(leaving, "the expelling country has a flow"); + this.assertGreater(leaving.outbound.get(1) || 0, 0, "people still leave under a policy"); + } + + // Natural population growth is not migration and must survive: the simple + // model only removes the income flows. + test_simple_economy_still_grows_population_naturally() { + const state = simpleState(); + const before = state.getPlayerPopulation(0); + state._tickPopulation(); + this.assertGreater(state.getPlayerPopulation(0), before, "a day of natural growth"); + } + + // The migration snapshot fields stay present and empty in simple mode rather + // than going missing, so the client's migration graph keeps its shape. + test_simple_economy_snapshot_has_empty_migration_fields() { + const state = simpleState(); + const snap = state.snapshot(0); + this.assertHas(snap, "migrations"); + this.assertEqual(snap.migrations.length, state.civilisations.length, "one entry per nation"); + this.assertHas(snap, "migrationGraph"); + this.assertEmpty(snap.migrationGraph.links, "the opening graph draws no flows"); + } + test_no_air_migration_without_airports() { const state = smallState(); state._tickMigration(); diff --git a/tests/simple_construction_test.js b/tests/simple_construction_test.js new file mode 100644 index 0000000..1b110e2 --- /dev/null +++ b/tests/simple_construction_test.js @@ -0,0 +1,326 @@ +// Simple construction introduced by 03-construction.md: an order starts at +// once, its material bill comes out of the nation's pool, the clock is set by +// the region's spare power, and every opening region keeps a fuel-free plant. +// The hard economy's gather-then-build path is exercised by +// `construction_site_test.js` and must stay untouched. + +import { TestCase } from "./framework/test_case.js"; +import { simpleState, cityOf, grantBuilding, smallConfig, SEED } from "./framework/helpers.js"; +import { GameServer } from "../server/game_server.js"; +import { key, parseKey } from "../shared/hex.js"; +import { + SIMPLE_ECONOMY, + tileImprovementById, + isResourceTileBuilding, +} from "../shared/data.js"; +import { buildingBuildCost } from "../shared/rules.js"; + +// The first land tile a nation owns that carries nothing, not even a road, so a +// transport order cannot be refused for replacing the same improvement. +function bareOwnedLand(state, civ = 0) { + return state.landCells.find((coords) => { + const k = key(coords.x, coords.y); + return state.civAt(coords) === civ && + !state.cityAt(coords) && + !state.roads.has(k) && + !state.railways.has(k) && + !state.tileImprovements.has(k) && + !state.constructionSiteAt(coords); + }); +} + +// A server that has configured its game but never listened, so the warm-up +// branch can be observed without opening a socket. +function configuredServer(setup = {}) { + const network = { getPeerIds: () => [], send: () => {} }; + const server = new GameServer(network); + server.configureGame({ + seed: SEED, + player_civ: 0, + civilisations: ["france", "britain"], + mapConfig: smallConfig(), + ...setup, + }); + return server; +} + +// Strips every power plant out of one city's region, so the energy floor can be +// exercised without the world's opening plants in the way. +function stripRegionPlants(state, city) { + for (const coords of state.regionTiles(city)) { + const k = key(coords.x, coords.y); + const proto = tileImprovementById(state.tileImprovements.get(k)); + if (isResourceTileBuilding(proto) && proto.resource === "energy") { + state.tileImprovements.delete(k); + state.tileImprovementOwner.delete(k); + state.tileImprovementHp.delete(k); + // A real removal bumps the improvement versions, which is what drops the + // memoised region power balances. Mirror that here. + state._improvementVersion += 1; + state._tileImprovementVersion += 1; + } + } +} + +export class SimpleConstructionTest extends TestCase { + test_a_build_order_starts_in_construction_with_no_materials_phase() { + const state = simpleState(); + state.applyStartingScenario(); + const city = cityOf(state, 0); + const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks"); + const proto = state.protoBuildings[barracks]; + const levelBefore = state.getCityBuildingLevel(city, barracks); + const pool = state.getResourcePool(0); + const steelBefore = pool.steel; + const cost = buildingBuildCost(proto, levelBefore); + const bill = state.constructionResourceCost(proto, levelBefore, cost); + this.assertTrue(state.requestBuild(city.id, barracks), "the order is accepted"); + const entry = state.training.get(city.id)[0]; + this.assertNotNull(entry, "the order is queued"); + this.assertEqual(entry.phase, "construction", "it starts building at once"); + this.assertEqual(entry.budget, 0, "no material budget is reserved"); + this.assertEqual(entry.needed.steel, 0, "it names no materials to gather"); + this.assertEqual(entry.bought.steel, 0, "and gathers none"); + this.assertApprox( + pool.steel, + steelBefore - bill.steel, + 1e-6, + "the pool paid the bill up front" + ); + this.assertEqual( + state.getCityBuildingLevel(city, barracks), + levelBefore, + "the level is not raised until the clock runs out" + ); + } + + test_a_building_is_raised_on_the_hourly_tick() { + const state = simpleState(); + state.applyStartingScenario(); + const city = cityOf(state, 0); + const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks"); + const levelBefore = state.getCityBuildingLevel(city, barracks); + this.assertTrue(state.requestBuild(city.id, barracks)); + for (let hour = 0; hour < 10_000 && state.training.has(city.id); hour++) { + state.advanceHour(); + } + this.assertFalse(state.training.has(city.id), "the order finished"); + this.assertEqual( + state.getCityBuildingLevel(city, barracks), + levelBefore + 1, + "the level arrived" + ); + } + + test_a_unit_order_trains_without_a_materials_phase() { + const state = simpleState(); + state.applyStartingScenario(); + const city = cityOf(state, 0); + grantBuilding(state, city, "barracks"); + const infantry = state.protoUnits.findIndex((p) => p.id === "modern_infantry"); + const before = state.units.filter((u) => u.civ === 0 && u.proto === infantry).length; + this.assertTrue(state.requestTrain(city.id, infantry), "the order is accepted"); + const entry = state.training.get(city.id)[0]; + this.assertEqual(entry.phase, "construction", "training starts at once"); + for (let hour = 0; hour < 2_000 && state.training.has(city.id); hour++) { + state.advanceHour(); + } + this.assertFalse(state.training.has(city.id), "the order finished"); + this.assertEqual( + state.units.filter((u) => u.civ === 0 && u.proto === infantry).length, + before + 1, + "the unit was trained" + ); + } + + test_more_regional_energy_strictly_shortens_the_clock() { + const state = simpleState(); + const city = cityOf(state, 0); + const cost = 100_000_000; + state.regionAvailableEnergy = () => 100_000_000; + const low = state._simpleConstructionHours(city, cost, "building"); + state.regionAvailableEnergy = () => 1_000_000_000; + const high = state._simpleConstructionHours(city, cost, "building"); + this.assertLess(high, low, "more spare power builds faster"); + this.assertGreater(high, 0, "and never instant"); + } + + test_a_blacked_out_region_builds_slowly_but_not_at_once() { + const state = simpleState(); + const city = cityOf(state, 0); + const cost = 100_000_000; + state.regionAvailableEnergy = () => 0; + const dead = state._simpleConstructionHours(city, cost, "building"); + state.regionAvailableEnergy = () => 100_000_000; + const lit = state._simpleConstructionHours(city, cost, "building"); + this.assertGreater(dead, lit, "a power-less region builds clearly slower"); + this.assertTrue(Number.isFinite(dead), "the clock is finite, not a divide-by-zero"); + this.assertGreaterOrEqual(dead, SIMPLE_ECONOMY.construction.minHours, "the floor holds"); + } + + test_a_region_with_no_plant_reports_no_spare_power() { + const state = simpleState(); + const city = cityOf(state, 0); + this.assertGreater(state.regionAvailableEnergy(city), 0, "the region opens powered"); + stripRegionPlants(state, city); + this.assertEqual(state.regionAvailableEnergy(city), 0, "no plants, no spare power"); + } + + test_every_starting_region_has_a_working_power_plant() { + const state = simpleState(); + for (const city of state.cities) { + this.assertTrue(state._regionHasPowerPlant(city), `city ${city.name} has a plant`); + this.assertGreater( + state.regionAvailableEnergy(city), + 0, + `city ${city.name}'s grid has spare power` + ); + } + } + + test_the_seeder_fills_a_region_that_has_no_plant() { + const state = simpleState(); + for (const city of state.cities) stripRegionPlants(state, city); + for (const city of state.cities) { + this.assertFalse(state._regionHasPowerPlant(city), "the region was stripped bare"); + } + state._seedSimpleRegionPlants(); + for (const city of state.cities) { + this.assertTrue(state._regionHasPowerPlant(city), `city ${city.name} got a plant`); + } + // The guaranteed plant is fuel-free, so the simple economy implies no fuel. + for (const [k, id] of state.tileImprovements) { + const proto = tileImprovementById(id); + if (isResourceTileBuilding(proto) && proto.resource === "energy") { + this.assertTrue(proto.renewable, `${proto.name} is fuel-free`); + this.assertFalse(!!proto.fuelEnergyPerDay, `${proto.name} burns no fuel`); + } + } + } + + test_an_order_the_pool_cannot_pay_is_refused() { + const state = simpleState(); + state.applyStartingScenario(); + const city = cityOf(state, 0); + const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks"); + const levelBefore = state.getCityBuildingLevel(city, barracks); + const pool = state.getResourcePool(0); + pool.steel = 0; + pool.hightech = 0; + this.assertFalse(state.requestBuild(city.id, barracks), "an unfunded order is refused"); + this.assertFalse(state.training.has(city.id), "nothing was queued"); + this.assertEqual( + state.getCityBuildingLevel(city, barracks), + levelBefore, + "and nothing was raised" + ); + } + + test_simple_construction_spends_no_treasury_money() { + const state = simpleState(); + state.applyStartingScenario(); + const city = cityOf(state, 0); + const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks"); + const budgetBefore = state.getBudget(0); + this.assertTrue(state.requestBuild(city.id, barracks)); + this.assertApprox(state.getBudget(0), budgetBefore, 1e-6, "the treasury paid nothing"); + const coords = bareOwnedLand(state); + this.assertTrue(state.requestBuildImprovement(0, coords, "road"), "a road is ordered"); + this.assertApprox(state.getBudget(0), budgetBefore, 1e-6, "the road paid nothing either"); + } + + test_a_tile_order_opens_already_building_and_finishes() { + const state = simpleState(); + state.applyStartingScenario(); + const coords = bareOwnedLand(state); + this.assertTrue(state.requestBuildImprovement(0, coords, "road")); + const site = state.constructionSiteAt(coords); + this.assertNotNull(site, "a site opened"); + this.assertEqual(site.phase, "construction", "it is already building"); + this.assertEqual(site.budget, 0, "no budget was moved"); + this.assertNull(site.cityId, "and it hauls from no city"); + for (let hour = 0; hour < 10_000 && state.constructionSiteAt(coords); hour++) { + state.advanceHour(); + } + this.assertTrue(state.roads.has(key(coords.x, coords.y)), "the road was raised"); + } + + test_a_public_upgrade_is_immediate_and_energy_driven() { + const state = simpleState(); + state.applyStartingScenario(); + let coords = null; + let k = null; + let proto = null; + for (const [tile, id] of state.tileImprovements) { + if (state.tileImprovementOwner.get(tile) !== 0) continue; + const candidate = tileImprovementById(id); + if (isResourceTileBuilding(candidate) && candidate.resource !== "energy") { + k = tile; + coords = parseKey(tile); + proto = candidate; + break; + } + } + this.assertNotNull(proto, "the fixture has a converter to upgrade"); + const pool = state.getResourcePool(0); + pool.steel = 1e9; + pool.hightech = 1e6; + const agent = state._ensureBuildingAgent(k); + const levelBefore = agent.level; + const budgetBefore = state.getBudget(0); + this.assertTrue(state._startBuildingUpgrade(k, proto, agent), "the upgrade starts"); + const site = state.constructionSiteAt(coords); + this.assertNotNull(site, "a site opened"); + this.assertEqual(site.kind, "upgrade"); + this.assertEqual(site.phase, "construction", "it is already building"); + this.assertFalse(site.private, "it is a public work"); + this.assertApprox(state.getBudget(0), budgetBefore, 1e-6, "the treasury paid nothing"); + for (let hour = 0; hour < 100_000 && state.constructionSiteAt(coords); hour++) { + state.advanceHour(); + } + this.assertNull(state.constructionSiteAt(coords), "the upgrade finished"); + this.assertEqual(agent.level, levelBefore + 1, "the level rose"); + } + + test_ordering_a_build_touches_no_supplier_search() { + const state = simpleState(); + state.applyStartingScenario(); + const city = cityOf(state, 0); + const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks"); + let searched = false; + state._nearbySuppliers = () => { searched = true; return []; }; + state._resourceNodes = () => { searched = true; return []; }; + this.assertTrue(state.requestBuild(city.id, barracks), "the order is accepted"); + this.assertFalse(searched, "the order did no trade-graph lookup"); + this.assertEqual(state.training.get(city.id)[0].phase, "construction", "it started building"); + } + + // 04-warmup.md: the simple economy opens on its authored scenario, so the + // server settles no days and the clock never moves before the first player + // joins. The hard model keeps its settle for comparison. + test_a_simple_game_skips_the_warmup() { + const server = configuredServer({ economyModel: "simple", warmupDays: 150 }); + this.assertEqual(server.settledDays, 0, "no days were settled"); + this.assertEqual(server.state.totalHours, 0, "the clock never advanced"); + } + + test_a_simple_game_ignores_an_explicit_warmup_cap() { + const capped = configuredServer({ economyModel: "simple", warmupDays: 150 }); + const none = configuredServer({ economyModel: "simple", warmupDays: 0 }); + this.assertEqual(capped.settledDays, 0, "the cap settled nothing"); + this.assertEqual(capped.state.totalHours, none.state.totalHours, "the same opening hour"); + for (const civ of [0, 1]) { + this.assertEqual( + capped.state.getResourcePool(civ), + none.state.getResourcePool(civ), + `civ ${civ}'s pool is the authored opening pool` + ); + } + } + + test_a_hard_game_still_settles_up_to_its_cap() { + const server = configuredServer({ economyModel: "hard", warmupDays: 2 }); + this.assertEqual(server.settledDays, 2, "the hard settle ran the cap"); + this.assertGreater(server.state.totalHours, 0, "the clock advanced"); + } +} diff --git a/tests/simple_market_test.js b/tests/simple_market_test.js new file mode 100644 index 0000000..ead06e0 --- /dev/null +++ b/tests/simple_market_test.js @@ -0,0 +1,246 @@ +// The global market (06-global-market.md): one shared world stock per storable +// good, a price derived from that stock, and a buy/sell order in fixed lots. +// The hard economy keeps its drifting reference prices and has no market order. + +import { TestCase } from "./framework/test_case.js"; +import { simpleState, smallState, smallConfig, SEED } from "./framework/helpers.js"; +import { + RESOURCE_IDS, + STORABLE_RESOURCE_IDS, + RESOURCE_MARKET_BASE, + MARKET_START_STOCK, + MARKET_LOTS, +} from "../shared/data.js"; +import { GameServer } from "../server/game_server.js"; + +function configuredServer(economyModel) { + const server = new GameServer({ getPeerIds: () => [], send: () => {} }); + server.configureGame({ + seed: SEED, + player_civ: 0, + civilisations: ["france", "britain"], + mapConfig: smallConfig(), + economyModel, + }); + server.peerCiv.set(1, 0); + return server; +} + +export class SimpleMarketTest extends TestCase { + test_the_opening_price_is_the_base_price() { + const state = simpleState(); + for (const id of STORABLE_RESOURCE_IDS) { + this.assertEqual(state.marketStock.get(id), MARKET_START_STOCK, `${id} opens full`); + this.assertApprox(state.marketPrice(id), RESOURCE_MARKET_BASE[id], 1e-9, `${id} at base`); + this.assertApprox( + state.getResourcePrice(id), + RESOURCE_MARKET_BASE[id], + 1e-9, + `${id} reads at base` + ); + } + // Energy is a regional flow, not a market good: it keeps its base price and + // is never stocked. + this.assertEqual(state.marketStock.has("energy"), false, "energy is not stocked"); + this.assertApprox( + state.getResourcePrice("energy"), + RESOURCE_MARKET_BASE.energy, + 1e-9, + "energy keeps its base price" + ); + } + + test_buying_drains_the_stock_and_raises_the_price() { + const state = simpleState(); + const pool = state.getResourcePool(0); + const before = pool.steel; + const treasuryBefore = state.getBudget(0); + const price = state.marketPrice("steel"); + const stockBefore = state.marketStock.get("steel"); + this.assertTrue(state.requestMarketTrade(0, "steel", "buy", 2000), "the buy is accepted"); + this.assertApprox(pool.steel, before + 2000, 1e-9, "the pool gained exactly the lot"); + this.assertApprox( + state.getBudget(0), + treasuryBefore - 2000 * price, + 1e-6, + "the treasury paid qty x price" + ); + this.assertApprox(state.marketStock.get("steel"), stockBefore - 2000, 1e-9, "the stock fell"); + this.assertGreater(state.marketPrice("steel"), price, "and the price rose"); + } + + test_selling_fills_the_stock_and_lowers_the_price() { + const state = simpleState(); + const pool = state.getResourcePool(0); + pool.food = 100_000; + const before = pool.food; + const treasuryBefore = state.getBudget(0); + const price = state.marketPrice("food"); + const stockBefore = state.marketStock.get("food"); + this.assertTrue(state.requestMarketTrade(0, "food", "sell", 2000), "the sell is accepted"); + this.assertApprox(pool.food, before - 2000, 1e-9, "the pool gave up exactly the lot"); + this.assertApprox( + state.getBudget(0), + treasuryBefore + 2000 * price, + 1e-6, + "the treasury was credited qty x price" + ); + this.assertApprox(state.marketStock.get("food"), stockBefore + 2000, 1e-9, "the stock rose"); + this.assertLess(state.marketPrice("food"), price, "and the price fell"); + } + + test_only_the_three_lots_are_accepted() { + const state = simpleState(); + state.getResourcePool(0).steel = 1e9; + state.budgets.set(0, 1e15); + for (const lot of MARKET_LOTS) { + this.assertTrue( + state.requestMarketTrade(0, "steel", "buy", lot), + `the ${lot} lot is accepted` + ); + } + for (const bad of [1, 50, 999, 2500, 100_000, 0, -100, NaN, undefined]) { + this.assertFalse( + state.requestMarketTrade(0, "steel", "buy", bad), + `the ${bad} quantity is refused` + ); + } + } + + test_a_refused_trade_moves_nothing() { + const state = simpleState(); + const pool = state.getResourcePool(0); + const poolBefore = pool.hightech; + const stockBefore = state.marketStock.get("hightech"); + // A treasury that cannot afford the lot. + state.budgets.set(0, 1); + this.assertFalse(state.requestMarketTrade(0, "hightech", "buy", 100), "no funds, no trade"); + this.assertEqual(state.getBudget(0), 1, "the treasury is untouched"); + this.assertEqual(pool.hightech, poolBefore, "the pool is untouched"); + this.assertEqual(state.marketStock.get("hightech"), stockBefore, "the stock is untouched"); + + // A pool that does not hold the goods being sold. + state.budgets.set(0, 1e12); + pool.steel = 50; + this.assertFalse(state.requestMarketTrade(0, "steel", "sell", 100), "nothing to sell"); + this.assertEqual(pool.steel, 50, "the pool keeps what it had"); + this.assertEqual(state.getBudget(0), 1e12, "and the treasury does not move"); + + // A world stock that cannot cover the buy. + state.marketStock.set("steel", 50); + this.assertFalse(state.requestMarketTrade(0, "steel", "buy", 100), "the world is short"); + this.assertEqual(state.marketStock.get("steel"), 50, "the stock keeps what it had"); + + // A malformed side or good. + this.assertFalse(state.requestMarketTrade(0, "steel", "hold", 100), "a bad side is refused"); + this.assertFalse(state.requestMarketTrade(0, "energy", "buy", 100), "energy is not traded"); + this.assertFalse(state.requestMarketTrade(0, "nonsense", "buy", 100), "an unknown good"); + } + + test_the_market_is_shared_by_every_player() { + const state = simpleState(); + const before = state.marketPrice("steel"); + this.assertTrue(state.requestMarketTrade(0, "steel", "buy", 50_000), "civ 0 buys"); + const after = state.marketPrice("steel"); + this.assertGreater(after, before, "the buy moved the price"); + this.assertApprox(state.marketPrice("steel"), after, 1e-9, "civ 1 reads the same market"); + this.assertApprox(state.getResourcePrice("steel"), after, 1e-9, "and the price read agrees"); + const view = state.getMarketView(1).find((entry) => entry.id === "steel"); + this.assertApprox(view.price, after, 1e-9, "the market view shows the shared price"); + this.assertApprox(view.stock, MARKET_START_STOCK - 50_000, 1e-9, "and the shared stock"); + } + + test_a_near_empty_stock_stays_finite() { + const state = simpleState(); + for (const stock of [0, -5, NaN, undefined]) { + state.marketStock.set("steel", stock); + const price = state.marketPrice("steel"); + this.assertTrue(Number.isFinite(price), `a stock of ${stock} gives a finite price`); + this.assertGreater(price, 0, "and a positive one"); + } + } + + test_the_simple_tick_leaves_the_price_to_the_market() { + const state = simpleState(); + state.applyStartingScenario(); + const steel = state.marketPrice("steel"); + const market = new Map(); + for (const id of RESOURCE_IDS) { + market.set(id, { supply: 0, demand: 1e6, price: state.getResourcePrice(id) }); + } + state._updateMarketPrices(market); + this.assertApprox(state.marketPrice("steel"), steel, 1e-9, "no supply/demand drift"); + for (let hour = 0; hour < 24; hour++) state.advanceHour(); + this.assertApprox(state.marketPrice("steel"), steel, 1e-9, "a whole day does not drift it"); + } + + test_the_snapshot_carries_the_stock_and_price() { + const state = simpleState(); + const market = state.snapshot(0).resourceMarket; + this.assertSize(market, RESOURCE_IDS.length, "one entry per resource"); + const steel = market.find((entry) => entry.id === "steel"); + this.assertEqual(steel.stock, MARKET_START_STOCK, "the stock ships"); + this.assertApprox(steel.price, RESOURCE_MARKET_BASE.steel, 1e-9, "the price ships"); + const energy = market.find((entry) => entry.id === "energy"); + this.assertEqual(energy.stock, null, "energy ships no stock"); + } + + test_a_trade_bumps_the_market_version() { + const state = simpleState(); + const before = state.snapshot(0).versions.market; + this.assertGreater(before, 0, "the version starts set"); + this.assertTrue(state.requestMarketTrade(0, "food", "sell", 100), "a trade is accepted"); + this.assertGreater(state.snapshot(0).versions.market, before, "the version moved with it"); + } + + test_the_server_validates_a_market_trade() { + const server = configuredServer("simple"); + const state = server.state; + const pool = state.getResourcePool(0); + const steelBefore = pool.steel; + this.assertTrue( + server.handleOrder(1, { t: "market_trade", side: "buy", resource: "steel", lot: 2000 }), + "a valid trade is accepted" + ); + this.assertEqual(pool.steel, steelBefore + 2000, "and the pool moved"); + this.assertFalse( + server.handleOrder(1, { t: "market_trade", side: "buy", resource: "steel", lot: 1234 }), + "a bad lot is refused" + ); + this.assertFalse( + server.handleOrder(1, { t: "market_trade", side: "hold", resource: "steel", lot: 100 }), + "a bad side is refused" + ); + this.assertFalse( + server.handleOrder(1, { t: "market_trade", side: "buy", resource: "energy", lot: 100 }), + "energy cannot be bought" + ); + this.assertFalse( + server.handleOrder(1, { t: "market_trade", side: "buy", resource: "nonsense", lot: 100 }), + "an unknown good is refused" + ); + } + + test_the_hard_server_has_no_market_order() { + const server = configuredServer("hard"); + this.assertFalse( + server.handleOrder(1, { t: "market_trade", side: "buy", resource: "steel", lot: 2000 }), + "the hard model refuses the market order" + ); + } + + test_the_hard_prices_still_drift_with_supply_and_demand() { + const state = smallState(); + const base = state.getResourcePrice("food"); + this.assertApprox(base, RESOURCE_MARKET_BASE.food, 1e-9, "a hard game opens at base"); + const market = new Map(); + for (const id of RESOURCE_IDS) { + market.set(id, { supply: 1000, demand: 0, price: state.getResourcePrice(id) }); + } + market.get("food").supply = 1; + market.get("food").demand = 1000; + state._updateMarketPrices(market); + this.assertGreater(state.getResourcePrice("food"), base, "a shortage lifts the hard price"); + this.assertEqual(typeof state.marketPrice, "undefined", "the hard model has no market stock API"); + } +} diff --git a/tests/simple_migration_test.js b/tests/simple_migration_test.js new file mode 100644 index 0000000..9614590 --- /dev/null +++ b/tests/simple_migration_test.js @@ -0,0 +1,131 @@ +// 08-migrations.md, tested from the 11-tests.md plan: the simple economy +// retires only the two *economic* migration mechanisms -- the daily income-ratio +// sweep (`_migrateForIncome`) and the opening income-levelling pass +// (`_equalizeRegionIncomes`) -- while political/policy flows and natural growth +// carry on. The hard model's income migration stays pinned by +// `migration_test.js`. + +import { TestCase } from "./framework/test_case.js"; +import { simpleState, smallState } from "./framework/helpers.js"; +import { key } from "../shared/hex.js"; +import { POLICY_EXPEL } from "../shared/data/politics.js"; + +// A pair of neighbouring tiles where `from` is owned by `fromCiv` and the +// neighbour `to` is owned by a different civilisation. +function borderPair(state, fromCiv, toCiv) { + for (const coords of state._territoryByCiv.get(fromCiv) || []) { + for (const neighbour of state._neighbours(coords)) { + if (state.territory.get(key(neighbour.x, neighbour.y)) === toCiv) { + return { from: coords, to: neighbour }; + } + } + } + return null; +} + +// A sparse (so rich) region beside a packed (so poor) one. The hard income +// sweep sends people out of the packed region toward the sparse one. +function splitRichAndPoor(state) { + const cities = state.cities.filter((city) => city.civ === 0); + const rich = cities[0]; + const poor = cities[1]; + for (const coords of state.regionTiles(rich)) { + state.tilePopulation.set(key(coords.x, coords.y), 1_000); + } + for (const coords of state.regionTiles(poor)) { + state.tilePopulation.set(key(coords.x, coords.y), 5_000_000); + } + state._gdpPerCapitaCache.clear(); + return { rich, poor }; +} + +export class SimpleMigrationTest extends TestCase { + // Two regions with very different income do not exchange people: the daily + // sweep is a no-op, so thirty days of politics leave every region untouched. + test_two_regions_with_different_income_do_not_exchange_people() { + const state = simpleState(); + splitRichAndPoor(state); + const before = state.cities.map((city) => state.regionPopulation(city)); + for (let day = 0; day < 30; day++) state._tickPolitics(); + const after = state.cities.map((city) => state.regionPopulation(city)); + for (let i = 0; i < before.length; i++) { + this.assertApprox(after[i], before[i], 1e-6, "no income-driven movement"); + } + } + + // The two overrides themselves are the decision: called directly with a + // glaring income gap, neither moves a single person. + test_the_income_sweep_and_the_levelling_pass_are_no_ops() { + const state = simpleState(); + const { rich, poor } = splitRichAndPoor(state); + let moved = 0; + const realMove = state._movePopulation.bind(state); + state._movePopulation = (...args) => { + moved += 1; + return realMove(...args); + }; + state._migrateForIncome(); + this.assertEqual(moved, 0, "the income sweep moved nobody"); + + const before = state.cities.map((city) => state.regionPopulation(city)); + state._equalizeRegionIncomes(); + const after = state.cities.map((city) => state.regionPopulation(city)); + for (let i = 0; i < before.length; i++) { + this.assertApprox(after[i], before[i], 1e-6, "the levelling pass moved nobody"); + } + // The gap is still there: nothing quietly flattened it. + this.assertGreater( + state.regionPopulation(poor), + state.regionPopulation(rich), + "the regions keep their different sizes" + ); + delete state._movePopulation; + } + + // Natural population growth is not migration and must survive: the simple + // model only removes the income flows. + test_natural_growth_still_applies() { + const state = simpleState(); + const before = state.getPlayerPopulation(0); + state._tickPopulation(); + this.assertGreater(state.getPlayerPopulation(0), before, "a day of natural growth"); + } + + // Option A keeps political migration in the simple economy: the income sweep + // is a no-op, but an expelling nation still drives a people across the border. + test_political_migration_still_applies() { + const state = simpleState(); + const border = borderPair(state, 0, 1); + this.assertNotNull(border, "the two nations share a border"); + state._setTileEthnicFractions(border.from, new Map([[0, 0.6], [1, 0.4]])); + state._setTileEthnicFractions(border.to, new Map([[1, 0.9], [0, 0.1]])); + this.assertTrue(state.requestPolicy(0, POLICY_EXPEL, 1)); + state._tickMigration(); + const leaving = state.migrations.get(0); + this.assertNotNull(leaving, "the expelling country has a flow"); + this.assertGreater(leaving.outbound.get(1) || 0, 0, "people still leave under a policy"); + } + + // The migration snapshot fields stay present and empty rather than going + // missing, so the client's migration graph keeps its shape. + test_the_migration_snapshot_is_present_and_empty() { + const state = simpleState(); + const snap = state.snapshot(0); + this.assertHas(snap, "migrations"); + this.assertEqual(snap.migrations.length, state.civilisations.length, "one entry per nation"); + this.assertHas(snap, "migrationGraph"); + this.assertEmpty(snap.migrationGraph.links, "the opening graph draws no flows"); + } + + // The hard model is the control: the same sparse-next-to-packed setup does + // send people to the rich region, so the simple no-op is a real difference. + test_the_hard_model_still_moves_people_for_income() { + const state = smallState(); + const { rich, poor } = splitRichAndPoor(state); + const richBefore = state.regionPopulation(rich); + const poorBefore = state.regionPopulation(poor); + state._migrateForIncome(); + this.assertGreater(state.regionPopulation(rich), richBefore, "the hard rich region gained"); + this.assertLess(state.regionPopulation(poor), poorBefore, "the hard poor region lost"); + } +} diff --git a/tests/simple_pool_test.js b/tests/simple_pool_test.js new file mode 100644 index 0000000..fd66407 --- /dev/null +++ b/tests/simple_pool_test.js @@ -0,0 +1,120 @@ +// The global resource pool and public production introduced by +// 02-resource-pool-and-production.md: one pool per player, every producer +// feeding it, and no per-tile store or private-agent accounting in the simple +// model. The hard economy must keep its per-region stores. +// +// Note on the hard fixtures: `smallState` now pins `economyModel: "hard"` so +// the historical suites keep exercising the model they were written against; +// these tests use `simpleState` for the simple model. + +import { TestCase } from "./framework/test_case.js"; +import { simpleState, smallState, cityOf } from "./framework/helpers.js"; +import { SIMPLE_ECONOMY, STORABLE_RESOURCE_IDS } from "../shared/data.js"; + +export class SimplePoolTest extends TestCase { + test_a_simple_game_has_one_seeded_pool_per_civ() { + const state = simpleState(); + this.assertEqual(state.resourcePools.size, state.civilisations.length, "one pool per nation"); + for (let civ = 0; civ < state.civilisations.length; civ++) { + const pool = state.getResourcePool(civ); + for (const id of STORABLE_RESOURCE_IDS) { + this.assertEqual( + pool[id], + SIMPLE_ECONOMY.startingPool[id], + `${id} in civ ${civ}'s pool` + ); + } + } + } + + test_a_simple_game_keeps_no_per_tile_stock() { + const state = simpleState(); + state.applyStartingScenario(); + for (let hour = 0; hour < 24; hour++) state.advanceHour(); + this.assertEqual(state.resourceStock.size, 0, "nothing is written to the per-tile store"); + } + + test_a_city_stock_is_a_view_of_its_owners_pool() { + const state = simpleState(); + const pool = state.getResourcePool(0); + const city = cityOf(state, 0); + this.assertTrue(state.getCityResourceStock(city) === pool, "the city reads the pool"); + state.getCityResourceStock(city).steel = 1234; + this.assertEqual(pool.steel, 1234, "an edit through the city view lands in the pool"); + } + + test_the_hard_game_keeps_per_tile_stock() { + const state = smallState(); + this.assertEqual(typeof state.getResourcePool, "undefined", "no pool API on the hard model"); + this.assertTrue(state.resourceStock.size > 0, "hard cities and works hold their own stock"); + } + + test_the_public_producers_fill_the_pool() { + const state = simpleState(); + // Isolate production from the day's consumption and grid, so whatever the + // pool gains is exactly the public works' and the land's output. + state._consumeCityResources = () => {}; + state._settleUpkeep = () => 1; + state._applyWorksMaintenance = () => {}; + state._buildEnergyLedger = () => new Map(); + const pool = state.getResourcePool(0); + for (const id of STORABLE_RESOURCE_IDS) pool[id] = 0; + state._tickResources(); + const produced = STORABLE_RESOURCE_IDS.filter((id) => pool[id] > 0); + this.assertNotEmpty(produced, `a public producer fed the pool (got ${JSON.stringify(pool)})`); + } + + test_procurement_is_a_pool_draw_without_a_supplier_search() { + const state = simpleState(); + const pool = state.getResourcePool(0); + pool.steel = 1000; + let searched = false; + state._nearbySuppliers = () => { searched = true; return []; }; + state._resourceNodes = () => { searched = true; return []; }; + const city = cityOf(state, 0); + const drawn = state._drawFromNearest(0, city.coords, { steel: 400, hightech: 0 }); + this.assertEqual(drawn.steel, 400, "the pool supplied the draw"); + this.assertEqual(pool.steel, 600, "and was debited exactly"); + this.assertFalse(searched, "no supplier search ran"); + const rest = state._drawFromNearest(0, city.coords, { steel: 1000, hightech: 0 }); + this.assertEqual(rest.steel, 600, "a shortfall takes only what is there"); + this.assertEqual(pool.steel, 0, "the pool is empty, not negative"); + } + + test_a_pool_shortfall_leaves_the_need_unmet() { + const state = simpleState(); + const pool = state.getResourcePool(0); + pool.steel = 100; + const missing = state._drawFromCities(0, "steel", 500); + this.assertEqual(missing, 400, "the unmet amount is reported"); + this.assertEqual(pool.steel, 0); + const city = cityOf(state, 0); + this.assertEqual( + state._procureFromNeighbours(city, "steel", 400, {}, state._resourceNodes(), {}), + 0, + "no bid is placed for the shortfall" + ); + } + + test_the_summary_reports_the_pool_and_its_public_production() { + const state = simpleState(); + const summary = state.getCivResourceSummary(0); + const pool = state.getResourcePool(0); + for (const id of STORABLE_RESOURCE_IDS) { + this.assertEqual(summary.stock[id], pool[id], `${id} stock is the pool`); + } + this.assertTrue(summary.production.food > 0, "the land's harvest is public production"); + } + + test_a_simple_day_moves_the_pool_without_throwing() { + const state = simpleState(); + state.applyStartingScenario(); + const before = { ...state.getResourcePool(0) }; + for (let hour = 0; hour < 24; hour++) state.advanceHour(); + const after = state.getResourcePool(0); + const changed = STORABLE_RESOURCE_IDS.some((id) => before[id] !== after[id]); + this.assertTrue(changed, "the day's production and consumption moved the pool"); + this.assertEqual(state.getPrivateBuildingCash(0), 0, "no private building cash is kept"); + this.assertEmpty(state.getProductionAgents(0), "no private production agents exist"); + } +} diff --git a/tests/simple_procurement_test.js b/tests/simple_procurement_test.js new file mode 100644 index 0000000..7bd62f5 --- /dev/null +++ b/tests/simple_procurement_test.js @@ -0,0 +1,321 @@ +// Procurement in the simple economy (07-procurement.md): every consumer draws +// from the owner's single resource pool in one subtraction. There is no +// supplier search, no trade graph, no distance, no freight and no bid -- a +// shortfall leaves the need unmet rather than raising a price. The hard +// economy's bid-and-haul path must stay untouched. + +import { TestCase } from "./framework/test_case.js"; +import { simpleState, smallState, cityOf } from "./framework/helpers.js"; +import { RESOURCE_IDS, RESOURCE_RULES, STORABLE_RESOURCE_IDS } from "../shared/data.js"; +import { foodNeedPerDay, steelNeedPerDay, luxuryNeedPerDay } from "../shared/resources.js"; + +// The storable goods whose daily movement is production minus consumption +// exactly. Food is left out of the exact-balance check because a marching army +// eats from the same pool after the civilian pass. +const BALANCED = ["steel", "luxury", "hightech"]; + +// A fresh market ledger, the shape `_consumeCityResources` expects. +function emptyMarket(state) { + const market = new Map(); + for (const id of RESOURCE_IDS) { + market.set(id, { supply: 0, demand: 0, price: state.getResourcePrice(id) }); + } + return market; +} + +export class SimpleProcurementTest extends TestCase { + // 07: a draw is a single subtraction from the pool, and nothing searches. + test_a_draw_is_one_subtraction_from_the_pool() { + const state = simpleState(); + const pool = state.getResourcePool(0); + pool.steel = 1000; + pool.hightech = 500; + let traded = false; + state._tradeGraph = () => { traded = true; return []; }; + const city = cityOf(state, 0); + const drawn = state._drawFromNearest(0, city.coords, { steel: 400, hightech: 1000 }); + this.assertEqual(drawn.steel, 400, "steel came from the pool"); + this.assertEqual(drawn.hightech, 500, "the pool gave all the high-tech it had"); + this.assertEqual(pool.steel, 600, "steel was debited exactly"); + this.assertEqual(pool.hightech, 0, "the pool is empty, not negative"); + this.assertFalse(traded, "no trade graph walked"); + const missing = state._drawFromCities(0, "steel", 1000); + this.assertEqual(missing, 400, "the unmet amount is reported"); + this.assertEqual(pool.steel, 0, "the pool stays empty"); + this.assertFalse(traded, "the city draw still walked no graph"); + } + + // 07: the quote is the plain world price, with no nominal haul folded in. + test_a_simple_quote_carries_no_freight() { + const state = simpleState(); + for (const id of STORABLE_RESOURCE_IDS) { + this.assertApprox( + state._marketUnitCost(id), + state.marketPrice(id), + 1e-9, + `${id} quotes the market price` + ); + } + // The hard model adds the energy of a nominal two-tile road delivery. + const hard = smallState(); + this.assertGreater( + hard._marketUnitCost("steel"), + hard.getResourcePrice("steel"), + "the hard quote still folds in freight" + ); + } + + // 07: a whole day moves the pool by exactly production minus consumption. + test_a_day_moves_the_pool_by_production_minus_consumption() { + const state = simpleState(); + state.applyStartingScenario(); + for (let day = 0; day < 2; day++) { + const before = { ...state.getResourcePool(0) }; + for (let hour = 0; hour < 24; hour++) state.advanceHour(); + const after = state.getResourcePool(0); + const summary = state.getCivResourceSummary(0); + for (const id of BALANCED) { + const delta = after[id] - before[id]; + const expected = (summary.production[id] || 0) - (summary.consumed[id] || 0); + this.assertApprox(delta, expected, 1e-3, `${id} moved by exactly the day's balance`); + } + } + } + + // 07: the city pass draws each good's daily need straight from the pool. + test_city_consumption_draws_exactly_from_the_pool() { + const state = simpleState(); + state.applyStartingScenario(); + const cities = state.cities.filter((city) => city.civ === 0); + const need = { food: 0, steel: 0, luxury: 0 }; + for (const city of cities) { + const population = state.getCityEconomy(city).population; + need.food += foodNeedPerDay(population); + need.steel += steelNeedPerDay(population); + need.luxury += luxuryNeedPerDay(population); + } + const pool = state.getResourcePool(0); + // A pool deep enough that every region is fed and nothing is procured. + for (const id of Object.keys(need)) pool[id] = 1e9; + let traded = false; + state._tradeGraph = () => { traded = true; return []; }; + state._consumeCityResources(new Map(), emptyMarket(state)); + for (const id of Object.keys(need)) { + this.assertApprox(1e9 - pool[id], need[id], 1e-6, `${id} consumed exactly its need`); + } + this.assertEmpty(state._resourceShortages, "a full pool leaves no shortage"); + this.assertFalse(traded, "consumption walked no trade graph"); + } + + // 07: a shortfall is unmet, not bid up, and the price does not move. + test_a_pool_shortfall_is_unmet_without_a_bid() { + const state = simpleState(); + state.applyStartingScenario(); + const pool = state.getResourcePool(0); + for (const id of STORABLE_RESOURCE_IDS) pool[id] = 0; + const prices = Object.fromEntries(RESOURCE_IDS.map((id) => [id, state.getResourcePrice(id)])); + let traded = false; + state._tradeGraph = () => { traded = true; return []; }; + state._consumeCityResources(new Map(), emptyMarket(state)); + this.assertNotEmpty(state._resourceShortages, "the need is recorded as a shortage"); + for (const shortage of state._resourceShortages.values()) { + this.assertEqual(shortage.food, 1, "the region cannot buy a bite"); + } + this.assertFalse(traded, "a shortfall never searches for a supplier"); + for (const id of RESOURCE_IDS) { + this.assertApprox( + state.getResourcePrice(id), + prices[id], + 1e-9, + `${id} prices no bid on the shortfall` + ); + } + state._tickFamine(); + this.assertNotEmpty(state._famine, "an unfed region is in famine"); + } + + // 07: combat materials are a pool draw, refused whole when the pool is short. + test_combat_materials_come_from_the_pool() { + const state = simpleState(); + const civ = 0; + const pool = state.getResourcePool(civ); + const kind = Object.keys(RESOURCE_RULES.combatConsumption)[0]; + const consumption = RESOURCE_RULES.combatConsumption[kind]; + pool.steel = 1e6; + pool.hightech = 1e6; + let traded = false; + state._tradeGraph = () => { traded = true; return []; }; + this.assertTrue(state.payCombatResources(civ, kind, 1), "the shot is paid"); + this.assertEqual(pool.steel, 1e6 - (consumption.steel || 0), "steel came out of the pool"); + this.assertEqual( + pool.hightech, + 1e6 - (consumption.hightech || 0), + "high-tech came out of the pool" + ); + this.assertFalse(traded, "no supplier was searched"); + + // A pool that cannot cover the shot refuses it, with no bid. + pool.steel = 0; + pool.hightech = 0; + this.assertFalse(state.payCombatResources(civ, kind, 1), "an unfunded shot is refused"); + this.assertEqual(pool.steel, 0, "the steel stays empty"); + this.assertEqual(pool.hightech, 0, "the high-tech stays empty"); + this.assertFalse(traded, "the refusal bid nothing"); + } + + // 07: upkeep draws the whole wear bill from the pool, exactly. + test_upkeep_draws_exactly_from_the_pool() { + const state = simpleState(); + state.applyStartingScenario(); + const civ = 0; + const categories = state.getCivUpkeepBreakdown(civ); + let steel = 0; + let hightech = 0; + for (const id of ["buildings", "units", "works", "transport"]) { + steel += (categories[id] && categories[id].steel) || 0; + hightech += (categories[id] && categories[id].hightech) || 0; + } + const pool = state.getResourcePool(civ); + pool.steel = 1e9; + pool.hightech = 1e9; + let traded = false; + state._tradeGraph = () => { traded = true; return []; }; + const settled = state._settleUpkeep(civ, categories); + this.assertApprox(settled, 1, 1e-9, "a funded pool settles the whole bill"); + this.assertApprox(1e9 - pool.steel, steel, 1e-6, "steel wear came from the pool"); + this.assertApprox(1e9 - pool.hightech, hightech, 1e-6, "high-tech wear came from the pool"); + this.assertFalse(traded, "upkeep searched no supplier"); + } + + // 07: neither the ordering nor a whole day's tick builds the trade graph. + test_a_simple_game_never_builds_the_trade_graph() { + const state = simpleState(); + state.applyStartingScenario(); + let graphCalls = 0; + state._tradeGraph = () => { graphCalls += 1; return []; }; + const city = cityOf(state, 0); + const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks"); + this.assertTrue(state.requestBuild(city.id, barracks), "the order is accepted"); + const infantry = state.protoUnits.findIndex((p) => p.id === "modern_infantry"); + state.requestTrain(city.id, infantry); + this.assertEqual(graphCalls, 0, "ordering walked no trade graph"); + for (let hour = 0; hour < 24; hour++) state.advanceHour(); + this.assertEqual(graphCalls, 0, "a whole day walked no trade graph"); + // The supplier search itself is retired: it finds no reachable node at all. + this.assertEmpty( + state._nearbySuppliers(city, "steel", state._resourceNodes(), true), + "no supplier is ever within reach" + ); + } + + // 07: a simple build/train entry and a tile site never re-enter the gather + // phase, so the overridden gather passes are dead code in a normal game. + test_a_simple_order_never_regathers() { + const state = simpleState(); + state.applyStartingScenario(); + const city = cityOf(state, 0); + const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks"); + let gathered = 0; + state._gatherBuildEntry = () => { gathered += 1; return true; }; + state._gatherSiteMaterials = () => { gathered += 1; }; + this.assertTrue(state.requestBuild(city.id, barracks), "the building is ordered"); + const entry = state.training.get(city.id)[0]; + this.assertEqual(entry.phase, "construction", "it starts building at once"); + this.assertEqual(entry.needed.steel, 0, "it names no materials to gather"); + for (let hour = 0; hour < 24; hour++) state.advanceHour(); + this.assertEqual(gathered, 0, "the gather passes never ran"); + } + + // 07: the fallback gather pass, if ever reached, is a pool draw too. + test_the_fallback_gather_draws_from_the_pool() { + const state = simpleState(); + state.applyStartingScenario(); + const pool = state.getResourcePool(0); + pool.steel = 500; + pool.hightech = 100; + const site = { + civ: 0, + phase: "materials", + needed: { steel: 300, hightech: 200 }, + bought: { steel: 0, hightech: 0 }, + stalled: true, + elapsedHours: 5, + }; + state._gatherSiteMaterials(site); + this.assertEqual(site.bought.steel, 300, "the site drew its steel from the pool"); + this.assertEqual(site.bought.hightech, 100, "and all the high-tech the pool held"); + this.assertEqual(site.phase, "materials", "a shortfall leaves the work stalled"); + this.assertTrue(site.stalled, "and marks it stalled"); + pool.hightech = 200; + state._gatherSiteMaterials(site); + this.assertEqual(site.bought.hightech, 200, "the rest is drawn once the pool can cover it"); + this.assertEqual(site.phase, "construction", "a covered bill starts building"); + this.assertFalse(site.stalled, "and is no longer stalled"); + } + + // 07: the queued-build gather fallback is a pool draw, not a supplier pass. + test_the_fallback_build_gather_draws_from_the_pool() { + const state = simpleState(); + state.applyStartingScenario(); + const pool = state.getResourcePool(0); + pool.steel = 500; + pool.hightech = 100; + const city = cityOf(state, 0); + const entry = { + phase: "materials", + stalled: true, + needed: { steel: 300, hightech: 200 }, + bought: { steel: 0, hightech: 0 }, + }; + this.assertFalse(state._gatherBuildEntry(city, entry), "a short bill does not start the work"); + this.assertEqual(entry.bought.steel, 300, "steel came from the pool"); + this.assertEqual(entry.bought.hightech, 100, "and all the high-tech the pool held"); + this.assertEqual(entry.phase, "materials", "the entry stays gathering"); + this.assertTrue(entry.stalled, "and is marked stalled"); + pool.hightech = 200; + this.assertTrue(state._gatherBuildEntry(city, entry), "a covered bill starts the work"); + this.assertEqual(entry.phase, "construction", "the entry flips to construction"); + } + + // 07: a public upgrade's material check and purchase read the owner's pool. + test_upgrade_materials_come_from_the_pool() { + const state = simpleState(); + const city = cityOf(state, 0); + const pool = state.getResourcePool(0); + pool.steel = 100; + pool.hightech = 10; + this.assertEqual( + state._buildingMaterialAvailability(0, city.coords, "steel"), + 100, + "the pool is the whole availability" + ); + this.assertTrue( + state._buyBuildingMaterials(0, city.coords, 0, { steel: 50, hightech: 5 }), + "an affordable bill is paid" + ); + this.assertEqual(pool.steel, 50, "the steel was debited"); + this.assertEqual(pool.hightech, 5, "the high-tech was debited"); + this.assertFalse( + state._buyBuildingMaterials(0, city.coords, 0, { steel: 1000, hightech: 0 }), + "an unaffordable bill is refused whole" + ); + this.assertEqual(pool.steel, 50, "and moves nothing"); + } + + // 07: the hard procurement path reads stores and quotes freight, untouched. + test_the_hard_procurement_path_is_unchanged() { + const state = smallState(); + this.assertEqual(typeof state.getResourcePool, "undefined", "the hard model has no pool"); + this.assertGreater( + state._marketUnitCost("steel"), + state.getResourcePrice("steel"), + "the hard delivered price folds in freight" + ); + const city = cityOf(state, 0); + const store = state.getCityResourceStock(city); + store.steel = 1234; + const drawn = state._drawFromNearest(0, city.coords, { steel: 1000, hightech: 0 }); + this.assertEqual(drawn.steel, 1000, "the hard draw reads the city store"); + this.assertEqual(store.steel, 234, "and debits that store"); + } +} diff --git a/tests/simple_snapshot_test.js b/tests/simple_snapshot_test.js new file mode 100644 index 0000000..cf1928a --- /dev/null +++ b/tests/simple_snapshot_test.js @@ -0,0 +1,203 @@ +// The simple economy's wire shape (10-snapshot-and-orders.md): which snapshot +// fields the client needs to render the pool and the global market, that the +// market is delta-shipped on its own version, and that `market_trade` is +// validated on the server. The hard model's snapshot must stay untouched. + +import { TestCase } from "./framework/test_case.js"; +import { simpleState, smallState, smallConfig, SEED } from "./framework/helpers.js"; +import { GameServer } from "../server/game_server.js"; +import { + RESOURCE_IDS, + STORABLE_RESOURCE_IDS, + RESOURCE_MARKET_BASE, + MARKET_START_STOCK, +} from "../shared/data.js"; + +function fakeNetwork() { + return { + peers: new Set(), + sent: [], + getPeerIds() { + return Array.from(this.peers); + }, + send(peerId, message) { + this.sent.push({ peerId, message }); + }, + }; +} + +// A configured simple server with peer 1 mapped to civ 0, so a snapshot can be +// walked through the same delta path the real broadcast uses. +function configuredServer(economyModel) { + const network = fakeNetwork(); + const server = new GameServer(network); + server.configureGame({ + seed: SEED, + player_civ: 0, + civilisations: ["france", "britain"], + mapConfig: smallConfig(), + economyModel, + }); + network.peers.add(1); + server.peerCiv.set(1, 0); + return { server, network }; +} + +function lastState(network) { + return network.sent[network.sent.length - 1].message.state; +} + +export class SimpleSnapshotTest extends TestCase { + test_a_simple_snapshot_carries_the_model_the_pool_and_the_market() { + const state = simpleState(); + const snap = state.snapshot(0); + + // The top-level model, so the client picks the simple UI. + this.assertEqual(snap.economyModel, "simple", "the model ships at the top level"); + + // The viewer's own pool is the `stock` of its resource summary; every + // region reads the same four numbers. + const pool = state.getResourcePool(0); + for (const id of STORABLE_RESOURCE_IDS) { + this.assertEqual( + snap.viewerStats.resources.stock[id], + pool[id], + `${id} ships the viewer's pool` + ); + } + + // The world market ships one entry per resource: stock plus the price the + // client renders without recomputing it. Energy is a flow, never stocked. + const market = snap.resourceMarket; + this.assertSize(market, RESOURCE_IDS.length, "one market entry per resource"); + const steel = market.find((entry) => entry.id === "steel"); + this.assertEqual(steel.stock, MARKET_START_STOCK, "the world stock ships"); + this.assertApprox(steel.price, RESOURCE_MARKET_BASE.steel, 1e-9, "and its price"); + const energy = market.find((entry) => entry.id === "energy"); + this.assertEqual(energy.stock, null, "energy ships no stock"); + } + + test_the_shipped_pool_tracks_the_live_pool() { + const state = simpleState(); + state.getResourcePool(0).steel = 4242; + this.assertEqual( + state.snapshot(0).viewerStats.resources.stock.steel, + 4242, + "an edit to the pool is visible to the very next snapshot" + ); + } + + test_a_hard_snapshot_keeps_its_own_market() { + const state = smallState(); + state.applyStartingScenario(); + const snap = state.snapshot(0); + this.assertEqual(snap.economyModel, "hard", "the hard model is reported"); + this.assertEqual(typeof state.getResourcePool, "undefined", "there is no pool API"); + // The hard market is still the supply/demand listing: it ships no stock and + // keeps its own price and supply/demand fields. + const steel = snap.resourceMarket.find((entry) => entry.id === "steel"); + this.assertEqual(steel.stock, null, "the hard market ships no stock"); + this.assertEqual(typeof steel.supply, "number", "but it keeps its supply"); + this.assertEqual(typeof steel.demand, "number", "and its demand"); + const versionBefore = snap.versions.market; + for (let hour = 0; hour < 24; hour++) state.advanceHour(); + this.assertGreater( + state.snapshot(0).versions.market, + versionBefore, + "and the hard day still moves its market version" + ); + } + + test_an_unchanged_market_is_left_out_of_a_delta() { + const { server, network } = configuredServer("simple"); + + server._broadcastState(); + const full = lastState(network); + this.assertFalse(full.delta, "the first snapshot is full"); + this.assertNotNull(full.resourceMarket, "and carries the whole market"); + + // Nothing moved, so the next broadcast is a delta without the market. + server._broadcastState(); + const quiet = lastState(network); + this.assertTrue(quiet.delta, "the second snapshot is a delta"); + this.assertNull(quiet.resourceMarket, "an unchanged market is omitted"); + // The pool lives in viewerStats, which every snapshot carries. + this.assertNotNull( + quiet.viewerStats.resources.stock, + "the pool is still shipped inline" + ); + } + + test_a_trade_reships_the_market_in_the_next_delta() { + const { server, network } = configuredServer("simple"); + + server._broadcastState(); + server._broadcastState(); + const before = lastState(network); + this.assertNull(before.resourceMarket, "the quiet delta held no market"); + const versionBefore = server.state.snapshot(0).versions.market; + const poolBefore = server.state.getResourcePool(0).steel; + + this.assertTrue( + server.state.requestMarketTrade(0, "steel", "buy", 100), + "a buy is accepted" + ); + server._broadcastState(); + const after = lastState(network); + this.assertNotNull(after.resourceMarket, "the changed market is re-shipped"); + this.assertGreater( + server.state.snapshot(0).versions.market, + versionBefore, + "the market version moved" + ); + this.assertEqual( + after.viewerStats.resources.stock.steel, + poolBefore + 100, + "and the pool rode along in the same delta" + ); + } + + test_the_server_validates_a_market_trade() { + const { server } = configuredServer("simple"); + const pool = server.state.getResourcePool(0); + const steelBefore = pool.steel; + const stockBefore = server.state.marketStock.get("steel"); + + this.assertTrue( + server.handleOrder(1, { type: "market_trade", side: "buy", resource: "steel", lot: 2000 }), + "a valid lot is accepted" + ); + this.assertEqual(pool.steel, steelBefore + 2000, "and the pool moved by the lot"); + this.assertEqual( + server.state.marketStock.get("steel"), + stockBefore - 2000, + "and the world stock paid for it" + ); + + for (const bad of [ + { side: "buy", resource: "steel", lot: 1234 }, + { side: "hold", resource: "steel", lot: 100 }, + { side: "buy", resource: "energy", lot: 100 }, + { side: "buy", resource: "nonsense", lot: 100 }, + ]) { + this.assertFalse( + server.handleOrder(1, { type: "market_trade", ...bad }), + `refused: ${JSON.stringify(bad)}` + ); + } + this.assertEqual(pool.steel, steelBefore + 2000, "a refused trade moves no pool"); + this.assertEqual( + server.state.marketStock.get("steel"), + stockBefore - 2000, + "and no stock" + ); + } + + test_the_hard_server_has_no_market_order() { + const { server } = configuredServer("hard"); + this.assertFalse( + server.handleOrder(1, { type: "market_trade", side: "buy", resource: "steel", lot: 2000 }), + "the hard model refuses the simple market order" + ); + } +} diff --git a/tests/simple_taxes_test.js b/tests/simple_taxes_test.js new file mode 100644 index 0000000..7e6dac5 --- /dev/null +++ b/tests/simple_taxes_test.js @@ -0,0 +1,269 @@ +// 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"); + } +} diff --git a/tests/simple_ui_test.js b/tests/simple_ui_test.js new file mode 100644 index 0000000..d2e8b5b --- /dev/null +++ b/tests/simple_ui_test.js @@ -0,0 +1,198 @@ +import { TestCase } from "./framework/test_case.js"; +import { setupDom, teardownDom } from "./framework/dom.js"; +import { CityModal, NationModal } from "../client/js/modals.js"; +import { GOVERNMENTS, TECHNOLOGIES } from "../shared/data.js"; +import { simpleState, smallState, cityOf } from "./framework/helpers.js"; +import { + setCurrency, + setCurrencyBook, + currencyFor, + rateToNational, +} from "../client/js/currency.js"; + +// The market figures a simple game's snapshot carries: the viewer's pool in the +// summary's stock, and the world stock and price per storable good. +const SIMPLE_MARKET = [ + { id: "energy", price: 0.003, stock: null }, + { id: "steel", price: 600, stock: 900_000 }, + { id: "food", price: 300, stock: 1_000_000 }, + { id: "luxury", price: 60, stock: 1_000_000 }, + { id: "hightech", price: 800, stock: 1_000_000 }, +]; + +const SIMPLE_SUMMARY = { + stock: { steel: 1_000_000, food: 500_000, luxury: 500_000, hightech: 20_000 }, + consumption: { steel: 10, food: 20, luxury: 30, hightech: 1, energy: 50 }, + demand: {}, + prices: {}, +}; + +export class SimpleUiTest extends TestCase { + async test_the_simple_resources_tab_is_a_market() { + const env = await setupDom(); + try { + const modal = new NationModal(); + const trades = []; + modal.onMarketTrade = (resource, side, lot) => trades.push([resource, side, lot]); + modal.show( + GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e9, 0, + {}, null, [], null, null, + { summary: SIMPLE_SUMMARY, market: SIMPLE_MARKET, economyModel: "simple", budget: 1.0e9 } + ); + env.$("#modal-nation .tab[data-tab=resources]").click(); + // One row per storable good; energy is a flow and is not marketable. + const rows = env.$("#resources-list .market-table tbody tr"); + this.assertSize(rows, 4, "the four storable goods are listed"); + this.assertTrue( + env.$("#resources-sub").text().includes("Treasury"), + "the treasury is shown with the market" + ); + // Steel leads the storable list; its first buy lot is the 100 button. + rows.eq(0).find(".market-lot").eq(0).click(); + this.assertSize(trades, 1, "a buy order is sent"); + this.assertEqual(trades[0][0], "steel"); + this.assertEqual(trades[0][1], "buy"); + this.assertEqual(trades[0][2], 100); + this.assertTrue( + env.$("#resources-subtabs").hasClass("hidden"), + "the hard sub-tabs are hidden in simple mode" + ); + modal.close(); + } finally { + teardownDom(env); + } + } + + async test_an_unaffordable_buy_and_an_uncovered_sell_are_disabled() { + const env = await setupDom(); + try { + const modal = new NationModal(); + const summary = { + ...SIMPLE_SUMMARY, + // Nothing in the pool to sell, and a treasury too small for any lot. + stock: { steel: 0, food: 0, luxury: 0, hightech: 0 }, + }; + modal.show( + GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e9, 0, + {}, null, [], null, null, + { summary, market: SIMPLE_MARKET, economyModel: "simple", budget: 0 } + ); + env.$("#modal-nation .tab[data-tab=resources]").click(); + const $lot = env.$("#resources-list .market-lot"); + this.assertTrue($lot.eq(0).prop("disabled"), "a buy the treasury cannot afford is disabled"); + this.assertTrue($lot.eq(3).prop("disabled"), "a sell the pool cannot cover is disabled"); + modal.close(); + } finally { + teardownDom(env); + } + } + + async test_the_simple_economy_tab_keeps_only_the_treasury_summary() { + const env = await setupDom(); + try { + const modal = new NationModal(); + modal.show( + GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e9, 0, + {}, null, [], null, null, null, + { economyModel: "simple", budget: 1.0e9, takeTax: { income: 5000 } } + ); + env.$("#modal-nation .tab[data-tab=economy]").click(); + this.assertTrue( + env.$("#economy-subtabs").hasClass("hidden"), + "no Taxes, central-bank or FX sub-tabs remain" + ); + const text = env.$("#economy-summary").text(); + this.assertTrue(text.includes("Tax income"), "the single tax income line is shown"); + this.assertTrue(text.includes("Treasury"), "the treasury is shown"); + modal.close(); + } finally { + teardownDom(env); + } + } + + async test_the_simple_city_resources_show_the_pool_and_region_power() { + const env = await setupDom(); + try { + const modal = new CityModal(); + modal.show( + { id: 1, name: "Lyon" }, + { + economyModel: "simple", + resources: { + stock: { steel: 123456, food: 500_000, luxury: 500_000, hightech: 20_000 }, + consumption: { steel: 10, food: 20, luxury: 30, hightech: 1, energy: 50 }, + demand: {}, + energy: 7_000_000, + }, + } + ); + env.$("#modal-city .tab[data-tab=resources]").click(); + const text = env.$("#city-resources-list").text(); + this.assertTrue(text.includes("National pool"), "the pool is named"); + this.assertTrue(text.includes("spare power"), "the region's spare power is named"); + this.assertTrue( + !env.$("#city-resources-list").text().includes("private sector holds"), + "no per-region store table is drawn" + ); + modal.close(); + } finally { + teardownDom(env); + } + } + + async test_the_simple_build_line_names_the_spare_power() { + const env = await setupDom(); + try { + const modal = new CityModal(); + modal.economyModel = "simple"; + modal.energyAvailable = 7_000_000; + modal.beginTraining("Riflemen", 10, 0, "Training"); + this.assertTrue( + env.$("#city-train-label").text().includes("spare power"), + "the progress line explains the build speed" + ); + modal.close(); + } finally { + teardownDom(env); + } + } + + test_a_single_currency_book_needs_no_conversion() { + setCurrency({ code: "EUR", symbol: "€", name: "Euro" }); + setCurrencyBook( + [ + { civ: 0, code: "EUR", symbol: "€", name: "Euro", value: 1 }, + { civ: 1, code: "EUR", symbol: "€", name: "Euro", value: 1 }, + ], + [], + 0 + ); + this.assertEqual(currencyFor(1), null, "one shared currency has no foreign money"); + this.assertEqual(rateToNational(1), 1, "and no rate to apply"); + } + + test_a_simple_city_ships_its_region_spare_power() { + const state = simpleState(); + state.applyStartingScenario(); + const shared = state.serializeShared(); + const city = cityOf(state, 0); + const entry = shared.cityStats.find((stats) => stats.id === city.id); + this.assertNotNull(entry, "the city has stats"); + this.assertEqual( + entry.resources.energy, + state.regionAvailableEnergy(city), + "the snapshot ships the region's spare power" + ); + this.assertGreater(entry.resources.energy, 0, "a simple region opens with spare power"); + } + + test_a_hard_city_ships_no_region_energy() { + const state = smallState(); + state.applyStartingScenario(); + const shared = state.serializeShared(); + const city = cityOf(state, 0); + const entry = shared.cityStats.find((stats) => stats.id === city.id); + this.assertNotNull(entry, "the city has stats"); + this.assertEqual(entry.resources.energy, undefined, "the hard city has no power field"); + } +} diff --git a/tests/testing_mode_test.js b/tests/testing_mode_test.js index 272c3fa..66e2a16 100644 --- a/tests/testing_mode_test.js +++ b/tests/testing_mode_test.js @@ -14,6 +14,9 @@ function makeServer(testing) { civilisations: ["france", "britain"], testing, mapConfig: smallConfig(), + // These fixtures compare the treasury before and after an order, so they + // pin the hard economy, where an order has a money cost to charge. + economyModel: "hard", }); return server; }