Moved the economy onto resources and surfaced each building's material bill
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.
This commit is contained in:
@@ -1348,7 +1348,9 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.row-card .name { font-weight: 600; }
|
||||
.row-card .desc { color: var(--muted); font-size: 12px; margin: 3px 0; }
|
||||
.row-card .effects { font-size: 12px; color: #bcd4ff; }
|
||||
.row-card .consume { font-size: 12px; color: #e2c08a; margin-top: 2px; }
|
||||
.row-card .cost { font-size: 12px; color: var(--muted); margin-top: 3px; }
|
||||
.row-card .cost .cost-money { color: var(--text); }
|
||||
.row-card .actions { display: flex; flex-direction: column; gap: 6px; justify-content: center; }
|
||||
.row-card .actions .aero { min-width: 170px; }
|
||||
.row-card .value {
|
||||
@@ -1415,6 +1417,21 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.economy-table td { padding: 5px 8px; text-align: left; border-bottom: 1px solid rgba(255, 255, 255, 0.08); }
|
||||
.economy-table th { color: var(--muted); font-weight: 600; font-size: 11px; }
|
||||
.economy-table .numeric { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
/* Every data table reads the same way: alternate-row shading and a highlight on
|
||||
the row under the pointer. Later, more specific rules (a local nation, a
|
||||
sorted header) may override these. */
|
||||
.economy-table tbody tr:nth-child(even),
|
||||
.diplomacy-table tbody tr:nth-child(even),
|
||||
.budget-table tbody tr:nth-child(even) { background: rgba(255, 255, 255, 0.045); }
|
||||
.economy-table tbody tr:hover,
|
||||
.diplomacy-table tbody tr:hover,
|
||||
.budget-table tbody tr:hover { background: rgba(110, 175, 255, 0.12); }
|
||||
/* A resource row that expands into its per-building demand. */
|
||||
.resource-caret { display: inline-block; width: 12px; color: var(--muted); }
|
||||
.economy-table td.clickable { cursor: pointer; }
|
||||
.economy-table td.clickable:hover { color: var(--accent); }
|
||||
.resource-demand td { padding-top: 2px; padding-bottom: 6px; }
|
||||
.demand-line { font-size: 12px; color: var(--muted); padding: 1px 0 1px 14px; }
|
||||
.central-bank-rate { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
|
||||
.central-bank-rate label { color: var(--muted); }
|
||||
.central-bank-rate input { width: 90px; }
|
||||
|
||||
@@ -27,6 +27,18 @@ export function currencySymbol() {
|
||||
return symbol;
|
||||
}
|
||||
|
||||
// A money amount in the viewer's currency, with the digits grouped by
|
||||
// non-breaking spaces. Every panel quotes currency through here, so the figures
|
||||
// read the same everywhere.
|
||||
export function formatMoney(value) {
|
||||
return `${symbol}${groupDigits(Math.round(value))}`;
|
||||
}
|
||||
|
||||
// A signed money amount, for budget lines that show a movement.
|
||||
export function formatMoneySigned(value) {
|
||||
return `${value < 0 ? "−" : "+"}${formatMoney(Math.abs(value))}`;
|
||||
}
|
||||
|
||||
export function viewerCurrencyCiv() {
|
||||
return viewer;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// Generated by scripts/generate-devlog.js from `git log`; do not edit.
|
||||
// The pre-commit hook refreshes it so the main menu shows the latest commits.
|
||||
export const DEVLOG = [
|
||||
{
|
||||
"hash": "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."
|
||||
},
|
||||
{
|
||||
"hash": "aa726ff",
|
||||
"date": "2026-09-22",
|
||||
@@ -54,11 +60,5 @@ export const DEVLOG = [
|
||||
"date": "2026-09-22",
|
||||
"subject": "Started a famine when a region's food runs out",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"hash": "b6287d7",
|
||||
"date": "2026-09-22",
|
||||
"subject": "Replaced the GDP tax with national sales and tariff taxes",
|
||||
"body": ""
|
||||
}
|
||||
];
|
||||
|
||||
+83
-26
@@ -4,16 +4,15 @@
|
||||
|
||||
import { groupDigits, hours } from "../../../shared/text_format.js";
|
||||
import { ECONOMY, BUILDINGS, resourceById, RESOURCE_IDS } from "../../../shared/data.js";
|
||||
import { constructionResourceCost, formatResourceAmount } from "../../../shared/resources.js";
|
||||
import { constructionResourceCost, formatResourceAmount, protoUpkeep } from "../../../shared/resources.js";
|
||||
import {
|
||||
buildingBuildCost,
|
||||
buildingUpkeep,
|
||||
} from "../../../shared/rules.js";
|
||||
import { buildingEffectText, signedMoney, quotedBuildCost } from "./format.js";
|
||||
import { renderBudget } from "./budget.js";
|
||||
import { bindTabs } from "./tabs.js";
|
||||
import { ethnicityChart } from "../ui/ethnicity.js";
|
||||
import { currencySymbol } from "../currency.js";
|
||||
import { currencySymbol, formatMoney } from "../currency.js";
|
||||
|
||||
export class CityModal {
|
||||
constructor() {
|
||||
@@ -59,6 +58,9 @@ export class CityModal {
|
||||
this.buildingRows = [];
|
||||
// Building types whose upkeep breakdown is expanded on the budget tab.
|
||||
this.expandedBudgetGroups = new Set();
|
||||
// Resources whose per-building demand breakdown is expanded on the
|
||||
// Resources tab.
|
||||
this.expandedResources = new Set();
|
||||
this._lastBreakdown = {};
|
||||
|
||||
this.onTrain = () => {};
|
||||
@@ -191,24 +193,26 @@ export class CityModal {
|
||||
}
|
||||
|
||||
// The region's material reserves: how much of each resource it holds, what
|
||||
// that reserve is worth at today's market, and how much its private sector
|
||||
// draws down each day. Energy is a flow, so it has no reserve of its own.
|
||||
// that reserve is worth at today's market, and how much its people and its
|
||||
// buildings draw each day. Every resource expands into the buildings that
|
||||
// demanded it and where in the region they stand. Energy is a flow, so it has
|
||||
// no reserve of its own.
|
||||
renderResources(resources) {
|
||||
if (!this.$resourcesList || !this.$resourcesList.length) return;
|
||||
this.$resourcesList.empty();
|
||||
const info = resources || {};
|
||||
const stock = info.stock || {};
|
||||
const consumption = info.consumption || {};
|
||||
const demand = info.demand || {};
|
||||
const value = info.value || 0;
|
||||
if (this.$resourcesSub && this.$resourcesSub.length) {
|
||||
this.$resourcesSub.text(
|
||||
`Reserves worth ${currencySymbol()}${groupDigits(Math.round(value))}`
|
||||
);
|
||||
this.$resourcesSub.text(`Reserves worth ${formatMoney(value)}`);
|
||||
}
|
||||
this.$resourcesList.append(
|
||||
$("<div class='desc'></div>").text(
|
||||
"The region's private sector holds these reserves and draws them down as " +
|
||||
"its people and buildings consume them. Energy is a flow -- it has no reserve."
|
||||
"its people and buildings consume them. Expand a resource to see which " +
|
||||
"buildings demand it. Energy is a flow -- it has no reserve."
|
||||
)
|
||||
);
|
||||
const $table = $("<table class='resources-table economy-table'></table>");
|
||||
@@ -226,7 +230,22 @@ export class CityModal {
|
||||
const storable = proto ? proto.storable : false;
|
||||
const held = stock[id] || 0;
|
||||
const price = this.prices[id] || 0;
|
||||
const row = $("<tr></tr>").append($("<td></td>").text(name));
|
||||
const consumers = (demand[id] || []).slice().sort((a, b) => b.amount - a.amount);
|
||||
const open = this.expandedResources.has(id);
|
||||
const row = $("<tr></tr>");
|
||||
if (consumers.length > 0) {
|
||||
const $name = $("<td class='clickable'></td>");
|
||||
$name.append($("<span class='resource-caret'></span>").text(open ? "▾" : "▸"));
|
||||
$name.append(document.createTextNode(` ${name}`));
|
||||
$name.on("click", () => {
|
||||
if (this.expandedResources.has(id)) this.expandedResources.delete(id);
|
||||
else this.expandedResources.add(id);
|
||||
this.renderResources(resources);
|
||||
});
|
||||
row.append($name);
|
||||
} else {
|
||||
row.append($("<td></td>").text(name));
|
||||
}
|
||||
row.append(
|
||||
$("<td class='numeric'></td>").text(storable ? formatResourceAmount(id, held) : "—")
|
||||
);
|
||||
@@ -234,11 +253,25 @@ export class CityModal {
|
||||
$("<td class='numeric'></td>").text(formatResourceAmount(id, consumption[id] || 0))
|
||||
);
|
||||
row.append(
|
||||
$("<td class='numeric'></td>").text(
|
||||
storable ? `${currencySymbol()}${groupDigits(Math.round(held * price))}` : "—"
|
||||
)
|
||||
$("<td class='numeric'></td>").text(storable ? formatMoney(held * price) : "—")
|
||||
);
|
||||
$body.append(row);
|
||||
if (open) {
|
||||
const $detail = $("<tr class='resource-demand'></tr>");
|
||||
const $cell = $("<td colspan='4'></td>");
|
||||
for (const consumer of consumers) {
|
||||
const where = consumer.coords
|
||||
? ` (${consumer.coords[0]}, ${consumer.coords[1]})`
|
||||
: "";
|
||||
$cell.append(
|
||||
$("<div class='demand-line'></div>").text(
|
||||
`${consumer.name}${where} · ${formatResourceAmount(id, consumer.amount)}/day`
|
||||
)
|
||||
);
|
||||
}
|
||||
$detail.append($cell);
|
||||
$body.append($detail);
|
||||
}
|
||||
}
|
||||
$table.append($head, $body);
|
||||
this.$resourcesList.append($table);
|
||||
@@ -461,9 +494,10 @@ export class CityModal {
|
||||
const $info = $("<div class='info'></div>");
|
||||
const $name = $("<div class='name'></div>");
|
||||
const $desc = $("<div class='desc'></div>").text(proto.description);
|
||||
const $effects = $("<div class='effects'></div>");
|
||||
const $produce = $("<div class='effects'></div>");
|
||||
const $consume = $("<div class='consume'></div>");
|
||||
const $cost = $("<div class='cost'></div>");
|
||||
$info.append($name, $desc, $effects, $cost);
|
||||
$info.append($name, $desc, $produce, $consume, $cost);
|
||||
const $actions = $("<div class='actions'></div>");
|
||||
const $upgrade = $("<button class='aero'></button>");
|
||||
const $downgrade = $("<button class='aero'></button>");
|
||||
@@ -479,7 +513,18 @@ export class CityModal {
|
||||
if ($icon) $card.append($icon);
|
||||
$card.append($info, $actions);
|
||||
this.$buildingsList.append($card);
|
||||
return { proto, index, $name, $effects, $cost, $upgrade, $downgrade, $free };
|
||||
return { proto, index, $name, $produce, $consume, $cost, $upgrade, $downgrade, $free };
|
||||
}
|
||||
|
||||
// The materials a bill spends, in resource units and with the commodity
|
||||
// named: "10 000 t steel · 200 u high-tech". An empty bill reads "nothing".
|
||||
_resourceBillText(resources, ids = ["steel", "energy", "hightech"]) {
|
||||
const parts = [];
|
||||
for (const id of ids) {
|
||||
const amount = resources[id] || 0;
|
||||
if (amount > 0) parts.push(formatResourceAmount(id, amount));
|
||||
}
|
||||
return parts.length > 0 ? parts.join(" · ") : "nothing";
|
||||
}
|
||||
|
||||
// Why a building cannot be raised here, or null when it can. Ports need the
|
||||
@@ -498,7 +543,7 @@ export class CityModal {
|
||||
|
||||
refreshBuildings(budget, levels, gdp = 0, queued = {}) {
|
||||
this._levels = levels || {};
|
||||
this.$buildingsBudget.text(`Budget: ${currencySymbol()}${groupDigits(Math.round(budget))}`);
|
||||
this.$buildingsBudget.text(`Budget: ${formatMoney(budget)}`);
|
||||
for (const row of this.buildingRows) {
|
||||
const level = levels[row.index] || 0;
|
||||
const pending = queued[row.index] || 0;
|
||||
@@ -507,18 +552,30 @@ export class CityModal {
|
||||
const nextLevel = level + pending;
|
||||
const levelText = pending > 0 ? `Level ${level} (+${pending} queued)` : `Level ${level}`;
|
||||
row.$name.text(`${row.proto.name} — ${levelText}`);
|
||||
row.$effects.text(buildingEffectText(row.proto, level > 0 ? level : Math.max(nextLevel, 1)));
|
||||
const parts = [];
|
||||
if (level > 0) parts.push(`Upkeep ${currencySymbol()}${groupDigits(Math.round(buildingUpkeep(row.proto, level)))}/h`);
|
||||
// What the building makes and wears out once it stands at its next level,
|
||||
// so an upgrade quotes the level it is buying. Resources lead; the money
|
||||
// is only the settlement the state falls back on.
|
||||
const shownLevel = Math.max(nextLevel, 1);
|
||||
row.$produce.text(`Produces ${buildingEffectText(row.proto, shownLevel)}`);
|
||||
const upkeep = protoUpkeep(row.proto, buildingBuildCost(row.proto, shownLevel - 1));
|
||||
row.$consume.text(`Consumes ${this._resourceBillText(upkeep)} /day`);
|
||||
const cost = buildingBuildCost(row.proto, nextLevel);
|
||||
const duration = ECONOMY.buildingHours(gdp, cost) * this._constructionTimeFactor();
|
||||
parts.push(`Build cost ${currencySymbol()}${groupDigits(this._quotedCost(cost, row.proto, nextLevel))} · ${hours(duration)}`);
|
||||
const materials = constructionResourceCost(row.proto, nextLevel, cost);
|
||||
const requirement = this._buildRequirement(row.proto);
|
||||
if (requirement) parts.push(requirement);
|
||||
row.$cost.text(parts.join(" · "));
|
||||
row.$upgrade
|
||||
.text(`Upgrade ${currencySymbol()}${groupDigits(this._quotedCost(cost, row.proto, nextLevel))}`)
|
||||
.prop("disabled", !!requirement);
|
||||
row.$cost.empty();
|
||||
row.$cost.append(
|
||||
document.createTextNode(
|
||||
`Costs ${this._resourceBillText(materials)} · ${hours(duration)} · `
|
||||
)
|
||||
);
|
||||
row.$cost.append(
|
||||
$("<span class='cost-money'></span>").text(
|
||||
formatMoney(this._quotedCost(cost, row.proto, nextLevel))
|
||||
)
|
||||
);
|
||||
if (requirement) row.$cost.append(document.createTextNode(` · ${requirement}`));
|
||||
row.$upgrade.text("Upgrade").prop("disabled", !!requirement);
|
||||
if (row.$free) {
|
||||
// Free does not ignore placement rules (a port still needs the sea).
|
||||
row.$free
|
||||
|
||||
@@ -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 { currencySymbol } from "../currency.js";
|
||||
import { formatMoneySigned } from "../currency.js";
|
||||
|
||||
// The world market's current prices, from the snapshot's market listing.
|
||||
export function marketPricesFrom(snapshot) {
|
||||
@@ -98,12 +98,12 @@ export function buildingEffectText(proto, level) {
|
||||
export const SOURCE_LABELS = { building: "building", government: "government", technology: "research" };
|
||||
|
||||
export function signedMoney(value) {
|
||||
return `${value < 0 ? "−" : "+"}${currencySymbol()}${groupDigits(Math.round(Math.abs(value)))}/h`;
|
||||
return `${formatMoneySigned(value)}/h`;
|
||||
}
|
||||
|
||||
// A signed money amount with no period suffix, for the monthly budget report.
|
||||
export function money(value) {
|
||||
return `${value < 0 ? "−" : "+"}${currencySymbol()}${groupDigits(Math.round(Math.abs(value)))}`;
|
||||
return formatMoneySigned(value);
|
||||
}
|
||||
|
||||
export function percent(value) {
|
||||
|
||||
@@ -97,11 +97,6 @@ export const MAP_CONFIG = {
|
||||
// leaves exactly this many landmasses.
|
||||
minContinents: 3,
|
||||
connectOceans: true,
|
||||
// How many nations open with a chip foundry, the only high-tech works the
|
||||
// opening economy ever gets. They are spread across distinct nations, drawn
|
||||
// from the world seed, and never the same nation twice. A nation with no land
|
||||
// to host one simply goes without.
|
||||
startingChipFabs: 2,
|
||||
citiesPerCiv: 10,
|
||||
// Minimum distance between any two cities during generation (never below 5).
|
||||
citySpacing: 5,
|
||||
|
||||
@@ -162,7 +162,7 @@ export const RESOURCE_RULES = {
|
||||
gold_mine: 10 * KWH_PER_MWH, // 10 MWh per carat
|
||||
diamond_mine: 5 * KWH_PER_MWH, // 5 MWh per carat
|
||||
},
|
||||
energyPerHighTechUnit: 100 * KWH_PER_MWH, // 100 MWh per unit
|
||||
energyPerHighTechUnit: 10 * KWH_PER_MWH, // 10 MWh per unit
|
||||
// Science buildings spend one high-tech unit for every research point.
|
||||
highTechPerResearchPoint: 1,
|
||||
|
||||
@@ -176,12 +176,15 @@ export const RESOURCE_RULES = {
|
||||
ship: 50, // 50 kWh per tonne per tile
|
||||
},
|
||||
|
||||
// Steel every construction spends per euro of its money cost. At 1/10000 a
|
||||
// 100 M building needs 10,000 t of steel.
|
||||
steelPerBudgetEuro: 1 / 10_000,
|
||||
// Steel every construction spends per euro of its money cost. At 1/1000 a
|
||||
// 100 M building needs 100,000 t of steel. The steel bill is deliberately an
|
||||
// order of magnitude larger than the cash price, so nothing is bought with
|
||||
// money alone: the state pays with materials it has, and the currency is only
|
||||
// the settlement it falls back on.
|
||||
steelPerBudgetEuro: 1 / 1_000,
|
||||
// The electricity a tile work draws as it is built, per euro of its money
|
||||
// cost. It is roughly the power embodied in the steel it spends: steel costs
|
||||
// 20 MWh a tonne and 1 t per 10,000 euro, so building is powered as well as
|
||||
// 20 MWh a tonne and 1 t per 1,000 euro, so building is powered as well as
|
||||
// supplied. Tile improvements pay this up front.
|
||||
constructionEnergyPerBudgetEuro: 2, // kWh per euro
|
||||
// High-tech a science or advanced building needs to be raised, per level.
|
||||
@@ -382,9 +385,9 @@ export const RESOURCE_BUILDINGS = [
|
||||
id: "steel_mill",
|
||||
name: "Steel mill",
|
||||
description:
|
||||
"Smelts iron into 2000 t of steel a day, spending 20 MWh of energy per tonne.",
|
||||
"Smelts iron into 20,000 t of steel a day, spending 20 MWh of energy per tonne.",
|
||||
resource: "steel",
|
||||
outputPerDay: 2_000,
|
||||
outputPerDay: 20_000,
|
||||
energyPerDay: 2_000 * RESOURCE_RULES.energyPerSteelTonne,
|
||||
buildCost: 800_000_000,
|
||||
maintenance: 10_000,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// The economic graph toolkit: a framework-free traversal over an abstract
|
||||
// transport graph, and the transport-energy costs that weight its edges. It
|
||||
// knows nothing about tiles, roads, ports or nations -- the caller supplies the
|
||||
// adjacency and the per-step cost -- so the same engine serves the trade
|
||||
// network, the delivery graph and any other economic spread over the map.
|
||||
//
|
||||
// Keeping this apart from `game_state/resources.js` means the simulation
|
||||
// describes *how* its world is wired, while this module owns *how* to walk a
|
||||
// graph and price a leg.
|
||||
|
||||
import { RESOURCE_RULES, resourceById } from "./data/resources.js";
|
||||
|
||||
// --------------------------------------------------------------- traversal --
|
||||
|
||||
// Breadth-first spread from `start` over an abstract graph, in rings of
|
||||
// increasing distance. `neighbours(node)` returns the outward edges of a node,
|
||||
// each `{ node, cost }`; a directed graph is just a `neighbours` that omits the
|
||||
// reverse edge. The traversal stops at `maxDistance` steps.
|
||||
//
|
||||
// `accept(node, { distance, cost })` is called for every newly reached node
|
||||
// (never the start) and decides whether it joins the result; returning false
|
||||
// still lets the search continue through it. The result is the accepted nodes
|
||||
// with their accumulated `distance` (steps) and `cost` (sum of edge costs),
|
||||
// sorted by cost.
|
||||
export function traverse(start, { neighbours, id = (node) => node, accept = null, maxDistance = Infinity }) {
|
||||
const results = [];
|
||||
const visited = new Set([id(start)]);
|
||||
let frontier = [{ node: start, distance: 0, cost: 0 }];
|
||||
while (frontier.length > 0) {
|
||||
const next = [];
|
||||
for (const current of frontier) {
|
||||
if (current.distance >= maxDistance) continue;
|
||||
for (const edge of neighbours(current.node)) {
|
||||
const key = id(edge.node);
|
||||
if (visited.has(key)) continue;
|
||||
visited.add(key);
|
||||
const reached = {
|
||||
node: edge.node,
|
||||
distance: current.distance + 1,
|
||||
cost: current.cost + (edge.cost || 0),
|
||||
};
|
||||
if (!accept || accept(edge.node, reached)) results.push(reached);
|
||||
next.push(reached);
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
results.sort((a, b) => a.cost - b.cost);
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- delivery --
|
||||
|
||||
// The kWh of transport energy one material tonne costs over `tiles` tiles in
|
||||
// the given mode. A material tonne and an energy tonne (10 GWh) share the
|
||||
// formula, so material deliveries and energy deliveries are priced alike.
|
||||
export function deliveryEnergyKwh(tonnes, tiles, mode, tileMovementCost = 1) {
|
||||
const d = RESOURCE_RULES.deliveryEnergy;
|
||||
const perTile = mode === "train"
|
||||
? d.train
|
||||
: mode === "ship"
|
||||
? d.ship
|
||||
: mode === "road"
|
||||
? d.truckRoad
|
||||
: d.truckOffRoad * Math.max(tileMovementCost, 0);
|
||||
return tonnes * Math.max(tiles, 0) * perTile;
|
||||
}
|
||||
|
||||
// The energy cost of delivering `amount` of a resource over `tiles`. Every
|
||||
// resource carries a mass per canonical unit, so a carat or a chip is charged
|
||||
// for the tonnes it actually weighs rather than as if it were a tonne.
|
||||
export function deliveryEnergyForResource(id, amount, tiles, mode, tileMovementCost = 1) {
|
||||
const proto = resourceById(id);
|
||||
const tonnes = amount * (proto ? proto.tonnesPerUnit : 1);
|
||||
return deliveryEnergyKwh(tonnes, tiles, mode, tileMovementCost);
|
||||
}
|
||||
+143
-86
@@ -33,11 +33,11 @@ import {
|
||||
producerEnergyPerDay,
|
||||
foodSynthesisEnergy,
|
||||
synthesisedFoodFor,
|
||||
deliveryEnergyForResource,
|
||||
nextMarketPrice,
|
||||
protoUpkeep,
|
||||
constructionResourceCost as sharedConstructionResourceCost,
|
||||
} from "../resources.js";
|
||||
import { deliveryEnergyForResource, traverse } from "../economy_graph.js";
|
||||
import { buildingBuildCost } from "../rules.js";
|
||||
import { Random } from "../rng.js";
|
||||
import { TILE_IMPROVEMENT_HP, HOURS_PER_DAY } from "./constants.js";
|
||||
@@ -50,13 +50,14 @@ const SUPPLIER_RESERVE_DAYS = 2;
|
||||
|
||||
// The producers a fresh nation may be seeded with. Energy uses the large
|
||||
// fossil plants so a nation needs few works to light its industry; the luxury
|
||||
// pick is randomised between the two mines. High-tech is deliberately absent:
|
||||
// a chip foundry is not covered by these passes but placed by hand for the two
|
||||
// nations that open with one (see `_seedResourceBuildings`). Fusion, locked
|
||||
// behind research, never appears either.
|
||||
// pick is randomised between the two mines. High-tech is an industry like any
|
||||
// other: a nation short of it places chip foundries just as it places mills and
|
||||
// mines, and the power pass then builds the grid to run them. Fusion, locked
|
||||
// behind research, never appears.
|
||||
const SEED_PRODUCERS = {
|
||||
steel: ["steel_mill"],
|
||||
luxury: ["diamond_mine", "gold_mine"],
|
||||
hightech: ["chip_foundry"],
|
||||
energy: ["coal_power_plant", "natural_gas_power_plant", "oil_power_plant"],
|
||||
};
|
||||
// How many top-up passes the start-of-game seeding makes before it gives up on
|
||||
@@ -68,6 +69,11 @@ const SEED_MAX_PASSES = 10;
|
||||
// because it scales with GDP, which the market moves.
|
||||
const SEED_MATERIAL_MARGIN = 1.05;
|
||||
const SEED_ENERGY_MARGIN = 1.15;
|
||||
// The steel and high-tech a city opens holding, on top of its people's own
|
||||
// staples. Construction is settled in steel, so the opening store is sized to
|
||||
// let a fresh region raise its first works before any producer comes online.
|
||||
const OPENING_STEEL_STORE = 200_000;
|
||||
const OPENING_HIGHTECH_STORE = 500;
|
||||
|
||||
function emptyStock() {
|
||||
return { steel: 0, food: 0, luxury: 0, hightech: 0 };
|
||||
@@ -207,15 +213,17 @@ export const resourceMethods = {
|
||||
return record;
|
||||
},
|
||||
|
||||
// Every city opens with most of a month of food, a little steel and luxury,
|
||||
// and some high-tech, so an early game with no producer yet can still build.
|
||||
// Every city opens with most of a month of food, a working store of steel and
|
||||
// luxury, and some high-tech, so an early game with no producer yet can still
|
||||
// build. Steel is stocked deeply because construction is paid in steel, not
|
||||
// cash: the opening store is sized to cover a first building's material bill.
|
||||
_seedCityResources(city) {
|
||||
const k = key(city.coords.x, city.coords.y);
|
||||
const stock = emptyStock();
|
||||
stock.food = foodMonthlyTarget(city.population) * 0.75;
|
||||
stock.steel = steelNeedPerDay(city.population) * 45;
|
||||
stock.steel = steelNeedPerDay(city.population) * 45 + OPENING_STEEL_STORE;
|
||||
stock.luxury = luxuryNeedPerDay(city.population) * 45;
|
||||
stock.hightech = 500;
|
||||
stock.hightech = OPENING_HIGHTECH_STORE;
|
||||
this.resourceStock.set(k, stock);
|
||||
},
|
||||
|
||||
@@ -285,16 +293,10 @@ export const resourceMethods = {
|
||||
}
|
||||
claims.push({ civ, spots, byComponent });
|
||||
}
|
||||
// Two nations open with a chip foundry -- the only high-tech the opening
|
||||
// world makes -- so early research is a prize two powers share. The pair is
|
||||
// drawn from the world seed and never the same nation twice; a nation with
|
||||
// no land to host one simply goes without.
|
||||
const fabCount = Math.max(0, this.mapConfig?.startingChipFabs ?? 2);
|
||||
const fabCandidates = fabCount > 0 ? claims.filter((claim) => claim.spots.length > 0) : [];
|
||||
rng.shuffle(fabCandidates);
|
||||
const fabCivs = new Set(fabCandidates.slice(0, fabCount).map((claim) => claim.civ));
|
||||
// The covering passes below seat every industry a nation is short of,
|
||||
// high-tech included, so no resource gets a special hand-placed start.
|
||||
for (const claim of claims) {
|
||||
this._layStartingIndustry(rng, components, foodEnergy, touchesNetwork, claim, fabCivs.has(claim.civ));
|
||||
this._layStartingIndustry(rng, components, foodEnergy, touchesNetwork, claim);
|
||||
}
|
||||
this._gdpPerCapitaCache.clear();
|
||||
},
|
||||
@@ -304,7 +306,7 @@ export const resourceMethods = {
|
||||
// stops as soon as a pass changes nothing. A work off the transport network
|
||||
// gets a one-tile road spur where it can, so its output can actually reach a
|
||||
// city.
|
||||
_layStartingIndustry(rng, components, foodEnergy, touchesNetwork, { civ, spots, byComponent }, chipFab = false) {
|
||||
_layStartingIndustry(rng, components, foodEnergy, touchesNetwork, { civ, spots, byComponent }) {
|
||||
const used = new Set();
|
||||
let cursor = 0;
|
||||
const takeSpot = (component) => {
|
||||
@@ -346,13 +348,10 @@ export const resourceMethods = {
|
||||
}
|
||||
return laid;
|
||||
};
|
||||
// A chip foundry is laid before the covering passes so the power seeding
|
||||
// sees its draw and builds the grid needed to run it.
|
||||
if (chipFab) place(tileImprovementById("chip_foundry"), undefined, 1);
|
||||
for (let pass = 0; pass < SEED_MAX_PASSES; pass++) {
|
||||
let changed = false;
|
||||
const deficits = this._resourceSeedDeficits(civ);
|
||||
for (const id of ["steel", "luxury"]) {
|
||||
for (const id of ["steel", "luxury", "hightech"]) {
|
||||
const target = deficits[id] * SEED_MATERIAL_MARGIN;
|
||||
if (target <= 0) continue;
|
||||
const proto = this._pickSeedProducer(rng, id);
|
||||
@@ -387,10 +386,17 @@ export const resourceMethods = {
|
||||
const population = this.getPlayerPopulation(civ);
|
||||
const upkeep = this.getCivUpkeepResources(civ);
|
||||
const made = this._civResourceProduction(civ);
|
||||
// A nation with no high-tech works of its own is treated as short one chip
|
||||
// foundry, so every power opens the game with a high-tech industry just as
|
||||
// it opens with steel mills and mines. Once one stands, the real deficit
|
||||
// takes over.
|
||||
const hightechFloor = made.hightech > 0
|
||||
? 0
|
||||
: tileImprovementById("chip_foundry").outputPerDay / SEED_MATERIAL_MARGIN;
|
||||
return {
|
||||
steel: steelNeedPerDay(population) + (upkeep.steel || 0) - made.steel,
|
||||
luxury: luxuryNeedPerDay(population) - made.luxury,
|
||||
hightech: (upkeep.hightech || 0) - made.hightech,
|
||||
hightech: (upkeep.hightech || 0) - made.hightech + hightechFloor,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -573,29 +579,77 @@ export const resourceMethods = {
|
||||
},
|
||||
|
||||
// What one region holds and draws per day, for its city panel: the reserve of
|
||||
// every storable resource, that reserve's market value, and the private
|
||||
// sector's daily consumption -- the people's staples, the region's building
|
||||
// upkeep and the grid power its industry pulls.
|
||||
// every storable resource, that reserve's market value, the private sector's
|
||||
// daily consumption, and a per-building breakdown of who demanded what. The
|
||||
// breakdown is inverted by resource -- `demand[id]` is a list of
|
||||
// `{ name, coords, amount }` consumers -- so the panel can expand any
|
||||
// resource into the buildings that drew it.
|
||||
getRegionResourceInfo(city) {
|
||||
const economy = this.getCityEconomy(city);
|
||||
const population = economy.population;
|
||||
const store = this.getCityResourceStock(city);
|
||||
const consumption = {
|
||||
|
||||
const consumers = [];
|
||||
const addConsumer = (name, coords, consume) => {
|
||||
consumers.push({ name, coords, consume });
|
||||
};
|
||||
|
||||
addConsumer("People", city.coords, {
|
||||
food: foodNeedPerDay(population),
|
||||
steel: steelNeedPerDay(population),
|
||||
luxury: luxuryNeedPerDay(population),
|
||||
hightech: 0,
|
||||
energy: cityEnergyPerDay(economy.gdp),
|
||||
};
|
||||
});
|
||||
// The region's baseline electric appetite, from its yearly GDP.
|
||||
const baseline = cityEnergyPerDay(economy.gdp);
|
||||
if (baseline > 0) addConsumer("Region grid", city.coords, { energy: baseline });
|
||||
|
||||
// Every building raised in the city wears out its daily materials.
|
||||
for (const [index, level] of Object.entries(city.buildings || {})) {
|
||||
if (level <= 0) continue;
|
||||
const proto = this.protoBuildings[Number(index)];
|
||||
if (!proto) continue;
|
||||
const costs = this.buildingUpkeepResources(proto, level);
|
||||
consumption.steel += costs.steel || 0;
|
||||
consumption.energy += costs.energy || 0;
|
||||
consumption.hightech += costs.hightech || 0;
|
||||
const up = this.buildingUpkeepResources(proto, level);
|
||||
addConsumer(proto.name, city.coords, {
|
||||
steel: up.steel || 0,
|
||||
energy: up.energy || 0,
|
||||
hightech: up.hightech || 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Every production building standing in this region draws power and wears
|
||||
// its own materials, wherever it sits.
|
||||
for (const [k, id] of this.tileImprovements) {
|
||||
if (this.tileImprovementOwner.get(k) !== city.civ) continue;
|
||||
const proto = tileImprovementById(id);
|
||||
if (!isResourceTileBuilding(proto)) continue;
|
||||
const coords = parseKey(k);
|
||||
if (this.regionCityAt(coords) !== city) continue;
|
||||
const up = protoUpkeep(proto, proto.buildCost || 0);
|
||||
const energy = this.resourceBuildingInputAt(k, proto, "energyPerDay") +
|
||||
this.resourceBuildingInputAt(k, proto, "fuelEnergyPerDay");
|
||||
addConsumer(proto.name, coords, {
|
||||
steel: up.steel || 0,
|
||||
hightech: up.hightech || 0,
|
||||
energy: energy + (up.energy || 0),
|
||||
});
|
||||
}
|
||||
|
||||
const consumption = { food: 0, steel: 0, luxury: 0, hightech: 0, energy: 0 };
|
||||
const demand = {};
|
||||
for (const id of RESOURCE_IDS) demand[id] = [];
|
||||
for (const consumer of consumers) {
|
||||
for (const id of RESOURCE_IDS) {
|
||||
const amount = consumer.consume[id] || 0;
|
||||
if (!(amount > 0)) continue;
|
||||
consumption[id] += amount;
|
||||
demand[id].push({
|
||||
name: consumer.name,
|
||||
coords: [consumer.coords.x, consumer.coords.y],
|
||||
amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const stock = {};
|
||||
let value = 0;
|
||||
for (const id of STORABLE_RESOURCE_IDS) {
|
||||
@@ -603,7 +657,7 @@ export const resourceMethods = {
|
||||
stock[id] = amount;
|
||||
value += amount * this.getResourcePrice(id);
|
||||
}
|
||||
return { stock, consumption, value };
|
||||
return { stock, consumption, value, demand };
|
||||
},
|
||||
|
||||
// The region's unmet resource needs over the last day: for each resource the
|
||||
@@ -2140,66 +2194,69 @@ export const resourceMethods = {
|
||||
},
|
||||
|
||||
// The storage nodes a city can haul from over the transport network: a
|
||||
// breadth-first search over land and sea, capped at RESOURCE_TRADE_RADIUS
|
||||
// breadth-first spread over land and sea, capped at RESOURCE_TRADE_RADIUS
|
||||
// tiles, that hands every reachable node to `accept`. `resource` only names
|
||||
// the commodity charged for the haul; the transport rules themselves are
|
||||
// commodity-independent, so the delivery graph passes one to price its rides.
|
||||
// The traversal itself lives in the economy-graph toolkit; this method only
|
||||
// describes the network it walks.
|
||||
_tradeReachable(city, nodes, allowSea, accept, resource = "steel") {
|
||||
const results = [];
|
||||
const byKey = new Map(nodes.map((node) => [key(node.coords.x, node.coords.y), node]));
|
||||
const origin = key(city.coords.x, city.coords.y);
|
||||
const blocked = this._hostileOccupied(city.civ);
|
||||
const visited = new Set([origin]);
|
||||
// Every delivery runs over the transport network: railways and roads on
|
||||
// land, shipping lanes across water (which need a working port).
|
||||
const queue = [{ coords: city.coords, tiles: 0, energy: 0 }];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
const here = byKey.get(key(current.coords.x, current.coords.y));
|
||||
if (here && key(here.coords.x, here.coords.y) !== origin) {
|
||||
const reached = traverse(city.coords, {
|
||||
id: (coords) => key(coords.x, coords.y),
|
||||
maxDistance: RESOURCE_TRADE_RADIUS,
|
||||
neighbours: (coords) => this._tradeNeighbours(city, coords, allowSea, resource, blocked),
|
||||
accept: (coords) => {
|
||||
const here = byKey.get(key(coords.x, coords.y));
|
||||
if (!here || key(here.coords.x, here.coords.y) === origin) return false;
|
||||
const hostile = here.civ >= 0 && here.civ !== city.civ && this.isAtWar(city.civ, here.civ);
|
||||
if (!hostile && accept(here)) {
|
||||
results.push({
|
||||
...here,
|
||||
tiles: current.tiles,
|
||||
routeEnergyPerTonne: current.energy,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (current.tiles >= RESOURCE_TRADE_RADIUS) continue;
|
||||
for (const neighbour of this._neighbours(current.coords)) {
|
||||
const nk = key(neighbour.x, neighbour.y);
|
||||
if (visited.has(nk) || blocked.has(nk)) continue;
|
||||
const tile = this.tiles[nk];
|
||||
if (!tile || !(tile.terrainClass === "Land" || tile.terrainClass === "Sea" ||
|
||||
tile.terrainClass === "Ice")) continue;
|
||||
const sea = tile.terrainClass === "Sea";
|
||||
let mode;
|
||||
if (sea) {
|
||||
// A shipping lane needs a working port to load and unload.
|
||||
if (!allowSea) continue;
|
||||
mode = "ship";
|
||||
} else {
|
||||
// Ground legs must run on a road or railway; there is no off-road
|
||||
// trucking in the trade network.
|
||||
if (this.railways.has(nk)) mode = "train";
|
||||
else if (this.roads.has(nk)) mode = "road";
|
||||
else continue;
|
||||
// Goods never cross the land of a nation you are at war with.
|
||||
const owner = this.civAt(neighbour);
|
||||
if (owner >= 0 && owner !== city.civ && this.isAtWar(city.civ, owner)) continue;
|
||||
}
|
||||
const stepEnergy = deliveryEnergyForResource(resource, 1, 1, mode, tile.movementCostMultiplier || 1);
|
||||
visited.add(nk);
|
||||
queue.push({
|
||||
coords: neighbour,
|
||||
tiles: current.tiles + 1,
|
||||
energy: current.energy + stepEnergy,
|
||||
});
|
||||
return !hostile && accept(here);
|
||||
},
|
||||
});
|
||||
return reached.map((entry) => ({
|
||||
...byKey.get(key(entry.node.x, entry.node.y)),
|
||||
tiles: entry.distance,
|
||||
routeEnergyPerTonne: entry.cost,
|
||||
}));
|
||||
},
|
||||
|
||||
// The passable outward edges of one tile for the trade network: land legs
|
||||
// must run on a road or railway, sea legs cross open water and need a working
|
||||
// port, and goods never cross the land of a nation you are at war with. Each
|
||||
// edge is priced in the transport energy one tonne of `resource` burns over
|
||||
// it, so the graph is commodity-aware while the traversal is not.
|
||||
_tradeNeighbours(city, coords, allowSea, resource, blocked) {
|
||||
const edges = [];
|
||||
for (const neighbour of this._neighbours(coords)) {
|
||||
const nk = key(neighbour.x, neighbour.y);
|
||||
if (blocked.has(nk)) continue;
|
||||
const tile = this.tiles[nk];
|
||||
if (!tile || !(tile.terrainClass === "Land" || tile.terrainClass === "Sea" ||
|
||||
tile.terrainClass === "Ice")) continue;
|
||||
const sea = tile.terrainClass === "Sea";
|
||||
let mode;
|
||||
if (sea) {
|
||||
// A shipping lane needs a working port to load and unload.
|
||||
if (!allowSea) continue;
|
||||
mode = "ship";
|
||||
} else {
|
||||
// Ground legs must run on a road or railway; there is no off-road
|
||||
// trucking in the trade network.
|
||||
if (this.railways.has(nk)) mode = "train";
|
||||
else if (this.roads.has(nk)) mode = "road";
|
||||
else continue;
|
||||
// Goods never cross the land of a nation you are at war with.
|
||||
const owner = this.civAt(neighbour);
|
||||
if (owner >= 0 && owner !== city.civ && this.isAtWar(city.civ, owner)) continue;
|
||||
}
|
||||
edges.push({
|
||||
node: neighbour,
|
||||
cost: deliveryEnergyForResource(resource, 1, 1, mode, tile.movementCostMultiplier || 1),
|
||||
});
|
||||
}
|
||||
results.sort((a, b) => a.routeEnergyPerTonne - b.routeEnergyPerTonne);
|
||||
return results;
|
||||
return edges;
|
||||
},
|
||||
|
||||
// Every supplier-to-city route among a nation's own storage nodes, whether or
|
||||
|
||||
+2
-26
@@ -11,6 +11,7 @@ import {
|
||||
resourceById,
|
||||
} from "./data/resources.js";
|
||||
import { EFFECT_RESEARCH } from "./data/effects.js";
|
||||
import { groupDigits } from "./text_format.js";
|
||||
|
||||
const DAYS_PER_YEAR = 365;
|
||||
|
||||
@@ -44,7 +45,7 @@ export function formatResourceAmount(id, value) {
|
||||
}
|
||||
|
||||
function trim(value) {
|
||||
if (value >= 100) return String(Math.round(value));
|
||||
if (value >= 100) return groupDigits(Math.round(value));
|
||||
if (value >= 10) return value.toFixed(1).replace(/\.0$/, "");
|
||||
return value.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
@@ -210,31 +211,6 @@ export function producerEnergyPerDay(id, outputPerDay) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- delivery --
|
||||
|
||||
// The kWh of transport energy one material tonne costs over `tiles` tiles in the
|
||||
// given mode. A material tonne and an energy tonne (10 GWh) share the formula.
|
||||
export function deliveryEnergyKwh(tonnes, tiles, mode, tileMovementCost = 1) {
|
||||
const d = RESOURCE_RULES.deliveryEnergy;
|
||||
const perTile = mode === "train"
|
||||
? d.train
|
||||
: mode === "ship"
|
||||
? d.ship
|
||||
: mode === "road"
|
||||
? d.truckRoad
|
||||
: d.truckOffRoad * Math.max(tileMovementCost, 0);
|
||||
return tonnes * Math.max(tiles, 0) * perTile;
|
||||
}
|
||||
|
||||
// The energy cost of delivering `amount` of a resource over `tiles`. Every
|
||||
// resource carries a mass per canonical unit, so a carat or a chip is charged
|
||||
// for the tonnes it actually weighs rather than as if it were a tonne.
|
||||
export function deliveryEnergyForResource(id, amount, tiles, mode, tileMovementCost = 1) {
|
||||
const proto = resourceById(id);
|
||||
const tonnes = amount * (proto ? proto.tonnesPerUnit : 1);
|
||||
return deliveryEnergyKwh(tonnes, tiles, mode, tileMovementCost);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- market --
|
||||
|
||||
// Moves a price toward what the day's world-wide supply and demand imply. A
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Small formatting helpers shared by the UI, ported from
|
||||
// scripts/classes/text_format.gd.
|
||||
|
||||
// Groups a number's digits in threes. The separators are non-breaking spaces,
|
||||
// so a money or resource figure never wraps in the middle of a number.
|
||||
export function groupDigits(value) {
|
||||
const truncated = Math.trunc(value);
|
||||
const digits = String(Math.abs(truncated));
|
||||
@@ -9,7 +11,7 @@ export function groupDigits(value) {
|
||||
for (let i = digits.length - 1; i >= 0; i--) {
|
||||
grouped = digits[i] + grouped;
|
||||
count += 1;
|
||||
if (count % 3 === 0 && i > 0) grouped = " " + grouped;
|
||||
if (count % 3 === 0 && i > 0) grouped = "\u00a0" + grouped;
|
||||
}
|
||||
return (truncated < 0 ? "-" : "") + grouped;
|
||||
}
|
||||
|
||||
@@ -112,6 +112,8 @@ export class AirTest extends TestCase {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const city = coastalCity(state, 0);
|
||||
// Construction is settled in materials, so stock the city before ordering.
|
||||
state.getCityResourceStock(city).steel = 5_000_000;
|
||||
const jet = protoIndex(state, "jet_fighter");
|
||||
const battleship = protoIndex(state, "battleship");
|
||||
this.assertFalse(state.requestTrain(city.id, jet), "no airport, no aircraft");
|
||||
|
||||
@@ -119,7 +119,7 @@ export class ConflictsModalTest extends TestCase {
|
||||
this.assertFalse(detail.hasClass("hidden"), "clicking a conflict reveals its details");
|
||||
const text = detail.text();
|
||||
this.assertTrue(text.includes("Conflict started 1 January 2000, 00:00"));
|
||||
this.assertTrue(text.includes("France — Military: 12 · Civilians: 3 400"));
|
||||
this.assertTrue(text.includes("France — Military: 12 · Civilians: 3\u00a0400"));
|
||||
this.assertTrue(text.includes("Britain — Military: 7 · Civilians: 900"));
|
||||
modal.close();
|
||||
} finally {
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ export class DataTest extends TestCase {
|
||||
// One work stands for a whole industry: the authored output is multiplied
|
||||
// tenfold so fewer of them stand on the map.
|
||||
const mill = RESOURCE_BUILDINGS.find((b) => b.id === "steel_mill");
|
||||
this.assertApprox(mill.outputPerDay, 20_000, 1e-6);
|
||||
this.assertApprox(mill.outputPerDay, 200_000, 1e-6);
|
||||
}
|
||||
|
||||
test_unknown_lookups_return_null() {
|
||||
|
||||
@@ -1290,7 +1290,7 @@ export class GameScreenTest extends TestCase {
|
||||
const $button = env.$("#unit-pillage");
|
||||
this.assertFalse($button.hasClass("hidden"), "the pillage button shows");
|
||||
const tooltip = $button.attr("data-tooltip") || "";
|
||||
this.assertTrue(tooltip.includes("19 000"), `forecast removes 19k: ${tooltip}`);
|
||||
this.assertTrue(tooltip.includes("19\u00a0000"), `forecast removes 19k: ${tooltip}`);
|
||||
this.assertTrue(tooltip.includes("0.49"), "forecast shows the GDP multiplier");
|
||||
this.assertTrue(tooltip.includes("+10") && tooltip.includes("+9"), "forecast shows both heals");
|
||||
|
||||
@@ -1557,7 +1557,7 @@ export class GameScreenTest extends TestCase {
|
||||
"the road button shows its icon"
|
||||
);
|
||||
const costText = options.eq(0).find(".improvement-cost").text();
|
||||
this.assertTrue(costText.includes("5000 t"), `the steel cost is shown: ${costText}`);
|
||||
this.assertTrue(costText.includes("50\u00a0000 t"), `the steel cost is shown: ${costText}`);
|
||||
this.assertTrue(costText.includes("100 GWh"), `the energy cost is shown: ${costText}`);
|
||||
// The money figure is the build cost plus the material bill at today's
|
||||
// market prices, not the raw build cost.
|
||||
@@ -1843,7 +1843,7 @@ export class GameScreenTest extends TestCase {
|
||||
const after = env.$("#tile-improvement-actions .transport-btn").eq(0)
|
||||
.find(".improvement-cost").text();
|
||||
this.assertNotEqual(before, after, `the quote moved: ${before} -> ${after}`);
|
||||
this.assertTrue(after.includes("5000 t"), "the steel amount is unchanged");
|
||||
this.assertTrue(after.includes("50\u00a0000 t"), "the steel amount is unchanged");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
|
||||
@@ -80,6 +80,9 @@ export class GameStateBuildingsTest extends TestCase {
|
||||
solo.budgets.set(0, BIG_BUDGET);
|
||||
const soloIndex = this.addBuilding(solo, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 }));
|
||||
const soloCity = cityOf(solo, 0);
|
||||
// Construction is settled in materials; stock the city so both queued
|
||||
// levels can gather without depending on the transport network.
|
||||
solo.getCityResourceStock(soloCity).steel = 5_000_000;
|
||||
solo.requestBuild(soloCity.id, soloIndex);
|
||||
solo.requestBuild(soloCity.id, soloIndex);
|
||||
const sequential = this.hoursUntilIdle(solo);
|
||||
@@ -88,6 +91,7 @@ export class GameStateBuildingsTest extends TestCase {
|
||||
yard.budgets.set(0, BIG_BUDGET);
|
||||
const yardIndex = this.addBuilding(yard, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 }));
|
||||
const yardCity = cityOf(yard, 0);
|
||||
yard.getCityResourceStock(yardCity).steel = 5_000_000;
|
||||
grantBuilding(yard, yardCity, "shipyard");
|
||||
yard.requestBuild(yardCity.id, yardIndex);
|
||||
yard.requestBuild(yardCity.id, yardIndex);
|
||||
|
||||
@@ -237,11 +237,12 @@ export class PowerMarketTest extends TestCase {
|
||||
state._converterProductionScale(k, proto, agent), 1,
|
||||
"a building with no history runs flat out"
|
||||
);
|
||||
agent.sales = [4_000, 4_000, 4_000, 4_000];
|
||||
agent.sales = [proto.outputPerDay * 0.2, proto.outputPerDay * 0.2,
|
||||
proto.outputPerDay * 0.2, proto.outputPerDay * 0.2];
|
||||
state._ensureResourceStock(k).steel = 1e9;
|
||||
const scale = state._converterProductionScale(k, proto, agent);
|
||||
this.assertLess(scale, 1, "a full store eases off");
|
||||
this.assertApprox(scale, 4_000 / proto.outputPerDay, 1e-3);
|
||||
this.assertApprox(scale, 0.2, 1e-3);
|
||||
}
|
||||
|
||||
test_a_converter_sells_out_and_then_grows() {
|
||||
|
||||
@@ -99,7 +99,7 @@ export class MapModesTest extends TestCase {
|
||||
this.assertFalse(env.$("#map-legend").hasClass("hidden"), "the legend is shown");
|
||||
this.assertEqual(env.$("#map-legend-title").text(), "GDP per capita", "the legend is titled");
|
||||
this.assertEqual(env.$("#map-legend-min").text(), "¤100", "the legend shows the minimum");
|
||||
this.assertEqual(env.$("#map-legend-max").text(), "¤1 234", "the legend shows the maximum");
|
||||
this.assertEqual(env.$("#map-legend-max").text(), "¤1\u00a0234", "the legend shows the maximum");
|
||||
// Every land tile carries the top-of-range colour, so the fill is live.
|
||||
const top = economicColour(1234, 100, 1234);
|
||||
let painted = false;
|
||||
|
||||
+11
-11
@@ -214,7 +214,7 @@ export class CityModalTest extends TestCase {
|
||||
modal.renderBudget(breakdown);
|
||||
this.assertTrue(env.$("#city-budget-sub").text().includes("+¤635/h"), "the region net is shown");
|
||||
const text = env.$("#city-budget-list").text();
|
||||
this.assertTrue(text.includes("+¤1 035/h"), "region tax income is green");
|
||||
this.assertTrue(text.includes("+¤1 035/h"), "region tax income is green");
|
||||
this.assertTrue(text.includes("Bank in Paris (building) +5.0%"), "a building bonus is listed");
|
||||
this.assertTrue(text.includes("−¤400/h"), "the building upkeep is listed");
|
||||
this.assertTrue(text.includes("TOTAL"), "a total row is present");
|
||||
@@ -292,7 +292,7 @@ export class CityModalTest extends TestCase {
|
||||
const text = env.$("#city-economy-list").text();
|
||||
this.assertTrue(text.includes("French Franc"), "the national currency is listed");
|
||||
this.assertTrue(text.includes("British Pound"), "a foreign holding is listed");
|
||||
this.assertTrue(env.$("#city-economy-sub").text().includes("4 250"), "the total value shows");
|
||||
this.assertTrue(env.$("#city-economy-sub").text().includes("4 250"), "the total value shows");
|
||||
this.assertTrue(env.$("#city-economy-sub").text().includes("Debt"), "the central-bank debt shows");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
@@ -318,7 +318,7 @@ export class CityModalTest extends TestCase {
|
||||
env.$("#modal-city .tab[data-tab=resources]").click();
|
||||
this.assertTrue(env.$("#city-tab-resources").hasClass("active"));
|
||||
this.assertTrue(
|
||||
env.$("#city-resources-sub").text().includes("123 456"),
|
||||
env.$("#city-resources-sub").text().includes("123 456"),
|
||||
"the reserve value shows"
|
||||
);
|
||||
const text = env.$("#city-resources-list").text();
|
||||
@@ -350,7 +350,7 @@ export class CityModalTest extends TestCase {
|
||||
const text = env.$("#city-people-list").text();
|
||||
this.assertTrue(text.includes("Resource shortages"), "the shortage section shows");
|
||||
this.assertTrue(text.includes("Steel"), "the short resource is named");
|
||||
this.assertTrue(text.includes("1200"), "the amount it is short by is shown");
|
||||
this.assertTrue(text.includes("1\u00a0200"), "the amount it is short by is shown");
|
||||
this.assertTrue(text.includes("40%"), "the share of need is shown");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
@@ -651,7 +651,7 @@ export class NationModalTest extends TestCase {
|
||||
|
||||
const text = env.$("#budget-list").text();
|
||||
this.assertTrue(text.includes("National sales tax"), "the month's taxes are shown");
|
||||
this.assertTrue(text.includes("+¤1 035"), "the tax take is shown");
|
||||
this.assertTrue(text.includes("+¤1 035"), "the tax take is shown");
|
||||
this.assertTrue(text.includes("Resource sales"), "resource sales are shown");
|
||||
this.assertTrue(text.includes("Building upkeep"), "a building category is listed");
|
||||
this.assertTrue(text.includes("−¤700"), "the rightmost column is the market value");
|
||||
@@ -938,7 +938,7 @@ export class NationModalTest extends TestCase {
|
||||
this.assertTrue(env.$("#tab-economy").hasClass("active"));
|
||||
const summary = env.$("#economy-summary").text();
|
||||
this.assertTrue(summary.includes("Paris"), "the region is listed");
|
||||
this.assertTrue(summary.includes("5 000"), "its private cash is shown");
|
||||
this.assertTrue(summary.includes("5 000"), "its private cash is shown");
|
||||
|
||||
env.$("#economy-subtabs .subtab[data-view=taxes]").click();
|
||||
const taxesText = env.$("#economy-taxes").text();
|
||||
@@ -954,13 +954,13 @@ export class NationModalTest extends TestCase {
|
||||
"the per-resource override is shown"
|
||||
);
|
||||
this.assertTrue(
|
||||
env.$("#economy-sub").text().includes("1 600"), "the day's total take is shown"
|
||||
env.$("#economy-sub").text().includes("1 600"), "the day's total take is shown"
|
||||
);
|
||||
|
||||
env.$("#economy-subtabs .subtab[data-view=centralbank]").click();
|
||||
const bank = env.$("#economy-central-bank").text();
|
||||
this.assertTrue(bank.includes("British Pound"), "the foreign reserve is listed");
|
||||
this.assertTrue(bank.includes("987 654"), "the outstanding loans are listed");
|
||||
this.assertTrue(bank.includes("987\u00a0654"), "the outstanding loans are listed");
|
||||
this.assertTrue(bank.includes("Commodity inflation"), "the inflation indicator is shown");
|
||||
this.assertTrue(env.$("#cb-rate").length === 1, "the rate input is present");
|
||||
|
||||
@@ -971,8 +971,8 @@ export class NationModalTest extends TestCase {
|
||||
env.$("#economy-subtabs .subtab[data-view=consumption]").click();
|
||||
const consumption = env.$("#economy-consumption").text();
|
||||
this.assertTrue(consumption.includes("Steel"), "the consumed resource is listed");
|
||||
this.assertTrue(consumption.includes("1200"), "its daily consumption is shown");
|
||||
this.assertTrue(consumption.includes("180 000"), "what was spent abroad is shown");
|
||||
this.assertTrue(consumption.includes("1\u00a0200"), "its daily consumption is shown");
|
||||
this.assertTrue(consumption.includes("180\u00a0000"), "what was spent abroad is shown");
|
||||
this.assertTrue(consumption.includes("40"), "what was sold abroad is shown");
|
||||
|
||||
env.$("#economy-subtabs .subtab[data-view=currency]").click();
|
||||
@@ -984,7 +984,7 @@ export class NationModalTest extends TestCase {
|
||||
this.assertTrue(currency.includes("Paris"), "a home region holding it is listed");
|
||||
this.assertTrue(currency.includes("London"), "a foreign region holding it is listed");
|
||||
this.assertTrue(currency.includes("Central bank of Britain"), "a foreign bank is listed");
|
||||
this.assertTrue(currency.includes("7 934"), "the total in circulation is shown");
|
||||
this.assertTrue(currency.includes("7 934"), "the total in circulation is shown");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
|
||||
+26
-20
@@ -17,10 +17,9 @@ import {
|
||||
luxuryNeedPerDay,
|
||||
foodMonthlyTarget,
|
||||
cityEnergyPerDay,
|
||||
deliveryEnergyKwh,
|
||||
deliveryEnergyForResource,
|
||||
nextMarketPrice,
|
||||
} from "../shared/resources.js";
|
||||
import { deliveryEnergyKwh, deliveryEnergyForResource } from "../shared/economy_graph.js";
|
||||
|
||||
function freeLandTile(state, civ = 0) {
|
||||
for (const coords of state._territoryByCiv.get(civ) || []) {
|
||||
@@ -234,9 +233,14 @@ export class ResourceModelTest extends TestCase {
|
||||
this.assertApprox(info.value, expected);
|
||||
const population = state.getCityEconomy(city).population;
|
||||
this.assertApprox(info.consumption.food, foodNeedPerDay(population));
|
||||
this.assertApprox(info.consumption.steel, steelNeedPerDay(population));
|
||||
this.assertApprox(info.consumption.luxury, luxuryNeedPerDay(population));
|
||||
this.assertTrue(info.consumption.energy > 0, "grid demand draws power");
|
||||
// The people's own steel need is named in the breakdown, and any industry
|
||||
// in the region adds its wear on top.
|
||||
const people = info.demand.steel.find((entry) => entry.name === "People");
|
||||
this.assertNotNull(people, "the people are a named steel consumer");
|
||||
this.assertApprox(people.amount, steelNeedPerDay(population));
|
||||
this.assertTrue(info.consumption.steel >= steelNeedPerDay(population) - 1e-6);
|
||||
}
|
||||
|
||||
test_city_stats_ship_the_region_resources() {
|
||||
@@ -272,7 +276,10 @@ export class ResourceModelTest extends TestCase {
|
||||
gdp += stats.gdp;
|
||||
population += stats.population;
|
||||
}
|
||||
this.assertApprox(snapshot.viewerStats.gdpPerCapita, gdp / population, 1e-6);
|
||||
// The national figure is the exact GDP over the exact population; the
|
||||
// regional figures ship rounded populations, so allow a person's worth of
|
||||
// rounding drift in the per-capita comparison.
|
||||
this.assertApprox(snapshot.viewerStats.gdpPerCapita, gdp / population, 0.01);
|
||||
}
|
||||
|
||||
test_construction_draws_steel_from_the_city_before_the_market() {
|
||||
@@ -602,23 +609,23 @@ export class ResourceModelTest extends TestCase {
|
||||
this.assertNotEqual(describe(first), describe(other), "a different seed lays out different works");
|
||||
}
|
||||
|
||||
test_two_nations_open_with_a_chip_foundry_apart() {
|
||||
const state = smallState(["france", "britain", "poland"], 12345, { startingChipFabs: 2 });
|
||||
const fabs = [];
|
||||
test_every_nation_opens_with_a_chip_foundry() {
|
||||
// High-tech is seeded like any other industry: a nation with no works of
|
||||
// its own opens with a chip foundry, on the transport network, and the
|
||||
// layout is drawn from the world seed.
|
||||
const state = smallState(["france", "britain", "poland"], 12345);
|
||||
const owners = new Set();
|
||||
for (const [k, id] of state.tileImprovements) {
|
||||
if (id !== "chip_foundry") continue;
|
||||
fabs.push({ k, owner: state.tileImprovementOwner.get(k) });
|
||||
}
|
||||
this.assertSize(fabs, 2, "the world opens with exactly two chip foundries");
|
||||
this.assertNotEqual(fabs[0].owner, fabs[1].owner, "the two foundries are in different nations");
|
||||
for (const fab of fabs) {
|
||||
owners.add(state.tileImprovementOwner.get(k));
|
||||
this.assertTrue(
|
||||
state.roads.has(fab.k) || state.railways.has(fab.k),
|
||||
state.roads.has(k) || state.railways.has(k),
|
||||
"a seeded foundry sits on the transport network"
|
||||
);
|
||||
}
|
||||
// The pair is drawn from the world seed, so a replay lays them out again.
|
||||
const again = smallState(["france", "britain", "poland"], 12345, { startingChipFabs: 2 });
|
||||
this.assertTrue(owners.size > 0, "the world opens with chip foundries");
|
||||
// The layout is drawn from the world seed, so a replay lays it out again.
|
||||
const again = smallState(["france", "britain", "poland"], 12345);
|
||||
const describe = (s) =>
|
||||
Array.from(s.tileImprovements.entries())
|
||||
.filter(([, id]) => id === "chip_foundry")
|
||||
@@ -629,15 +636,14 @@ export class ResourceModelTest extends TestCase {
|
||||
}
|
||||
|
||||
test_the_chip_foundries_are_powered_on_a_full_map() {
|
||||
// A full roster: with only two nations the tiny starting islands can leave
|
||||
// one nation without the land a foundry's grid needs.
|
||||
// A full roster: the seeded chip foundries draw a lot, so the power pass
|
||||
// must build the grid to run them on every nation's land.
|
||||
const state = defaultState(["france", "britain", "germany", "poland"]);
|
||||
const fabs = [];
|
||||
for (const [k, id] of state.tileImprovements) {
|
||||
if (id === "chip_foundry") fabs.push(state.tileImprovementOwner.get(k));
|
||||
if (id === "chip_foundry") fabs.push(k);
|
||||
}
|
||||
this.assertSize(fabs, 2, "a full map opens with two chip foundries");
|
||||
this.assertNotEqual(fabs[0], fabs[1], "the two foundries are in different nations");
|
||||
this.assertTrue(fabs.length > 0, "a full map opens with chip foundries");
|
||||
// Their huge draw is settled like any other converter's, so every grid opens
|
||||
// with the power to run them.
|
||||
const components = state._resourceLandComponents();
|
||||
|
||||
@@ -6,9 +6,10 @@ export class TextFormatTest extends TestCase {
|
||||
this.assertEqual(groupDigits(0), "0");
|
||||
this.assertEqual(groupDigits(7), "7");
|
||||
this.assertEqual(groupDigits(123), "123");
|
||||
this.assertEqual(groupDigits(1234), "1 234");
|
||||
this.assertEqual(groupDigits(1234567), "1 234 567");
|
||||
this.assertEqual(groupDigits(-1234), "-1 234");
|
||||
// The separators are non-breaking spaces, so a number never wraps.
|
||||
this.assertEqual(groupDigits(1234), "1\u00a0234");
|
||||
this.assertEqual(groupDigits(1234567), "1\u00a0234\u00a0567");
|
||||
this.assertEqual(groupDigits(-1234), "-1\u00a0234");
|
||||
}
|
||||
|
||||
test_compact() {
|
||||
|
||||
Reference in New Issue
Block a user