Files
Battle-for-Tismo/shared/game_state/simple_economy.js
T
adrien d5305da449 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.
2026-09-25 14:58:49 +02:00

1132 lines
46 KiB
JavaScript

// Simple-economy overrides for GameState. When a game runs the simple model,
// `configure()` assigns these methods onto the instance, so they shadow the
// hard-economy mixins on the prototype without changing them. The hard model
// needs no override module: it is the existing mixins untouched.
//
// Instance-level assignment is safe because the economy model is fixed for the
// life of a game and the browser never runs a live GameState. Name every
// override after the method it replaces, so the diff is obvious.
//
// Decision (02-resource-pool-and-production.md): every nation keeps **one
// resource pool** holding the four storable goods, available in every region.
// Energy is deliberately **not** pooled: it stays a per-region flow so
// `03-construction.md` can key construction speed to the region's available
// power. Production buildings are public -- their output lands in the owner's
// pool -- and the private-agent accounting (cash, debt, sales, upgrades) is
// retired by overriding the agent hooks to no-ops. The daily resource tick
// itself is the hard one, re-pointed at the pool: `_ensureResourceStock` now
// returns the owner's pool, `getCityResourceStock` hands out that same pool,
// and the sourcing searches are overridden to find nothing, so every draw is a
// single subtraction from the pool.
//
// Decision (03-construction.md): an order starts building at once. Its whole
// steel/high-tech bill is drawn from the pool the moment it is placed -- a bill
// the pool cannot cover is refused rather than gathered over days -- and there
// is no materials phase, no energy-buy phase and no trade-graph lookup. The
// construction or training clock is set from the spare power of the region the
// work stands in: `regionAvailableEnergy` is the region's plant output less what
// its cities, buildings and converters already draw, floored so a blacked-out
// region still builds slowly instead of dividing by zero. Money is not spent
// internally on construction (the single tax and the market cover that in
// `05-`/`06-`), so the treasury gates nothing. Every opening region is guaranteed
// at least one plant -- a fuel-free one where the hard seeding left it without --
// so the speed rule never starts at zero.
//
// The private demand-driven upgrade stays retired (02-); `_startBuildingUpgrade`
// is overridden so that if an upgrade is ever undertaken it is a public,
// immediate, energy-driven work rather than an agent-funded one.
//
// Decision (07-procurement.md): every consumer sources from the one pool. The
// sourcing search is emptied (`_nearbySuppliers`/`_resourceNodes` find nothing,
// `_procureFromNeighbours` returns zero) and the draw helpers
// (`_drawFromNearest`/`_drawFromCities`) are re-pointed at the pool, so upkeep,
// city consumption, combat, repairs and unit supply all take a single
// subtraction -- no bidding, no distance, no freight, never a higher price for a
// shortfall. The construction-site and queued-build gather passes are overridden
// to the pool too, so even a stray call from the hard daily tick cannot rebuild
// the trade graph. The day's ledger (`resourceSpend`, consumption/production
// totals) is still filled by the hard tick; only the sourcing changed.
import { key, parseKey } from "../hex.js";
import {
RESOURCE_IDS,
STORABLE_RESOURCE_IDS,
RESOURCE_MARKET_BASE,
SIMPLE_ECONOMY,
MARKET_START_STOCK,
MARKET_LOTS,
marketPriceFor,
EFFECT_TAX_INCOME,
TAXES,
tileImprovementById,
isResourceTileBuilding,
resourceBuildingUpgradeMoneyCost,
} from "../data.js";
import { buildingBuildCost } from "../rules.js";
import {
cityEnergyPerDay,
foodNeedPerDay,
steelNeedPerDay,
luxuryNeedPerDay,
tileFoodOutput,
tileImprovementResourceCost,
protoUpkeep,
} from "../resources.js";
import { TRAINING_QUEUE_LIMIT, TILE_IMPROVEMENT_HP, HOURS_PER_DAY } from "./constants.js";
// A fresh pool of the four storable goods. Mirrors the hard economy's
// `emptyStock`; the simple model never owns per-tile stock.
function emptyPool() {
return { steel: 0, food: 0, luxury: 0, hightech: 0 };
}
// The bill a construction site or queue entry reports. Shaped like the hard
// one's `emptyResources` so the snapshot and the panels keep their fields.
function emptySiteResources() {
return { steel: 0, hightech: 0, energy: 0 };
}
export const simpleEconomyMethods = {
// -------------------------------------------------------------- pool --
// Builds the pool collection and the market series exactly as the hard
// initialiser does, but seeds one pool per nation instead of a store for
// every city and resource tile. No `_seedCityResources` call: there is no
// per-tile stock in the simple economy.
_initResources() {
this.resourcePools = new Map();
// Kept as an empty map so the hard methods that guard on `resourceStock`
// (the tick, unit supply) still run and the ones that iterate it find
// nothing to do. Nothing is ever stored here in simple mode.
this.resourceStock = new Map();
this.resourcePrices = new Map();
this.resourceMarketStats = new Map();
for (const id of RESOURCE_IDS) {
this.resourcePrices.set(id, RESOURCE_MARKET_BASE[id]);
this.resourceMarketStats.set(id, { supply: 0, demand: 0, price: RESOURCE_MARKET_BASE[id] });
}
this.resourceMonthlyPrices = new Map();
for (const id of RESOURCE_IDS) this.resourceMonthlyPrices.set(id, [RESOURCE_MARKET_BASE[id]]);
this.resourceDailyPrices = new Map();
for (const id of RESOURCE_IDS) this.resourceDailyPrices.set(id, [RESOURCE_MARKET_BASE[id]]);
this.resourceLinks = [];
this._resourceShortages = new Map();
this._resourceGraphCache = null;
this._relayStock = new Map();
this._famine = new Set();
this._resourceVersion = 0;
this.resourceTrade = new Map();
this.resourceExternal = new Map();
this._resourceAlerts = new Map();
this.resourceSpend = new Map();
this.cityRepairQueue = new Map();
this.tileRepairDebt = new Map();
for (let civ = 0; civ < this.civilisations.length; civ++) {
const pool = this._ensurePool(civ);
for (const id of STORABLE_RESOURCE_IDS) pool[id] = SIMPLE_ECONOMY.startingPool[id] || 0;
}
this._initSimpleMarket();
},
// The one pool every region of a nation draws on, created on first use.
_ensurePool(civ) {
if (!this.resourcePools) this.resourcePools = new Map();
let pool = this.resourcePools.get(civ);
if (!pool) {
pool = emptyPool();
this.resourcePools.set(civ, pool);
}
return pool;
},
// A nation's whole resource pool. Callers that used to ask a city for its
// store now ask the owner for the pool.
getResourcePool(civ) {
return this._ensurePool(civ);
},
// Takes up to `amount` of one good out of a nation's pool and returns what
// was actually drawn: the single procurement primitive of the simple
// economy. Never returns negative and never bids a price up.
_drawFromPool(civ, id, amount) {
const pool = this._ensurePool(civ);
const available = Math.max(0, pool[id] || 0);
const take = Math.min(available, Math.max(0, amount));
if (take > 0) pool[id] = available - take;
return take;
},
// The public producers write here. The hard tick adds each work's output to
// `_ensureResourceStock(k)`, so mapping that onto the owner's pool is all it
// takes for every producer -- food from the land, materials from the
// converters -- to feed the shared pool.
_ensureResourceStock(k) {
const coords = parseKey(k);
const city = this.cityAt(coords);
let civ = city ? city.civ : this.tileImprovementOwner.get(k);
if (civ === undefined || civ === null || civ < 0) civ = this.civAt(coords);
if (civ === undefined || civ === null || civ < 0) return emptyPool();
return this._ensurePool(civ);
},
// The read every surviving consumer makes: a city's stock is its owner's
// pool, so a region has exactly what the nation holds.
getCityResourceStock(city) {
return this._ensurePool(city.civ);
},
// ------------------------------------------------- global market (06) --
// The one world stock of each storable good, opening full. A single shared
// stock, not a per-player order book: every nation trades against the same
// figures, so the price one player moves is the price the next one pays.
// Energy is not traded -- it stays a per-region flow -- so only the four
// storable goods are stocked.
_initSimpleMarket() {
this.marketStock = new Map();
for (const id of STORABLE_RESOURCE_IDS) this.marketStock.set(id, MARKET_START_STOCK);
this._marketVersion = (this._marketVersion || 0) + 1;
},
// The world price of a good straight from the shared stock. A good with no
// stock (energy) keeps its base price. Computed rather than memoised: it is
// one Map lookup and a pow, and deriving it keeps the stock the single source
// of truth, so no stale cache can lag a trade.
marketPrice(id) {
const stock = this.marketStock ? this.marketStock.get(id) : undefined;
if (stock === undefined) return RESOURCE_MARKET_BASE[id] || 1;
return marketPriceFor(RESOURCE_MARKET_BASE[id], stock);
},
// Every surviving caller -- upkeep valuation, construction quotes, food
// synthesis, the panels -- reads the market price in the simple economy, so
// one override keeps them all in step with the stock. Closes the loop with
// the daily tick: a trade that drains the stock raises what the next order
// costs and what the stores are worth.
getResourcePrice(id) {
return this.marketPrice(id);
},
// The simple market block of the daily tick. Prices come from the world
// stock, not from the day's supply and demand, so the hard drift has nothing
// to do: the stock only moves when a player trades.
_updateMarketPrices(_market) {},
// A nation's share of the market, for its Resources panel: the world stock
// and price of each buyable good, how much of it the nation holds, and the
// lots it may trade in.
getMarketView(civ) {
const pool = this._ensurePool(civ);
return STORABLE_RESOURCE_IDS.map((id) => ({
id,
price: this.marketPrice(id),
stock: this.marketStock ? (this.marketStock.get(id) || 0) : 0,
pool: pool[id] || 0,
lots: MARKET_LOTS.slice(),
}));
},
// Buys or sells one lot of one good at the world price. Atomic: every check
// runs before any figure moves, so a refused trade leaves the treasury, the
// pool and the stock exactly as they were, and there is no overdraft to hide
// a shortfall. `qty` must be one of the three lots; the server re-validates
// whatever the client sends.
requestMarketTrade(civ, resource, side, qty) {
if (!this._validCiv(civ)) return false;
if (side !== "buy" && side !== "sell") return false;
if (!STORABLE_RESOURCE_IDS.includes(resource)) return false;
const amount = Number(qty);
if (!MARKET_LOTS.includes(amount)) return false;
if (!this.marketStock || !this.marketStock.has(resource)) return false;
const price = this.marketPrice(resource);
if (!(price > 0) || !Number.isFinite(price)) return false;
const pool = this._ensurePool(civ);
const stock = this.marketStock.get(resource) || 0;
const treasury = this.getBudget(civ);
if (side === "buy") {
const cost = amount * price;
// The world must hold the goods and the nation must hold the money; a
// near miss is refused whole rather than part-filled.
if (stock + 1e-9 < amount) return false;
if (treasury + 1e-9 < cost) return false;
this.budgets.set(civ, treasury - cost);
pool[resource] = (pool[resource] || 0) + amount;
this.marketStock.set(resource, stock - amount);
this._recordBudgetCash(civ, "provisioning", -cost);
this._recordBudgetResource(civ, resource, cost, 0);
this._recordBudgetCategoryResource(civ, "provisioning", resource, cost, "market", amount);
} else {
if ((pool[resource] || 0) + 1e-9 < amount) return false;
const revenue = amount * price;
pool[resource] = (pool[resource] || 0) - amount;
this.marketStock.set(resource, stock + amount);
this.budgets.set(civ, treasury + revenue);
this._recordBudgetCash(civ, "sell", revenue);
this._recordBudgetResource(civ, resource, 0, revenue);
}
this._marketVersion += 1;
this._emitChanged();
return true;
},
// ------------------------------------------------- money and taxes (05) --
// A simple nation keeps one treasury in one currency. The central bank, its
// reserves, the FX desk and the private-sector region cash baskets are all
// hard-economy machinery: the simple model still creates the collection
// shapes so the serialisers can walk them, but seeds no money anywhere except
// the fixed opening treasury (`_initBudgets`), so every basket reads empty.
_initMoney() {
this.regionCash = new Map();
this.regionDebt = new Map();
this.centralBankDebt = new Map();
this.centralBankRate = new Map();
this.centralBankReserves = new Map();
this.currencyValues = new Map();
this._currencyDemand = new Map();
this._currencyCivMap = null;
this._fxSubsidySpent = new Map();
for (let civ = 0; civ < this.civilisations.length; civ++) {
this.centralBankRate.set(civ, 0);
this.centralBankReserves.set(civ, new Map());
this.centralBankDebt.set(civ, new Map());
this.currencyValues.set(civ, 1);
this._currencyDemand.set(civ, 0);
}
},
// A nation opens with a fixed euro reserve, not the hard model's tenth of
// GDP: money buys only market goods, so the opening figure is day-one buying
// power rather than a working balance.
_initBudgets() {
for (let i = 0; i < this.civilisations.length; i++) {
this.budgets.set(i, SIMPLE_ECONOMY.startingTreasury);
}
},
// The one currency: every nation's prices are quoted in euros, at par.
currencyOf(_civ) {
return SIMPLE_ECONOMY.euro;
},
currencyValue(_civ) {
return 1;
},
currencyValueOf(_code) {
return 1;
},
exchangeRate(_fromCiv, _toCiv) {
return 1;
},
// No central bank work runs in the simple tick: nothing borrows, no currency
// floats with demand and no region repatriates foreign cash.
_tickMoney() {},
requestSetInterestRate(_civ, _rate) {
return false;
},
// ------------------------------------------------------ taxes (05) --
// The day's one revenue: every inhabitant pays `taxPerInhabitant`, scaled by
// twice their region's derived approval (a 0..2 factor, clamped) and the
// nation's tax-income modifier. Booked once per day, after the daily ledger
// reset, so the tax ledger always holds the last full day's take.
_tickApprovalTaxes() {
const factorCap = SIMPLE_ECONOMY.approvalTaxFactor;
for (let civ = 0; civ < this.civilisations.length; civ++) {
const modifier = this.taxIncomeModifier(civ);
let total = 0;
for (const city of this.cities) {
if (city.civ !== civ) continue;
const population = Math.max(0, this.getCityPopulation(city));
const approval = Math.max(0, Math.min(1, this.getCityApproval(city)));
const factor = Math.min(factorCap, approval * factorCap);
const income = population * SIMPLE_ECONOMY.taxPerInhabitant * factor * modifier;
if (!(income > 0) || !Number.isFinite(income)) continue;
total += income;
this._bookCityApprovalTax(city, income);
}
if (!(total > 0)) continue;
this.budgets.set(civ, this.getBudget(civ) + total);
this._recordBudgetCash(civ, "taxIncome", total);
let day = this.taxLedger.get(civ);
if (!day) {
day = { income: 0 };
this.taxLedger.set(civ, day);
}
day.income += total;
}
},
// The slice of the day's tax a single region raised, for the region tables.
_bookCityApprovalTax(city, income) {
let entry = this.cityTaxLedger.get(city.id);
if (!entry) {
entry = { cityId: city.id, income: 0 };
this.cityTaxLedger.set(city.id, entry);
}
entry.income += income;
},
// The one modifier on the approval tax. No government, building or
// technology grants EFFECT_TAX_INCOME yet, so this is 1; the hook is here so
// a later policy can move it.
taxIncomeModifier(civ) {
const bonus = this.getCivModifiers(civ)[EFFECT_TAX_INCOME] || 0;
return Math.max(0, 1 + bonus);
},
// The taxes a nation collected over the last day: one "income" figure, in
// place of the hard economy's sales/export/import kinds.
getTaxTake(civ) {
const day = this.taxLedger ? this.taxLedger.get(civ) : null;
return { sales: 0, export: 0, import: 0, income: day ? day.income : 0 };
},
getCityTaxTake(city) {
const region = this.cityTaxLedger ? this.cityTaxLedger.get(city.id) : null;
return { sales: 0, export: 0, import: 0, income: region ? region.income : 0 };
},
// There is no inter-region trade to tax and no player-set rate: the one tax
// is the approval tax above, so a trade-tax booking or a tax control is a
// hard-only concern.
_recordTax(_civ, _kind, _resource, _amount, _city) {},
requestSetTaxRate(_civ, _kind, _rate, _resource) {
return false;
},
// The client renders the tax shape it knows, so it is returned with zeroed
// rates and no overrides rather than omitted; the tab itself is hidden in
// `09-`. The server refuses any attempt to move a rate.
getTaxConfigView(_civ) {
const view = {};
for (const kind of TAXES.kinds) view[kind] = { rate: 0, resources: {} };
return view;
},
// ------------------------------------------------- no internal money --
// Money never moves inside the state in the simple economy, so no spend can
// touch the treasury. Construction, training, upkeep and repair draw from the
// resource pool; the only money movements are the daily tax in and the market
// in `06-`.
_spendBudget(_civ, _amount, _category) {
return true;
},
// The hard upkeep figure is money plus the market value of materials. Money
// upkeep is retired, so only the material figure a caller adds remains.
getPlayerUpkeep(_civ) {
return 0;
},
_campaignUpkeepSources(_civ) {
return [];
},
// Propaganda is paid for in culture: a campaign drains its hourly rate from
// the national culture stock each day. A nation that cannot fund a full day
// loses its campaigns rather than running them on credit, which the hard
// economy's treasury overdraft would have allowed.
_tickEconomy() {
for (let civ = 0; civ < this.civilisations.length; civ++) {
const upkeep = this.getCampaignUpkeep(civ) * HOURS_PER_DAY;
if (!(upkeep > 0)) continue;
const culture = this.getCulture(civ);
if (culture >= upkeep) {
this.culture.set(civ, culture - upkeep);
continue;
}
this.culture.set(civ, 0);
this.campaigns = this.campaigns.filter((campaign) => campaign.civ !== civ);
this._touchPolitics();
}
},
// The two campaign cost hooks, retargeted from money to culture. The opinion
// shift still scales with spend because `_campaignSpendFactor` reads these.
_campaignDefaultCost() {
return SIMPLE_ECONOMY.campaign.defaultCulturePerHour;
},
_campaignMaxCost() {
return SIMPLE_ECONOMY.campaign.maxCulturePerHour;
},
// --------------------------------------------------- public production --
// Production buildings are public works, not private agents: the simple
// economy never creates an agent for one. This gates the agent creation in
// `_seedResourceBuildings` and the private branches of the site/money code.
_isProductionAgent(_proto) {
return false;
},
// A public converter runs at its nameplate output. The hard model throttles
// one that cannot cover its power bill; the simple model is not run for a
// private profit, so only grid power (above) limits it.
_converterProductionScale(_k, _proto, _agent) {
return 1;
},
_converterProfitable(_k, _proto, _agent) {
return true;
},
// Retire the private-agent money hooks: no agent cash, no debt, no central
// bank loan. They are no-ops rather than absent so the hard callers that
// reach them keep running.
_creditBuildingCash(_k, _code, _amount) {},
_chargeBuildingCash(_k, _code, _cost) {},
_chargeBuildingAccount(_k, _amount) {},
getPrivateBuildingCash(_civ) {
return 0;
},
getPrivateBuildingDebt(_civ) {
return 0;
},
getProductionAgents(_civ) {
return [];
},
// No private upgrades: the demand-driven reinvestment of the hard model has
// no owner to serve.
_tickIndustry() {},
// ------------------------------------------------------- procurement --
// Every good in the simple economy lives in the pool, so there is nothing to
// search for. These overrides empty the sourcing search so no trade graph,
// trade radius or supplier bid can run, and point the draws at the pool.
_nearbySuppliers() {
return [];
},
_resourceNodes() {
return [];
},
// With no per-tile stores there is no route to price: the trade graph is
// empty. Its only callers (`_nearbySuppliers`, `_resourceCandidateEdges`) are
// already retired above, but returning early keeps the walk unreachable.
_tradeGraph() {
return [];
},
// The hard consumers call this to buy a shortfall after exhausting the city
// store. In simple mode the store *is* the pool, so the goods are already
// gone or they never existed: returning zero means a shortfall simply stalls
// the work instead of triggering a bid.
_procureFromNeighbours(_city, _id, _amount, _cityStore, _nodes, _options) {
return 0;
},
// The materials a government or region draws are pulled straight from the
// pool. Kept to the hard signatures so construction, combat, repairs and
// unit supply all land here unchanged.
_drawFromNearest(civ, _coords, wanted, _payer = null) {
return {
steel: this._drawFromPool(civ, "steel", wanted.steel || 0),
hightech: this._drawFromPool(civ, "hightech", wanted.hightech || 0),
};
},
// Returns what is still missing, like the hard version, but the source is
// the pool rather than the nation's city stores.
_drawFromCities(civ, id, amount, _payer = null) {
const drawn = this._drawFromPool(civ, id, amount);
return Math.max(0, amount - drawn);
},
// With no freight and no currency conversion, a good is worth its world
// price. The hard model folds in a nominal two-tile haul; the simple model
// quotes the market directly.
_marketUnitCost(id) {
return this.getResourcePrice(id);
},
// The hard model pays a region for material drawn from its store and lets the
// treasury carry the bill. The simple model has neither private region cash
// nor internal money: the good comes out of the pool and nothing is settled.
_payRegionForMaterial(_city, _id, _amount, _payer) {},
// A tile work's daily gather is a hard-economy phase: a simple site opened
// already building with its whole bill paid. Kept to the hard signature and
// drawn from the pool best-effort, so a stray call still finishes rather than
// stalling on a supplier that does not exist. Energy is a regional flow and is
// never pooled, so it is never drawn here.
_gatherSiteMaterials(site) {
if (site.phase !== "materials") return;
for (const id of ["steel", "hightech"]) {
const missing = Math.max(0, (site.needed[id] || 0) - (site.bought[id] || 0));
if (!(missing > 0)) continue;
site.bought[id] = (site.bought[id] || 0) + this._drawFromPool(site.civ, id, missing);
}
const stillMissing = ["steel", "hightech"].some(
(id) => (site.needed[id] || 0) - (site.bought[id] || 0) > 1e-9
);
site.stalled = stillMissing;
if (!stillMissing) {
site.phase = "construction";
site.elapsedHours = 0;
this._emitChanged();
}
},
// A site's materials come from the owner's pool, not a city store, and no
// budget is charged: the pool already paid at order time. Energy is a flow.
_siteBuy(site, id, amount) {
if (id === "energy") return 0;
return this._drawFromPool(site.civ, id, amount);
},
// A queued building or unit starts constructing at once, so its gather pass
// draws the whole remaining bill from the pool in one go and flips the entry.
_gatherBuildEntry(city, entry) {
for (const id of ["steel", "hightech"]) {
const missing = Math.max(0, (entry.needed[id] || 0) - (entry.bought[id] || 0));
if (!(missing > 0)) continue;
entry.bought[id] = (entry.bought[id] || 0) + this._drawFromPool(city.civ, id, missing);
}
const stillMissing = ["steel", "hightech"].some(
(id) => (entry.needed[id] || 0) - (entry.bought[id] || 0) > 1e-9
);
if (!stillMissing) entry.phase = "construction";
entry.stalled = stillMissing;
return !stillMissing;
},
// The pool is available everywhere, so a work's material is never stranded.
_siteUnreachableMaterials(_site) {
return [];
},
// A public upgrade's materials are the owner's pool, checked whole so an
// underfunded upgrade never starts half-paid.
_buyBuildingMaterials(_k, _coords, civ, materials) {
const steel = Math.max(0, materials.steel || 0);
const hightech = Math.max(0, materials.hightech || 0);
return this._payPoolMaterials(civ, { steel, hightech });
},
// The owner's pool is the whole availability for an upgrade's material.
_buildingMaterialAvailability(civ, _coords, id) {
return this._ensurePool(civ)[id] || 0;
},
// ------------------------------------------------------------ energy --
// No regional power trading: energy is a per-region flow in the simple
// economy, so each grid balances what its own plants make against what its
// own cities and industry draw. The hard tick reads `imports` when it folds
// the day's balance, so they are zeroed rather than left undefined.
_tradeGridPower(ledger) {
for (const component of ledger.values()) {
component.imports = 0;
component.exports = 0;
component.importCost = 0;
}
},
// Power is not bought or sold for money in the simple model; the physical
// balance the ledger already computed is all that matters. Consumers are
// billed in `05-money-and-taxes.md`'s single tax, not per kWh.
_settleGridPower(_ledger) {},
// ------------------------------------------------------ scenario seed --
// The hard scenario fills every region with a year of its own consumption.
// The simple scenario seeds the one pool each nation opens with; the warm-up
// may have spent the initial seed before the scenario is applied, so it is
// reset here to the fixed working stock.
_seedStartingReserves() {
for (let civ = 0; civ < this.civilisations.length; civ++) {
const pool = this._ensurePool(civ);
for (const id of STORABLE_RESOURCE_IDS) pool[id] = SIMPLE_ECONOMY.startingPool[id] || 0;
}
},
// ------------------------------------------------------- migration (08) --
// Decision (08-migrations.md): only *economic* migration is retired. The
// income-chasing flows and the start-of-game equalize pass are the two
// mechanisms that move people between regions for money, so they are the two
// no-ops below. Political migration -- expelling a people, an immigration
// policy, airport traffic -- is not economic and stays, exactly as in the hard
// model, so `_tickMigration` is left untouched. The snapshot fields
// (`migrations`, `migrationGraph`) keep their shape: the hard tick still
// fills them, and with no policies and no airports they simply open empty
// rather than missing.
//
// People leaving a poor region for a rich one: finds nothing to do.
_migrateForIncome() {},
// The opening levelling the hard model runs when the world is placed, which
// redistributes each nation's people so every region starts at the same
// income per head. Without it a simple game keeps the scenario's raw spread;
// population follows natural growth and politics, not GDP.
_equalizeRegionIncomes() {},
// ---------------------------------------------------------- summary --
// What the nation holds, needs, makes and consumes. The stock is the pool
// once, not the pool summed once per city as the hard summary would do; the
// production is the public works' output plus the land's harvest.
getCivResourceSummary(civ) {
const pool = this._ensurePool(civ);
const stock = {
steel: pool.steel || 0,
food: pool.food || 0,
luxury: pool.luxury || 0,
hightech: pool.hightech || 0,
};
const need = emptyPool();
let energyNeed = 0;
for (const city of this.cities) {
if (city.civ !== civ) continue;
const economy = this.getCityEconomy(city);
const population = economy.population;
need.food += foodNeedPerDay(population);
need.steel += steelNeedPerDay(population);
need.luxury += luxuryNeedPerDay(population);
energyNeed += cityEnergyPerDay(economy.gdp);
}
const production = emptyPool();
let energySupply = 0;
for (const [k, id] of this.tileImprovements) {
if (this.tileImprovementOwner.get(k) !== civ) continue;
const proto = tileImprovementById(id);
if (!isResourceTileBuilding(proto)) continue;
if (proto.resource === "energy") {
energySupply += this.resourceBuildingOutputAt(k, proto);
} else {
production[proto.resource] += this.resourceBuildingOutputAt(k, proto);
}
}
const foodMultipliers = new Map();
for (const coords of this._territoryByCiv.get(civ) || []) {
const k = key(coords.x, coords.y);
if ((this.tilePopulation.get(k) || 0) <= 0) continue;
const city = this.regionCityAt(coords);
let multiplier = 1;
if (city) {
multiplier = foodMultipliers.get(city.id);
if (multiplier === undefined) {
multiplier = this._regionFoodMultiplier(city);
foodMultipliers.set(city.id, multiplier);
}
}
production.food += tileFoodOutput(this.tiles[k]) * multiplier;
}
const upkeep = this.getCivUpkeepResources(civ);
const consumed = {
food: need.food + (upkeep.food || 0),
steel: need.steel + (upkeep.steel || 0),
luxury: need.luxury + (upkeep.luxury || 0),
hightech: upkeep.hightech || 0,
energy: energyNeed + (upkeep.energy || 0),
};
return {
stock,
need,
production,
energy: { need: energyNeed, supply: energySupply },
upkeep,
consumed,
trade: this._civResourceTrade(civ),
external: this._civExternalTrade(civ),
prices: Object.fromEntries(RESOURCE_IDS.map((id) => [id, this.getResourcePrice(id)])),
};
},
// The delivery graph is a hard-economy overlay: there are no routes to draw
// when every region shares one pool. Returning an empty graph also keeps the
// snapshot off the trade-graph walk entirely.
_serializeResourceGraph(_viewerCiv) {
return { nodes: [], links: [], edges: [] };
},
// ------------------------------------------------- construction (03) --
// The spare power of a city's region, in kWh a day: what its plants make less
// what its cities, buildings and converters already draw. Region-based rather
// than grid-based, because the simple economy has no regional power market; a
// tile outside every region reads zero. Answers the construction-speed
// question and (in `09-`) explains it to the player.
//
// The city Resources tab and the build/site clocks all read this, and the
// snapshot does so for every city on every 10 Hz broadcast, so the region walk
// is memoised against the versions that can move a power balance: the region's
// tiles (territory/regions), its works (improvements/warfare), its buildings
// and its people (modifiers/population).
regionAvailableEnergy(city) {
if (!city) return 0;
const stamp = `${this._territoryVersion}:${this._regionVersion}:` +
`${this._improvementVersion}:${this._tileImprovementVersion}:` +
`${this._modifiersVersion}:${this._populationVersion}:${this._gdpEpoch}`;
if (!this._regionEnergyCache) this._regionEnergyCache = new Map();
const cached = this._regionEnergyCache.get(city.id);
if (cached && cached.stamp === stamp) return cached.value;
const value = this._computeRegionAvailableEnergy(city);
this._regionEnergyCache.set(city.id, { stamp, value });
return value;
},
_computeRegionAvailableEnergy(city) {
// A city's own people and its buildings are the first draw on its region.
let committed = cityEnergyPerDay(this.getCityEconomy(city).gdp);
for (const [index, level] of Object.entries(city.buildings || {})) {
if (level <= 0) continue;
const proto = this.protoBuildings[Number(index)];
if (proto) committed += this.buildingUpkeepResources(proto, level).energy;
}
let supply = 0;
for (const coords of this.regionTiles(city)) {
const k = key(coords.x, coords.y);
const id = this.tileImprovements.get(k);
if (!id) continue;
const proto = tileImprovementById(id);
if (!proto) continue;
if (isResourceTileBuilding(proto)) {
if (proto.resource === "energy") {
supply += this.resourceBuildingOutputAt(k, proto);
} else {
committed += this.resourceBuildingInputAt(k, proto, "energyPerDay");
}
// A plant's own fuel is a draw on the grid, as in the hard ledger.
committed += this.resourceBuildingInputAt(k, proto, "fuelEnergyPerDay");
}
// Every work's operating power rides on the grid, as in the hard ledger.
committed += protoUpkeep(proto, proto.buildCost || 0).energy;
}
return Math.max(0, supply - committed);
},
// The base hours a work of `cost` takes in `city`'s region at today's spare
// power. `kind` is "unit" for training, anything else for a building, tile
// work or upgrade -- which the hard economy runs ten times quicker. The energy
// is floored so a blacked-out region still builds, slowly.
_simpleConstructionHours(city, cost, kind) {
const energy = Math.max(
this.regionAvailableEnergy(city),
SIMPLE_ECONOMY.construction.minEnergy
);
const base = Math.max(0, cost) *
SIMPLE_ECONOMY.construction.energyPerConstructionHour / energy;
const scaled = kind === "unit"
? base
: base * SIMPLE_ECONOMY.construction.buildingTimeMultiplier;
return Math.max(SIMPLE_ECONOMY.construction.minHours, scaled);
},
// Pays a construction bill out of the nation's pool. Atomic: a bill the pool
// cannot cover is refused whole, so an order can never start half-funded.
_payPoolMaterials(civ, materials) {
const steel = Math.max(0, materials.steel || 0);
const hightech = Math.max(0, materials.hightech || 0);
const pool = this._ensurePool(civ);
if ((pool.steel || 0) + 1e-9 < steel) return false;
if ((pool.hightech || 0) + 1e-9 < hightech) return false;
pool.steel = (pool.steel || 0) - steel;
pool.hightech = (pool.hightech || 0) - hightech;
return true;
},
// Draws a tile work's bill from the pool best-effort. A tile already carries
// at most one work, so a partial draw is a slow-down the player sees, not a
// way to spam cheap works.
_paySiteMaterials(civ, materials) {
this._drawFromPool(civ, "steel", Math.max(0, materials.steel || 0));
this._drawFromPool(civ, "hightech", Math.max(0, materials.hightech || 0));
},
// Opens a site already under construction: the whole bill is paid from the
// pool now, the clock is set from the region's spare power, and there is
// nothing to gather, haul or buy each day. The hard economy's reserved
// budget, locked prices and haul route are all left out.
_openConstructionSite({
civ, coords, proto, kind = "build", targetLevel = 0, buildCost, level = 0,
}) {
const k = key(coords.x, coords.y);
const materials = kind === "upgrade"
? this.constructionResourceCost(proto, level, buildCost)
: tileImprovementResourceCost(proto, buildCost);
this._paySiteMaterials(civ, materials);
const city = this.cityAt(coords) || this.regionCityAt(coords) || this._nearestCity(civ, coords);
const cost = buildCost !== null && buildCost !== undefined
? buildCost
: ((proto && proto.buildCost) || 0);
const site = {
coords: { x: coords.x, y: coords.y },
id: proto.id,
civ,
kind,
targetLevel,
private: false,
phase: "construction",
budget: 0,
prices: {},
needed: emptySiteResources(),
bought: emptySiteResources(),
elapsedHours: 0,
totalHours: Math.max(1,
this._simpleConstructionHours(city, cost, "building") *
this.constructionSpeedMultiplier(civ)),
cityId: null,
path: [],
stalled: false,
budgetRaises: 0,
lastBuyDay: -1,
};
this.constructionSites.set(k, site);
this._emitChanged();
return site;
},
// A simple site has nothing to gather or buy; it is already building.
_kickoffSite(_site) {},
// Simple construction is paid from the material pool, not the treasury, so no
// money is reserved for a site. Kept as a no-op so the hard callers that reach
// it in simple mode move nothing.
_reserveSiteBudget(_civ, _amount, _category) {
return 0;
},
// ------------------------------------------------------ orders (03) --
// Queues one unit. Its material bill is drawn from the pool up front and it
// starts training at once: no money, no production-capacity gate, no gather
// phase. The clock comes from the city's spare power, scaled by approval and
// the usual construction-speed research, exactly as the hard order is.
requestTrain(cityId, protoIndex, free = false) {
const city = this.findCity(cityId);
if (!city) return false;
if (protoIndex < 0 || protoIndex >= this.protoUnits.length) return false;
const proto = this.protoUnits[protoIndex];
if (proto.requiresBuilding && !this.hasCityBuilding(city, proto.requiresBuilding)) return false;
if (proto.requiresTechnology && !this.hasTechnology(city.civ, proto.requiresTechnology)) return false;
if (
Array.isArray(proto.requiresTechnologies) &&
!proto.requiresTechnologies.every((id) => this.hasTechnology(city.civ, id))
) {
return false;
}
if (free) {
this._spawnTrainedUnit(city, proto);
this._visibilityDirty = true;
this._emitChanged();
return true;
}
const queue = this.training.get(cityId);
if (queue && queue.length >= TRAINING_QUEUE_LIMIT) return false;
if (proto.cost <= 0) {
this._spawnTrainedUnit(city, proto);
this._visibilityDirty = true;
this._emitChanged();
return true;
}
const materials = this.constructionResourceCost(proto, 0, proto.cost);
if (!this._payPoolMaterials(city.civ, materials)) return false;
const entry = {
kind: "unit",
protoIndex,
phase: "construction",
budget: 0,
budgetCategory: "training",
prices: {},
needed: emptySiteResources(),
bought: emptySiteResources(),
elapsedHours: 0,
totalHours: Math.max(1,
this._simpleConstructionHours(city, proto.cost, "unit") *
this.constructionApprovalMultiplier(city) *
this.constructionSpeedMultiplier(city.civ)),
lastGatherDay: -1,
};
if (queue) queue.push(entry);
else this.training.set(cityId, [entry]);
this._emitChanged();
return true;
},
// Queues one level of a city building, paid from the pool and started at
// once, with the level priced off whatever the queue will leave behind.
requestBuild(cityId, protoIndex, free = false) {
const city = this.findCity(cityId);
if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false;
const proto = this.protoBuildings[protoIndex];
if (proto.coastal && !this.isCoastalCity(city)) return false;
if (proto.requiresBuilding && !this.hasCityBuilding(city, proto.requiresBuilding)) return false;
if (proto.requiresTechnology && !this.hasTechnology(city.civ, proto.requiresTechnology)) return false;
if (free) {
const level =
this.getCityBuildingLevel(city, protoIndex) +
this._pendingBuildingLevels(cityId, protoIndex);
city.buildings[protoIndex] = level + 1;
this._clearTileGdpCache();
this._touchModifiers();
this._emitChanged();
return true;
}
const queue = this.training.get(cityId);
if (queue && queue.length >= TRAINING_QUEUE_LIMIT) return false;
const level =
this.getCityBuildingLevel(city, protoIndex) +
this._pendingBuildingLevels(cityId, protoIndex);
const cost = buildingBuildCost(proto, level);
if (cost <= 0) {
city.buildings[protoIndex] = level + 1;
this._clearTileGdpCache();
this._touchModifiers();
this._emitChanged();
return true;
}
const materials = this.constructionResourceCost(proto, level, cost);
if (!this._payPoolMaterials(city.civ, materials)) return false;
const entry = {
kind: "building",
protoIndex,
level,
phase: "construction",
budget: 0,
prices: {},
needed: emptySiteResources(),
bought: emptySiteResources(),
elapsedHours: 0,
totalHours: Math.max(1,
this._simpleConstructionHours(city, cost, "building") *
this.constructionApprovalMultiplier(city) *
this.constructionSpeedMultiplier(city.civ)),
lastGatherDay: -1,
};
if (queue) queue.push(entry);
else this.training.set(cityId, [entry]);
this._emitChanged();
return true;
},
// Lays a military or resource tile improvement. Like a city order it pays the
// pool at once and starts building, with the clock from the region's spare
// power; there is no GDP-capacity gate, and a producer is public (it never
// opens a private agent).
requestBuildTileImprovement(civ, coords, id) {
if (!this._validCiv(civ) || !coords) return false;
if (!Number.isFinite(coords.x) || !Number.isFinite(coords.y)) return false;
const proto = tileImprovementById(id);
if (!proto) return false;
const k = key(coords.x, coords.y);
const tile = this.tiles[k];
if (!tile || tile.terrainClass !== "Land") return false;
if (this.civAt(coords) !== civ) return false;
if (this.tileImprovements.has(k) || this.constructionSiteAt(coords)) return false;
if (proto.coastal && !this.isCoastalCoords(coords)) return false;
if (proto.resource && this.cityAt(coords)) return false;
if (proto.requiresTechnology && !this.hasTechnology(civ, proto.requiresTechnology)) return false;
this._openConstructionSite({
civ, coords, proto, kind: "build", buildCost: proto.buildCost,
});
this._emitChanged();
return true;
},
// A public upgrade of a production building: immediate and energy-driven like
// any other simple work, funded by the pool, never by a private agent. The
// daily demand-driven decision that would call this stays retired (02-).
_startBuildingUpgrade(k, proto, agent) {
const owner = this.tileImprovementOwner.get(k);
if (owner === undefined || owner < 0) return false;
if (this.constructionSites && this.constructionSites.has(k)) return false;
const level = agent.level;
const coords = parseKey(k);
this._openConstructionSite({
civ: owner,
coords,
proto,
kind: "upgrade",
targetLevel: level + 1,
buildCost: resourceBuildingUpgradeMoneyCost(proto, level),
level,
});
this._tileImprovementVersion += 1;
this._emitChanged();
return true;
},
// --------------------------------------------------- region plant seed --
// Gives every city region that opened without a plant one fuel-free producer,
// placed deterministically on a valid owned land tile, so the energy-driven
// construction rule always starts from a non-zero basis.
_seedSimpleRegionPlants() {
const ids = SIMPLE_ECONOMY.construction.seedPlantIds;
let added = false;
for (const city of this.cities) {
if (this._regionHasPowerPlant(city)) continue;
const coords = this._pickSimplePlantTile(city);
if (!coords) continue;
const proto = ids
.map((id) => tileImprovementById(id))
.find((candidate) => candidate && !candidate.coastal);
if (!proto) continue;
const k = key(coords.x, coords.y);
this.tileImprovements.set(k, proto.id);
this.tileImprovementOwner.set(k, city.civ);
this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[proto.id] || 1000);
// A plant beside the network gets a one-tile road spur, so it is reachable
// exactly as a hard-seeded work is.
if (!this.roads.has(k) && !this.railways.has(k) && this._touchesNetwork(coords)) {
this.roads.add(k);
}
added = true;
}
// The seeded plants change what each region can spare; drop the memoised
// power balances so the next read rebuilds them.
if (added) {
this._improvementVersion += 1;
this._regionEnergyCache = null;
}
},
_regionHasPowerPlant(city) {
for (const coords of this.regionTiles(city)) {
const id = this.tileImprovements.get(key(coords.x, coords.y));
if (!id) continue;
const proto = tileImprovementById(id);
if (isResourceTileBuilding(proto) && proto.resource === "energy") return true;
}
return false;
},
// Whether a tile sits beside a road or railway.
_touchesNetwork(coords) {
for (const neighbour of this._neighbours(coords)) {
const nk = key(neighbour.x, neighbour.y);
if (this.roads.has(nk) || this.railways.has(nk)) return true;
}
return false;
},
// The first owned land tile in the region with room for a plant. Iterated in
// `regionTiles` order, which is deterministic for a given world, so a replay
// places the same plants.
_pickSimplePlantTile(city) {
for (const coords of this.regionTiles(city)) {
if (this.cityAt(coords)) continue;
if (!this._isLand(coords)) continue;
if (this.civAt(coords) !== city.civ) continue;
const k = key(coords.x, coords.y);
if (this.tileImprovements.has(k)) continue;
if (this.constructionSiteAt(coords)) continue;
return coords;
}
return null;
},
};