Gave the world a simple economy: one pool per nation and a global market

The simple economy is now the default and the old simulation is frozen as the hard model, selectable per game. A nation keeps one resource pool available in every region, fed by public production works rather than private per-region stores, and construction and training begin at once, paced by the region's spare power instead of gathering materials over days.

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

Simple games skip the price-settling warmup and economic migration; the snapshot carries the economy model, the market and the pool figures the client draws. Added the matching test suites and the design briefs under docs/simple_economy.
This commit is contained in:
2026-09-25 14:58:49 +02:00
parent 49613f2a1c
commit d5305da449
44 changed files with 4929 additions and 69 deletions
+1
View File
@@ -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.
+6
View File
@@ -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; }
+10 -1
View File
@@ -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];
+6 -6
View File
@@ -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."
}
];
+4
View File
@@ -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" }
);
+24 -3
View File
@@ -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,
};
},
+99 -2
View File
@@ -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(
$("<div class='desc'></div>").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 = $("<table class='resources-table economy-table'></table>");
const $head = $("<thead></thead>").append(
$("<tr></tr>")
.append($("<th></th>").text("Resource"))
.append($("<th class='numeric'></th>").text("National pool / available"))
.append($("<th class='numeric'></th>").text("Drawn / day"))
);
const $body = $("<tbody></tbody>");
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 = $("<tr></tr>");
if (consumers.length > 0) {
const $name = $("<td class='clickable'></td>");
$name.append($("<span class='resource-caret'></span>").text(open ? "▾" : "▸"));
$name.append(document.createTextNode(` ${name}`));
$name.on("click", () => {
if (this.expandedResources.has(id)) this.expandedResources.delete(id);
else this.expandedResources.add(id);
this.renderResources(info);
});
row.append($name);
} else {
row.append($("<td></td>").text(name));
}
row.append(
$("<td class='numeric'></td>").text(formatResourceAmount(id, held))
);
row.append(
$("<td class='numeric'></td>").text(formatResourceAmount(id, consumption[id] || 0))
);
$body.append(row);
if (open) {
const $detail = $("<tr class='resource-demand'></tr>");
const $cell = $("<td colspan='3'></td>");
for (const consumer of consumers) {
const where = consumer.coords
? ` (${consumer.coords[0]}, ${consumer.coords[1]})`
: "";
$cell.append(
$("<div class='demand-line'></div>").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)}%`);
}
+197 -4
View File
@@ -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(
$("<div class='desc'></div>").text(
"Money is used only in the global market. Taxes are collected each day from " +
"every inhabitant, and construction and upkeep draw on your resource pool " +
"instead of the treasury."
)
);
const $table = $("<table class='resources-table economy-table'></table>");
$table.append(
$("<thead></thead>").append(
$("<tr></tr>")
.append($("<th></th>").text("Item"))
.append($("<th class='numeric'></th>").text("Per day"))
)
);
const $body = $("<tbody></tbody>").append(
$("<tr></tr>")
.append($("<td></td>").text("Tax income"))
.append($("<td class='numeric positive'></td>").text(`+${formatMoney(income)}`)),
$("<tr></tr>")
.append($("<td></td>").text("Treasury"))
.append($("<td class='numeric'></td>").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(
$("<div class='desc'></div>").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 = $("<table class='resources-table economy-table market-table'></table>");
const $head = $("<thead></thead>").append(
$("<tr></tr>")
.append($("<th></th>").text("Resource"))
.append($("<th class='numeric'></th>").text("In pool"))
.append($("<th class='numeric'></th>").text("World stock"))
.append($("<th class='numeric'></th>").text("Price"))
.append($("<th></th>").text("Buy"))
.append($("<th></th>").text("Sell"))
);
const $body = $("<tbody></tbody>");
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 = $("<tr></tr>");
const $name = $("<td class='name'></td>");
$name.append(
$("<img class='resource-icon' width='18' height='18'>")
.attr("src", `assets/${proto.icon}`)
.attr("alt", proto.name)
);
$name.append($("<span></span>").text(` ${proto.name}`));
$row.append($name);
$row.append(
$("<td class='numeric'></td>").text(formatResourceAmount(id, held))
);
$row.append(
$("<td class='numeric'></td>").text(
stock === null || stock === undefined ? "—" : formatResourceAmount(id, stock)
)
);
$row.append(
$("<td class='numeric'></td>").append(this._resourcePriceNode(proto, price))
);
$row.append($("<td class='market-actions'></td>").append(
this._marketControls(id, "buy", held, price, budget, stock)
));
$row.append($("<td class='market-actions'></td>").append(
this._marketControls(id, "sell", held, price, budget, stock)
));
$body.append($row);
}
$table.append($head, $body);
this.$resourcesList.append($table);
this.$resourcesList.append(
$("<div class='desc'></div>").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 = $("<span class='market-lots'></span>");
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 = $("<button type='button' class='aero market-lot'></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 || [];
@@ -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.
@@ -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<civ, Stock>` 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.
+101
View File
@@ -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.
+47
View File
@@ -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.
+102
View File
@@ -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, <category>, 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.
+99
View File
@@ -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.
+78
View File
@@ -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-`.
+81
View File
@@ -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.
+101
View File
@@ -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 <path>`. 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.
@@ -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.
+119
View File
@@ -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 <name>.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`).
+124
View File
@@ -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/<topic>_test.js` extending `TestCase` (base in
`tests/framework/`). During development run **one** suite:
`node tests/run_tests.js --file <name>.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 <name>.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/<topic>_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. |
+28 -3
View File
@@ -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;
}
+32 -9
View File
@@ -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)://<host>/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"),
});
}
+4
View File
@@ -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
+1
View File
@@ -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";
+106
View File
@@ -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;
}
+22 -1
View File
@@ -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
+6 -4
View File
@@ -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;
+27 -12
View File
@@ -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,
+22 -5
View File
@@ -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 *
+41 -15
View File
@@ -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();
+15 -1
View File
@@ -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,
};
});
},
File diff suppressed because it is too large Load Diff
+140
View File
@@ -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"
);
}
}
+16 -2
View File
@@ -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;
}
+61 -1
View File
@@ -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();
+326
View File
@@ -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");
}
}
+246
View File
@@ -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");
}
}
+131
View File
@@ -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");
}
}
+120
View File
@@ -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");
}
}
+321
View File
@@ -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");
}
}
+203
View File
@@ -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"
);
}
}
+269
View File
@@ -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");
}
}
+198
View File
@@ -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");
}
}
+3
View File
@@ -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;
}