Gave production buildings their own economy and fed island regions

Production buildings are now independent private-sector agents: they keep what their output sells for, run only to the demand they can actually sell, and spend that cash on steel and high-tech upgrades when their demand grows. Grid power is a paid private flow between plants and their consumers, and regions synthesise the food their own harvest and the reachable market cannot supply.

Island regions no longer famine: a region with no reachable supplier grows its whole shortfall from energy, and grid power now feeds cities and food before the converters, so a famine never waits on a factory.

Bundles the in-progress tree cleanup that was already present in the working tree.
This commit is contained in:
2026-09-22 14:34:14 +02:00
parent 238985b6fe
commit 3e429ccb64
34 changed files with 1253 additions and 277 deletions
+3
View File
@@ -0,0 +1,3 @@
* In some circumstances, it seems likes aircraft cannot bomb within their own range, as per an error message reported by a user.
* War crime (pillaging) seems to have a permanent effect on popularity that I just cannot get rid off. War crime popularity debuff should be instant and only once per occurence.
* Double airplanes range
-9
View File
@@ -2,8 +2,6 @@
// every money string reads in the right unit. The symbol is set from the // every money string reads in the right unit. The symbol is set from the
// snapshot; until then a neutral mark stands in. // snapshot; until then a neutral mark stands in.
import { groupDigits } from "../../shared/text_format.js";
let symbol = "¤"; let symbol = "¤";
export function setCurrency(currency) { export function setCurrency(currency) {
@@ -13,10 +11,3 @@ export function setCurrency(currency) {
export function currencySymbol() { export function currencySymbol() {
return symbol; return symbol;
} }
// A signed money amount, with an optional "/h" for hourly figures.
export function moneyText(value, { signed = true, perHour = false } = {}) {
const amount = `${symbol}${groupDigits(Math.round(Math.abs(value)))}`;
const sign = signed ? (value < 0 ? "−" : "+") : "";
return `${sign}${amount}${perHour ? "/h" : ""}`;
}
-4
View File
@@ -34,10 +34,6 @@ export class GameFeed {
return $line; return $line;
} }
lineCount() {
return this.$container ? this.$container.children(".feed-line").length : 0;
}
_retire($line) { _retire($line) {
$line.addClass("leaving"); $line.addClass("leaving");
if (this.fade <= 0) { if (this.fade <= 0) {
+49 -25
View File
@@ -3,7 +3,7 @@
// on the GameScreen instance (composed in ../game_screen.js) and only read the // on the GameScreen instance (composed in ../game_screen.js) and only read the
// snapshot and forward orders. // snapshot and forward orders.
import { describeNews, marketPricesFrom, quotedMoney, quotedBuildCost } from "../modals.js"; import { describeNews, marketPricesFrom, quotedBuildCost } from "../modals.js";
import { compact, groupDigits, hours, improvements as fmtImprovements } from "../../../shared/text_format.js"; import { compact, groupDigits, hours, improvements as fmtImprovements } from "../../../shared/text_format.js";
import { import {
CIVILISATIONS, CIVILISATIONS,
@@ -28,12 +28,12 @@ import {
resourceById, resourceById,
RESOURCES, RESOURCES,
RESOURCE_RULES, RESOURCE_RULES,
resourceBuildingOutputAt,
tileImprovementById, tileImprovementById,
AIR_STRIKE,
} from "../../../shared/data.js"; } from "../../../shared/data.js";
import { defenseFactors, unitDefense, productionFactors, productionMultiplier, popularityDisplay } from "../../../shared/rules.js"; import { defenseFactors, unitDefense, productionFactors, productionMultiplier, popularityDisplay } from "../../../shared/rules.js";
import { formatEnergy, formatResourceAmount, foodNeedPerDay, protoUpkeep, tileImprovementResourceCost } from "../../../shared/resources.js"; import { formatEnergy, formatResourceAmount, foodNeedPerDay, protoUpkeep, tileImprovementResourceCost } from "../../../shared/resources.js";
import { dateString, HOURS_PER_DAY, hoursInNextMonth } from "../../../shared/game_clock.js"; import { dateString, hoursInNextMonth } from "../../../shared/game_clock.js";
import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js"; import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
import { ethnicityChart } from "../ui/ethnicity.js"; import { ethnicityChart } from "../ui/ethnicity.js";
import { isEconomicMode } from "../map_view/economic.js"; import { isEconomicMode } from "../map_view/economic.js";
@@ -386,12 +386,11 @@ export const panelMethods = {
}, },
// Why an air strike cannot be issued right now, or null when it can. Blocks a // Why an air strike cannot be issued right now, or null when it can. Blocks a
// plane that is already flying a mission, is still re-arming, or whose // plane that is already flying a mission or is still re-arming, and a nation
// nation cannot afford the (deliberately steep) price. // whose stores cannot cover the munitions the sortie burns.
_airStrikeBlocked(units) { _airStrikeBlocked(units) {
const snapshot = this.snapshot || {}; const snapshot = this.snapshot || {};
const totalHours = snapshot.totalHours || 0; const totalHours = snapshot.totalHours || 0;
const budget = (snapshot.viewerStats || {}).budget || 0;
for (const unit of units) { for (const unit of units) {
if (unit.strikeTarget) { if (unit.strikeTarget) {
return { title: "Aircraft engaged", message: "This aircraft is already on a strike mission." }; return { title: "Aircraft engaged", message: "This aircraft is already on a strike mission." };
@@ -404,12 +403,16 @@ export const panelMethods = {
}; };
} }
} }
const cost = (AIR_STRIKE.cost || 0) * units.length; // A sortie is paid for in munitions, not money (GameState.requestAirStrike):
if (cost > budget) { // every aircraft spends steel and high-tech from the nation's stores.
const prices = marketPricesFrom(this.snapshot); const per = RESOURCE_RULES.combatConsumption.airStrike || {};
const neededSteel = (per.steel || 0) * units.length;
const neededTech = (per.hightech || 0) * units.length;
const stock = (((snapshot.viewerStats || {}).resources || {}).stock) || {};
if (neededSteel > (stock.steel || 0) + 1e-6 || neededTech > (stock.hightech || 0) + 1e-6) {
return { return {
title: "Insufficient budget", title: "Insufficient munitions",
message: `An air strike costs ${currencySymbol()}${groupDigits(quotedMoney(cost, prices))}, which the treasury cannot cover.`, message: "The nation's stores cannot cover this air strike's steel and high-tech.",
}; };
} }
return null; return null;
@@ -937,7 +940,10 @@ export const panelMethods = {
const militaryWorks = this.map.tileImprovements.get(k); const militaryWorks = this.map.tileImprovements.get(k);
if (militaryWorks) { if (militaryWorks) {
const proto = tileImprovementById(militaryWorks.id); const proto = tileImprovementById(militaryWorks.id);
works.push(proto ? proto.name : militaryWorks.id); const level = militaryWorks.level || 0;
works.push(
(proto ? proto.name : militaryWorks.id) + (level > 0 ? ` (level ${level})` : "")
);
} }
$("#tile-improvements").text( $("#tile-improvements").text(
`Improvements: ${fmtImprovements(Array.from(new Set([...cityImprovements, ...transport, ...works])))}` `Improvements: ${fmtImprovements(Array.from(new Set([...cityImprovements, ...transport, ...works])))}`
@@ -969,19 +975,33 @@ export const panelMethods = {
const work = queue.find( const work = queue.find(
(entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y (entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y
); );
if (!work) return; if (work) {
const proto = tileImprovementById(work.id); const proto = tileImprovementById(work.id);
const name = proto ? proto.name : work.id; const name = proto ? proto.name : work.id;
const total = Math.max(work.totalHours || 0, 0.0001); const total = Math.max(work.totalHours || 0, 0.0001);
const elapsed = work.elapsedHours || 0; const elapsed = work.elapsedHours || 0;
const pct = Math.max(0, Math.min(100, (elapsed / total) * 100)); const pct = Math.max(0, Math.min(100, (elapsed / total) * 100));
const remaining = Math.max(0, Math.ceil(total - elapsed)); const remaining = Math.max(0, Math.ceil(total - elapsed));
const $bar = $("<div class='progress city-production-bar'></div>"); const $bar = $("<div class='progress city-production-bar'></div>");
$bar.append($("<div></div>").css("width", `${pct}%`)); $bar.append($("<div></div>").css("width", `${pct}%`));
$el.append( $el.append(
$("<div class='city-production-label'></div>").text(`Building ${name} — ${remaining} h left`), $("<div class='city-production-label'></div>").text(`Building ${name} — ${remaining} h left`),
$bar $bar
);
}
// A production building upgrades itself: show the work its agent has begun.
const stats = this.snapshot.viewerStats || {};
const building = (stats.productionBuildings || []).find(
(entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y
); );
if (building && building.upgrading) {
$el.append(
$("<div class='city-production-label'></div>").text(
`Upgrading ${building.name} to level ${(building.level || 0) + 1}` +
` — ${building.upgradeDaysLeft || 0} days left`
)
);
}
}, },
// The city panel's quick view of what is currently being produced there, so // The city panel's quick view of what is currently being produced there, so
@@ -1372,7 +1392,7 @@ export const panelMethods = {
const improvement = this.map.tileImprovements.get(k); const improvement = this.map.tileImprovements.get(k);
const proto = improvement ? tileImprovementById(improvement.id) : null; const proto = improvement ? tileImprovementById(improvement.id) : null;
if (proto && proto.resource && proto.outputPerDay) { if (proto && proto.resource && proto.outputPerDay) {
amounts[proto.resource] += proto.outputPerDay * multiplier; amounts[proto.resource] += resourceBuildingOutputAt(proto, improvement.level) * multiplier;
} }
return { amounts, factors, foodMultiplier }; return { amounts, factors, foodMultiplier };
}, },
@@ -1784,6 +1804,10 @@ export const panelMethods = {
exchangeRates: stats.exchangeRates || [], exchangeRates: stats.exchangeRates || [],
centralBank: stats.centralBank || { rate: 0, reserves: [] }, centralBank: stats.centralBank || { rate: 0, reserves: [] },
privateCash: stats.privateCash || 0, privateCash: stats.privateCash || 0,
// Production buildings are private-sector agents of their own, held apart
// from the regions in the Economy tab.
privateBuildingCash: stats.privateBuildingCash || 0,
productionBuildings: stats.productionBuildings || [],
// Where the viewer's own currency sits: treasury, regions, foreign banks. // Where the viewer's own currency sits: treasury, regions, foreign banks.
currencyHoldings: stats.currencyHoldings || null, currencyHoldings: stats.currencyHoldings || null,
// The viewer's trade-tax rates and the day's take, for the Taxes sub-tab. // The viewer's trade-tax rates and the day's take, for the Taxes sub-tab.
+6 -9
View File
@@ -25,12 +25,11 @@ export const DRAG_THRESHOLD = 5;
export const ZOOM_MIN = 0.2; export const ZOOM_MIN = 0.2;
export const ZOOM_MAX = 2.0; export const ZOOM_MAX = 2.0;
// The camera looks north from an orthographic elevation of 70° above the // The camera looks north from an orthographic elevation of 45° above the
// ground. That foreshortens the north-south axis by sin(70°) ~= 0.94 while the // ground. That foreshortens the north-south axis by sin(45°) ~= 0.71 while the
// east-west axis is untouched: a gentle 3D tilt that keeps the current // east-west axis is untouched. It is applied to the terrain, roads, borders,
// orientation. It is applied to the terrain, roads, borders, fog and ground // fog and ground overlays; units, cities and labels stay upright on top.
// overlays; units, cities and labels stay upright on top. const CAMERA_ELEVATION_DEG = 45;
export const CAMERA_ELEVATION_DEG = 45;
export const CAMERA_TILT = Math.sin((CAMERA_ELEVATION_DEG * Math.PI) / 180); export const CAMERA_TILT = Math.sin((CAMERA_ELEVATION_DEG * Math.PI) / 180);
// How far the tallest terrain (normalised height 1, see map_view/heightmap.js) // How far the tallest terrain (normalised height 1, see map_view/heightmap.js)
@@ -105,10 +104,8 @@ export function politicalMode(zoom) {
} }
// A battle spreads the two stacks to either side of the tile centre, with the // A battle spreads the two stacks to either side of the tile centre, with the
// battle icon between them. Side offset is in pixels; step is the spacing // battle icon between them. The side offset is in pixels.
// between the (half-size) units of one stack.
export const BATTLE_SIDE_OFFSET = 15; export const BATTLE_SIDE_OFFSET = 15;
export const BATTLE_STACK_STEP = 9;
// The detailed view is built from square blocks of tiles. Only the blocks that // The detailed view is built from square blocks of tiles. Only the blocks that
// intersect the viewport (plus CHUNK_MARGIN blocks of slack on every side) are // intersect the viewport (plus CHUNK_MARGIN blocks of slack on every side) are
+2 -2
View File
@@ -89,8 +89,8 @@ export const entityMethods = {
this._warfareSignature = signature; this._warfareSignature = signature;
this.trenches = new Set(trenches.map((entry) => key(entry[0], entry[1]))); this.trenches = new Set(trenches.map((entry) => key(entry[0], entry[1])));
this.tileImprovements = new Map(); this.tileImprovements = new Map();
for (const [x, y, id, owner, hp] of improvements) { for (const [x, y, id, owner, hp, level] of improvements) {
this.tileImprovements.set(key(x, y), { id, owner, hp }); this.tileImprovements.set(key(x, y), { id, owner, hp, level: level || 0 });
} }
this._renderWarfare(); this._renderWarfare();
}, },
-6
View File
@@ -875,12 +875,6 @@ export class GLMapRenderer {
); );
} }
// Fog has no texture, so it uses a compact position/height/colour vertex. That
// keeps the buffer (re-uploaded whenever visibility changes) small.
_pushSimple(arr, x, y, height, colour) {
arr.push(x, y, height || 0, colour[0], colour[1], colour[2], colour[3]);
}
// Adds a convex quad given its four vertices (with per-vertex heights) as two // Adds a convex quad given its four vertices (with per-vertex heights) as two
// triangles. Wind order does not matter: we never cull faces. // triangles. Wind order does not matter: we never cull faces.
_quad(arr, corners, colour) { _quad(arr, corners, colour) {
+78 -17
View File
@@ -443,15 +443,18 @@ export class NationModal {
return `${currencySymbol()}${groupDigits(Math.round(value || 0))}`; return `${currencySymbol()}${groupDigits(Math.round(value || 0))}`;
} }
// The aggregate private-sector cash the nation's regions hold, and each // The aggregate private-sector cash the nation's regions and its independent
// region's own reserve. // production buildings hold, each listed on its own row.
_renderPrivateSector(money) { _renderPrivateSector(money) {
if (!this.$economySummary || !this.$economySummary.length) return; if (!this.$economySummary || !this.$economySummary.length) return;
const cash = money.privateCash || 0; const cash = money.privateCash || 0;
const buildingCash = money.privateBuildingCash || 0;
if (this.$economySub && this.$economySub.length) { if (this.$economySub && this.$economySub.length) {
this.$economySub.text( this.$economySub.text(
`Private-sector money: ${currencySymbol()}${groupDigits(Math.round(cash))} · ` + `Private-sector money: ${currencySymbol()}${groupDigits(Math.round(cash + buildingCash))}` +
`${(money.currency && money.currency.name) || ""}` ` · regions ${currencySymbol()}${groupDigits(Math.round(cash))}` +
` · production ${currencySymbol()}${groupDigits(Math.round(buildingCash))}` +
` · ${(money.currency && money.currency.name) || ""}`
); );
} }
this.$economySummary.empty(); this.$economySummary.empty();
@@ -462,36 +465,94 @@ export class NationModal {
) )
); );
const cities = (money.cities || []).filter((city) => city.civ === money.viewerCiv); const cities = (money.cities || []).filter((city) => city.civ === money.viewerCiv);
if (cities.length === 0) return; if (cities.length > 0) {
const $table = $("<table class='resources-table economy-table'></table>");
const $head = $("<thead></thead>").append(
$("<tr></tr>")
.append($("<th></th>").text("Region"))
.append($("<th class='numeric'></th>").text("Private cash"))
);
const $body = $("<tbody></tbody>");
let total = 0;
for (const city of cities) {
total += city.cashValue || 0;
$body.append(
$("<tr></tr>")
.append($("<td></td>").text(city.name))
.append(
$("<td class='numeric'></td>").text(
`${currencySymbol()}${groupDigits(Math.round(city.cashValue || 0))}`
)
)
);
}
$table.append($head, $body);
const $foot = $("<tfoot></tfoot>").append(
$("<tr class='budget-total'></tr>")
.append($("<td></td>").text("Total"))
.append($("<td class='numeric'></td>").text(
`${currencySymbol()}${groupDigits(Math.round(total))}`
))
);
$table.append($foot);
this.$economySummary.append($table);
}
this._renderProductionBuildings(money.productionBuildings || []);
}
// Production buildings are private-sector agents too, but a separate class
// from the regions: each keeps its own cash and reinvests it in upgrades.
_renderProductionBuildings(buildings) {
if (!this.$economySummary || buildings.length === 0) return;
this.$economySummary.append(
$("<div class='desc'></div>").text(
"Production buildings are independent private agents: they keep what " +
"their output sells for, and spend it upgrading when demand has grown."
)
);
const $table = $("<table class='resources-table economy-table'></table>"); const $table = $("<table class='resources-table economy-table'></table>");
const $head = $("<thead></thead>").append( const $head = $("<thead></thead>").append(
$("<tr></tr>") $("<tr></tr>")
.append($("<th></th>").text("Region")) .append($("<th></th>").text("Production building"))
.append($("<th></th>").text("Output / day"))
.append($("<th class='numeric'></th>").text("Level"))
.append($("<th class='numeric'></th>").text("Private cash")) .append($("<th class='numeric'></th>").text("Private cash"))
); );
const $body = $("<tbody></tbody>"); const $body = $("<tbody></tbody>");
let total = 0; let total = 0;
for (const city of cities) { for (const building of buildings) {
total += city.cashValue || 0; total += building.cash || 0;
const level = building.level || 0;
const resource = resourceById(building.resource);
const $output = resource && building.outputPerDay
? $("<td></td>").text(formatResourceAmount(building.resource, building.outputPerDay))
: $("<td></td>").text("—");
$body.append( $body.append(
$("<tr></tr>") $("<tr></tr>")
.append($("<td></td>").text(city.name)) .append($("<td></td>").text(building.name))
.append($output)
.append( .append(
$("<td class='numeric'></td>").text( $("<td class='numeric'></td>").text(
`${currencySymbol()}${groupDigits(Math.round(city.cashValue || 0))}` level + (building.upgrading ? " ↑" : "")
)
)
.append(
$("<td class='numeric'></td>").text(
`${currencySymbol()}${groupDigits(Math.round(building.cash || 0))}`
) )
) )
); );
} }
$table.append($head, $body); $table.append($head, $body);
const $foot = $("<tfoot></tfoot>").append( $table.append(
$("<tr class='budget-total'></tr>") $("<tfoot></tfoot>").append(
.append($("<td></td>").text("Total")) $("<tr class='budget-total'></tr>")
.append($("<td class='numeric'></td>").text( .append($("<td colspan='3'></td>").text("Total"))
`${currencySymbol()}${groupDigits(Math.round(total))}` .append($("<td class='numeric'></td>").text(
)) `${currencySymbol()}${groupDigits(Math.round(total))}`
))
)
); );
$table.append($foot);
this.$economySummary.append($table); this.$economySummary.append($table);
} }
-3
View File
@@ -14,9 +14,6 @@ import { CIVILISATIONS, MAP_CONFIG } from "../shared/data.js";
import { RESOURCE_BUILDINGS, RESOURCE_RULES, RESOURCE_MARKET_BASE } from "../shared/data/resources.js"; import { RESOURCE_BUILDINGS, RESOURCE_RULES, RESOURCE_MARKET_BASE } from "../shared/data/resources.js";
import { import {
cityEnergyPerDay, cityEnergyPerDay,
foodNeedPerDay,
steelNeedPerDay,
luxuryNeedPerDay,
} from "../shared/resources.js"; } from "../shared/resources.js";
import { key } from "../shared/hex.js"; import { key } from "../shared/hex.js";
+3 -3
View File
@@ -56,10 +56,10 @@ export const CITY_BOMBARD = {
// Aircraft sorties: a plane flies to its target, bombs once, returns to a // Aircraft sorties: a plane flies to its target, bombs once, returns to a
// friendly airport and then cannot strike again for `cooldownHours` (two days). // friendly airport and then cannot strike again for `cooldownHours` (two days).
// Each attacking aircraft costs `cost` from its nation's budget, charged up // Each attacking aircraft burns munitions from its nation's stores (see
// front so bombing cannot be spammed for free. // RESOURCE_RULES.combatConsumption.airStrike), charged up front so bombing
// cannot be spammed for free.
export const AIR_STRIKE = { export const AIR_STRIKE = {
cost: 10_000_000,
cooldownHours: 48, cooldownHours: 48,
}; };
-2
View File
@@ -84,8 +84,6 @@ export const PROPAGANDA = {
export const CAMPAIGN_ETHNICITY = "ethnicity"; export const CAMPAIGN_ETHNICITY = "ethnicity";
export const CAMPAIGN_GOVERNMENT = "government"; export const CAMPAIGN_GOVERNMENT = "government";
export const CAMPAIGN_WARM = 1;
export const CAMPAIGN_COOL = -1;
// Minority policies a government may proclaim. Any number may be active at // Minority policies a government may proclaim. Any number may be active at
// once, but the same policy may not be enacted twice for the same ethnicity. // once, but the same policy may not be enacted twice for the same ethnicity.
+55 -1
View File
@@ -9,7 +9,6 @@
// Energy is stored and moved as kWh. These keep the catalogue readable. // Energy is stored and moved as kWh. These keep the catalogue readable.
export const KWH_PER_MWH = 1_000; export const KWH_PER_MWH = 1_000;
export const KWH_PER_GWH = 1_000_000; export const KWH_PER_GWH = 1_000_000;
export const KWH_PER_TWH = 1_000_000_000;
// One tonne of any material counts as this much energy on the wire, so material // One tonne of any material counts as this much energy on the wire, so material
// deliveries and energy deliveries share one transport-cost formula. // deliveries and energy deliveries share one transport-cost formula.
@@ -134,6 +133,11 @@ export const RESOURCE_RULES = {
// Energy the material producers consume to make one canonical unit. // Energy the material producers consume to make one canonical unit.
energyPerFoodTonne: 1 * KWH_PER_MWH, // 1 MWh per tonne energyPerFoodTonne: 1 * KWH_PER_MWH, // 1 MWh per tonne
// Food a region can force from energy beyond its own harvest, in greenhouses
// and synthesis. The energy for `x` extra tonnes a day is this coefficient
// times `x^2`, so each further tonne costs more energy than the last and a
// region grows only while its marginal energy beats the market price of food.
foodSynthesisQuadratic: 20, // kWh per tonne squared
energyPerSteelTonne: 20 * KWH_PER_MWH, // 20 MWh per tonne energyPerSteelTonne: 20 * KWH_PER_MWH, // 20 MWh per tonne
energyPerLuxuryCarat: { energyPerLuxuryCarat: {
gold_mine: 10 * KWH_PER_MWH, // 10 MWh per carat gold_mine: 10 * KWH_PER_MWH, // 10 MWh per carat
@@ -412,6 +416,56 @@ for (const building of RESOURCE_BUILDINGS) {
if (building.takesCombatDamage === undefined) building.takesCombatDamage = true; if (building.takesCombatDamage === undefined) building.takesCombatDamage = true;
} }
// Every resource building -- a material producer or a power plant -- is an
// independent private-sector agent. It keeps its own cash from what it sells,
// tries to grow when demand for its output has been rising, and spends that
// cash to upgrade itself. Power is a paid flow: the plants are paid for the
// power the grid's consumers draw, and a plant that keeps selling out expands.
//
// An upgrade to level u raises throughput exponentially (`outputPerDay` times
// `alpha^u`) but the extra output costs more power: the operating input grows
// linearly (`1 + inputPerLevel*u`). Each further level costs `costAlpha^u` more
// steel and high-tech, so the benefit compounds slower than the bill.
export const RESOURCE_BUILDING_UPGRADE = {
alpha: 1.1,
inputPerLevel: 1,
costAlpha: 1.1,
// A ceiling on the compounding, so a very old building cannot run away.
maxLevel: 12,
// How many days of sales the agent watches for the demand trend, and how much
// its recent average must beat its older one before it commits to an upgrade.
salesWindow: 6,
demandGrowth: 0,
// A producer that sells at least this share of what it makes is demand-bound
// (it sells out), so it grows even when its sales look flat.
sellThrough: 0.9,
// The cash headroom over the quoted material bill an agent wants before it
// starts, so a price swing cannot leave an upgrade half-bought.
cashMargin: 1.15,
};
// A production building's output per day at upgrade `level`, exponential in the
// level. Shared by the simulation and the browser so both quote the same figure.
export function resourceBuildingOutputAt(proto, level) {
const base = (proto && proto.outputPerDay) || 0;
return base * Math.pow(RESOURCE_BUILDING_UPGRADE.alpha, Math.max(0, level || 0));
}
// A production building's operating input at upgrade `level`: the plant's fuel
// or a converter's energy, growing linearly with the level. `field` is the
// proto field to scale (`energyPerDay` or `fuelEnergyPerDay`).
export function resourceBuildingInputAt(proto, level, field = "energyPerDay") {
const base = (proto && proto[field]) || 0;
return base * (1 + RESOURCE_BUILDING_UPGRADE.inputPerLevel * Math.max(0, level || 0));
}
// The money an agent pays to reach the next level from `level`: the build cost
// compounded by `costAlpha` per level already gained.
export function resourceBuildingUpgradeMoneyCost(proto, level) {
const base = (proto && proto.buildCost) || 0;
return base * Math.pow(RESOURCE_BUILDING_UPGRADE.costAlpha, Math.max(0, level || 0));
}
export const RESOURCE_BUILDING_BY_ID = Object.fromEntries( export const RESOURCE_BUILDING_BY_ID = Object.fromEntries(
RESOURCE_BUILDINGS.map((building) => [building.id, building]) RESOURCE_BUILDINGS.map((building) => [building.id, building])
); );
+6
View File
@@ -47,6 +47,7 @@ import { serializationMethods } from "./game_state/serialization.js";
import { monthlyMethods } from "./game_state/monthly.js"; import { monthlyMethods } from "./game_state/monthly.js";
import { warfareMethods } from "./game_state/warfare.js"; import { warfareMethods } from "./game_state/warfare.js";
import { resourceMethods } from "./game_state/resources.js"; import { resourceMethods } from "./game_state/resources.js";
import { industryMethods } from "./game_state/industry.js";
import { budgetMethods } from "./game_state/budget.js"; import { budgetMethods } from "./game_state/budget.js";
import { moneyMethods } from "./game_state/money.js"; import { moneyMethods } from "./game_state/money.js";
import { taxMethods } from "./game_state/taxes.js"; import { taxMethods } from "./game_state/taxes.js";
@@ -118,6 +119,9 @@ export class GameState {
this.tileImprovementOwner = new Map(); this.tileImprovementOwner = new Map();
this.tileImprovementHp = new Map(); this.tileImprovementHp = new Map();
this.tileWorks = new Map(); this.tileWorks = new Map();
// Production buildings as independent private-sector agents, keyed by tile:
// each keeps its own cash, upgrade level and sales history.
this.buildingAgents = new Map();
this._tileImprovementVersion = 0; this._tileImprovementVersion = 0;
// Bumped whenever the transport network changes, so the serialised road and // Bumped whenever the transport network changes, so the serialised road and
// railway lists are rebuilt only when something actually moved. // railway lists are rebuilt only when something actually moved.
@@ -384,6 +388,7 @@ export class GameState {
} }
this._tickBuildings(); this._tickBuildings();
this._tickTileWorks(); this._tickTileWorks();
this._tickIndustry();
this._tickEmbark(); this._tickEmbark();
this._tickCoastalCannons(); this._tickCoastalCannons();
// A unit no longer sharing a tile with an enemy is not an invader any more. // A unit no longer sharing a tile with an enemy is not an invader any more.
@@ -413,6 +418,7 @@ Object.assign(
monthlyMethods, monthlyMethods,
warfareMethods, warfareMethods,
resourceMethods, resourceMethods,
industryMethods,
budgetMethods, budgetMethods,
moneyMethods, moneyMethods,
taxMethods taxMethods
+3 -3
View File
@@ -288,7 +288,7 @@ export const airMethods = {
// Applies one ranged blow to a target unit, with the special fates of exposed // Applies one ranged blow to a target unit, with the special fates of exposed
// support units, and passes a tenth of the damage on to any structures on the // support units, and passes a tenth of the damage on to any structures on the
// tile. // tile.
_applyStrikeDamage(target, damage, attackerProto = null) { _applyStrikeDamage(target, damage) {
const proto = this.unitProto(target); const proto = this.unitProto(target);
const alone = this.unitsAt(target.coords).length <= 1; const alone = this.unitsAt(target.coords).length <= 1;
if (proto && proto.destroyedByRangedAlone && alone) damage = target.hp; if (proto && proto.destroyedByRangedAlone && alone) damage = target.hp;
@@ -420,7 +420,7 @@ export const airMethods = {
const target = this._strikeTargetAt(goal, unit.civ); const target = this._strikeTargetAt(goal, unit.civ);
if (target) { if (target) {
const damage = Math.max(1, this._unitAttack(unit) - this._effectiveDefense(target, true)); const damage = Math.max(1, this._unitAttack(unit) - this._effectiveDefense(target, true));
this._applyStrikeDamage(target, damage, this.unitProto(unit)); this._applyStrikeDamage(target, damage);
} else { } else {
const city = this._strikeCityAt(goal, unit.civ); const city = this._strikeCityAt(goal, unit.civ);
if (city) { if (city) {
@@ -471,7 +471,7 @@ export const airMethods = {
if (damage.size === 0) return; if (damage.size === 0) return;
const destroyed = []; const destroyed = [];
for (const [target, amount] of damage) { for (const [target, amount] of damage) {
if (this._applyStrikeDamage(target, amount, null)) destroyed.push(target); if (this._applyStrikeDamage(target, amount)) destroyed.push(target);
} }
this._emitChanged(); this._emitChanged();
}, },
+1 -10
View File
@@ -133,7 +133,7 @@ export const economyMethods = {
const id = this.tileImprovements.get(k); const id = this.tileImprovements.get(k);
const proto = id ? tileImprovementById(id) : null; const proto = id ? tileImprovementById(id) : null;
if (isResourceTileBuilding(proto)) { if (isResourceTileBuilding(proto)) {
daily += (proto.outputPerDay || 0) * this.getResourcePrice(proto.resource); daily += this.resourceBuildingOutputAt(k, proto) * this.getResourcePrice(proto.resource);
} }
} }
return daily * DAYS_PER_YEAR; return daily * DAYS_PER_YEAR;
@@ -522,15 +522,6 @@ export const economyMethods = {
return groups.sort((a, b) => b.amount - a.amount); return groups.sort((a, b) => b.amount - a.amount);
}, },
_buildingUpkeepSources(civ) {
return this._buildingUpkeepGroups(civ).map((group) => ({
label: group.label,
kind: "building",
amount: group.amount,
resources: group.resources,
}));
},
_unitUpkeepSources(civ) { _unitUpkeepSources(civ) {
const sources = new Map(); const sources = new Map();
const battle = this._battleUnitIds(); const battle = this._battleUnitIds();
+374
View File
@@ -0,0 +1,374 @@
// Production buildings as independent private-sector agents. Every resource
// building -- a steel mill, a mine, a chip foundry, a power plant -- is not
// merely a region's asset: it keeps its own cash from what it sells, watches
// how its own demand moves, and reinvests that cash in upgrading itself. A
// converter makes only what it can sell and the grid pays its plants for the
// power it delivers (see `_settleGridPower`), so no agent is forced to run at
// a permanent loss.
//
// The daily tick rolls each agent's sales into a short window, then lets an
// agent whose recent sales beat its older ones -- or that is selling out -- buy
// the steel and high-tech for its next level and start the work. An upgrade
// raises throughput exponentially but the extra output costs power linearly
// (see RESOURCE_BUILDING_UPGRADE in shared/data/resources.js), and an agent may
// only have one upgrade under way at a time.
import { key, parseKey } from "../hex.js";
import { ECONOMY } from "../data/economy.js";
import { RESOURCE_RULES } from "../data/resources.js";
import {
RESOURCE_BUILDING_UPGRADE,
resourceBuildingOutputAt,
resourceBuildingInputAt,
resourceBuildingUpgradeMoneyCost,
tileImprovementById,
isResourceTileBuilding,
} from "../data.js";
import { HOURS_PER_DAY } from "./constants.js";
function emptyBuildingAgent() {
return { level: 0, cash: 0, salesToday: 0, sales: [], upgrading: null };
}
export const industryMethods = {
// ------------------------------------------------------- agent state --
// Every resource building trades: a material producer sells its good, a
// power plant sells the power its grid delivers.
_isProductionAgent(proto) {
return isResourceTileBuilding(proto);
},
_ensureBuildingAgent(k) {
if (!this.buildingAgents) this.buildingAgents = new Map();
let agent = this.buildingAgents.get(k);
if (!agent) {
agent = emptyBuildingAgent();
this.buildingAgents.set(k, agent);
}
return agent;
},
// Drops the agent of a tile whose building is gone.
_removeBuildingAgent(k) {
if (this.buildingAgents) this.buildingAgents.delete(k);
},
buildingAgentAt(coords) {
if (!this.buildingAgents) return null;
return this.buildingAgents.get(key(coords.x, coords.y)) || null;
},
resourceBuildingLevel(k) {
const agent = this.buildingAgents ? this.buildingAgents.get(k) : null;
return agent ? agent.level : 0;
},
// The output per day of the building on `k`, compounded by its level.
resourceBuildingOutputAt(k, proto) {
return resourceBuildingOutputAt(proto, this.resourceBuildingLevel(k));
},
// The operating input of the building on `k` at its level. `field` names the
// proto field to scale (`energyPerDay` or `fuelEnergyPerDay`).
resourceBuildingInputAt(k, proto, field = "energyPerDay") {
return resourceBuildingInputAt(proto, this.resourceBuildingLevel(k), field);
},
// ------------------------------------------------------------ money --
// A buyer's payment reaches the producing building, into its own cash, in the
// currency of the nation it belongs to whatever currency it was paid in.
_creditBuildingCash(k, code, amount) {
if (!(amount > 0)) return;
const owner = this.tileImprovementOwner.get(k);
if (owner === undefined || owner < 0) return;
const agent = this._ensureBuildingAgent(k);
const value = amount * this.currencyValueOf(code) / this.currencyValue(owner);
agent.cash = (agent.cash || 0) + value;
},
// Records that a production agent sold `qty` of its output during the day,
// feeding the demand trend that decides whether it grows.
_recordBuildingSale(k, qty) {
if (!(qty > 0)) return;
const agent = this._ensureBuildingAgent(k);
agent.salesToday = (agent.salesToday || 0) + qty;
},
// 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;
payer.total += cost;
};
return payer;
},
// 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.
_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;
},
// Pays a grid's plants for `kwh` of power worth `money`, split by how much
// each contributed, and credits each with the sale its demand trend reads.
_payGridProducers(component, money, kwh) {
const producers = (component && component.producers) || [];
let total = 0;
for (const producer of producers) total += producer.output || 0;
if (!(total > 0)) {
if (money > 0 && component.civ >= 0) this._creditRegions(component.civ, money);
return;
}
const code = this.currencyOf(component.civ).code;
for (const producer of producers) {
const share = (producer.output || 0) / total;
if (money > 0) this._creditBuildingCash(producer.k, code, money * share);
if (kwh > 0) this._recordBuildingSale(producer.k, kwh * share);
}
},
// Everything the nation's production buildings hold, in the nation's currency.
getPrivateBuildingCash(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.cash || 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) {
const result = [];
if (!this.buildingAgents) return result;
for (const [k, agent] of this.buildingAgents) {
if (this.tileImprovementOwner.get(k) !== civ) continue;
const id = this.tileImprovements.get(k);
const proto = id ? tileImprovementById(id) : null;
if (!this._isProductionAgent(proto)) continue;
const coords = parseKey(k);
result.push({
coords: [coords.x, coords.y],
name: proto.name,
resource: proto.resource,
level: agent.level,
cash: agent.cash || 0,
outputPerDay: resourceBuildingOutputAt(proto, agent.level),
upgrading: !!agent.upgrading,
upgradeDaysLeft: agent.upgrading
? Math.max(0, Math.ceil((agent.upgrading.totalHours - agent.upgrading.elapsedHours) / HOURS_PER_DAY))
: 0,
});
}
return result;
},
// The steel and high-tech one upgrade level needs, scaled off its money cost
// like any other construction. Only an advanced producer needs the high-tech.
_upgradeMaterialCost(proto, level) {
const money = resourceBuildingUpgradeMoneyCost(proto, level);
return {
steel: money * RESOURCE_RULES.steelPerBudgetEuro,
hightech: proto.advanced ? RESOURCE_RULES.constructionHighTechPerLevel * (level + 1) : 0,
};
},
// How much of its capacity a converter should run at. It runs flat out while
// its store is thin -- so it always keeps up with demand -- and only eases off
// once it is sitting on more stock than it has been selling, making roughly
// what it sells so it never burns power on goods it cannot move. A brand-new
// building has no history and runs flat out.
_converterProductionScale(k, proto, agent) {
const capacity = resourceBuildingOutputAt(proto, agent ? agent.level : 0);
if (!(capacity > 0)) return 1;
const sales = (agent && agent.sales) || [];
let expected = capacity;
if (sales.length > 0) {
const half = Math.max(1, Math.floor(sales.length / 2));
const recent = sales.slice(-half);
expected = recent.reduce((sum, value) => sum + value, 0) / recent.length;
}
const target = Math.max(capacity, expected * 2);
const store = this.resourceStock ? this.resourceStock.get(k) : null;
const stock = (store || {})[proto.resource] || 0;
if (stock <= target) return 1;
// Overstocked: make only what has been selling, but never switch off.
return Math.max(0.1, Math.min(1, expected / capacity));
},
// What the next level's materials would cost at today's delivered market
// price, in the agent's national currency. The delivered cost folds in a
// nominal two-tile haul, so the quoted bill is not beaten by a distant
// supplier's freight.
_upgradeMaterialValue(civ, materials) {
const value = materials.steel * this._marketUnitCost("steel") +
materials.hightech * this._marketUnitCost("hightech");
return value / this.currencyValue(civ);
},
// ------------------------------------------------------- daily tick --
// One hour of every agent's upgrade, and once a day the demand read that may
// start a new one.
_tickIndustry() {
if (!this.buildingAgents || this.buildingAgents.size === 0) return;
for (const [k, agent] of this.buildingAgents) {
if (!agent.upgrading) continue;
agent.upgrading.elapsedHours += 1;
if (agent.upgrading.elapsedHours >= agent.upgrading.totalHours) {
this._completeBuildingUpgrade(k, agent);
}
}
if (this.totalHours % HOURS_PER_DAY === 0) this._tickBuildingDecisions();
},
_tickBuildingDecisions() {
// Roll today's sales into the trend window before reading it.
for (const agent of this.buildingAgents.values()) {
agent.sales.push(agent.salesToday || 0);
if (agent.sales.length > RESOURCE_BUILDING_UPGRADE.salesWindow) agent.sales.shift();
agent.salesToday = 0;
}
for (const [k, agent] of this.buildingAgents) {
if (agent.upgrading) continue;
if (agent.level >= RESOURCE_BUILDING_UPGRADE.maxLevel) continue;
const id = this.tileImprovements.get(k);
const proto = id ? tileImprovementById(id) : null;
if (!this._isProductionAgent(proto)) continue;
if (!this._buildingDemandGrowing(agent, this.resourceBuildingOutputAt(k, proto))) {
continue;
}
this._startBuildingUpgrade(k, proto, agent);
}
},
// Whether the agent should grow: its recent sales have to beat its older ones,
// or it has to be selling out (demand at least matching what it can make).
_buildingDemandGrowing(agent, capacity = 0) {
const sales = agent.sales || [];
if (sales.length < 2) return false;
const half = Math.floor(sales.length / 2);
const older = sales.slice(0, half);
const recent = sales.slice(half);
const average = (list) => list.reduce((sum, value) => sum + value, 0) / list.length;
const before = average(older);
const now = average(recent);
if (now > before * (1 + RESOURCE_BUILDING_UPGRADE.demandGrowth)) return true;
return capacity > 0 &&
now >= capacity * RESOURCE_BUILDING_UPGRADE.sellThrough &&
now >= before;
},
// Commit an agent to its next level: buy the materials from its own stores and
// the reachable market with its own cash, then start the work. Only one
// upgrade may run at a time.
_startBuildingUpgrade(k, proto, agent) {
const owner = this.tileImprovementOwner.get(k);
if (owner === undefined || owner < 0) return false;
const level = agent.level;
const materials = this._upgradeMaterialCost(proto, level);
const quoted = this._upgradeMaterialValue(owner, materials) *
RESOURCE_BUILDING_UPGRADE.cashMargin;
if ((agent.cash || 0) < quoted) return false;
const coords = parseKey(k);
if (this._buildingMaterialAvailability(owner, coords, "steel") < materials.steel) return false;
if (materials.hightech > 0 &&
this._buildingMaterialAvailability(owner, coords, "hightech") < materials.hightech) {
return false;
}
if (!this._buyBuildingMaterials(k, coords, owner, materials)) return false;
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(owner));
const money = resourceBuildingUpgradeMoneyCost(proto, level);
const totalHours = capacity > 0
? Math.max(1, (money / capacity) * this.constructionSpeedMultiplier(owner))
: 1;
agent.upgrading = { elapsedHours: 0, totalHours, targetLevel: level + 1 };
this._tileImprovementVersion += 1;
this._emitChanged();
return true;
},
_completeBuildingUpgrade(k, agent) {
agent.level = agent.upgrading ? agent.upgrading.targetLevel : agent.level + 1;
agent.upgrading = null;
// The extra output lifts the region's GDP, so the cached production is stale.
this._gdpPerCapitaCache.clear();
this._touchModifiers();
this._tileImprovementVersion += 1;
this._emitChanged();
},
// The amount of `id` an agent could lay hands on for an upgrade: its own
// store for free, the nation's city stores, and the nearby producers' surplus.
_buildingMaterialAvailability(civ, coords, id) {
const k = key(coords.x, coords.y);
let available = (this.resourceStock.get(k) || {})[id] || 0;
for (const city of this.cities) {
if (city.civ !== civ) continue;
available += this.getCityResourceStock(city)[id] || 0;
}
const city = this._nearestCity(civ, coords);
if (!city) return available;
const nodes = this._resourceNodes();
const allowSea = this._cityMarketAccess(city);
for (const supplier of this._nearbySuppliers(city, id, nodes, allowSea)) {
if (supplier.kind !== "building") continue;
if (key(supplier.coords.x, supplier.coords.y) === k) continue;
available += this._nodeSurplus(supplier, id);
}
return available;
},
// Spends the agent's cash on the upgrade's materials: its own store first
// (already its own), then the nation's regions, then the reachable market.
_buyBuildingMaterials(k, coords, civ, materials) {
const own = this._ensureResourceStock(k);
const ownSteel = Math.min(materials.steel, own.steel || 0);
const ownTech = Math.min(materials.hightech, own.hightech || 0);
if (ownSteel > 0) own.steel -= ownSteel;
if (ownTech > 0) own.hightech -= ownTech;
let needSteel = materials.steel - ownSteel;
let needTech = materials.hightech - ownTech;
const payer = this._buildingPayer(k);
if (needSteel > 0) {
needSteel -= this._drawFromNearest(civ, coords, { steel: needSteel, hightech: 0 }, payer).steel;
}
if (needTech > 0) {
needTech -= this._drawFromNearest(civ, coords, { steel: 0, hightech: needTech }, payer).hightech;
}
if (needSteel > 0 || needTech > 0) {
const city = this._nearestCity(civ, coords);
if (city) {
const nodes = this._resourceNodes();
const allowSea = this._cityMarketAccess(city);
const haul = { steel: 0, hightech: 0 };
if (needSteel > 0) {
needSteel -= this._procureFromNeighbours(
city, "steel", needSteel, haul, nodes, { payer, allowSea }
);
}
if (needTech > 0) {
needTech -= this._procureFromNeighbours(
city, "hightech", needTech, haul, nodes, { payer, allowSea }
);
}
}
}
return needSteel <= 1e-6 && needTech <= 1e-6;
},
};
+15 -12
View File
@@ -96,18 +96,6 @@ export const moneyMethods = {
// --------------------------------------------------- region cash -- // --------------------------------------------------- region cash --
// Takes `amount` of one currency out of a region's basket. Returns false (and
// charges nothing) when the region does not hold enough.
_debitRegionCash(city, code, amount) {
if (!(amount > 0)) return true;
const basket = this.regionCash ? this.regionCash.get(city.id) : null;
if (!basket) return false;
const held = basket.get(code) || 0;
if (held + 1e-9 < amount) return false;
basket.set(code, held - amount);
return true;
},
_creditRegionCash(city, code, amount) { _creditRegionCash(city, code, amount) {
if (!(amount > 0)) return; if (!(amount > 0)) return;
const basket = this.regionCash ? this.regionCash.get(city.id) : null; const basket = this.regionCash ? this.regionCash.get(city.id) : null;
@@ -302,6 +290,8 @@ export const moneyMethods = {
// An overdrawn treasury is a claim on the bank, not circulating money. // An overdrawn treasury is a claim on the bank, not circulating money.
let amount = Math.max(0, this.getBudget(civ) || 0); let amount = Math.max(0, this.getBudget(civ) || 0);
for (const city of this.cities) amount += this.getRegionCash(city).get(code) || 0; for (const city of this.cities) amount += this.getRegionCash(city).get(code) || 0;
// The production buildings are private agents too, holding their own cash.
amount += this.getPrivateBuildingCash(civ);
for (const reserves of this.centralBankReserves.values()) { for (const reserves of this.centralBankReserves.values()) {
amount += reserves.get(code) || 0; amount += reserves.get(code) || 0;
} }
@@ -405,6 +395,19 @@ export const moneyMethods = {
banks.push({ civ: c, name: host ? host.name : "", amount }); banks.push({ civ: c, name: host ? host.name : "", amount });
bankTotal += amount; bankTotal += amount;
} }
// The nation's production buildings hold their own cash, in the national
// currency, alongside the regions.
const buildingCash = this.getPrivateBuildingCash(civ);
if (buildingCash !== 0) {
const host = this.civilisations[civ];
own.push({
name: "Production buildings",
civ,
civName: host ? host.name : "",
amount: buildingCash,
});
ownTotal += buildingCash;
}
const budget = this.getBudget(civ) || 0; const budget = this.getBudget(civ) || 0;
const treasury = Math.max(0, budget); const treasury = Math.max(0, budget);
const treasuryDebt = Math.max(0, -budget); const treasuryDebt = Math.max(0, -budget);
-8
View File
@@ -378,19 +378,11 @@ export const politicsMethods = {
return this.getCivOpinions(civ).approval; return this.getCivOpinions(civ).approval;
}, },
getCityPopularity(city) {
return this.getCityOpinions(city).popularity;
},
getCivPopularity(civ) { getCivPopularity(civ) {
return this.getCivOpinions(civ).popularity; return this.getCivOpinions(civ).popularity;
}, },
// What the player sees, in 0..1: the popularity points through the logistic. // What the player sees, in 0..1: the popularity points through the logistic.
getCityPopularityDisplay(city) {
return popularityDisplay(this.getCityPopularity(city));
},
getCivPopularityDisplay(civ) { getCivPopularityDisplay(civ) {
return popularityDisplay(this.getCivPopularity(civ)); return popularityDisplay(this.getCivPopularity(civ));
}, },
+205 -52
View File
@@ -19,6 +19,7 @@ import {
FAMINE, FAMINE,
tileImprovementById, tileImprovementById,
isResourceTileBuilding, isResourceTileBuilding,
resourceById,
MONEY, MONEY,
} from "../data.js"; } from "../data.js";
import { import {
@@ -29,9 +30,10 @@ import {
luxuryNeedPerDay, luxuryNeedPerDay,
foodMonthlyTarget, foodMonthlyTarget,
producerEnergyPerDay, producerEnergyPerDay,
foodSynthesisEnergy,
synthesisedFoodFor,
deliveryEnergyForResource, deliveryEnergyForResource,
nextMarketPrice, nextMarketPrice,
upkeepResources,
protoUpkeep, protoUpkeep,
constructionResourceCost as sharedConstructionResourceCost, constructionResourceCost as sharedConstructionResourceCost,
} from "../resources.js"; } from "../resources.js";
@@ -314,6 +316,7 @@ export const resourceMethods = {
this.tileImprovements.set(k, proto.id); this.tileImprovements.set(k, proto.id);
this.tileImprovementOwner.set(k, civ); this.tileImprovementOwner.set(k, civ);
this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[proto.id] || 1000); this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[proto.id] || 1000);
if (this._isProductionAgent(proto)) this._ensureBuildingAgent(k);
// A work beside the network gets a spur; one already on it needs none. // A work beside the network gets a spur; one already on it needs none.
if (!this.roads.has(k) && !this.railways.has(k) && touchesNetwork(coords)) { if (!this.roads.has(k) && !this.railways.has(k) && touchesNetwork(coords)) {
this.roads.add(k); this.roads.add(k);
@@ -375,7 +378,7 @@ export const resourceMethods = {
if (this.tileImprovementOwner.get(k) !== civ) continue; if (this.tileImprovementOwner.get(k) !== civ) continue;
const proto = tileImprovementById(id); const proto = tileImprovementById(id);
if (!isResourceTileBuilding(proto) || proto.resource === "energy") continue; if (!isResourceTileBuilding(proto) || proto.resource === "energy") continue;
made[proto.resource] = (made[proto.resource] || 0) + proto.outputPerDay; made[proto.resource] = (made[proto.resource] || 0) + this.resourceBuildingOutputAt(k, proto);
} }
return made; return made;
}, },
@@ -589,10 +592,17 @@ export const resourceMethods = {
if (this.tileImprovementOwner.get(k) !== civ) continue; if (this.tileImprovementOwner.get(k) !== civ) continue;
const proto = tileImprovementById(id); const proto = tileImprovementById(id);
if (!isResourceTileBuilding(proto)) continue; if (!isResourceTileBuilding(proto)) continue;
if (proto.resource === "energy") energySupply += proto.outputPerDay; if (proto.resource === "energy") {
else production[proto.resource] += proto.outputPerDay; energySupply += this.resourceBuildingOutputAt(k, proto);
} else {
// A converter makes only what it can sell, so report the throttled
// figure the daily tick used rather than the nameplate capacity.
const throttle = this._converterScales ? (this._converterScales.get(k) ?? 1) : 1;
production[proto.resource] += this.resourceBuildingOutputAt(k, proto) * throttle;
}
} }
// The land's harvest is part of what the nation produces. // The land's harvest is part of what the nation produces, and so is the
// extra food its regions force from energy when the harvest falls short.
const foodMultipliers = new Map(); const foodMultipliers = new Map();
for (const coords of this._territoryByCiv.get(civ) || []) { for (const coords of this._territoryByCiv.get(civ) || []) {
const k = key(coords.x, coords.y); const k = key(coords.x, coords.y);
@@ -608,6 +618,12 @@ export const resourceMethods = {
} }
production.food += tileFoodOutput(this.tiles[k]) * multiplier; production.food += tileFoodOutput(this.tiles[k]) * multiplier;
} }
if (this._foodSynthesis) {
for (const city of this.cities) {
if (city.civ !== civ) continue;
production.food += this._foodSynthesis.get(city.id) || 0;
}
}
const upkeep = this.getCivUpkeepResources(civ); const upkeep = this.getCivUpkeepResources(civ);
// What the nation actually consumes in a day: the people's needs plus the // What the nation actually consumes in a day: the people's needs plus the
// materials its buildings, units, works and transport wear out. Energy is a // materials its buildings, units, works and transport wear out. Energy is a
@@ -1198,6 +1214,9 @@ export const resourceMethods = {
// charged to the tile's grid component. // charged to the tile's grid component.
const pendingFood = []; const pendingFood = [];
const foodMultipliers = new Map(); const foodMultipliers = new Map();
// The land's harvest per region, so the synthesis pass below knows who is
// short of what its people eat.
const regionFood = new Map();
for (const k in this.tiles) { for (const k in this.tiles) {
const coords = parseKey(k); const coords = parseKey(k);
if (!this._isLand(coords)) continue; if (!this._isLand(coords)) continue;
@@ -1212,6 +1231,7 @@ export const resourceMethods = {
} }
const produced = tileFoodOutput(this.tiles[k]) * foodMultiplier; const produced = tileFoodOutput(this.tiles[k]) * foodMultiplier;
if (produced <= 0) continue; if (produced <= 0) continue;
regionFood.set(city.id, (regionFood.get(city.id) || 0) + produced);
pendingFood.push({ pendingFood.push({
cityId: city.id, cityId: city.id,
coords: city.coords, coords: city.coords,
@@ -1219,7 +1239,50 @@ export const resourceMethods = {
amount: produced, amount: produced,
}); });
const component = ledger.get(components.get(k)); const component = ledger.get(components.get(k));
if (component) component.foodEnergy += producerEnergyPerDay("food", produced); if (component) {
const foodPower = producerEnergyPerDay("food", produced);
component.foodEnergy += foodPower;
component.foodConsumers.push({ city, amount: foodPower });
}
}
// A region whose own harvest falls short of what its people eat can force
// extra food from energy -- greenhouses and synthesis. Growing is cheapest
// for the first tonnes, so the region grows while the marginal energy for
// another tonne beats buying, then completes with the dearer option: what
// the reachable market cannot actually supply, it grows itself.
const foodPrice = this.getResourcePrice("food");
const energyPrice = this.getResourcePrice("energy");
const nodes = this._resourceNodes();
this._foodSynthesis = new Map();
for (const city of this.cities) {
const produced = regionFood.get(city.id) || 0;
const need = foodNeedPerDay(this.getCityEconomy(city).population);
const shortfall = need - produced;
if (!(shortfall > 0)) continue;
const allowSea = this._cityMarketAccess(city) &&
!this._isEncircled(city.coords, city.civ);
// The food the region can actually buy: the reachable surplus. A distant
// island reaches none, so it must grow every tonne itself.
let buyable = 0;
for (const supplier of this._nearbySuppliers(city, "food", nodes, allowSea)) {
buyable += this._nodeSurplus(supplier, "food");
}
const extra = synthesisedFoodFor(foodPrice, energyPrice, shortfall, buyable);
if (!(extra > 0)) continue;
const componentId = components.get(key(city.coords.x, city.coords.y));
const component = ledger.get(componentId);
if (!component) continue;
const energy = foodSynthesisEnergy(extra);
component.foodEnergy += energy;
component.foodConsumers.push({ city, amount: energy });
pendingFood.push({
cityId: city.id,
coords: city.coords,
component: componentId,
amount: extra,
});
this._foodSynthesis.set(city.id, extra);
} }
// Settle every connected grid: what the plants make against what the cities // Settle every connected grid: what the plants make against what the cities
@@ -1234,27 +1297,32 @@ export const resourceMethods = {
for (const component of ledger.values()) { for (const component of ledger.values()) {
if (component.civ < 0) continue; if (component.civ < 0) continue;
component.available = component.supply + component.imports; component.available = component.supply + component.imports;
const industrial = component.converterEnergy + component.foodEnergy; // Cities are served first, then the food the region grows, then the
component.industrialScale = industrial > 0 // converters: a famine is worse than an idle factory, so the power that
? Math.max(0, Math.min(1, (component.available - component.cityEnergy) / industrial)) // feeds people is never throttled away by industry.
: 1; const cityServed = Math.min(component.cityEnergy, component.available);
const cityServed = Math.min(
component.cityEnergy,
Math.max(0, component.available - industrial * component.industrialScale)
);
component.cityShortage = component.cityEnergy > 0 component.cityShortage = component.cityEnergy > 0
? Math.max(0, Math.min(1, (component.cityEnergy - cityServed) / component.cityEnergy)) ? Math.max(0, Math.min(1, (component.cityEnergy - cityServed) / component.cityEnergy))
: 0; : 0;
const afterCity = Math.max(0, component.available - cityServed);
const foodServed = Math.min(component.foodEnergy, afterCity);
component.foodScale = component.foodEnergy > 0 ? foodServed / component.foodEnergy : 1;
const afterFood = afterCity - foodServed;
component.industrialScale = component.converterEnergy > 0
? Math.max(0, Math.min(1, afterFood / component.converterEnergy))
: 1;
market.get("energy").supply += component.supply; market.get("energy").supply += component.supply;
market.get("energy").demand += component.demand; market.get("energy").demand += component.demand;
} }
// A grid that cannot power its industry names the works left idle. // A grid that cannot power its industry names the works left idle.
this._noteGridPowerShortages(ledger, components); this._noteGridPowerShortages(ledger, components);
// Food lands in the region's store, scaled by the power the grid could give. // Food lands in the region's store, scaled by the power the grid could give
// it. Food is served before the converters, so the harvest survives a
// power squeeze that idles the factories.
for (const entry of pendingFood) { for (const entry of pendingFood) {
const component = ledger.get(entry.component); const component = ledger.get(entry.component);
const scale = component ? component.industrialScale : 1; const scale = component ? component.foodScale : 1;
const produced = entry.amount * scale; const produced = entry.amount * scale;
if (produced <= 0) continue; if (produced <= 0) continue;
this._ensureResourceStock(key(entry.coords.x, entry.coords.y)).food += produced; this._ensureResourceStock(key(entry.coords.x, entry.coords.y)).food += produced;
@@ -1268,7 +1336,8 @@ export const resourceMethods = {
if (!isResourceTileBuilding(proto) || proto.resource === "energy") continue; if (!isResourceTileBuilding(proto) || proto.resource === "energy") continue;
const component = ledger.get(components.get(k)); const component = ledger.get(components.get(k));
const scale = component ? component.industrialScale : 1; const scale = component ? component.industrialScale : 1;
const produced = proto.outputPerDay * scale; const throttle = this._converterScales ? (this._converterScales.get(k) ?? 1) : 1;
const produced = this.resourceBuildingOutputAt(k, proto) * throttle * scale;
if (produced <= 0) continue; if (produced <= 0) continue;
this._ensureResourceStock(k)[proto.resource] += produced; this._ensureResourceStock(k)[proto.resource] += produced;
market.get(proto.resource).supply += produced; market.get(proto.resource).supply += produced;
@@ -1276,7 +1345,11 @@ export const resourceMethods = {
// The people eat, wear and spend. Shortfalls are matched against reachable // The people eat, wear and spend. Shortfalls are matched against reachable
// stores -- home regions first, then foreign ones. // stores -- home regions first, then foreign ones.
this._consumeCityResources(components, market); this._consumeCityResources(components, market, nodes);
// Power is a paid private flow: the grid's consumers settle with its plants,
// after the day's sales so an agent can pay from what it just earned.
this._settleGridPower(ledger);
// A region that could not feed its people is in famine: it tells the world, // A region that could not feed its people is in famine: it tells the world,
// and its people start dying. // and its people start dying.
@@ -1341,56 +1414,98 @@ export const resourceMethods = {
// supply, filled in as the world is walked. // supply, filled in as the world is walked.
_buildEnergyLedger(components) { _buildEnergyLedger(components) {
const ledger = new Map(); const ledger = new Map();
// Each converter's production scale for the day, so its power draw matches
// what it actually intends to make. Read again by the production loop.
this._converterScales = new Map();
for (const [k, id] of components) { for (const [k, id] of components) {
if (ledger.has(id)) continue; if (ledger.has(id)) continue;
ledger.set(id, { ledger.set(id, {
civ: this.territory.get(k), civ: this.territory.get(k),
supply: 0, supply: 0,
// The draw that makes up the physical balance: cities and their
// buildings, the converters and the land's food, plus the works' wear
// and the plants' own fuel as internal losses.
cityEnergy: 0, cityEnergy: 0,
converterEnergy: 0, converterEnergy: 0,
foodEnergy: 0, foodEnergy: 0,
fuelEnergy: 0,
// The part of `cityEnergy` the regions actually pay for, so the works'
// and plants' internal losses are not billed to them.
billableCityEnergy: 0,
demand: 0, demand: 0,
available: 0, available: 0,
industrialScale: 1, industrialScale: 1,
foodScale: 1,
cityShortage: 0, cityShortage: 0,
// Who makes the power and who draws it, so the grid's bill can be
// settled between them: the plants sell, the converters pay from their
// own cash and the regions pay for their cities and their food.
producers: [],
cities: [],
converters: [],
foodConsumers: [],
imports: 0,
exports: 0,
importCost: 0,
}); });
} }
for (const city of this.cities) { for (const city of this.cities) {
const component = ledger.get(components.get(key(city.coords.x, city.coords.y))); const component = ledger.get(components.get(key(city.coords.x, city.coords.y)));
if (!component) continue; if (!component) continue;
component.cityEnergy += cityEnergyPerDay(this.getCityEconomy(city).gdp); let amount = cityEnergyPerDay(this.getCityEconomy(city).gdp);
// The city's buildings run on the grid too, so their operating power is // The city's buildings run on the grid too, so their operating power is
// charged to the same component. // charged to the same component.
for (const [index, level] of Object.entries(city.buildings || {})) { for (const [index, level] of Object.entries(city.buildings || {})) {
if (level <= 0) continue; if (level <= 0) continue;
const proto = this.protoBuildings[Number(index)]; const proto = this.protoBuildings[Number(index)];
if (proto) component.cityEnergy += this.buildingUpkeepResources(proto, level).energy; if (proto) amount += this.buildingUpkeepResources(proto, level).energy;
} }
component.cityEnergy += amount;
component.billableCityEnergy += amount;
component.cities.push({ city, amount });
} }
for (const [k, id] of this.tileImprovements) { for (const [k, id] of this.tileImprovements) {
const proto = tileImprovementById(id); const proto = tileImprovementById(id);
if (!proto) continue; if (!proto) continue;
const component = ledger.get(components.get(k)); const component = ledger.get(components.get(k));
if (!component) continue; if (!component) continue;
if (isResourceTileBuilding(proto) && proto.resource === "energy") { if (isResourceTileBuilding(proto)) {
component.supply += proto.outputPerDay; if (proto.resource === "energy") {
} else if (isResourceTileBuilding(proto)) { const output = this.resourceBuildingOutputAt(k, proto);
component.converterEnergy += proto.energyPerDay || 0; component.supply += output;
component.producers.push({ k, output });
} else {
const agent = this.buildingAgents ? this.buildingAgents.get(k) : null;
const scale = this._converterProductionScale(k, proto, agent);
this._converterScales.set(k, scale);
const amount = this.resourceBuildingInputAt(k, proto, "energyPerDay") * scale;
component.converterEnergy += amount;
component.converters.push({ k, amount });
}
if (proto.fuelEnergyPerDay) {
const fuel = this.resourceBuildingInputAt(k, proto, "fuelEnergyPerDay");
component.fuelEnergy += fuel;
component.cityEnergy += fuel;
}
} }
// Every work's operating power is a draw on the grid; it rides with the
// cities and is not billed back to a region.
component.cityEnergy += protoUpkeep(proto, proto.buildCost || 0).energy; component.cityEnergy += protoUpkeep(proto, proto.buildCost || 0).energy;
if (proto.fuelEnergyPerDay) component.cityEnergy += proto.fuelEnergyPerDay;
} }
return ledger; return ledger;
}, },
// Moves power between grids. A grid in deficit buys from the reachable grids // Moves power between grids. A grid in deficit buys from the reachable grids
// that hold a surplus, cheapest route first; power is a flow with no store, so // that hold a surplus, cheapest route first; power is a flow with no store, so
// it flows the same day. Each importer's treasury pays the exporter, whose // it flows the same day. The seller's plants are paid for the power, and the
// regions receive the money. // buyer's consumers carry the cost. No government is involved.
_tradeGridPower(ledger, components) { _tradeGridPower(ledger, components) {
const price = this.getResourcePrice("energy"); const price = this.getResourcePrice("energy");
const surplus = new Map(); const surplus = new Map();
for (const [id, component] of ledger) { for (const [id, component] of ledger) {
component.imports = 0;
component.exports = 0;
component.importCost = 0;
surplus.set(id, Math.max(0, component.supply - component.demand)); surplus.set(id, Math.max(0, component.supply - component.demand));
} }
for (const [id, component] of ledger) { for (const [id, component] of ledger) {
@@ -1404,31 +1519,70 @@ export const resourceMethods = {
.filter(([sid]) => sid !== id && (surplus.get(sid) || 0) > 0) .filter(([sid]) => sid !== id && (surplus.get(sid) || 0) > 0)
.sort((a, b) => a[1] - b[1]); .sort((a, b) => a[1] - b[1]);
let needed = deficit; let needed = deficit;
let spent = 0;
for (const [sid, routeEnergy] of suppliers) { for (const [sid, routeEnergy] of suppliers) {
if (needed <= 0) break; if (needed <= 0) break;
const available = surplus.get(sid) || 0; const available = surplus.get(sid) || 0;
const take = Math.min(needed, available); const take = Math.min(needed, available);
if (!(take > 0)) continue; if (!(take > 0)) continue;
// The power costs its price plus the energy burned carrying it. // The power costs its price plus the energy burned carrying it. A unit
const cost = take * (price + routeEnergy * price); // of energy weighs `tonnesPerUnit`, so the freight is charged on the
// real tonnage moved, not as if every kWh were a tonne.
const tonnes = take * (resourceById("energy")?.tonnesPerUnit || 1);
const cost = take * price + tonnes * routeEnergy * price;
const seller = ledger.get(sid); const seller = ledger.get(sid);
this.budgets.set(component.civ, this.getBudget(component.civ) - cost); this._payGridProducers(seller, cost, take);
this._recordBudgetCash(component.civ, "energy", -cost);
this._recordBudgetCategoryResource(component.civ, "energy", "energy", cost);
if (seller.civ !== component.civ) { if (seller.civ !== component.civ) {
this._creditRegions(seller.civ, cost);
this._recordExternalBuy(component.civ, "energy", take, cost); this._recordExternalBuy(component.civ, "energy", take, cost);
this._recordExternalSell(seller.civ, "energy", take, cost); this._recordExternalSell(seller.civ, "energy", take, cost);
} else {
this._creditRegions(seller.civ, cost);
} }
surplus.set(sid, available - take); surplus.set(sid, available - take);
component.imports += take; component.imports += take;
component.importCost += cost;
seller.exports += take;
needed -= take; needed -= take;
spent += cost;
} }
if (spent > 0) this._recordResourceSpend(component.civ, "energy", spent, "energy"); }
},
// Settles one grid's power bill: the regions pay for their cities' and their
// food's draw, the converters pay for their own from their cash, and the
// plants are paid for what was actually delivered. The works' wear and the
// plants' fuel are the grid's internal losses, folded into the price.
_settleGridPower(ledger) {
const price = this.getResourcePrice("energy");
for (const component of ledger.values()) {
if (component.civ < 0) continue;
const cityServed = component.cityEnergy * (1 - (component.cityShortage || 0));
const billableCityServed = Math.min(cityServed, component.billableCityEnergy);
const foodServed = component.foodEnergy * (component.foodScale ?? 1);
const converterServed = component.converterEnergy * (component.industrialScale ?? 1);
const billableServed = billableCityServed + foodServed + converterServed;
if (!(billableServed > 0)) continue;
const totalConsumed = cityServed + foodServed + converterServed;
const ownUsed = Math.min(component.supply, totalConsumed);
const bill = ownUsed * price + (component.importCost || 0);
const unitPrice = bill / billableServed;
const code = this.currencyOf(component.civ).code;
const cityFactor = component.billableCityEnergy > 0
? billableCityServed / component.billableCityEnergy
: 0;
const foodFactor = component.foodEnergy > 0 ? foodServed / component.foodEnergy : 0;
const converterFactor = component.converterEnergy > 0
? converterServed / component.converterEnergy
: 0;
for (const entry of component.cities || []) {
const cost = entry.amount * cityFactor * unitPrice;
if (cost > 0) this._chargeRegionCash(entry.city, code, cost);
}
for (const entry of component.foodConsumers || []) {
const cost = entry.amount * foodFactor * unitPrice;
if (cost > 0) this._chargeRegionCash(entry.city, code, cost);
}
for (const entry of component.converters || []) {
const cost = entry.amount * converterFactor * unitPrice;
if (cost > 0) this._chargeBuildingCash(entry.k, code, cost);
}
this._payGridProducers(component, ownUsed * price, ownUsed);
} }
}, },
@@ -1486,8 +1640,8 @@ export const resourceMethods = {
return reach; return reach;
}, },
_consumeCityResources(components, market) { _consumeCityResources(components, market, nodes = null) {
const nodes = this._resourceNodes(); nodes = nodes || this._resourceNodes();
// How much of each supplier's surplus earlier buyers have already claimed // How much of each supplier's surplus earlier buyers have already claimed
// today, so a contested surplus goes to whoever bids highest. // today, so a contested surplus goes to whoever bids highest.
this._nodeClaims = new Map(); this._nodeClaims = new Map();
@@ -1679,6 +1833,10 @@ export const resourceMethods = {
supplier.stock[id] -= bought; supplier.stock[id] -= bought;
cityStore[id] += bought; cityStore[id] += bought;
remaining -= bought; remaining -= bought;
// A production building's sale feeds the demand trend it grows on.
if (supplier.kind === "building") {
this._recordBuildingSale(key(supplier.coords.x, supplier.coords.y), bought);
}
this.resourceLinks.push({ this.resourceLinks.push({
from: [supplier.coords.x, supplier.coords.y], from: [supplier.coords.x, supplier.coords.y],
to: [city.coords.x, city.coords.y], to: [city.coords.x, city.coords.y],
@@ -1689,9 +1847,15 @@ export const resourceMethods = {
return amount - remaining; return amount - remaining;
}, },
// Pays the region that owns a surplus, into its private-sector cash. // Pays the producer that owns a surplus. A resource building is an
// independent private-sector agent, so its goods are paid into its own cash;
// a city's surplus is paid to the region that stores it.
_paySupplier(node, code, amount) { _paySupplier(node, code, amount) {
if (!(amount > 0)) return; if (!(amount > 0)) return;
if (node.kind === "building") {
this._creditBuildingCash(key(node.coords.x, node.coords.y), code, amount);
return;
}
const city = node.city || this._regionCityNear(node.civ, node.coords); const city = node.city || this._regionCityNear(node.civ, node.coords);
if (city) this._creditRegionCash(city, code, amount); if (city) this._creditRegionCash(city, code, amount);
}, },
@@ -1725,17 +1889,6 @@ export const resourceMethods = {
return best; return best;
}, },
// The capital, or the nation's first city when it has no capital.
_capitalCity(civ) {
let first = null;
for (const city of this.cities) {
if (city.civ !== civ) continue;
if (city.isCapital) return city;
if (!first) first = city;
}
return first;
},
// The market's delivered price per unit: the good plus the energy a nominal // The market's delivered price per unit: the good plus the energy a nominal
// two-tile road delivery burns. Used to value materials, not to source them. // two-tile road delivery burns. Used to value materials, not to source them.
_marketUnitCost(id) { _marketUnitCost(id) {
+7
View File
@@ -157,6 +157,8 @@ export const serializationMethods = {
taxes: null, taxes: null,
taxTake: { sales: 0, export: 0, import: 0 }, taxTake: { sales: 0, export: 0, import: 0 },
commodityInflation: 0, commodityInflation: 0,
privateBuildingCash: 0,
productionBuildings: [],
}; };
} }
const headline = this._civHeadline(viewerCiv); const headline = this._civHeadline(viewerCiv);
@@ -197,6 +199,10 @@ export const serializationMethods = {
exchangeRates: this._serializeExchangeRates(viewerCiv), exchangeRates: this._serializeExchangeRates(viewerCiv),
centralBank: this._serializeCentralBank(viewerCiv), centralBank: this._serializeCentralBank(viewerCiv),
privateCash: this.getPrivateSectorCash(viewerCiv), privateCash: this.getPrivateSectorCash(viewerCiv),
// 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),
productionBuildings: this.getProductionAgents(viewerCiv),
// Where every unit of the viewer's currency is stored: its treasury, its // Where every unit of the viewer's currency is stored: its treasury, its
// regions, and foreign regions and central banks that hold it. // regions, and foreign regions and central banks that hold it.
currencyHoldings: this.getCurrencyHoldings(viewerCiv), currencyHoldings: this.getCurrencyHoldings(viewerCiv),
@@ -334,6 +340,7 @@ export const serializationMethods = {
id, id,
this.tileImprovementOwner.get(k) ?? -1, this.tileImprovementOwner.get(k) ?? -1,
this.tileImprovementHp.get(k) || 0, this.tileImprovementHp.get(k) || 0,
this.resourceBuildingLevel(k),
]); ]);
} }
return result; return result;
-44
View File
@@ -78,50 +78,6 @@ export const statusMethods = {
return !!(proto && !proto.air && (proto.traversableTerrains || []).includes("Sea")); return !!(proto && !proto.air && (proto.traversableTerrains || []).includes("Sea"));
}, },
// Land connected components for one civ, with enemy-occupied and enemy-held
// city tiles acting as walls. Each component remembers how many of the civ's
// cities it contains. Used by the unit supply rule to find a clear route to
// an allied city.
_landComponents(civ) {
const byTile = new Map();
const cityCountByComp = new Map();
const totalCities = this.cities.filter((city) => city.civ === civ).length;
let component = 0;
for (const start of this.landCells) {
const startKey = key(start.x, start.y);
if (byTile.has(startKey) || !this._landPassable(start, civ)) continue;
const id = component++;
byTile.set(startKey, id);
let cities = 0;
let frontier = [start];
while (frontier.length > 0) {
const next = [];
for (const coords of frontier) {
const city = this.cityAt(coords);
if (city && city.civ === civ) cities += 1;
for (const neighbour of this._neighbours(coords)) {
const nk = key(neighbour.x, neighbour.y);
if (byTile.has(nk) || !this._landPassable(neighbour, civ)) continue;
byTile.set(nk, id);
next.push(neighbour);
}
}
frontier = next;
}
cityCountByComp.set(id, cities);
}
return { byTile, cityCountByComp, totalCities };
},
_landPassable(coords, civ) {
const tile = this.tiles[key(coords.x, coords.y)];
if (!tile || tile.terrainClass !== "Land") return false;
if (this.unitsAt(coords).some((unit) => unit.civ !== civ)) return false;
const city = this.cityAt(coords);
if (city && city.civ !== civ) return false;
return true;
},
_statusHpLossPerDay(def, inCity) { _statusHpLossPerDay(def, inCity) {
if (inCity && typeof def.cityHpLossPerDay === "number") return def.cityHpLossPerDay; if (inCity && typeof def.cityHpLossPerDay === "number") return def.cityHpLossPerDay;
return typeof def.hpLossPerDay === "number" ? def.hpLossPerDay : 0; return typeof def.hpLossPerDay === "number" ? def.hpLossPerDay : 0;
+8 -17
View File
@@ -4,7 +4,7 @@
import { key, parseKey } from "../hex.js"; import { key, parseKey } from "../hex.js";
import { ECONOMY } from "../data/economy.js"; import { ECONOMY } from "../data/economy.js";
import { TRANSPORT_BY_ID, tileImprovementById } from "../data/improvements.js"; import { tileImprovementById } from "../data/improvements.js";
import { TRENCH } from "../data/combat.js"; import { TRENCH } from "../data/combat.js";
import { TILE_IMPROVEMENT_HP, COASTAL_CANNON, EMBARK_HOURS } from "./constants.js"; import { TILE_IMPROVEMENT_HP, COASTAL_CANNON, EMBARK_HOURS } from "./constants.js";
import { protoUpkeep, tileImprovementResourceCost } from "../resources.js"; import { protoUpkeep, tileImprovementResourceCost } from "../resources.js";
@@ -54,7 +54,7 @@ export const warfareMethods = {
if (!tile || tile.terrainClass !== "Land") return false; if (!tile || tile.terrainClass !== "Land") return false;
if (this.civAt(coords) !== civ) return false; if (this.civAt(coords) !== civ) return false;
if (this.tileImprovements.has(k)) return false; if (this.tileImprovements.has(k)) return false;
if (proto.coastal && !this._isCoastalTile(coords)) return false; if (proto.coastal && !this.isCoastalCoords(coords)) return false;
// Resource producers feed the map, not a city: they stand on open land. // Resource producers feed the map, not a city: they stand on open land.
if (proto.resource && this.cityAt(coords)) return false; if (proto.resource && this.cityAt(coords)) return false;
if (proto.requiresTechnology && !this.hasTechnology(civ, proto.requiresTechnology)) return false; if (proto.requiresTechnology && !this.hasTechnology(civ, proto.requiresTechnology)) return false;
@@ -89,6 +89,7 @@ export const warfareMethods = {
if (this.tileImprovements.delete(k)) { if (this.tileImprovements.delete(k)) {
this.tileImprovementOwner.delete(k); this.tileImprovementOwner.delete(k);
this.tileImprovementHp.delete(k); this.tileImprovementHp.delete(k);
this._removeBuildingAgent(k);
removed = true; removed = true;
} }
if (!removed) return false; if (!removed) return false;
@@ -128,26 +129,13 @@ export const warfareMethods = {
this.tileImprovements.set(k, work.id); this.tileImprovements.set(k, work.id);
this.tileImprovementOwner.set(k, civ); this.tileImprovementOwner.set(k, civ);
this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[work.id] || 1000); this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[work.id] || 1000);
if (this._isProductionAgent(proto)) this._ensureBuildingAgent(k);
this._tileImprovementVersion += 1; this._tileImprovementVersion += 1;
this._gdpPerCapitaCache.delete(k); this._gdpPerCapitaCache.delete(k);
this._visibilityDirty = true; this._visibilityDirty = true;
this._emitChanged(); this._emitChanged();
}, },
// Whether a land tile touches the sea, so a coastal cannon may be raised.
_isCoastalTile(coords) {
for (const neighbour of this._neighbours(coords)) {
const tile = this.tiles[key(neighbour.x, neighbour.y)];
if (tile && tile.terrainClass === "Sea") return true;
}
return false;
},
_tileImprovementAt(coords) {
const id = this.tileImprovements.get(key(coords.x, coords.y));
return id ? tileImprovementById(id) : null;
},
// The extra defense a tile's fortifications and trenches give a defender, // The extra defense a tile's fortifications and trenches give a defender,
// against a melee or artillery attacker. // against a melee or artillery attacker.
_tileFortificationMultiplier(coords, attackerProto) { _tileFortificationMultiplier(coords, attackerProto) {
@@ -193,6 +181,7 @@ export const warfareMethods = {
this.tileImprovements.delete(k); this.tileImprovements.delete(k);
this.tileImprovementOwner.delete(k); this.tileImprovementOwner.delete(k);
this.tileImprovementHp.delete(k); this.tileImprovementHp.delete(k);
this._removeBuildingAgent(k);
this._tileImprovementVersion += 1; this._tileImprovementVersion += 1;
this._emitChanged(); this._emitChanged();
return true; return true;
@@ -285,7 +274,9 @@ export const warfareMethods = {
// Finishes any boarding: at zero hours the infantry leave the map and become // Finishes any boarding: at zero hours the infantry leave the map and become
// the launch's cargo, and are carried until put ashore. // the launch's cargo, and are carried until put ashore.
_tickEmbark() { _tickEmbark() {
for (const unit of this.units) { // Iterate a copy: boarding destroys the infantry, and splicing the live
// array would skip whatever unit follows them.
for (const unit of this.units.slice()) {
if (!unit.embarking) continue; if (!unit.embarking) continue;
unit.embarking.hoursLeft -= 1; unit.embarking.hoursLeft -= 1;
if (unit.embarking.hoursLeft > 0) continue; if (unit.embarking.hoursLeft > 0) continue;
-1
View File
@@ -4,7 +4,6 @@
import { ECONOMY } from "../data/economy.js"; import { ECONOMY } from "../data/economy.js";
import { RESOURCE_RULES } from "../data/resources.js"; import { RESOURCE_RULES } from "../data/resources.js";
import { ROADS } from "../data/roads.js";
import { transportImprovement } from "../data/improvements.js"; import { transportImprovement } from "../data/improvements.js";
import { buildRoadNetwork } from "../roads.js"; import { buildRoadNetwork } from "../roads.js";
import { MapTopology, key, parseKey } from "../hex.js"; import { MapTopology, key, parseKey } from "../hex.js";
+35 -9
View File
@@ -80,6 +80,41 @@ export function foodMonthlyTarget(population) {
RESOURCE_RULES.foodMonthlyBuffer; RESOURCE_RULES.foodMonthlyBuffer;
} }
// The energy a region spends to force `extraFood` tonnes a day from the land
// beyond its harvest. Quadratic in the extra food, so the marginal tonne gets
// dearer the harder the land is pushed.
export function foodSynthesisEnergy(extraFood) {
const x = Math.max(0, extraFood);
return RESOURCE_RULES.foodSynthesisQuadratic * x * x;
}
// The marginal energy for one more tonne of synthesised food, the slope of
// `foodSynthesisEnergy`.
export function foodSynthesisMarginalEnergy(extraFood) {
return 2 * RESOURCE_RULES.foodSynthesisQuadratic * Math.max(0, extraFood);
}
// How much extra food a region grows for a `shortfall` it must cover. Growing
// is always cheapest for the first tonnes (its marginal energy starts near
// zero) and buying is a flat price, so the region grows while the marginal
// energy is cheaper than the market, up to the break-even. Then it completes
// with the dearer option: what the reachable market cannot actually supply --
// `buyable` tonnes -- it must grow itself, even past the break-even. With an
// unreachable or bottomless market the default `buyable = Infinity` gives the
// plain break-even result.
export function synthesisedFoodFor(foodPrice, energyPrice, shortfall, buyable = Infinity) {
const gap = Math.max(0, shortfall);
if (!(gap > 0)) return 0;
const forced = Math.max(0, gap - Math.max(0, buyable));
const quadratic = RESOURCE_RULES.foodSynthesisQuadratic;
if (!(energyPrice > 0) || !(quadratic > 0) || !(foodPrice > 0)) {
return Math.min(gap, forced);
}
const breakEven = foodPrice / (2 * quadratic * energyPrice);
if (!(breakEven > 0)) return Math.min(gap, forced);
return Math.min(gap, Math.max(breakEven, forced));
}
// A city's baseline electric appetite, kWh a day, from its yearly GDP. // A city's baseline electric appetite, kWh a day, from its yearly GDP.
export function cityEnergyPerDay(gdp) { export function cityEnergyPerDay(gdp) {
return Math.max(0, gdp) * RESOURCE_RULES.gdpEnergyIntensity / DAYS_PER_YEAR; return Math.max(0, gdp) * RESOURCE_RULES.gdpEnergyIntensity / DAYS_PER_YEAR;
@@ -200,15 +235,6 @@ export function deliveryEnergyForResource(id, amount, tiles, mode, tileMovementC
return deliveryEnergyKwh(tonnes, tiles, mode, tileMovementCost); return deliveryEnergyKwh(tonnes, tiles, mode, tileMovementCost);
} }
// The transport mode a tile offers, most efficient first: rail beats road, and
// an unimproved tile falls back to an off-road truck. Sea legs ship.
export function transportModeForTile(tile, road, railway, sea) {
if (sea || (tile && tile.terrainClass === "Sea")) return "ship";
if (railway) return "train";
if (road) return "road";
return "truck";
}
// ----------------------------------------------------------------- market -- // ----------------------------------------------------------------- market --
// Moves a price toward what the day's world-wide supply and demand imply. A // Moves a price toward what the day's world-wide supply and demand imply. A
-6
View File
@@ -15,12 +15,6 @@ export function popularityDisplay(points) {
return 1 / (1 + Math.exp(-POPULARITY.logisticScale * points)); return 1 / (1 + Math.exp(-POPULARITY.logisticScale * points));
} }
// The hourly pace at which a population at `points` returns to neutral: one
// point an hour, so the estimate is simply the distance left.
export function hoursToNeutral(points) {
return Math.abs(points);
}
// Fixed cost of raising a building to `level` (0-indexed: the cost of the next // Fixed cost of raising a building to `level` (0-indexed: the cost of the next
// level). It grows only with the level, never with the nation's GDP. // level). It grows only with the level, never with the nation's GDP.
export function buildingBuildCost(proto, level) { export function buildingBuildCost(proto, level) {
+1 -1
View File
@@ -1,7 +1,7 @@
import { TestCase } from "./framework/test_case.js"; import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js"; import { smallState } from "./framework/helpers.js";
import { parseKey, key } from "../shared/hex.js"; import { parseKey, key } from "../shared/hex.js";
import { AIR_STRIKE, RESOURCE_RULES } from "../shared/data.js"; import { RESOURCE_RULES } from "../shared/data.js";
const BIG_BUDGET = 1.0e15; const BIG_BUDGET = 1.0e15;
+121
View File
@@ -0,0 +1,121 @@
import { TestCase } from "./framework/test_case.js";
import { smallState, cityOf } from "./framework/helpers.js";
import { key } from "../shared/hex.js";
import { RESOURCE_RULES } from "../shared/data.js";
import {
foodNeedPerDay,
tileFoodOutput,
foodSynthesisEnergy,
foodSynthesisMarginalEnergy,
synthesisedFoodFor,
} from "../shared/resources.js";
const Q = RESOURCE_RULES.foodSynthesisQuadratic;
// The land's daily harvest in one region, exactly as the daily tick sums it.
function regionFood(state, city) {
let total = 0;
for (const coords of state.regionTiles(city)) {
const k = key(coords.x, coords.y);
if ((state.tilePopulation.get(k) || 0) <= 0) continue;
total += tileFoodOutput(state.tiles[k]) * state._regionFoodMultiplier(city);
}
return total;
}
export class FoodSynthesisMathTest extends TestCase {
test_energy_for_extra_food_is_quadratic() {
this.assertApprox(foodSynthesisEnergy(0), 0);
this.assertApprox(foodSynthesisEnergy(10), Q * 100);
this.assertApprox(foodSynthesisEnergy(20), 4 * foodSynthesisEnergy(10));
}
test_the_marginal_tonne_costs_more_than_the_last() {
this.assertApprox(foodSynthesisMarginalEnergy(0), 0);
this.assertApprox(foodSynthesisMarginalEnergy(10), 2 * Q * 10);
this.assertGreater(
foodSynthesisMarginalEnergy(20), foodSynthesisMarginalEnergy(10),
"diminishing returns: the next tonne costs more"
);
}
test_a_region_grows_until_energy_meets_the_market_price() {
const foodPrice = 5100;
const energyPrice = 0.051;
const breakEven = foodPrice / (2 * Q * energyPrice);
this.assertApprox(synthesisedFoodFor(foodPrice, energyPrice, 1e6), breakEven);
// A shortfall smaller than the break-even caps the extra food.
this.assertApprox(synthesisedFoodFor(foodPrice, energyPrice, 10), 10);
// Cheap energy grows past a larger shortfall; dear energy grows almost none.
this.assertGreater(synthesisedFoodFor(foodPrice, 0.0001, 1e6), breakEven);
this.assertApprox(
synthesisedFoodFor(foodPrice, 1000, 1e6), foodPrice / (2 * Q * 1000)
);
this.assertApprox(synthesisedFoodFor(foodPrice, 1e9, 1e6), foodPrice / (2 * Q * 1e9));
}
test_no_synthesis_without_a_price_or_a_shortfall() {
this.assertApprox(synthesisedFoodFor(5100, 0, 1000), 0);
this.assertApprox(synthesisedFoodFor(0, 0.05, 1000), 0);
this.assertApprox(synthesisedFoodFor(5100, 0.05, 0), 0);
this.assertApprox(synthesisedFoodFor(5100, 0.05, -10), 0);
}
test_the_region_grows_whatever_the_market_cannot_supply() {
const foodPrice = 5100;
const energyPrice = 0.051;
const shortfall = 10_000;
const breakEven = foodPrice / (2 * Q * energyPrice);
// No reachable supplier: grow the whole shortfall.
this.assertApprox(synthesisedFoodFor(foodPrice, energyPrice, shortfall, 0), shortfall);
// A market that covers only part: grow the rest beyond the break-even.
this.assertApprox(
synthesisedFoodFor(foodPrice, energyPrice, shortfall, 4000),
shortfall - 4000
);
// A market that covers the affordable portion: grow only the break-even.
this.assertApprox(
synthesisedFoodFor(foodPrice, energyPrice, shortfall, 9000),
breakEven
);
this.assertGreater(breakEven, 0);
}
}
export class FoodSynthesisModelTest extends TestCase {
test_a_region_short_of_food_forces_extra_from_energy() {
const state = smallState();
const city = cityOf(state, 0);
const shortfall = foodNeedPerDay(state.getCityEconomy(city).population) -
regionFood(state, city);
this.assertGreater(shortfall, 0, "the fixture region opens short of food");
// Cheap energy against a dear market: force as much as the shortfall allows.
state.setResourcePrice("energy", 0.00001);
state.setResourcePrice("food", 1e9);
state._tickResources();
const extra = state._foodSynthesis.get(city.id) || 0;
this.assertApprox(extra, shortfall, 1, "the whole shortfall is grown");
}
test_a_region_buys_instead_when_energy_is_dearer_than_food() {
const state = smallState();
const city = cityOf(state, 0);
state.setResourcePrice("energy", 1e9);
state.setResourcePrice("food", 1);
state._tickResources();
this.assertApprox(state._foodSynthesis.get(city.id) || 0, 0);
}
test_the_extra_food_lands_in_the_region_s_store() {
const state = smallState();
const city = cityOf(state, 0);
state.setResourcePrice("energy", 0.00001);
state.setResourcePrice("food", 1e9);
state._tickResources();
const produced = state.getCivResourceSummary(0).production.food;
this.assertGreater(
produced, regionFood(state, city),
"the nation's food production includes the forced extra"
);
}
}
+30 -7
View File
@@ -1,8 +1,7 @@
import { TestCase } from "./framework/test_case.js"; import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js"; import { setupDom, teardownDom } from "./framework/dom.js";
import { GameScreen } from "../client/js/game_screen.js"; import { GameScreen } from "../client/js/game_screen.js";
import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, ECONOMY, TRANSPORT_IMPROVEMENTS } from "../shared/data.js"; import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, TRANSPORT_IMPROVEMENTS } from "../shared/data.js";
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
import { hoursInNextMonth } from "../shared/game_clock.js"; import { hoursInNextMonth } from "../shared/game_clock.js";
import { compact } from "../shared/text_format.js"; import { compact } from "../shared/text_format.js";
import { tileImprovementResourceCost } from "../shared/resources.js"; import { tileImprovementResourceCost } from "../shared/resources.js";
@@ -417,23 +416,47 @@ export class GameScreenTest extends TestCase {
} }
} }
async test_unaffordable_air_strike_is_refused_with_a_message() { async test_air_strike_without_munitions_is_refused_with_a_message() {
const env = await setupDom(); const env = await setupDom();
try { try {
const network = stubNetwork(); const network = stubNetwork();
const screen = new GameScreen(network, makeConfig()); const screen = new GameScreen(network, makeConfig());
screen.enter(); screen.enter();
const state = smallState(); const state = smallState();
state.budgets.set(0, 0);
const home = state.units.find((u) => u.civ === 0); const home = state.units.find((u) => u.civ === 0);
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter"); const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
const jet = state._spawnUnit(home.coords, 0, jetProto); const jet = state._spawnUnit(home.coords, 0, jetProto);
const goal = adjacentLand(state, home); const goal = adjacentLand(state, home);
state._spawnUnit(goal, 1, state.protoUnits[0]); state._spawnUnit(goal, 1, state.protoUnits[0]);
screen.onState(state.snapshot(0)); const snapshot = state.snapshot(0);
// The server charges an air strike in munitions, not money: without the
// steel and high-tech in store the browser must refuse it.
snapshot.viewerStats.resources.stock.steel = 0;
snapshot.viewerStats.resources.stock.hightech = 0;
screen.onState(snapshot);
await screen._orderMove([screen._unit(jet.id)], goal, "attack"); await screen._orderMove([screen._unit(jet.id)], goal, "attack");
this.assertEqual(network.orders.length, 0, "no order is sent without the budget"); this.assertEqual(network.orders.length, 0, "no order is sent without the munitions");
this.assertEqual(env.$("#confirm-title").text(), "Insufficient budget"); this.assertEqual(env.$("#confirm-title").text(), "Insufficient munitions");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_air_strike_with_munitions_is_allowed() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const home = state.units.find((u) => u.civ === 0);
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
const jet = state._spawnUnit(home.coords, 0, jetProto);
screen.onState(state.snapshot(0));
this.assertNull(
screen._airStrikeBlocked([screen._unit(jet.id)]),
"full stores arm the aircraft"
);
screen.leave(); screen.leave();
} finally { } finally {
teardownDom(env); teardownDom(env);
+1 -1
View File
@@ -1,7 +1,7 @@
import { TestCase } from "./framework/test_case.js"; import { TestCase } from "./framework/test_case.js";
import { smallState, cityOf, gdpBuilding, grantBuilding } from "./framework/helpers.js"; import { smallState, cityOf, gdpBuilding, grantBuilding } from "./framework/helpers.js";
import { key } from "../shared/hex.js"; import { key } from "../shared/hex.js";
import { EFFECT_RESEARCH, RESOURCE_RULES } from "../shared/data.js"; import { EFFECT_RESEARCH } from "../shared/data.js";
import { tileFoodOutput } from "../shared/resources.js"; import { tileFoodOutput } from "../shared/resources.js";
import { DAYS_PER_YEAR } from "../shared/game_state/constants.js"; import { DAYS_PER_YEAR } from "../shared/game_state/constants.js";
+248
View File
@@ -0,0 +1,248 @@
import { TestCase } from "./framework/test_case.js";
import { smallState, cityOf } from "./framework/helpers.js";
import { key } from "../shared/hex.js";
import {
RESOURCE_BUILDING_UPGRADE,
resourceBuildingOutputAt,
resourceBuildingInputAt,
resourceBuildingUpgradeMoneyCost,
tileImprovementById,
} from "../shared/data.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
// carry a work; it is replaced.
function placeProducer(state, id, civ = 0) {
let coords = null;
for (const candidate of state._territoryByCiv.get(civ) || []) {
if (state.cityAt(candidate)) continue;
if (!state._isLand(candidate)) continue;
if (state.tileImprovements.has(key(candidate.x, candidate.y))) continue;
coords = candidate;
break;
}
const k = key(coords.x, coords.y);
state.tileImprovements.set(k, id);
state.tileImprovementOwner.set(k, civ);
state.tileImprovementHp.set(k, 1000);
state._ensureResourceStock(k);
const agent = state._ensureBuildingAgent(k);
return { coords, k, agent };
}
// Gives the nation's cities enough stores to cover an upgrade's steel bill.
function fillMaterialStores(state, civ, amount = 1e7) {
for (const city of state.cities) {
if (city.civ !== civ) continue;
state.getCityResourceStock(city).steel = amount;
state.getCityResourceStock(city).hightech = amount;
}
}
export class ProductionBuildingUpgradeMathTest extends TestCase {
test_output_compounds_exponentially_with_the_level() {
const proto = { outputPerDay: 1000 };
const alpha = RESOURCE_BUILDING_UPGRADE.alpha;
this.assertApprox(resourceBuildingOutputAt(proto, 0), 1000);
this.assertApprox(resourceBuildingOutputAt(proto, 1), 1000 * alpha);
this.assertApprox(resourceBuildingOutputAt(proto, 2), 1000 * alpha * alpha);
}
test_input_grows_linearly_with_the_level() {
const proto = { energyPerDay: 500 };
this.assertApprox(resourceBuildingInputAt(proto, 0, "energyPerDay"), 500);
this.assertApprox(resourceBuildingInputAt(proto, 2, "energyPerDay"), 1500);
this.assertApprox(
resourceBuildingInputAt(proto, 1, "energyPerDay"),
500 * (1 + RESOURCE_BUILDING_UPGRADE.inputPerLevel)
);
}
test_each_upgrade_cost_more_than_the_last() {
const proto = { buildCost: 800_000_000 };
const first = resourceBuildingUpgradeMoneyCost(proto, 0);
const second = resourceBuildingUpgradeMoneyCost(proto, 1);
this.assertApprox(first, 800_000_000);
this.assertGreater(second, first);
this.assertApprox(second, 800_000_000 * RESOURCE_BUILDING_UPGRADE.costAlpha);
}
}
export class ProductionBuildingAgentTest extends TestCase {
test_a_production_building_keeps_what_it_sells() {
const state = smallState();
const { coords, k, agent } = placeProducer(state, "steel_mill");
const code = state.currencyOf(0).code;
const city = cityOf(state, 0);
const regionBefore = state.getRegionCashValue(city);
state._paySupplier({ kind: "building", coords, civ: 0 }, code, 12_345);
this.assertApprox(agent.cash, 12_345, 1e-6, "the sale reached the building");
this.assertApprox(state.getRegionCashValue(city), regionBefore, 1e-6, "not the region");
this.assertEqual(state.getPrivateBuildingCash(0), agent.cash);
}
test_plants_trade_their_power_like_any_producer() {
const state = smallState();
const plant = tileImprovementById("coal_power_plant");
const mill = tileImprovementById("steel_mill");
this.assertTrue(state._isProductionAgent(plant), "a plant sells power");
this.assertTrue(state._isProductionAgent(mill), "a mill sells steel");
}
test_growth_needs_the_recent_half_of_the_window_to_beat_the_older_half() {
const state = smallState();
const { agent } = placeProducer(state, "steel_mill");
agent.sales = [10, 10, 10];
this.assertFalse(state._buildingDemandGrowing(agent), "flat demand is not growth");
agent.sales = [20, 20, 20, 30, 30, 30];
this.assertTrue(state._buildingDemandGrowing(agent), "rising demand is growth");
agent.sales = [30, 30, 30, 20, 20, 20];
this.assertFalse(state._buildingDemandGrowing(agent), "falling demand is not growth");
}
test_a_building_starts_an_upgrade_and_cannot_start_a_second() {
const state = smallState();
const { coords, k, agent } = placeProducer(state, "steel_mill");
agent.cash = 1e12;
agent.sales = [10, 12, 15, 18, 22, 27];
fillMaterialStores(state, 0);
state._tickBuildingDecisions();
this.assertNotNull(agent.upgrading, "the upgrade started");
const job = agent.upgrading;
this.assertEqual(agent.level, 0, "the level waits for the work");
// A second read may not replace the work already under way.
agent.sales = [100, 200, 300, 400, 500, 600];
state._tickBuildingDecisions();
this.assertEqual(agent.upgrading, job, "only one upgrade at a time");
}
test_spending_on_an_upgrade_consumes_cash_and_materials() {
const state = smallState();
const { agent, k } = placeProducer(state, "steel_mill");
agent.cash = 1e12;
agent.sales = [10, 12, 15, 18, 22, 27];
fillMaterialStores(state, 0);
const before = agent.cash;
state._tickBuildingDecisions();
this.assertLess(agent.cash, before, "the agent paid for its materials");
this.assertGreater(state.getCityResourceStock(cityOf(state, 0)).steel, 0, "stores left over");
this.assertNotNull(tileImprovementById(state.tileImprovements.get(k)));
}
test_completing_an_upgrade_raises_the_level_and_its_output() {
const state = smallState();
const { k, agent } = placeProducer(state, "steel_mill");
const proto = tileImprovementById("steel_mill");
agent.cash = 1e12;
agent.sales = [10, 12, 15, 18, 22, 27];
fillMaterialStores(state, 0);
const baseOutput = state.resourceBuildingOutputAt(k, proto);
// Keep the day-boundary decision from firing while the work is completed.
state.totalHours = 1;
state._tickBuildingDecisions();
this.assertNotNull(agent.upgrading);
agent.upgrading.totalHours = 0;
state._tickIndustry();
this.assertEqual(agent.level, 1, "the level arrived");
this.assertNull(agent.upgrading, "the work is done");
this.assertGreater(
state.resourceBuildingOutputAt(k, proto), baseOutput,
"the upgraded building makes more"
);
}
test_an_agent_without_enough_cash_waits() {
const state = smallState();
const { agent } = placeProducer(state, "steel_mill");
agent.cash = 0;
agent.sales = [10, 12, 15, 18, 22, 27];
fillMaterialStores(state, 0);
state._tickBuildingDecisions();
this.assertNull(agent.upgrading, "no cash, no upgrade");
}
test_the_snapshot_ships_the_production_agents_apart_from_the_regions() {
const state = smallState();
const { coords, agent } = placeProducer(state, "steel_mill");
agent.cash = 4_000_000;
const stats = state.snapshot(0).viewerStats;
this.assertApprox(stats.privateBuildingCash, state.getPrivateBuildingCash(0));
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);
}
}
export class PowerMarketTest extends TestCase {
test_a_grid_pays_its_plants_for_the_power_it_delivers() {
const state = smallState();
const plant = placeProducer(state, "coal_power_plant");
const converter = placeProducer(state, "steel_mill");
const city = cityOf(state, 0);
const plantBefore = plant.agent.cash;
const converterBefore = converter.agent.cash;
const regionBefore = state.getRegionCashValue(city);
const price = state.getResourcePrice("energy");
const ledger = new Map([[0, {
civ: 0,
supply: 1000,
cityEnergy: 100,
converterEnergy: 200,
foodEnergy: 0,
billableCityEnergy: 100,
cityShortage: 0,
industrialScale: 1,
producers: [{ k: plant.k, output: 1000 }],
cities: [{ city, amount: 100 }],
converters: [{ k: converter.k, amount: 200 }],
foodConsumers: [],
imports: 0,
importCost: 0,
}]]);
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(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");
}
test_imported_power_is_paid_to_the_exporting_grid_s_plants() {
const state = smallState();
const plant = placeProducer(state, "coal_power_plant");
const component = { civ: 0, producers: [{ k: plant.k, output: 100 }] };
const before = plant.agent.cash;
state._payGridProducers(component, 5_000, 40);
this.assertApprox(plant.agent.cash - before, 5_000, 1e-6);
this.assertApprox(plant.agent.salesToday, 40, 1e-6);
}
test_an_overstocked_converter_throttles_to_what_it_sells() {
const state = smallState();
const { k, agent } = placeProducer(state, "steel_mill");
const proto = tileImprovementById("steel_mill");
this.assertEqual(
state._converterProductionScale(k, proto, agent), 1,
"a building with no history runs flat out"
);
agent.sales = [4_000, 4_000, 4_000, 4_000];
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);
}
test_a_converter_sells_out_and_then_grows() {
const state = smallState();
const { k, agent } = placeProducer(state, "steel_mill");
const proto = tileImprovementById("steel_mill");
agent.sales = new Array(6).fill(proto.outputPerDay);
this.assertTrue(
state._buildingDemandGrowing(agent, proto.outputPerDay),
"selling out is a reason to grow"
);
}
}
-1
View File
@@ -1,6 +1,5 @@
import { TestCase } from "./framework/test_case.js"; import { TestCase } from "./framework/test_case.js";
import { smallState, cityOf } from "./framework/helpers.js"; import { smallState, cityOf } from "./framework/helpers.js";
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
import { MONEY } from "../shared/data/economy.js"; import { MONEY } from "../shared/data/economy.js";
export class MoneyTest extends TestCase { export class MoneyTest extends TestCase {
+1 -1
View File
@@ -1,7 +1,7 @@
import { TestCase } from "./framework/test_case.js"; import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js"; import { setupDom, teardownDom } from "./framework/dom.js";
import { MapView } from "../client/js/map_view.js"; import { MapView } from "../client/js/map_view.js";
import { MapTopology, key, parseKey } from "../shared/hex.js"; import { MapTopology, key } from "../shared/hex.js";
import { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js"; import { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js";
import { politicalMode, POLITICAL_ZOOM } from "../client/js/map_view/constants.js"; import { politicalMode, POLITICAL_ZOOM } from "../client/js/map_view/constants.js";
import { import {
-1
View File
@@ -9,7 +9,6 @@ import {
TECHNOLOGIES, TECHNOLOGIES,
TERRAIN_TILES, TERRAIN_TILES,
MAP_CONFIG, MAP_CONFIG,
ECONOMY,
TAXES, TAXES,
BATTLE, BATTLE,
effectValue, effectValue,
+1 -22
View File
@@ -1,7 +1,7 @@
import { TestCase } from "./framework/test_case.js"; import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js"; import { setupDom, teardownDom } from "./framework/dom.js";
import { smallState, mapLayers, SEED, grantBuilding } from "./framework/helpers.js"; import { smallState, mapLayers, SEED, grantBuilding } from "./framework/helpers.js";
import { parseKey, key } from "../shared/hex.js"; import { key } from "../shared/hex.js";
import { CIVILISATIONS, PROTO_UNITS, statusById, STATUS_ENCIRCLED, STATUS_BESIEGED } from "../shared/data.js"; import { CIVILISATIONS, PROTO_UNITS, statusById, STATUS_ENCIRCLED, STATUS_BESIEGED } from "../shared/data.js";
import { MapView } from "../client/js/map_view.js"; import { MapView } from "../client/js/map_view.js";
@@ -232,24 +232,3 @@ function findCoastalClearCity(state, civ) {
} }
return null; return null;
} }
// A sea tile with two non-adjacent land neighbours: l1 for the cut-off unit,
// l2 for the coastal destination city.
function findCoastalPair(state) {
for (const k in state.tiles) {
if (state.tiles[k].terrainClass !== "Sea") continue;
const s = parseKey(k);
const land = state.topology.neighbours(s.x, s.y).filter(
(n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n)
);
for (let i = 0; i < land.length; i++) {
for (let j = i + 1; j < land.length; j++) {
if (state.topology.tileDistance(land[i], land[j]) <= 1) continue;
const neighbours = state.topology.neighbours(land[i].x, land[i].y).filter((n) => state._isLand(n));
if (neighbours.some((n) => state.unitAt(n) || state.cityAt(n))) continue;
return { s: { x: s.x, y: s.y }, l1: { x: land[i].x, y: land[i].y }, l2: { x: land[j].x, y: land[j].y } };
}
}
}
return null;
}