Made producers pay their own way and settled the world to steady prices
- 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. - Private producers settle their daily wear from their own cash and borrow the shortfall from the central bank, so their cash never goes negative. - 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. - 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). - The shipped month report carries its label again, so the budget header and pager name the month.
This commit is contained in:
@@ -19,10 +19,11 @@ everything else (transport included) is hand-written.
|
||||
- Start the game server and client locally:
|
||||
`node server/server.js --port 27015 --bind 127.0.0.1`. Then open
|
||||
`http://127.0.0.1:27015/` (redirects to `/client/index.html`). The browser
|
||||
connects to `ws(s)://<page-origin>/ws`. The server settles the world for 150
|
||||
days before it listens so the economy opens established; pass `--warmup <days>`
|
||||
(or set `WARMUP`) to change that and `--warmup 0` to skip it. The settle logs
|
||||
its progress and takes tens of seconds on the standard map.
|
||||
connects to `ws(s)://<page-origin>/ws`. Before it listens the server settles
|
||||
the world until commodity prices hold steady over a seven-day window, so the
|
||||
economy opens established; pass `--warmup <days>` (or set `WARMUP`) to cap how
|
||||
many days the settle may run and `--warmup 0` to skip it. The settle logs how
|
||||
many days it took and usually stops well short of the cap on the standard map.
|
||||
- Install the versioned git hooks (runs the tests on every commit):
|
||||
`./scripts/install-git-hooks.sh`; uninstall with
|
||||
`git config --unset core.hooksPath`.
|
||||
@@ -139,9 +140,11 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
|
||||
so any network change rebuilds lazily while a quiet world reuses it; a unit
|
||||
move only drops it while a war is on (a hostile camp can sever a route). This
|
||||
is what stops a settled world re-flooding every sea lane each day.
|
||||
`GameState.warmUp(days)` runs the tick without players and the server calls it
|
||||
from `configureGame` with the `warmupDays` that `startServer`/the `--warmup`
|
||||
flag supplies (0 skips it).
|
||||
`GameState.warmUp(days)` runs a fixed number of days without players, while
|
||||
`warmUpToStability({ maxDays })` settles until `pricesAreSteady` (every
|
||||
commodity within 10% over the trailing seven days) or the cap, and the server
|
||||
calls the latter from `configureGame` with the `warmupDays` that
|
||||
`startServer`/the `--warmup` flag supplies (0 skips it).
|
||||
- Food is the one good a region synthesises from energy, so its price signal must
|
||||
come from the reserve, not the day's meal: `_tickResources` grows toward
|
||||
`need + (stockpileTarget - store) - harvest`, and `_consumeCityResources` books
|
||||
@@ -186,3 +189,14 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
|
||||
`git pull` and `systemctl restart battle-for-tismo`. Keep the client and
|
||||
server trees on the same commit: the client mirrors the server's movement
|
||||
model, so a mismatched pair makes prediction fight the server.
|
||||
|
||||
<!-- CODEGRAPH_START -->
|
||||
## CodeGraph
|
||||
|
||||
In repositories indexed by CodeGraph (a `.codegraph/` directory exists at the repo root), reach for it BEFORE grep/find or reading files when you need to understand or locate code:
|
||||
|
||||
- **MCP tool** (when available): `codegraph_explore` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them, including dynamic-dispatch hops grep can't follow. Name a file or symbol in the query to read its current line-numbered source. If it's listed but deferred, load it by name via tool search.
|
||||
- **Shell** (always works): `codegraph explore "<symbol names or question>"` prints the same output.
|
||||
|
||||
If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is the user's decision.
|
||||
<!-- CODEGRAPH_END -->
|
||||
|
||||
@@ -1707,6 +1707,8 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.budget-caret { display: inline-block; width: 12px; color: var(--muted); }
|
||||
.budget-note { margin-top: 2px; color: var(--muted); font-size: 11px; }
|
||||
.budget-table tr.budget-buy .budget-name { padding-left: 26px; }
|
||||
.budget-table .res { display: inline; white-space: nowrap; }
|
||||
.budget-table .res img { width: 13px; height: 13px; vertical-align: -2px; margin-right: 3px; }
|
||||
.budget-table tr.budget-source .budget-name { padding-left: 44px; color: var(--muted); }
|
||||
.budget-table tr.budget-source .budget-value { color: var(--muted); }
|
||||
.budget-table tfoot .budget-total td {
|
||||
|
||||
@@ -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": "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."
|
||||
},
|
||||
{
|
||||
"hash": "7dbbe86",
|
||||
"date": "2026-09-23",
|
||||
@@ -54,11 +60,5 @@ export const DEVLOG = [
|
||||
"date": "2026-09-22",
|
||||
"subject": "Moved the economy onto resources and surfaced each building's material bill",
|
||||
"body": "Rebalanced the resource rules so nothing is priced primarily in money: steel, electricity and high-tech lead every construction bill, chip foundries are seeded everywhere instead of being hand-placed, and cities open with stores of steel and high-tech. City building rows now lead with the resource bill and keep the money as an aside, and the region's resources expand into the buildings that demand them. Pulled the graph and cost maths into a framework-free shared/economy_graph.js, quoted currency through one helper, and gave every data table the same striping and row highlight."
|
||||
},
|
||||
{
|
||||
"hash": "1324128",
|
||||
"date": "2026-09-22",
|
||||
"subject": "Showed idle trade routes and flagged migrant flows on the economic maps",
|
||||
"body": "The delivery graph now ships every route the viewer's own storage nodes could trade over, not just the legs that ran, and keeps exports as well as imports so goods leaving the nation appear. The map draws the idle routes grey beneath the active white legs, with a key entry for them.\n\nThe population map replaces its bare flow arrows with the arrows plus a disc textured with the origin nation's flag drifting from city to city, mirroring the merchandise that rides the trade graph."
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1078,7 +1078,9 @@ export const panelMethods = {
|
||||
}
|
||||
$el.append(
|
||||
$("<div class='site-material-label'></div>").text(
|
||||
`Budget ${currencySymbol()}${compact(site.budget || 0)}`
|
||||
site.private
|
||||
? "Private work — the owner's cash and credit buy the materials"
|
||||
: `Budget ${currencySymbol()}${compact(site.budget || 0)}`
|
||||
)
|
||||
);
|
||||
$el.append(
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
// per-city cost. `breakdown` mirrors the shape both the national and the
|
||||
// regional economy use, so one renderer serves them both.
|
||||
|
||||
import { percent, signedMoney, money, SOURCE_LABELS } from "./format.js";
|
||||
import { percent, signedMoney, money, unitMoney, SOURCE_LABELS } from "./format.js";
|
||||
import { BUDGET_EXPENSE_CATEGORIES, resourceById } from "../../../shared/data.js";
|
||||
import { groupDigits } from "../../../shared/text_format.js";
|
||||
import { resourceIcon } from "../ui/card.js";
|
||||
import { currencySymbol } from "../currency.js";
|
||||
|
||||
// Draws the table into `$list`. `expanded` is the set of open building types,
|
||||
@@ -172,10 +173,23 @@ export function renderMonthlyBudget($list, report, { expanded = new Set(), onTog
|
||||
$body.append($tr);
|
||||
if (isOpen) {
|
||||
for (const buy of row.buys) {
|
||||
const [resource, amount, sources] = buy;
|
||||
const [resource, amount, sources, quantity = 0] = buy;
|
||||
const proto = resourceById(resource);
|
||||
const name = proto ? proto.name : resource;
|
||||
$body.append(monthRow(name, -amount, "expense-detail budget-buy", null));
|
||||
const unit = proto ? proto.unit : "";
|
||||
// The money is the total bill; the quantity and the average delivered
|
||||
// price the money bought it at is what shows the cost of the work.
|
||||
const $label = $("<span class='res'></span>");
|
||||
$label.append(resourceIcon(resource));
|
||||
if (quantity > 0) {
|
||||
$label.append(document.createTextNode(
|
||||
`${groupDigits(Math.round(quantity))}${unit ? ` ${unit}` : ""}` +
|
||||
` @ ${unitMoney(amount / quantity)}${unit ? `/${unit}` : ""} avg`
|
||||
));
|
||||
} else {
|
||||
$label.append(document.createTextNode(name));
|
||||
}
|
||||
$body.append(monthRow($label, -amount, "expense-detail budget-buy", null));
|
||||
for (const [source, sourceAmount] of sources || []) {
|
||||
$body.append(
|
||||
monthRow(source, -sourceAmount, "expense-detail budget-source", null)
|
||||
@@ -208,7 +222,9 @@ export function renderMonthlyBudget($list, report, { expanded = new Set(), onTog
|
||||
|
||||
function monthRow(label, value, cls, note) {
|
||||
const $tr = $("<tr></tr>").addClass(cls);
|
||||
const $name = $("<td class='budget-name'></td>").text(label);
|
||||
const $name = $("<td class='budget-name'></td>");
|
||||
if (typeof label === "string") $name.text(label);
|
||||
else $name.append(label);
|
||||
if (note) $name.append($("<div class='budget-note'></div>").text(note));
|
||||
$tr.append($name, $("<td class='budget-value'></td>").text(money(value)));
|
||||
return $tr;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { groupDigits } from "../../../shared/text_format.js";
|
||||
import { policyById, resourceById } from "../../../shared/data.js";
|
||||
import { marketPriceIndex, resourceMarketValue } from "../../../shared/resources.js";
|
||||
import { formatMoneySigned } from "../currency.js";
|
||||
import { formatMoneySigned, currencySymbol } from "../currency.js";
|
||||
import { researchNode } from "../ui/card.js";
|
||||
|
||||
// The world market's current prices, from the snapshot's market listing.
|
||||
@@ -131,6 +131,16 @@ export function money(value) {
|
||||
return formatMoneySigned(value);
|
||||
}
|
||||
|
||||
// A per-unit price. Ordinary figures round to the nearest unit of currency;
|
||||
// sub-unit prices (a kilowatt-hour, say) keep significant digits so the row
|
||||
// does not read as free.
|
||||
export function unitMoney(value) {
|
||||
const amount = Number(value) || 0;
|
||||
if (amount >= 100) return `${currencySymbol()}${groupDigits(Math.round(amount))}`;
|
||||
if (amount >= 1) return `${currencySymbol()}${amount.toFixed(1)}`;
|
||||
return `${currencySymbol()}${amount.toPrecision(3)}`;
|
||||
}
|
||||
|
||||
export function percent(value) {
|
||||
return `${value < 0 ? "−" : "+"}${(Math.abs(value) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,15 @@ import { formatMoney } from "../currency.js";
|
||||
// The materials a construction bills in, in reading order.
|
||||
export const CARD_RESOURCES = ["steel", "energy", "hightech"];
|
||||
|
||||
// A commodity's icon as an image node, for a row that names a resource with a
|
||||
// picture rather than the word.
|
||||
export function resourceIcon(id) {
|
||||
const proto = resourceById(id);
|
||||
const $img = $("<img>");
|
||||
if (proto) $img.attr("src", `assets/${proto.icon}`).attr("alt", proto.name);
|
||||
return $img;
|
||||
}
|
||||
|
||||
// A resource amount with its commodity icon. `sign` marks the direction of the
|
||||
// flow: "+" for output, "−" for a cost.
|
||||
function resourceLine(id, amount, sign) {
|
||||
|
||||
@@ -109,15 +109,21 @@ export class GameServer extends EventEmitter {
|
||||
});
|
||||
// A fresh world can be settled before the first player joins, so the game
|
||||
// opens with its economy and reserves already established rather than at a
|
||||
// cold start. Off by default; the launcher asks for the days it wants.
|
||||
// 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);
|
||||
if (warmupDays > 0) this.state.warmUp(warmupDays);
|
||||
this.settledDays = warmupDays > 0
|
||||
? this.state.warmUpToStability({ maxDays: warmupDays })
|
||||
: 0;
|
||||
this._configured = true;
|
||||
for (const peerId of this.network.getPeerIds()) {
|
||||
this._assignCiv(peerId, this.peerNames.get(peerId) || "");
|
||||
}
|
||||
this._broadcastState();
|
||||
this.emit("game_configured");
|
||||
return this.settledDays;
|
||||
}
|
||||
|
||||
_loadCivs(ids) {
|
||||
|
||||
+11
-5
@@ -128,9 +128,13 @@ export function startServer({
|
||||
};
|
||||
|
||||
const gameServer = new GameServer(network);
|
||||
if (warmupDays > 0) log(`settling the world for ${warmupDays} days...`);
|
||||
gameServer.configureGame({ seed, player_civ: 0, civilisations, testing, mapConfig, warmupDays });
|
||||
if (warmupDays > 0) log(`world settled at day ${warmupDays}`);
|
||||
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,
|
||||
});
|
||||
if (warmupDays > 0) log(`world settled at day ${settledDays}`);
|
||||
|
||||
function broadcastPlayers() {
|
||||
const players = {};
|
||||
@@ -268,8 +272,10 @@ 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 this many days before serving, so the game opens with
|
||||
// its economy established. `--warmup 0` skips it.
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -446,12 +446,18 @@ export class GameState {
|
||||
try {
|
||||
for (let hour = 0; hour < hours; hour++) this.advanceHour();
|
||||
} finally {
|
||||
this._tradeGraphCache = null;
|
||||
this._tradeGraphStamp = null;
|
||||
this._marketAccessCache = null;
|
||||
this._resetSettleCaches();
|
||||
}
|
||||
}
|
||||
|
||||
// The caches a settle leaves stale: the trade graph is kept across the run
|
||||
// (nothing alters the network), so it is dropped only once the settle ends.
|
||||
_resetSettleCaches() {
|
||||
this._tradeGraphCache = null;
|
||||
this._tradeGraphStamp = null;
|
||||
this._marketAccessCache = null;
|
||||
}
|
||||
|
||||
tickHour() {
|
||||
this.totalHours += 1;
|
||||
// Natural growth is a whole number of days, so the cached production figures
|
||||
|
||||
+30
-11
@@ -74,19 +74,32 @@ export const budgetMethods = {
|
||||
|
||||
// The money one expense category spent on one resource, so the row can be
|
||||
// expanded to show what it bought. `source` names what consumed it -- a
|
||||
// building, unit, work or transport type -- and is optional.
|
||||
_recordBudgetCategoryResource(civ, category, resource, amount, source = null) {
|
||||
// building, unit, work or transport type -- and is optional. `quantity` is how
|
||||
// many units of the resource the money bought, so the row can also show the
|
||||
// price paid per unit.
|
||||
_recordBudgetCategoryResource(civ, category, resource, amount, source = null, quantity = 0) {
|
||||
if (civ < 0 || !(amount > 0)) return;
|
||||
const record = this._budgetRecord(civ);
|
||||
const byResource = record.resourceByCategory[category]
|
||||
|| (record.resourceByCategory[category] = {});
|
||||
let entry = byResource[resource];
|
||||
if (!entry || typeof entry === "number") {
|
||||
entry = { total: typeof entry === "number" ? entry : 0, sources: {} };
|
||||
entry = {
|
||||
total: typeof entry === "number" ? entry : 0,
|
||||
quantity: 0,
|
||||
sources: {},
|
||||
sourceQuantity: {},
|
||||
};
|
||||
byResource[resource] = entry;
|
||||
}
|
||||
entry.total += amount;
|
||||
if (source) entry.sources[source] = (entry.sources[source] || 0) + amount;
|
||||
if (quantity > 0) entry.quantity = (entry.quantity || 0) + quantity;
|
||||
if (source) {
|
||||
entry.sources[source] = (entry.sources[source] || 0) + amount;
|
||||
if (quantity > 0) {
|
||||
entry.sourceQuantity[source] = (entry.sourceQuantity[source] || 0) + quantity;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// The money a nation spent buying and earned selling one resource to the
|
||||
@@ -139,20 +152,26 @@ export const budgetMethods = {
|
||||
const resourceByCategory = (record && record.resourceByCategory) || {};
|
||||
for (const category of BUDGET_EXPENSE_CATEGORIES) {
|
||||
const spent = Math.max(0, -(cash[category.id] || 0));
|
||||
const cost = Math.max(0, economic[category.id] || 0, spent);
|
||||
if (spent === 0 && cost === 0) continue;
|
||||
// Each buy is [resource, money, [[source, money], ...]]: what the
|
||||
// category bought and which buildings, units or works consumed it.
|
||||
// Each buy is [resource, money, [[source, money, quantity], ...], quantity]:
|
||||
// what the category bought, which buildings, units or works consumed it,
|
||||
// and how many units the money bought (so the panel can show the price).
|
||||
const buys = Object.entries(resourceByCategory[category.id] || {})
|
||||
.map(([resource, detail]) => {
|
||||
if (typeof detail === "number") return [resource, detail, []];
|
||||
if (typeof detail === "number") return [resource, detail, [], 0];
|
||||
const sourceQuantity = detail.sourceQuantity || {};
|
||||
const sources = Object.entries(detail.sources || {})
|
||||
.filter(([, amount]) => amount > 0)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
return [resource, detail.total || 0, sources];
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([name, amount]) => [name, amount, sourceQuantity[name] || 0]);
|
||||
return [resource, detail.total || 0, sources, detail.quantity || 0];
|
||||
})
|
||||
.filter(([, amount]) => amount > 0)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
// A category that only bought materials -- a private improvement upgrade,
|
||||
// whose cash never passed through the treasury -- still has a cost.
|
||||
const bought = buys.reduce((sum, buy) => sum + buy[1], 0);
|
||||
const cost = Math.max(0, economic[category.id] || 0, spent, bought);
|
||||
if (spent === 0 && cost === 0) continue;
|
||||
expenses.push({
|
||||
id: category.id,
|
||||
label: category.label,
|
||||
|
||||
@@ -1069,13 +1069,13 @@ export const economyMethods = {
|
||||
let spentSteel = fromStock.steel * this.getResourcePrice("steel") / v;
|
||||
let spentTech = fromStock.hightech * this.getResourcePrice("hightech") / v;
|
||||
let paid = 1;
|
||||
let gotSteel = 0;
|
||||
let gotTech = 0;
|
||||
if (shortfall.steel > 0 || shortfall.hightech > 0) {
|
||||
// The replacement steel and high-tech come from reachable regions,
|
||||
// bought by the treasury. Whatever is not available is not healed.
|
||||
const nodes = this._resourceNodes();
|
||||
const haul = { steel: 0, hightech: 0 };
|
||||
let gotSteel = 0;
|
||||
let gotTech = 0;
|
||||
if (shortfall.steel > 0) {
|
||||
const before = payer.total;
|
||||
gotSteel = this._procureFromNeighbours(
|
||||
@@ -1096,8 +1096,12 @@ export const economyMethods = {
|
||||
}
|
||||
if (spentSteel + spentTech > 0) {
|
||||
this._recordBudgetCash(city.civ, "units", -(spentSteel + spentTech));
|
||||
this._recordBudgetCategoryResource(city.civ, "units", "steel", spentSteel);
|
||||
this._recordBudgetCategoryResource(city.civ, "units", "hightech", spentTech);
|
||||
this._recordBudgetCategoryResource(
|
||||
city.civ, "units", "steel", spentSteel, proto.name, fromStock.steel + gotSteel
|
||||
);
|
||||
this._recordBudgetCategoryResource(
|
||||
city.civ, "units", "hightech", spentTech, proto.name, fromStock.hightech + gotTech
|
||||
);
|
||||
this._recordResourceSpend(city.civ, "upkeep", spentSteel + spentTech, {
|
||||
steel: spentSteel,
|
||||
hightech: spentTech,
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
import { HOURS_PER_DAY } from "./constants.js";
|
||||
|
||||
function emptyBuildingAgent() {
|
||||
return { level: 0, cash: 0, salesToday: 0, sales: [], upgrading: null };
|
||||
return { level: 0, cash: 0, debt: 0, salesToday: 0, sales: [], upgrading: null };
|
||||
}
|
||||
|
||||
export const industryMethods = {
|
||||
@@ -115,28 +115,40 @@ export const industryMethods = {
|
||||
|
||||
// A payer that draws on one production building's own cash.
|
||||
_buildingPayer(k) {
|
||||
const agent = this._ensureBuildingAgent(k);
|
||||
const payer = { total: 0 };
|
||||
payer.charge = (cost) => {
|
||||
if (!(cost > 0)) return;
|
||||
// An agent never mints money: a bill it cannot cover leaves it overdrawn,
|
||||
// to be paid off from the next sales.
|
||||
agent.cash = (agent.cash || 0) - cost;
|
||||
this._chargeBuildingAccount(k, cost);
|
||||
payer.total += cost;
|
||||
};
|
||||
return payer;
|
||||
},
|
||||
|
||||
// Charges one production building, spending its cash and borrowing whatever
|
||||
// is missing from the national central bank at the bank's rate. Like a region,
|
||||
// an agent never goes into the red: it takes on a loan instead, which the
|
||||
// daily tick compounds and repays from later sales.
|
||||
_chargeBuildingAccount(k, amount) {
|
||||
if (!(amount > 0)) return;
|
||||
const agent = this._ensureBuildingAgent(k);
|
||||
const cash = agent.cash || 0;
|
||||
if (cash >= amount) {
|
||||
agent.cash = cash - amount;
|
||||
return;
|
||||
}
|
||||
agent.cash = 0;
|
||||
agent.debt = (agent.debt || 0) + (amount - cash);
|
||||
},
|
||||
|
||||
// Charges one production building for something the grid delivered to it,
|
||||
// from its own cash. Like `_buildingPayer`, it may go overdrawn rather than
|
||||
// fail, so the money a supplier receives is real.
|
||||
// from its own cash, borrowing from the central bank where the cash falls
|
||||
// short, so the money a supplier receives is real.
|
||||
_chargeBuildingCash(k, code, cost) {
|
||||
if (!(cost > 0)) return;
|
||||
const owner = this.tileImprovementOwner.get(k);
|
||||
if (owner === undefined || owner < 0) return;
|
||||
const agent = this._ensureBuildingAgent(k);
|
||||
const value = cost * this.currencyValueOf(code) / this.currencyValue(owner);
|
||||
agent.cash = (agent.cash || 0) - value;
|
||||
this._chargeBuildingAccount(k, value);
|
||||
},
|
||||
|
||||
// Pays a grid's plants for `kwh` of power worth `money`, split by how much
|
||||
@@ -168,6 +180,17 @@ export const industryMethods = {
|
||||
return total;
|
||||
},
|
||||
|
||||
// What the nation's production buildings owe the central bank in total.
|
||||
getPrivateBuildingDebt(civ) {
|
||||
if (!this.buildingAgents) return 0;
|
||||
let total = 0;
|
||||
for (const [k, agent] of this.buildingAgents) {
|
||||
if (this.tileImprovementOwner.get(k) !== civ) continue;
|
||||
total += agent.debt || 0;
|
||||
}
|
||||
return total;
|
||||
},
|
||||
|
||||
// A nation's production agents as plain objects, for the Economy tab's
|
||||
// separate private-sector row and the tile panel.
|
||||
getProductionAgents(civ) {
|
||||
@@ -188,6 +211,7 @@ export const industryMethods = {
|
||||
resource: proto.resource,
|
||||
level: agent.level,
|
||||
cash: agent.cash || 0,
|
||||
debt: agent.debt || 0,
|
||||
outputPerDay: resourceBuildingOutputAt(proto, agent.level),
|
||||
upgrading: !!job,
|
||||
upgradeDaysLeft: job
|
||||
@@ -326,17 +350,18 @@ export const industryMethods = {
|
||||
return false;
|
||||
}
|
||||
const money = resourceBuildingUpgradeMoneyCost(proto, level);
|
||||
const budget = Math.max(0, agent.cash || 0);
|
||||
agent.cash = 0;
|
||||
// A private work: the agent keeps its cash and the site draws on it (then a
|
||||
// central-bank loan) as it buys, so an upgrade is never a public expense.
|
||||
const site = this._openConstructionSite({
|
||||
civ: owner,
|
||||
coords,
|
||||
proto,
|
||||
kind: "upgrade",
|
||||
targetLevel: level + 1,
|
||||
money: budget,
|
||||
money,
|
||||
buildCost: money,
|
||||
level,
|
||||
isPrivate: true,
|
||||
});
|
||||
this._kickoffSite(site);
|
||||
this._tileImprovementVersion += 1;
|
||||
|
||||
@@ -604,6 +604,24 @@ export const moneyMethods = {
|
||||
this._repatriateRegion(city, basket);
|
||||
this._repayRegionDebt(city, basket);
|
||||
}
|
||||
// Production buildings borrow from the same bank: their loans compound and
|
||||
// any cash they hold pays the debt down, so one never has to go into the red.
|
||||
for (const [k, agent] of this.buildingAgents || []) {
|
||||
const owner = this.tileImprovementOwner.get(k);
|
||||
if (owner === undefined || owner < 0) continue;
|
||||
const debt = agent.debt || 0;
|
||||
if (debt > 0) {
|
||||
const rate = this.getCentralBankRate(owner) / DAYS_PER_YEAR;
|
||||
agent.debt = debt * (1 + rate);
|
||||
}
|
||||
const owing = agent.debt || 0;
|
||||
const cash = agent.cash || 0;
|
||||
if (owing > 0 && cash > 0) {
|
||||
const repay = Math.min(cash, owing);
|
||||
agent.cash = cash - repay;
|
||||
agent.debt = owing - repay;
|
||||
}
|
||||
}
|
||||
for (let civ = 0; civ < this.civilisations.length; civ++) {
|
||||
// A treasury in the red is an overdraft that compounds too.
|
||||
const budget = this.getBudget(civ);
|
||||
|
||||
+106
-26
@@ -543,6 +543,46 @@ export const resourceMethods = {
|
||||
}
|
||||
},
|
||||
|
||||
// Settles the world until its commodity prices are steady: it keeps advancing
|
||||
// whole days until, over the trailing `window` days, no commodity has moved
|
||||
// more than `tolerance` from where it stood at the start of that window. A
|
||||
// quiet market is reached in far fewer days than a fixed settle would wait, so
|
||||
// the game can open with its economy already established without the long
|
||||
// fixed run. `maxDays` caps the settle so an unsettled world cannot hang the
|
||||
// server; a price series too short to judge simply keeps the economy running.
|
||||
// Returns the number of days actually settled.
|
||||
warmUpToStability({ window = 7, tolerance = 0.1, maxDays = 150 } = {}) {
|
||||
const cap = Math.max(0, Math.floor(maxDays));
|
||||
if (cap <= 0) return 0;
|
||||
let day = 0;
|
||||
try {
|
||||
while (day < cap) {
|
||||
for (let hour = 0; hour < HOURS_PER_DAY; hour++) this.advanceHour();
|
||||
day += 1;
|
||||
if (day >= window && this.pricesAreSteady(window, tolerance)) break;
|
||||
}
|
||||
} finally {
|
||||
this._resetSettleCaches();
|
||||
}
|
||||
return day;
|
||||
},
|
||||
|
||||
// Whether every commodity's world price has held within `tolerance` of its
|
||||
// price `window` days ago. A series shorter than the window cannot be judged,
|
||||
// so it reads as not yet steady.
|
||||
pricesAreSteady(window = 7, tolerance = 0.1) {
|
||||
if (!this.resourceDailyPrices) return true;
|
||||
for (const id of RESOURCE_IDS) {
|
||||
const series = this.resourceDailyPrices.get(id) || [];
|
||||
if (series.length <= window) return false;
|
||||
const before = series[series.length - 1 - window];
|
||||
const now = series[series.length - 1];
|
||||
if (!(before > 0)) continue;
|
||||
if (Math.abs(now / before - 1) > tolerance) return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
// A commodity's price index and its month-on-month and year-on-year inflation,
|
||||
// from the monthly series. The index is 100 at the opening price in January
|
||||
// 2000. Monthly compares with last month's opening, yearly with the same month
|
||||
@@ -1086,41 +1126,69 @@ export const resourceMethods = {
|
||||
// demand is cut by what it covered, so a well funded work spares the treasury
|
||||
// until it runs dry. The spent money is paid into the nation's regions, since
|
||||
// it is the private sector the state would otherwise have paid.
|
||||
//
|
||||
// A private producer -- a mine, a mill or a plant -- is the exception: it is
|
||||
// its own business, so it pays the whole day's wear from the cash its output
|
||||
// earned and borrows from the central bank when that cash falls short. The
|
||||
// state never subsidises a producer, so its wear never reaches the treasury
|
||||
// bill.
|
||||
_applyWorksMaintenance(civ, categories) {
|
||||
if (!this.improvementCash || !categories) return;
|
||||
if (!categories) return;
|
||||
const steelUnit = this._marketUnitCost("steel");
|
||||
const techUnit = this._marketUnitCost("hightech");
|
||||
let covered = 0;
|
||||
// Spend one work's reserve against its own steel/high-tech wear, trimming
|
||||
// the matching budget category by whatever the reserve could cover.
|
||||
const apply = (k, steel, hightech, category) => {
|
||||
const reserve = this.improvementCash.get(k) || 0;
|
||||
if (!(reserve > 0)) return;
|
||||
// the matching budget category and its per-label source by whatever it
|
||||
// covered. `full` bills the owner for the whole day whatever its reserve
|
||||
// (a private producer, which pays from its cash and borrows what is short).
|
||||
const apply = (reserve, spend, steel, hightech, label, category, full = false) => {
|
||||
const value = steel * steelUnit + hightech * techUnit;
|
||||
if (!(value > 0)) return;
|
||||
const fraction = Math.min(1, reserve / value);
|
||||
const fraction = full ? 1 : (reserve > 0 ? Math.min(1, reserve / value) : 0);
|
||||
if (!(fraction > 0)) return;
|
||||
const spent = value * fraction;
|
||||
covered += spent;
|
||||
const after = reserve - spent;
|
||||
if (after > 0) this.improvementCash.set(k, after);
|
||||
else this.improvementCash.delete(k);
|
||||
spend(spent);
|
||||
if (category) {
|
||||
category.steel = Math.max(0, (category.steel || 0) - steel * fraction);
|
||||
category.hightech = Math.max(0, (category.hightech || 0) - hightech * fraction);
|
||||
const source = (category.sources || []).find((entry) => entry.label === label);
|
||||
if (source) {
|
||||
source.steel = Math.max(0, (source.steel || 0) - steel * fraction);
|
||||
source.hightech = Math.max(0, (source.hightech || 0) - hightech * fraction);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Pay a reserve from the tile's own improvement cash, which never overdraws.
|
||||
const spendReserve = (k) => (spent) => {
|
||||
const after = (this.improvementCash.get(k) || 0) - spent;
|
||||
if (after > 0) this.improvementCash.set(k, after);
|
||||
else this.improvementCash.delete(k);
|
||||
};
|
||||
for (const [k, id] of this.tileImprovements) {
|
||||
if (this.tileImprovementOwner.get(k) !== civ) continue;
|
||||
const proto = tileImprovementById(id);
|
||||
if (!proto) continue;
|
||||
const costs = protoUpkeep(proto, proto.buildCost || 0);
|
||||
apply(k, costs.steel || 0, costs.hightech || 0, categories.works);
|
||||
const steel = costs.steel || 0;
|
||||
const hightech = costs.hightech || 0;
|
||||
if (this._isProductionAgent(proto)) {
|
||||
apply(0, (spent) => this._chargeBuildingAccount(k, spent),
|
||||
steel, hightech, proto.name, categories.works, true);
|
||||
} else {
|
||||
apply(this.improvementCash.get(k) || 0, spendReserve(k),
|
||||
steel, hightech, proto.name, categories.works);
|
||||
}
|
||||
}
|
||||
for (const [tiles, id] of [[this.roads, "road"], [this.railways, "railway"]]) {
|
||||
for (const [tiles, id, label] of [
|
||||
[this.roads, "road", "Roads"],
|
||||
[this.railways, "railway", "Railways"],
|
||||
]) {
|
||||
const steel = RESOURCE_RULES.transportUpkeepSteel[id] || 0;
|
||||
for (const k of tiles) {
|
||||
if (this.civAt(parseKey(k)) !== civ) continue;
|
||||
apply(k, steel, 0, categories.transport);
|
||||
apply(this.improvementCash.get(k) || 0, spendReserve(k),
|
||||
steel, 0, label, categories.transport);
|
||||
}
|
||||
}
|
||||
// The reserves paid the private sector the state would otherwise have paid.
|
||||
@@ -1209,7 +1277,10 @@ export const resourceMethods = {
|
||||
hightech: spentTech,
|
||||
});
|
||||
// Split the cash across the categories that wore the material, then across
|
||||
// the buildings, units, works and transport that consumed it.
|
||||
// the buildings, units, works and transport that consumed it. The worn
|
||||
// quantity travels with the cash, so each row can show the price it paid.
|
||||
const consumedSteel = drawnSteel + gotSteel;
|
||||
const consumedTech = drawnTech + gotTech;
|
||||
for (const id of ids) {
|
||||
const demand = categories[id] || {};
|
||||
const steelCash = totalSteel > 0 ? spentSteel * ((demand.steel || 0) / totalSteel) : 0;
|
||||
@@ -1218,10 +1289,12 @@ export const resourceMethods = {
|
||||
if (cash > 0) this._recordBudgetCash(civ, id, -cash);
|
||||
const sources = (categories[id] && categories[id].sources) || [];
|
||||
if (steelCash > 0) {
|
||||
this._recordResourceBySource(civ, id, "steel", steelCash, sources, "steel");
|
||||
const qty = totalSteel > 0 ? consumedSteel * ((demand.steel || 0) / totalSteel) : 0;
|
||||
this._recordResourceBySource(civ, id, "steel", steelCash, sources, "steel", qty);
|
||||
}
|
||||
if (techCash > 0) {
|
||||
this._recordResourceBySource(civ, id, "hightech", techCash, sources, "hightech");
|
||||
const qty = totalTech > 0 ? consumedTech * ((demand.hightech || 0) / totalTech) : 0;
|
||||
this._recordResourceBySource(civ, id, "hightech", techCash, sources, "hightech", qty);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1235,23 +1308,28 @@ export const resourceMethods = {
|
||||
|
||||
// Files a category's market purchase of one resource under the sources that
|
||||
// consumed it -- the building, unit, work or transport type -- split in
|
||||
// proportion to how much of that resource each source wore out.
|
||||
_recordResourceBySource(civ, category, resource, cash, sources, field) {
|
||||
// proportion to how much of that resource each source wore out. `quantity` is
|
||||
// how much of the resource the cash bought, split the same way, so the budget
|
||||
// row can show the average price paid per unit.
|
||||
_recordResourceBySource(civ, category, resource, cash, sources, field, quantity = 0) {
|
||||
let total = 0;
|
||||
for (const source of sources) total += source[field] || 0;
|
||||
if (!(total > 0)) {
|
||||
this._recordBudgetCategoryResource(civ, category, resource, cash);
|
||||
this._recordBudgetCategoryResource(civ, category, resource, cash, null, quantity);
|
||||
return;
|
||||
}
|
||||
let remaining = cash;
|
||||
let remainingQty = quantity;
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const source = sources[i];
|
||||
const amount = i === sources.length - 1
|
||||
? remaining
|
||||
: cash * ((source[field] || 0) / total);
|
||||
const last = i === sources.length - 1;
|
||||
const share = (source[field] || 0) / total;
|
||||
const amount = last ? remaining : cash * share;
|
||||
const qty = last ? remainingQty : quantity * share;
|
||||
remaining -= amount;
|
||||
remainingQty -= qty;
|
||||
if (amount > 0) {
|
||||
this._recordBudgetCategoryResource(civ, category, resource, amount, source.label);
|
||||
this._recordBudgetCategoryResource(civ, category, resource, amount, source.label, qty);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2701,17 +2779,19 @@ export const resourceMethods = {
|
||||
// The city's own food is bought from the region that stored it.
|
||||
const takeValue = take * this.getResourcePrice("food") / this.currencyValue(unit.civ);
|
||||
this._payRegionForMaterial(city, "food", take, payer);
|
||||
this._recordProvisionSpend(unit.civ, takeValue, procuredSpent);
|
||||
const proto = this.unitProto(unit);
|
||||
this._recordProvisionSpend(unit.civ, takeValue, procuredSpent, tonnes, proto ? proto.name : null);
|
||||
return true;
|
||||
},
|
||||
|
||||
// Books the food a unit ate, whether it came from a region's store or from a
|
||||
// foreign one.
|
||||
_recordProvisionSpend(civ, takeValue, procuredSpent) {
|
||||
// foreign one. `quantity` is the ration in tonnes and `source` the unit type,
|
||||
// so the budget row can show what was eaten and the average price paid.
|
||||
_recordProvisionSpend(civ, takeValue, procuredSpent, quantity = 0, source = null) {
|
||||
const spent = takeValue + procuredSpent;
|
||||
if (!(spent > 0)) return;
|
||||
this._recordBudgetCash(civ, "units", -spent);
|
||||
this._recordBudgetCategoryResource(civ, "units", "food", spent);
|
||||
this._recordBudgetCategoryResource(civ, "units", "food", spent, source, quantity);
|
||||
this._recordResourceSpend(civ, "upkeep", spent, "food");
|
||||
},
|
||||
|
||||
|
||||
@@ -166,6 +166,7 @@ export const serializationMethods = {
|
||||
taxTake: { sales: 0, export: 0, import: 0 },
|
||||
commodityInflation: 0,
|
||||
privateBuildingCash: 0,
|
||||
privateBuildingDebt: 0,
|
||||
productionBuildings: [],
|
||||
};
|
||||
}
|
||||
@@ -210,6 +211,7 @@ export const serializationMethods = {
|
||||
// Production buildings are private-sector agents too, but a separate
|
||||
// class from the regions: they keep their own cash, level and upgrade.
|
||||
privateBuildingCash: this.getPrivateBuildingCash(viewerCiv),
|
||||
privateBuildingDebt: this.getPrivateBuildingDebt(viewerCiv),
|
||||
productionBuildings: this.getProductionAgents(viewerCiv),
|
||||
// Where every unit of the viewer's currency is stored: its treasury, its
|
||||
// regions, and foreign regions and central banks that hold it.
|
||||
@@ -234,6 +236,9 @@ export const serializationMethods = {
|
||||
return this.budgetMonthReports(civ).map((report) => ({
|
||||
monthIndex: report.monthIndex,
|
||||
isCurrent: report.isCurrent,
|
||||
// The human-readable month, so the pager and the subtitle can name the
|
||||
// month without rebuilding the calendar on the client.
|
||||
label: report.label,
|
||||
income: report.income,
|
||||
taxes: report.taxes,
|
||||
sales: report.sales,
|
||||
@@ -519,6 +524,7 @@ export const serializationMethods = {
|
||||
id: site.id,
|
||||
kind: site.kind,
|
||||
targetLevel: site.targetLevel,
|
||||
private: !!site.private,
|
||||
phase: site.phase,
|
||||
stalled: site.stalled,
|
||||
budget: site.budget,
|
||||
|
||||
@@ -118,10 +118,14 @@ export const siteMethods = {
|
||||
|
||||
// Opens a construction site on a tile. `money` is the forecast budget moved
|
||||
// out of the coffers (or a private agent's cash) into the node; the caller has
|
||||
// already reserved it. `proto` is the improvement being raised and `level` the
|
||||
// production level a fresh build starts from (0) or the level an upgrade
|
||||
// targets minus one.
|
||||
_openConstructionSite({ civ, coords, proto, kind = "build", targetLevel = 0, money, buildCost, level = 0 }) {
|
||||
// already reserved it. A `private` site belongs to a production agent: it keeps
|
||||
// `money` only as the forecast the panel shows, and every purchase is charged
|
||||
// to that agent's account (cash, then a central-bank loan) instead of the
|
||||
// treasury. `proto` is the improvement being raised and `level` the production
|
||||
// level a fresh build starts from (0) or the level an upgrade targets minus one.
|
||||
_openConstructionSite({
|
||||
civ, coords, proto, kind = "build", targetLevel = 0, money, buildCost, level = 0, isPrivate = false,
|
||||
}) {
|
||||
const k = key(coords.x, coords.y);
|
||||
const materials = kind === "upgrade"
|
||||
? this.constructionResourceCost(proto, level, buildCost)
|
||||
@@ -138,6 +142,7 @@ export const siteMethods = {
|
||||
civ,
|
||||
kind,
|
||||
targetLevel,
|
||||
private: !!isPrivate,
|
||||
phase: "materials",
|
||||
budget: Math.max(0, money || 0),
|
||||
prices: this._lockedUnitPrices(),
|
||||
@@ -251,7 +256,8 @@ export const siteMethods = {
|
||||
// 8%, ...), so a stubborn price is met within a few days. A supplier still
|
||||
// above the raised cap simply stalls it once more; the count resets as soon as
|
||||
// it buys. When no supplier can reach the site at any price the rise cannot
|
||||
// help, so it is left alone.
|
||||
// help, so it is left alone. A private site raises its own agent's account
|
||||
// instead of the treasury: the money is borrowed, not subsidised.
|
||||
_raiseSiteBudgetIfStalled(site) {
|
||||
if (!site.stalled || site.phase !== "materials") return;
|
||||
if (this._siteUnreachableMaterials(site).length > 0) return;
|
||||
@@ -260,7 +266,7 @@ export const siteMethods = {
|
||||
for (const id of RESOURCE_IDS) site.prices[id] *= factor;
|
||||
site.budgetRaises = raises + 1;
|
||||
const add = this._siteTopUp(site, site.prices);
|
||||
this._reserveSiteBudget(site.civ, add);
|
||||
if (!site.private) this._reserveSiteBudget(site.civ, add);
|
||||
site.budget += add;
|
||||
this._gatherSiteMaterials(site);
|
||||
this._emitChanged();
|
||||
@@ -276,6 +282,7 @@ export const siteMethods = {
|
||||
const city = this._cityById.get(site.cityId);
|
||||
if (!city) return 0;
|
||||
const payer = this._sitePayer(site);
|
||||
const paidBefore = payer.total;
|
||||
let bought = 0;
|
||||
// The hub city already holds some of the material; the state buys it from
|
||||
// the region at the market value before reaching further afield.
|
||||
@@ -299,6 +306,15 @@ export const siteMethods = {
|
||||
});
|
||||
bought += temp[id] || 0;
|
||||
}
|
||||
// File the purchase under tile construction with its quantity, so the
|
||||
// budget panel can show how many units were bought and at what price. A
|
||||
// private work is not a public expense, so it is left out of the budget.
|
||||
if (bought > 0 && !site.private) {
|
||||
const proto = tileImprovementById(site.id) || transportImprovement(site.id);
|
||||
this._recordBudgetCategoryResource(
|
||||
site.civ, "tilebuild", id, payer.total - paidBefore, proto ? proto.name : site.id, bought
|
||||
);
|
||||
}
|
||||
return bought;
|
||||
},
|
||||
|
||||
@@ -316,9 +332,20 @@ export const siteMethods = {
|
||||
},
|
||||
|
||||
// A payer that empties the site's own account first, then draws on the
|
||||
// nation's treasury, so an overrun is public money.
|
||||
// nation's treasury, so an overrun is public money. A private site instead
|
||||
// draws on its production agent: that agent's cash first, then a central-bank
|
||||
// loan, so a producer is never subsidised by the state.
|
||||
_sitePayer(site) {
|
||||
const payer = { total: 0, overflow: 0 };
|
||||
if (site.private) {
|
||||
const k = key(site.coords.x, site.coords.y);
|
||||
payer.charge = (cost) => {
|
||||
if (!(cost > 0)) return;
|
||||
this._chargeBuildingAccount(k, cost);
|
||||
payer.total += cost;
|
||||
};
|
||||
return payer;
|
||||
}
|
||||
payer.charge = (cost) => {
|
||||
if (!(cost > 0)) return;
|
||||
const fromBudget = Math.min(site.budget, cost);
|
||||
@@ -413,7 +440,9 @@ export const siteMethods = {
|
||||
const agent = this._ensureBuildingAgent(k);
|
||||
agent.level = site.targetLevel;
|
||||
agent.upgrading = null;
|
||||
agent.cash = (agent.cash || 0) + site.budget;
|
||||
// A private upgrade borrowed as it spent, so there is no unspent public
|
||||
// budget to hand back.
|
||||
if (!site.private) agent.cash = (agent.cash || 0) + site.budget;
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._touchModifiers();
|
||||
this._tileImprovementVersion += 1;
|
||||
@@ -432,7 +461,9 @@ export const siteMethods = {
|
||||
this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[site.id] || 1000);
|
||||
if (this._isProductionAgent(proto)) {
|
||||
const agent = this._ensureBuildingAgent(k);
|
||||
agent.cash = (agent.cash || 0) + site.budget;
|
||||
// A public work hands its unspent budget to the new private owner as
|
||||
// working capital; a private one already borrowed for itself.
|
||||
if (!site.private) agent.cash = (agent.cash || 0) + site.budget;
|
||||
} else if (site.budget > 0) {
|
||||
this.improvementCash.set(k, (this.improvementCash.get(k) || 0) + site.budget);
|
||||
}
|
||||
|
||||
@@ -62,9 +62,14 @@ export const warfareMethods = {
|
||||
const gdp = this.getPlayerGdp(civ);
|
||||
if (ECONOMY.productionCapacity(gdp) <= 0) return false;
|
||||
const money = this._siteForecastBudget(civ, proto, proto.buildCost);
|
||||
this._reserveSiteBudget(civ, money);
|
||||
// A resource producer is a private business: it funds its construction from
|
||||
// its own agent, borrowing from the central bank as it buys, so the state
|
||||
// pays nothing. Military works are public and come out of the treasury.
|
||||
const isPrivate = this._isProductionAgent(proto);
|
||||
if (isPrivate) this._ensureBuildingAgent(k);
|
||||
else this._reserveSiteBudget(civ, money);
|
||||
const site = this._openConstructionSite({
|
||||
civ, coords, proto, kind: "build", money, buildCost: proto.buildCost,
|
||||
civ, coords, proto, kind: "build", money, buildCost: proto.buildCost, isPrivate,
|
||||
});
|
||||
this._kickoffSite(site);
|
||||
this._emitChanged();
|
||||
|
||||
@@ -2,6 +2,21 @@ import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState } from "./framework/helpers.js";
|
||||
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
|
||||
import { BUDGET_HISTORY_MONTHS } from "../shared/game_state/budget.js";
|
||||
import { key } from "../shared/hex.js";
|
||||
|
||||
// Plants a state-owned work on a free land tile, so the day's works upkeep is
|
||||
// billed to the treasury rather than settled by a private producer's cash.
|
||||
function placeFortification(state, civ = 0) {
|
||||
const coords = state.landCells.find((c) => {
|
||||
if (state.civAt(c) !== civ || state.cityAt(c)) return false;
|
||||
const k = key(c.x, c.y);
|
||||
return !state.tileImprovements.has(k) && !state.roads.has(k) && !state.railways.has(k);
|
||||
});
|
||||
const k = key(coords.x, coords.y);
|
||||
state.tileImprovements.set(k, "fortifications");
|
||||
state.tileImprovementOwner.set(k, civ);
|
||||
state.tileImprovementHp.set(k, 1000);
|
||||
}
|
||||
|
||||
export class BudgetTest extends TestCase {
|
||||
test_the_opening_month_is_the_only_report() {
|
||||
@@ -50,6 +65,7 @@ export class BudgetTest extends TestCase {
|
||||
|
||||
test_daily_upkeep_records_cash_and_market_value() {
|
||||
const state = smallState();
|
||||
placeFortification(state);
|
||||
state.advanceHour();
|
||||
for (let i = 0; i < 23; i++) state.advanceHour();
|
||||
const report = state.budgetMonthReports(0).at(-1);
|
||||
@@ -111,6 +127,19 @@ export class BudgetTest extends TestCase {
|
||||
this.assertApprox(steel[1], 300);
|
||||
}
|
||||
|
||||
test_expense_rows_carry_the_quantity_that_was_bought() {
|
||||
const state = smallState();
|
||||
state._recordBudgetCash(0, "tilebuild", -1_000);
|
||||
state._recordBudgetCategoryResource(0, "tilebuild", "steel", 1_000, "Road", 50);
|
||||
const report = state.budgetMonthReports(0).at(-1);
|
||||
const tilebuild = report.expenses.find((entry) => entry.id === "tilebuild");
|
||||
const steel = tilebuild.buys.find((entry) => entry[0] === "steel");
|
||||
this.assertApprox(steel[1], 1_000);
|
||||
this.assertApprox(steel[3], 50, 1e-9, "the quantity travels with the money");
|
||||
this.assertApprox(steel[1] / steel[3], 20, 1e-9, "so the price per unit can be read");
|
||||
this.assertApprox(steel[2][0][2], 50, 1e-9, "the source carries its quantity too");
|
||||
}
|
||||
|
||||
test_expense_rows_name_the_buildings_that_consumed_a_resource() {
|
||||
const state = smallState();
|
||||
state._recordBudgetCash(0, "buildings", -500);
|
||||
@@ -145,6 +174,29 @@ export class BudgetTest extends TestCase {
|
||||
const buildings = report.expenses.find((entry) => entry.id === "buildings");
|
||||
this.assertNotNull(buildings, "building upkeep is recorded");
|
||||
this.assertGreater(buildings.buys.length, 0, "the row names the resource it bought");
|
||||
const steel = buildings.buys.find((entry) => entry[0] === "steel");
|
||||
this.assertGreater(steel[3], 0, "the row knows how much steel it wore");
|
||||
const sourceQty = steel[2].reduce((sum, source) => sum + (source[2] || 0), 0);
|
||||
this.assertApprox(sourceQty, steel[3], 1e-9, "the sources carry the worn quantity too");
|
||||
}
|
||||
|
||||
test_upkeep_splits_the_worn_quantity_across_its_sources() {
|
||||
const state = smallState();
|
||||
state._recordBudgetCash(0, "transport", -900);
|
||||
state._recordResourceBySource(
|
||||
0, "transport", "steel", 900,
|
||||
[{ label: "Roads", steel: 2 }, { label: "Railways", steel: 1 }],
|
||||
"steel", 300
|
||||
);
|
||||
const report = state.budgetMonthReports(0).at(-1);
|
||||
const transport = report.expenses.find((entry) => entry.id === "transport");
|
||||
const steel = transport.buys.find((entry) => entry[0] === "steel");
|
||||
this.assertApprox(steel[3], 300, 1e-9, "the resource line carries the total quantity");
|
||||
const sources = new Map(steel[2].map((source) => [source[0], source]));
|
||||
this.assertApprox(sources.get("Roads")[1], 600, 1e-9);
|
||||
this.assertApprox(sources.get("Roads")[2], 200, 1e-9, "two thirds of the wear");
|
||||
this.assertApprox(sources.get("Railways")[1], 300, 1e-9);
|
||||
this.assertApprox(sources.get("Railways")[2], 100, 1e-9, "one third of the wear");
|
||||
}
|
||||
|
||||
test_the_snapshot_ships_the_month_reports() {
|
||||
@@ -155,5 +207,6 @@ export class BudgetTest extends TestCase {
|
||||
const current = months[months.length - 1];
|
||||
this.assertTrue(current.isCurrent, "the last shipped report is the live month");
|
||||
this.assertTrue(Array.isArray(current.expenses), "the report carries its expenses");
|
||||
this.assertEqual(current.label, "January 2000", "the shipped month is named");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,19 @@ function bareOwnedLand(state, civ = 0) {
|
||||
});
|
||||
}
|
||||
|
||||
// Makes `id` the only tile improvement in the world, so the works-upkeep math
|
||||
// is not disturbed by the producers the world opens with.
|
||||
function soleWork(state, coords, id) {
|
||||
state.tileImprovements.clear();
|
||||
state.tileImprovementOwner.clear();
|
||||
state.tileImprovementHp.clear();
|
||||
if (state.buildingAgents) state.buildingAgents.clear();
|
||||
const k = key(coords.x, coords.y);
|
||||
state.tileImprovements.set(k, id);
|
||||
state.tileImprovementOwner.set(k, 0);
|
||||
return k;
|
||||
}
|
||||
|
||||
// Registers a construction site literal, so the site tick can be exercised
|
||||
// without a full build order.
|
||||
function makeSite(state, coords, overrides = {}) {
|
||||
@@ -121,6 +134,26 @@ export class ConstructionSiteTest extends TestCase {
|
||||
this.assertLess(site.budget, 1e9, "the site's own budget paid for the materials");
|
||||
}
|
||||
|
||||
test_a_site_files_what_it_bought_and_what_it_paid() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
state.getCityResourceStock(city).steel = 1000;
|
||||
const coords = bareOwnedLand(state);
|
||||
const site = makeSite(state, coords, {
|
||||
cityId: city.id,
|
||||
budget: 1e9,
|
||||
needed: { steel: 100, hightech: 0, energy: 0 },
|
||||
});
|
||||
state._gatherSiteMaterials(site);
|
||||
const report = state.budgetMonthReports(0).at(-1);
|
||||
const tilebuild = report.expenses.find((entry) => entry.id === "tilebuild");
|
||||
this.assertNotNull(tilebuild, "the tile-improvement expense is recorded");
|
||||
const steel = tilebuild.buys.find((entry) => entry[0] === "steel");
|
||||
this.assertNotNull(steel, "the steel it bought is broken out");
|
||||
this.assertApprox(steel[3], 100, 1e-6, "the quantity bought travels with the money");
|
||||
this.assertGreater(steel[1], 0, "and so does what it paid for it");
|
||||
}
|
||||
|
||||
test_a_day_that_buys_nothing_stalls_the_site() {
|
||||
const state = smallState();
|
||||
clearMaterials(state);
|
||||
@@ -300,6 +333,71 @@ export class ConstructionSiteTest extends TestCase {
|
||||
this.assertNotNull(state.constructionSiteAt(coords));
|
||||
}
|
||||
|
||||
test_a_private_site_borrows_instead_of_touching_the_treasury() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
state.getCityResourceStock(city).steel = 1000;
|
||||
const coords = state.landCells.find((cell) => {
|
||||
const k = key(cell.x, cell.y);
|
||||
return state.civAt(cell) === 0 && !state.tileImprovements.has(k)
|
||||
&& !state.constructionSiteAt(cell);
|
||||
});
|
||||
const k = key(coords.x, coords.y);
|
||||
const agent = state._ensureBuildingAgent(k);
|
||||
agent.cash = 0;
|
||||
const treasury = 5e8;
|
||||
state.budgets.set(0, treasury);
|
||||
const site = makeSite(state, coords, {
|
||||
id: "steel_mill",
|
||||
cityId: city.id,
|
||||
budget: 0,
|
||||
private: true,
|
||||
needed: { steel: 100, hightech: 0, energy: 0 },
|
||||
});
|
||||
state._gatherSiteMaterials(site);
|
||||
this.assertApprox(site.bought.steel, 100, 1e-6, "the agent bought the steel");
|
||||
this.assertApprox(state.getBudget(0), treasury, 1e-6, "the treasury was not touched");
|
||||
this.assertGreater(agent.debt, 0, "the agent borrowed the money");
|
||||
const report = state.budgetMonthReports(0).at(-1);
|
||||
this.assertEqual(
|
||||
report.expenses.find((entry) => entry.id === "tilebuild"),
|
||||
undefined,
|
||||
"a private work files no public tile-improvement expense"
|
||||
);
|
||||
}
|
||||
|
||||
test_ordering_a_producer_spends_no_treasury_and_creates_its_agent() {
|
||||
const state = smallState();
|
||||
state.tileImprovements.clear();
|
||||
state.tileImprovementOwner.clear();
|
||||
state.tileImprovementHp.clear();
|
||||
if (state.buildingAgents) state.buildingAgents.clear();
|
||||
for (const city of state.cities) {
|
||||
if (city.civ !== 0) continue;
|
||||
const stock = state.getCityResourceStock(city);
|
||||
stock.steel = 1e9;
|
||||
stock.hightech = 1e9;
|
||||
stock.energy = 1e9;
|
||||
}
|
||||
const treasury = 1e9;
|
||||
state.budgets.set(0, treasury);
|
||||
const coords = state.landCells.find(
|
||||
(cell) => state.civAt(cell) === 0 && !state.cityAt(cell)
|
||||
);
|
||||
const k = key(coords.x, coords.y);
|
||||
this.assertTrue(state.requestBuildTileImprovement(0, coords, "steel_mill"));
|
||||
const agent = state.buildingAgents.get(k);
|
||||
this.assertNotNull(agent, "the producer's agent opens with the order");
|
||||
this.assertApprox(state.getBudget(0), treasury, 1e-6, "the treasury pays nothing");
|
||||
this.assertGreater(agent.debt, 0, "the work is funded by a bank loan");
|
||||
const report = state.budgetMonthReports(0).at(-1);
|
||||
this.assertEqual(
|
||||
report.expenses.find((entry) => entry.id === "tilebuild"),
|
||||
undefined,
|
||||
"nothing is filed as a public tile-improvement expense"
|
||||
);
|
||||
}
|
||||
|
||||
test_a_finished_site_keeps_its_leftover_as_improvement_cash() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
@@ -325,9 +423,7 @@ export class ConstructionSiteTest extends TestCase {
|
||||
test_a_works_reserve_pays_toward_its_own_upkeep() {
|
||||
const state = smallState();
|
||||
const coords = bareOwnedLand(state);
|
||||
const k = key(coords.x, coords.y);
|
||||
state.tileImprovements.set(k, "fortifications");
|
||||
state.tileImprovementOwner.set(k, 0);
|
||||
const k = soleWork(state, coords, "fortifications");
|
||||
state.improvementCash.set(k, 1e9);
|
||||
const proto = tileImprovementById("fortifications");
|
||||
const costs = protoUpkeep(proto, proto.buildCost || 0);
|
||||
@@ -344,9 +440,7 @@ export class ConstructionSiteTest extends TestCase {
|
||||
test_a_works_reserve_only_covers_what_its_money_reaches() {
|
||||
const state = smallState();
|
||||
const coords = bareOwnedLand(state);
|
||||
const k = key(coords.x, coords.y);
|
||||
state.tileImprovements.set(k, "fortifications");
|
||||
state.tileImprovementOwner.set(k, 0);
|
||||
const k = soleWork(state, coords, "fortifications");
|
||||
const proto = tileImprovementById("fortifications");
|
||||
const costs = protoUpkeep(proto, proto.buildCost || 0);
|
||||
const unit = state._marketUnitCost("steel");
|
||||
|
||||
+40
-2
@@ -8,6 +8,8 @@ import {
|
||||
resourceBuildingUpgradeMoneyCost,
|
||||
tileImprovementById,
|
||||
} from "../shared/data.js";
|
||||
import { protoUpkeep } from "../shared/resources.js";
|
||||
import { DAYS_PER_YEAR } from "../shared/game_state/constants.js";
|
||||
|
||||
// A production building of `id` on the first land tile of `civ`'s that is not a
|
||||
// city, wired into the agent maps and with an empty stock. The tile may already
|
||||
@@ -89,6 +91,37 @@ export class ProductionBuildingAgentTest extends TestCase {
|
||||
this.assertTrue(state._isProductionAgent(mill), "a mill sells steel");
|
||||
}
|
||||
|
||||
test_a_producer_borrows_what_its_own_wear_cannot_cover() {
|
||||
const state = smallState();
|
||||
const { k, agent } = placeProducer(state, "steel_mill");
|
||||
const proto = tileImprovementById("steel_mill");
|
||||
const costs = protoUpkeep(proto, proto.buildCost || 0);
|
||||
const value = costs.steel * state._marketUnitCost("steel") +
|
||||
costs.hightech * state._marketUnitCost("hightech");
|
||||
// Half a day's wear in the account: the rest becomes a central-bank loan.
|
||||
agent.cash = value / 2;
|
||||
const categories = {
|
||||
works: { steel: costs.steel, hightech: costs.hightech, sources: [] },
|
||||
};
|
||||
state._applyWorksMaintenance(0, categories);
|
||||
this.assertApprox(agent.cash, 0, 1e-6, "the cash was spent first");
|
||||
this.assertApprox(agent.debt, value / 2, 1e-6, "the rest is a loan");
|
||||
this.assertApprox(categories.works.steel, 0, 1e-6, "the state is not billed for a producer");
|
||||
this.assertApprox(categories.works.hightech, 0, 1e-6, "nor for its high-tech");
|
||||
}
|
||||
|
||||
test_a_producers_loan_compounds_and_is_repaid_from_its_cash() {
|
||||
const state = smallState();
|
||||
const { agent } = placeProducer(state, "steel_mill");
|
||||
agent.debt = 1_000;
|
||||
const rate = state.getCentralBankRate(0) / DAYS_PER_YEAR;
|
||||
// Exactly one day's interest plus the principal, so repayment clears it.
|
||||
agent.cash = 1_000 * (1 + rate);
|
||||
state._tickMoney();
|
||||
this.assertApprox(agent.debt, 0, 1e-6, "the loan is repaid");
|
||||
this.assertApprox(agent.cash, 0, 1e-6, "the cash it held paid it down");
|
||||
}
|
||||
|
||||
test_growth_needs_the_recent_half_of_the_window_to_beat_the_older_half() {
|
||||
const state = smallState();
|
||||
const { agent } = placeProducer(state, "steel_mill");
|
||||
@@ -174,14 +207,17 @@ export class ProductionBuildingAgentTest extends TestCase {
|
||||
const state = smallState();
|
||||
const { coords, agent } = placeProducer(state, "steel_mill");
|
||||
agent.cash = 4_000_000;
|
||||
agent.debt = 250_000;
|
||||
const stats = state.snapshot(0).viewerStats;
|
||||
this.assertApprox(stats.privateBuildingCash, state.getPrivateBuildingCash(0));
|
||||
this.assertApprox(stats.privateBuildingDebt, 250_000, 1e-6, "the loan is shipped too");
|
||||
const entry = stats.productionBuildings.find(
|
||||
(item) => item.coords[0] === coords.x && item.coords[1] === coords.y
|
||||
);
|
||||
this.assertNotNull(entry, "the building is listed on its own row");
|
||||
this.assertEqual(entry.level, 0);
|
||||
this.assertApprox(entry.cash, 4_000_000, 1e-6);
|
||||
this.assertApprox(entry.debt, 250_000, 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +228,7 @@ export class PowerMarketTest extends TestCase {
|
||||
const converter = placeProducer(state, "steel_mill");
|
||||
const city = cityOf(state, 0);
|
||||
const plantBefore = plant.agent.cash;
|
||||
const converterBefore = converter.agent.cash;
|
||||
const converterDebtBefore = converter.agent.debt;
|
||||
const regionBefore = state.getRegionCashValue(city);
|
||||
const price = state.getResourcePrice("energy");
|
||||
const ledger = new Map([[0, {
|
||||
@@ -213,7 +249,9 @@ export class PowerMarketTest extends TestCase {
|
||||
}]]);
|
||||
state._settleGridPower(ledger);
|
||||
this.assertApprox(plant.agent.cash - plantBefore, 300 * price, 1e-6, "the plant is paid");
|
||||
this.assertApprox(converter.agent.cash - converterBefore, -200 * price, 1e-6, "the converter pays");
|
||||
this.assertApprox(converter.agent.debt - converterDebtBefore, 200 * price, 1e-6,
|
||||
"the converter borrows to pay");
|
||||
this.assertApprox(converter.agent.cash, 0, 1e-6, "and its cash stays out of the red");
|
||||
this.assertApprox(state.getRegionCashValue(city) - regionBefore, -100 * price, 1e-6,
|
||||
"the region pays");
|
||||
this.assertApprox(plant.agent.salesToday, 300, 1e-6, "the sale feeds the demand trend");
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState } from "./framework/helpers.js";
|
||||
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
|
||||
|
||||
// The opening settle: the server runs the world forward until commodity prices
|
||||
// hold steady, so a fresh game is not served mid-shakeout.
|
||||
export class WarmupTest extends TestCase {
|
||||
test_prices_are_not_steady_before_the_window_is_full() {
|
||||
const state = smallState();
|
||||
this.assertFalse(state.pricesAreSteady(7, 0.1), "a fresh market has no history to judge");
|
||||
}
|
||||
|
||||
test_settling_stops_once_prices_hold_steady() {
|
||||
const state = smallState();
|
||||
const days = state.warmUpToStability({ maxDays: 90 });
|
||||
this.assertLess(days, 90, "it stopped early rather than at the cap");
|
||||
this.assertGreaterOrEqual(days, 7, "it ran at least the seven-day window");
|
||||
this.assertTrue(state.pricesAreSteady(7, 0.1), "prices are steady when it decides to stop");
|
||||
}
|
||||
|
||||
test_settling_stops_at_the_cap_when_it_never_steadies() {
|
||||
const state = smallState();
|
||||
const days = state.warmUpToStability({ maxDays: 3 });
|
||||
this.assertEqual(days, 3, "the cap bounds a world that never settles");
|
||||
this.assertEqual(state.totalHours, 3 * HOURS_PER_DAY, "three days were actually run");
|
||||
}
|
||||
|
||||
test_a_zero_cap_skips_the_settle() {
|
||||
const state = smallState();
|
||||
this.assertEqual(state.warmUpToStability({ maxDays: 0 }), 0, "nothing is run");
|
||||
this.assertEqual(state.totalHours, 0, "the clock did not move");
|
||||
}
|
||||
|
||||
test_a_fixed_warm_up_still_runs_the_exact_days() {
|
||||
const state = smallState();
|
||||
state.warmUp(5);
|
||||
this.assertEqual(state.totalHours, 5 * HOURS_PER_DAY, "a fixed settle runs every day asked for");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user