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