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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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" }
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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-`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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
@@ -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
@@ -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"),
|
||||
});
|
||||
}
|
||||
|
||||
Vendored
+4
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 *
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user