diff --git a/FIXME.md b/FIXME.md
new file mode 100644
index 0000000..2ae0d16
--- /dev/null
+++ b/FIXME.md
@@ -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
diff --git a/client/js/currency.js b/client/js/currency.js
index 4c8c642..625f688 100644
--- a/client/js/currency.js
+++ b/client/js/currency.js
@@ -2,8 +2,6 @@
// every money string reads in the right unit. The symbol is set from the
// snapshot; until then a neutral mark stands in.
-import { groupDigits } from "../../shared/text_format.js";
-
let symbol = "¤";
export function setCurrency(currency) {
@@ -13,10 +11,3 @@ export function setCurrency(currency) {
export function currencySymbol() {
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" : ""}`;
-}
diff --git a/client/js/feed.js b/client/js/feed.js
index 44ff7f3..189327c 100644
--- a/client/js/feed.js
+++ b/client/js/feed.js
@@ -34,10 +34,6 @@ export class GameFeed {
return $line;
}
- lineCount() {
- return this.$container ? this.$container.children(".feed-line").length : 0;
- }
-
_retire($line) {
$line.addClass("leaving");
if (this.fade <= 0) {
diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js
index 5933007..9b2128b 100644
--- a/client/js/game_screen/panels.js
+++ b/client/js/game_screen/panels.js
@@ -3,7 +3,7 @@
// on the GameScreen instance (composed in ../game_screen.js) and only read the
// 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 {
CIVILISATIONS,
@@ -28,12 +28,12 @@ import {
resourceById,
RESOURCES,
RESOURCE_RULES,
+ resourceBuildingOutputAt,
tileImprovementById,
- AIR_STRIKE,
} from "../../../shared/data.js";
import { defenseFactors, unitDefense, productionFactors, productionMultiplier, popularityDisplay } from "../../../shared/rules.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 { ethnicityChart } from "../ui/ethnicity.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
- // plane that is already flying a mission, is still re-arming, or whose
- // nation cannot afford the (deliberately steep) price.
+ // plane that is already flying a mission or is still re-arming, and a nation
+ // whose stores cannot cover the munitions the sortie burns.
_airStrikeBlocked(units) {
const snapshot = this.snapshot || {};
const totalHours = snapshot.totalHours || 0;
- const budget = (snapshot.viewerStats || {}).budget || 0;
for (const unit of units) {
if (unit.strikeTarget) {
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;
- if (cost > budget) {
- const prices = marketPricesFrom(this.snapshot);
+ // A sortie is paid for in munitions, not money (GameState.requestAirStrike):
+ // every aircraft spends steel and high-tech from the nation's stores.
+ 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 {
- title: "Insufficient budget",
- message: `An air strike costs ${currencySymbol()}${groupDigits(quotedMoney(cost, prices))}, which the treasury cannot cover.`,
+ title: "Insufficient munitions",
+ message: "The nation's stores cannot cover this air strike's steel and high-tech.",
};
}
return null;
@@ -937,7 +940,10 @@ export const panelMethods = {
const militaryWorks = this.map.tileImprovements.get(k);
if (militaryWorks) {
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(
`Improvements: ${fmtImprovements(Array.from(new Set([...cityImprovements, ...transport, ...works])))}`
@@ -969,19 +975,33 @@ export const panelMethods = {
const work = queue.find(
(entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y
);
- if (!work) return;
- const proto = tileImprovementById(work.id);
- const name = proto ? proto.name : work.id;
- const total = Math.max(work.totalHours || 0, 0.0001);
- const elapsed = work.elapsedHours || 0;
- const pct = Math.max(0, Math.min(100, (elapsed / total) * 100));
- const remaining = Math.max(0, Math.ceil(total - elapsed));
- const $bar = $("
");
- $bar.append($("").css("width", `${pct}%`));
- $el.append(
- $("").text(`Building ${name} — ${remaining} h left`),
- $bar
+ if (work) {
+ const proto = tileImprovementById(work.id);
+ const name = proto ? proto.name : work.id;
+ const total = Math.max(work.totalHours || 0, 0.0001);
+ const elapsed = work.elapsedHours || 0;
+ const pct = Math.max(0, Math.min(100, (elapsed / total) * 100));
+ const remaining = Math.max(0, Math.ceil(total - elapsed));
+ const $bar = $("");
+ $bar.append($("").css("width", `${pct}%`));
+ $el.append(
+ $("").text(`Building ${name} — ${remaining} h left`),
+ $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(
+ $("").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
@@ -1372,7 +1392,7 @@ export const panelMethods = {
const improvement = this.map.tileImprovements.get(k);
const proto = improvement ? tileImprovementById(improvement.id) : null;
if (proto && proto.resource && proto.outputPerDay) {
- amounts[proto.resource] += proto.outputPerDay * multiplier;
+ amounts[proto.resource] += resourceBuildingOutputAt(proto, improvement.level) * multiplier;
}
return { amounts, factors, foodMultiplier };
},
@@ -1784,6 +1804,10 @@ export const panelMethods = {
exchangeRates: stats.exchangeRates || [],
centralBank: stats.centralBank || { rate: 0, reserves: [] },
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.
currencyHoldings: stats.currencyHoldings || null,
// The viewer's trade-tax rates and the day's take, for the Taxes sub-tab.
diff --git a/client/js/map_view/constants.js b/client/js/map_view/constants.js
index 914fb2c..9ffae6f 100644
--- a/client/js/map_view/constants.js
+++ b/client/js/map_view/constants.js
@@ -25,12 +25,11 @@ export const DRAG_THRESHOLD = 5;
export const ZOOM_MIN = 0.2;
export const ZOOM_MAX = 2.0;
-// The camera looks north from an orthographic elevation of 70° above the
-// ground. That foreshortens the north-south axis by sin(70°) ~= 0.94 while the
-// east-west axis is untouched: a gentle 3D tilt that keeps the current
-// orientation. It is applied to the terrain, roads, borders, fog and ground
-// overlays; units, cities and labels stay upright on top.
-export const CAMERA_ELEVATION_DEG = 45;
+// The camera looks north from an orthographic elevation of 45° above the
+// ground. That foreshortens the north-south axis by sin(45°) ~= 0.71 while the
+// east-west axis is untouched. It is applied to the terrain, roads, borders,
+// fog and ground overlays; units, cities and labels stay upright on top.
+const CAMERA_ELEVATION_DEG = 45;
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)
@@ -105,10 +104,8 @@ export function politicalMode(zoom) {
}
// 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
-// between the (half-size) units of one stack.
+// battle icon between them. The side offset is in pixels.
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
// intersect the viewport (plus CHUNK_MARGIN blocks of slack on every side) are
diff --git a/client/js/map_view/entities.js b/client/js/map_view/entities.js
index 5a9d2ff..da43ec6 100644
--- a/client/js/map_view/entities.js
+++ b/client/js/map_view/entities.js
@@ -89,8 +89,8 @@ export const entityMethods = {
this._warfareSignature = signature;
this.trenches = new Set(trenches.map((entry) => key(entry[0], entry[1])));
this.tileImprovements = new Map();
- for (const [x, y, id, owner, hp] of improvements) {
- this.tileImprovements.set(key(x, y), { id, owner, hp });
+ for (const [x, y, id, owner, hp, level] of improvements) {
+ this.tileImprovements.set(key(x, y), { id, owner, hp, level: level || 0 });
}
this._renderWarfare();
},
diff --git a/client/js/map_view/webgl.js b/client/js/map_view/webgl.js
index 1991949..6bdcb5a 100644
--- a/client/js/map_view/webgl.js
+++ b/client/js/map_view/webgl.js
@@ -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
// triangles. Wind order does not matter: we never cull faces.
_quad(arr, corners, colour) {
diff --git a/client/js/modals/nation.js b/client/js/modals/nation.js
index 5988658..9850fc5 100644
--- a/client/js/modals/nation.js
+++ b/client/js/modals/nation.js
@@ -443,15 +443,18 @@ export class NationModal {
return `${currencySymbol()}${groupDigits(Math.round(value || 0))}`;
}
- // The aggregate private-sector cash the nation's regions hold, and each
- // region's own reserve.
+ // The aggregate private-sector cash the nation's regions and its independent
+ // production buildings hold, each listed on its own row.
_renderPrivateSector(money) {
if (!this.$economySummary || !this.$economySummary.length) return;
const cash = money.privateCash || 0;
+ const buildingCash = money.privateBuildingCash || 0;
if (this.$economySub && this.$economySub.length) {
this.$economySub.text(
- `Private-sector money: ${currencySymbol()}${groupDigits(Math.round(cash))} · ` +
- `${(money.currency && money.currency.name) || ""}`
+ `Private-sector money: ${currencySymbol()}${groupDigits(Math.round(cash + buildingCash))}` +
+ ` · regions ${currencySymbol()}${groupDigits(Math.round(cash))}` +
+ ` · production ${currencySymbol()}${groupDigits(Math.round(buildingCash))}` +
+ ` · ${(money.currency && money.currency.name) || ""}`
);
}
this.$economySummary.empty();
@@ -462,36 +465,94 @@ export class NationModal {
)
);
const cities = (money.cities || []).filter((city) => city.civ === money.viewerCiv);
- if (cities.length === 0) return;
+ if (cities.length > 0) {
+ const $table = $("");
+ const $head = $("").append(
+ $("
")
+ .append($(" | ").text("Region"))
+ .append($(" | ").text("Private cash"))
+ );
+ const $body = $("");
+ let total = 0;
+ for (const city of cities) {
+ total += city.cashValue || 0;
+ $body.append(
+ $("
")
+ .append($(" | ").text(city.name))
+ .append(
+ $(" | ").text(
+ `${currencySymbol()}${groupDigits(Math.round(city.cashValue || 0))}`
+ )
+ )
+ );
+ }
+ $table.append($head, $body);
+ const $foot = $("").append(
+ $("
")
+ .append($(" | ").text("Total"))
+ .append($(" | ").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(
+ $("").text(
+ "Production buildings are independent private agents: they keep what " +
+ "their output sells for, and spend it upgrading when demand has grown."
+ )
+ );
const $table = $("");
const $head = $("").append(
$("
")
- .append($(" | ").text("Region"))
+ .append($(" | ").text("Production building"))
+ .append($(" | ").text("Output / day"))
+ .append($(" | ").text("Level"))
.append($(" | ").text("Private cash"))
);
const $body = $("");
let total = 0;
- for (const city of cities) {
- total += city.cashValue || 0;
+ for (const building of buildings) {
+ total += building.cash || 0;
+ const level = building.level || 0;
+ const resource = resourceById(building.resource);
+ const $output = resource && building.outputPerDay
+ ? $(" | ").text(formatResourceAmount(building.resource, building.outputPerDay))
+ : $(" | ").text("—");
$body.append(
$("
")
- .append($(" | ").text(city.name))
+ .append($(" | ").text(building.name))
+ .append($output)
.append(
$(" | ").text(
- `${currencySymbol()}${groupDigits(Math.round(city.cashValue || 0))}`
+ level + (building.upgrading ? " ↑" : "")
+ )
+ )
+ .append(
+ $(" | ").text(
+ `${currencySymbol()}${groupDigits(Math.round(building.cash || 0))}`
)
)
);
}
$table.append($head, $body);
- const $foot = $("").append(
- $("
")
- .append($(" | ").text("Total"))
- .append($(" | ").text(
- `${currencySymbol()}${groupDigits(Math.round(total))}`
- ))
+ $table.append(
+ $("").append(
+ $("
")
+ .append($(" | ").text("Total"))
+ .append($(" | ").text(
+ `${currencySymbol()}${groupDigits(Math.round(total))}`
+ ))
+ )
);
- $table.append($foot);
this.$economySummary.append($table);
}
diff --git a/scripts/simulate_resources.js b/scripts/simulate_resources.js
index 0a7e429..0053d48 100644
--- a/scripts/simulate_resources.js
+++ b/scripts/simulate_resources.js
@@ -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 {
cityEnergyPerDay,
- foodNeedPerDay,
- steelNeedPerDay,
- luxuryNeedPerDay,
} from "../shared/resources.js";
import { key } from "../shared/hex.js";
diff --git a/shared/data/combat.js b/shared/data/combat.js
index 20c4533..08c2792 100644
--- a/shared/data/combat.js
+++ b/shared/data/combat.js
@@ -56,10 +56,10 @@ export const CITY_BOMBARD = {
// Aircraft sorties: a plane flies to its target, bombs once, returns to a
// friendly airport and then cannot strike again for `cooldownHours` (two days).
-// Each attacking aircraft costs `cost` from its nation's budget, charged up
-// front so bombing cannot be spammed for free.
+// Each attacking aircraft burns munitions from its nation's stores (see
+// RESOURCE_RULES.combatConsumption.airStrike), charged up front so bombing
+// cannot be spammed for free.
export const AIR_STRIKE = {
- cost: 10_000_000,
cooldownHours: 48,
};
diff --git a/shared/data/politics.js b/shared/data/politics.js
index 286e123..fee7623 100644
--- a/shared/data/politics.js
+++ b/shared/data/politics.js
@@ -84,8 +84,6 @@ export const PROPAGANDA = {
export const CAMPAIGN_ETHNICITY = "ethnicity";
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
// once, but the same policy may not be enacted twice for the same ethnicity.
diff --git a/shared/data/resources.js b/shared/data/resources.js
index 01983c8..91526a5 100644
--- a/shared/data/resources.js
+++ b/shared/data/resources.js
@@ -9,7 +9,6 @@
// Energy is stored and moved as kWh. These keep the catalogue readable.
export const KWH_PER_MWH = 1_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
// 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.
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
energyPerLuxuryCarat: {
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;
}
+// 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(
RESOURCE_BUILDINGS.map((building) => [building.id, building])
);
diff --git a/shared/game_state.js b/shared/game_state.js
index 85b51de..26fb46a 100644
--- a/shared/game_state.js
+++ b/shared/game_state.js
@@ -47,6 +47,7 @@ import { serializationMethods } from "./game_state/serialization.js";
import { monthlyMethods } from "./game_state/monthly.js";
import { warfareMethods } from "./game_state/warfare.js";
import { resourceMethods } from "./game_state/resources.js";
+import { industryMethods } from "./game_state/industry.js";
import { budgetMethods } from "./game_state/budget.js";
import { moneyMethods } from "./game_state/money.js";
import { taxMethods } from "./game_state/taxes.js";
@@ -118,6 +119,9 @@ export class GameState {
this.tileImprovementOwner = new Map();
this.tileImprovementHp = 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;
// Bumped whenever the transport network changes, so the serialised road and
// railway lists are rebuilt only when something actually moved.
@@ -384,6 +388,7 @@ export class GameState {
}
this._tickBuildings();
this._tickTileWorks();
+ this._tickIndustry();
this._tickEmbark();
this._tickCoastalCannons();
// A unit no longer sharing a tile with an enemy is not an invader any more.
@@ -413,6 +418,7 @@ Object.assign(
monthlyMethods,
warfareMethods,
resourceMethods,
+ industryMethods,
budgetMethods,
moneyMethods,
taxMethods
diff --git a/shared/game_state/air.js b/shared/game_state/air.js
index bb49c63..3c747ea 100644
--- a/shared/game_state/air.js
+++ b/shared/game_state/air.js
@@ -288,7 +288,7 @@ export const airMethods = {
// 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
// tile.
- _applyStrikeDamage(target, damage, attackerProto = null) {
+ _applyStrikeDamage(target, damage) {
const proto = this.unitProto(target);
const alone = this.unitsAt(target.coords).length <= 1;
if (proto && proto.destroyedByRangedAlone && alone) damage = target.hp;
@@ -420,7 +420,7 @@ export const airMethods = {
const target = this._strikeTargetAt(goal, unit.civ);
if (target) {
const damage = Math.max(1, this._unitAttack(unit) - this._effectiveDefense(target, true));
- this._applyStrikeDamage(target, damage, this.unitProto(unit));
+ this._applyStrikeDamage(target, damage);
} else {
const city = this._strikeCityAt(goal, unit.civ);
if (city) {
@@ -471,7 +471,7 @@ export const airMethods = {
if (damage.size === 0) return;
const destroyed = [];
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();
},
diff --git a/shared/game_state/economy.js b/shared/game_state/economy.js
index aa0eb71..3951f6a 100644
--- a/shared/game_state/economy.js
+++ b/shared/game_state/economy.js
@@ -133,7 +133,7 @@ export const economyMethods = {
const id = this.tileImprovements.get(k);
const proto = id ? tileImprovementById(id) : null;
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;
@@ -522,15 +522,6 @@ export const economyMethods = {
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) {
const sources = new Map();
const battle = this._battleUnitIds();
diff --git a/shared/game_state/industry.js b/shared/game_state/industry.js
new file mode 100644
index 0000000..08a83a6
--- /dev/null
+++ b/shared/game_state/industry.js
@@ -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;
+ },
+};
diff --git a/shared/game_state/money.js b/shared/game_state/money.js
index c9961d5..9257d37 100644
--- a/shared/game_state/money.js
+++ b/shared/game_state/money.js
@@ -96,18 +96,6 @@ export const moneyMethods = {
// --------------------------------------------------- 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) {
if (!(amount > 0)) return;
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.
let amount = Math.max(0, this.getBudget(civ) || 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()) {
amount += reserves.get(code) || 0;
}
@@ -405,6 +395,19 @@ export const moneyMethods = {
banks.push({ civ: c, name: host ? host.name : "", 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 treasury = Math.max(0, budget);
const treasuryDebt = Math.max(0, -budget);
diff --git a/shared/game_state/politics.js b/shared/game_state/politics.js
index b5ce808..6db4f14 100644
--- a/shared/game_state/politics.js
+++ b/shared/game_state/politics.js
@@ -378,19 +378,11 @@ export const politicsMethods = {
return this.getCivOpinions(civ).approval;
},
- getCityPopularity(city) {
- return this.getCityOpinions(city).popularity;
- },
-
getCivPopularity(civ) {
return this.getCivOpinions(civ).popularity;
},
// What the player sees, in 0..1: the popularity points through the logistic.
- getCityPopularityDisplay(city) {
- return popularityDisplay(this.getCityPopularity(city));
- },
-
getCivPopularityDisplay(civ) {
return popularityDisplay(this.getCivPopularity(civ));
},
diff --git a/shared/game_state/resources.js b/shared/game_state/resources.js
index 41f81fc..3e46629 100644
--- a/shared/game_state/resources.js
+++ b/shared/game_state/resources.js
@@ -19,6 +19,7 @@ import {
FAMINE,
tileImprovementById,
isResourceTileBuilding,
+ resourceById,
MONEY,
} from "../data.js";
import {
@@ -29,9 +30,10 @@ import {
luxuryNeedPerDay,
foodMonthlyTarget,
producerEnergyPerDay,
+ foodSynthesisEnergy,
+ synthesisedFoodFor,
deliveryEnergyForResource,
nextMarketPrice,
- upkeepResources,
protoUpkeep,
constructionResourceCost as sharedConstructionResourceCost,
} from "../resources.js";
@@ -314,6 +316,7 @@ export const resourceMethods = {
this.tileImprovements.set(k, proto.id);
this.tileImprovementOwner.set(k, civ);
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.
if (!this.roads.has(k) && !this.railways.has(k) && touchesNetwork(coords)) {
this.roads.add(k);
@@ -375,7 +378,7 @@ export const resourceMethods = {
if (this.tileImprovementOwner.get(k) !== civ) continue;
const proto = tileImprovementById(id);
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;
},
@@ -589,10 +592,17 @@ export const resourceMethods = {
if (this.tileImprovementOwner.get(k) !== civ) continue;
const proto = tileImprovementById(id);
if (!isResourceTileBuilding(proto)) continue;
- if (proto.resource === "energy") energySupply += proto.outputPerDay;
- else production[proto.resource] += proto.outputPerDay;
+ if (proto.resource === "energy") {
+ 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();
for (const coords of this._territoryByCiv.get(civ) || []) {
const k = key(coords.x, coords.y);
@@ -608,6 +618,12 @@ export const resourceMethods = {
}
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);
// 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
@@ -1198,6 +1214,9 @@ export const resourceMethods = {
// charged to the tile's grid component.
const pendingFood = [];
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) {
const coords = parseKey(k);
if (!this._isLand(coords)) continue;
@@ -1212,6 +1231,7 @@ export const resourceMethods = {
}
const produced = tileFoodOutput(this.tiles[k]) * foodMultiplier;
if (produced <= 0) continue;
+ regionFood.set(city.id, (regionFood.get(city.id) || 0) + produced);
pendingFood.push({
cityId: city.id,
coords: city.coords,
@@ -1219,7 +1239,50 @@ export const resourceMethods = {
amount: produced,
});
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
@@ -1234,27 +1297,32 @@ export const resourceMethods = {
for (const component of ledger.values()) {
if (component.civ < 0) continue;
component.available = component.supply + component.imports;
- const industrial = component.converterEnergy + component.foodEnergy;
- component.industrialScale = industrial > 0
- ? Math.max(0, Math.min(1, (component.available - component.cityEnergy) / industrial))
- : 1;
- const cityServed = Math.min(
- component.cityEnergy,
- Math.max(0, component.available - industrial * component.industrialScale)
- );
+ // Cities are served first, then the food the region grows, then the
+ // converters: a famine is worse than an idle factory, so the power that
+ // feeds people is never throttled away by industry.
+ const cityServed = Math.min(component.cityEnergy, component.available);
component.cityShortage = component.cityEnergy > 0
? Math.max(0, Math.min(1, (component.cityEnergy - cityServed) / component.cityEnergy))
: 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").demand += component.demand;
}
// A grid that cannot power its industry names the works left idle.
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) {
const component = ledger.get(entry.component);
- const scale = component ? component.industrialScale : 1;
+ const scale = component ? component.foodScale : 1;
const produced = entry.amount * scale;
if (produced <= 0) continue;
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;
const component = ledger.get(components.get(k));
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;
this._ensureResourceStock(k)[proto.resource] += 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
// 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,
// and its people start dying.
@@ -1341,56 +1414,98 @@ export const resourceMethods = {
// supply, filled in as the world is walked.
_buildEnergyLedger(components) {
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) {
if (ledger.has(id)) continue;
ledger.set(id, {
civ: this.territory.get(k),
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,
converterEnergy: 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,
available: 0,
industrialScale: 1,
+ foodScale: 1,
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) {
const component = ledger.get(components.get(key(city.coords.x, city.coords.y)));
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
// charged to the same component.
for (const [index, level] of Object.entries(city.buildings || {})) {
if (level <= 0) continue;
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) {
const proto = tileImprovementById(id);
if (!proto) continue;
const component = ledger.get(components.get(k));
if (!component) continue;
- if (isResourceTileBuilding(proto) && proto.resource === "energy") {
- component.supply += proto.outputPerDay;
- } else if (isResourceTileBuilding(proto)) {
- component.converterEnergy += proto.energyPerDay || 0;
+ if (isResourceTileBuilding(proto)) {
+ if (proto.resource === "energy") {
+ const output = this.resourceBuildingOutputAt(k, proto);
+ 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;
- if (proto.fuelEnergyPerDay) component.cityEnergy += proto.fuelEnergyPerDay;
}
return ledger;
},
// 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
- // it flows the same day. Each importer's treasury pays the exporter, whose
- // regions receive the money.
+ // it flows the same day. The seller's plants are paid for the power, and the
+ // buyer's consumers carry the cost. No government is involved.
_tradeGridPower(ledger, components) {
const price = this.getResourcePrice("energy");
const surplus = new Map();
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));
}
for (const [id, component] of ledger) {
@@ -1404,31 +1519,70 @@ export const resourceMethods = {
.filter(([sid]) => sid !== id && (surplus.get(sid) || 0) > 0)
.sort((a, b) => a[1] - b[1]);
let needed = deficit;
- let spent = 0;
for (const [sid, routeEnergy] of suppliers) {
if (needed <= 0) break;
const available = surplus.get(sid) || 0;
const take = Math.min(needed, available);
if (!(take > 0)) continue;
- // The power costs its price plus the energy burned carrying it.
- const cost = take * (price + routeEnergy * price);
+ // The power costs its price plus the energy burned carrying it. A unit
+ // 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);
- this.budgets.set(component.civ, this.getBudget(component.civ) - cost);
- this._recordBudgetCash(component.civ, "energy", -cost);
- this._recordBudgetCategoryResource(component.civ, "energy", "energy", cost);
+ this._payGridProducers(seller, cost, take);
if (seller.civ !== component.civ) {
- this._creditRegions(seller.civ, cost);
this._recordExternalBuy(component.civ, "energy", take, cost);
this._recordExternalSell(seller.civ, "energy", take, cost);
- } else {
- this._creditRegions(seller.civ, cost);
}
surplus.set(sid, available - take);
component.imports += take;
+ component.importCost += cost;
+ seller.exports += 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;
},
- _consumeCityResources(components, market) {
- const nodes = this._resourceNodes();
+ _consumeCityResources(components, market, nodes = null) {
+ nodes = nodes || this._resourceNodes();
// How much of each supplier's surplus earlier buyers have already claimed
// today, so a contested surplus goes to whoever bids highest.
this._nodeClaims = new Map();
@@ -1679,6 +1833,10 @@ export const resourceMethods = {
supplier.stock[id] -= bought;
cityStore[id] += 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({
from: [supplier.coords.x, supplier.coords.y],
to: [city.coords.x, city.coords.y],
@@ -1689,9 +1847,15 @@ export const resourceMethods = {
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) {
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);
if (city) this._creditRegionCash(city, code, amount);
},
@@ -1725,17 +1889,6 @@ export const resourceMethods = {
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
// two-tile road delivery burns. Used to value materials, not to source them.
_marketUnitCost(id) {
diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js
index c276191..7b90a3d 100644
--- a/shared/game_state/serialization.js
+++ b/shared/game_state/serialization.js
@@ -157,6 +157,8 @@ export const serializationMethods = {
taxes: null,
taxTake: { sales: 0, export: 0, import: 0 },
commodityInflation: 0,
+ privateBuildingCash: 0,
+ productionBuildings: [],
};
}
const headline = this._civHeadline(viewerCiv);
@@ -197,6 +199,10 @@ export const serializationMethods = {
exchangeRates: this._serializeExchangeRates(viewerCiv),
centralBank: this._serializeCentralBank(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
// regions, and foreign regions and central banks that hold it.
currencyHoldings: this.getCurrencyHoldings(viewerCiv),
@@ -334,6 +340,7 @@ export const serializationMethods = {
id,
this.tileImprovementOwner.get(k) ?? -1,
this.tileImprovementHp.get(k) || 0,
+ this.resourceBuildingLevel(k),
]);
}
return result;
diff --git a/shared/game_state/statuses.js b/shared/game_state/statuses.js
index 42f5a52..05d77f5 100644
--- a/shared/game_state/statuses.js
+++ b/shared/game_state/statuses.js
@@ -78,50 +78,6 @@ export const statusMethods = {
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) {
if (inCity && typeof def.cityHpLossPerDay === "number") return def.cityHpLossPerDay;
return typeof def.hpLossPerDay === "number" ? def.hpLossPerDay : 0;
diff --git a/shared/game_state/warfare.js b/shared/game_state/warfare.js
index 78bbdc2..efcda9e 100644
--- a/shared/game_state/warfare.js
+++ b/shared/game_state/warfare.js
@@ -4,7 +4,7 @@
import { key, parseKey } from "../hex.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 { TILE_IMPROVEMENT_HP, COASTAL_CANNON, EMBARK_HOURS } from "./constants.js";
import { protoUpkeep, tileImprovementResourceCost } from "../resources.js";
@@ -54,7 +54,7 @@ export const warfareMethods = {
if (!tile || tile.terrainClass !== "Land") return false;
if (this.civAt(coords) !== civ) 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.
if (proto.resource && this.cityAt(coords)) return false;
if (proto.requiresTechnology && !this.hasTechnology(civ, proto.requiresTechnology)) return false;
@@ -89,6 +89,7 @@ export const warfareMethods = {
if (this.tileImprovements.delete(k)) {
this.tileImprovementOwner.delete(k);
this.tileImprovementHp.delete(k);
+ this._removeBuildingAgent(k);
removed = true;
}
if (!removed) return false;
@@ -128,26 +129,13 @@ export const warfareMethods = {
this.tileImprovements.set(k, work.id);
this.tileImprovementOwner.set(k, civ);
this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[work.id] || 1000);
+ if (this._isProductionAgent(proto)) this._ensureBuildingAgent(k);
this._tileImprovementVersion += 1;
this._gdpPerCapitaCache.delete(k);
this._visibilityDirty = true;
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,
// against a melee or artillery attacker.
_tileFortificationMultiplier(coords, attackerProto) {
@@ -193,6 +181,7 @@ export const warfareMethods = {
this.tileImprovements.delete(k);
this.tileImprovementOwner.delete(k);
this.tileImprovementHp.delete(k);
+ this._removeBuildingAgent(k);
this._tileImprovementVersion += 1;
this._emitChanged();
return true;
@@ -285,7 +274,9 @@ export const warfareMethods = {
// Finishes any boarding: at zero hours the infantry leave the map and become
// the launch's cargo, and are carried until put ashore.
_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;
unit.embarking.hoursLeft -= 1;
if (unit.embarking.hoursLeft > 0) continue;
diff --git a/shared/game_state/world.js b/shared/game_state/world.js
index c281272..e95a9f0 100644
--- a/shared/game_state/world.js
+++ b/shared/game_state/world.js
@@ -4,7 +4,6 @@
import { ECONOMY } from "../data/economy.js";
import { RESOURCE_RULES } from "../data/resources.js";
-import { ROADS } from "../data/roads.js";
import { transportImprovement } from "../data/improvements.js";
import { buildRoadNetwork } from "../roads.js";
import { MapTopology, key, parseKey } from "../hex.js";
diff --git a/shared/resources.js b/shared/resources.js
index 3a79bcf..1dd0c52 100644
--- a/shared/resources.js
+++ b/shared/resources.js
@@ -80,6 +80,41 @@ export function foodMonthlyTarget(population) {
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.
export function cityEnergyPerDay(gdp) {
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);
}
-// 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 --
// Moves a price toward what the day's world-wide supply and demand imply. A
diff --git a/shared/rules.js b/shared/rules.js
index 043247f..7ce316a 100644
--- a/shared/rules.js
+++ b/shared/rules.js
@@ -15,12 +15,6 @@ export function popularityDisplay(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
// level). It grows only with the level, never with the nation's GDP.
export function buildingBuildCost(proto, level) {
diff --git a/tests/air_test.js b/tests/air_test.js
index 28d3567..66f8229 100644
--- a/tests/air_test.js
+++ b/tests/air_test.js
@@ -1,7 +1,7 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.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;
diff --git a/tests/food_synthesis_test.js b/tests/food_synthesis_test.js
new file mode 100644
index 0000000..210d7da
--- /dev/null
+++ b/tests/food_synthesis_test.js
@@ -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"
+ );
+ }
+}
diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js
index 3886c52..1a6ab04 100644
--- a/tests/game_screen_test.js
+++ b/tests/game_screen_test.js
@@ -1,8 +1,7 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { GameScreen } from "../client/js/game_screen.js";
-import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, ECONOMY, TRANSPORT_IMPROVEMENTS } from "../shared/data.js";
-import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
+import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, TRANSPORT_IMPROVEMENTS } from "../shared/data.js";
import { hoursInNextMonth } from "../shared/game_clock.js";
import { compact } from "../shared/text_format.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();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
- state.budgets.set(0, 0);
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);
const goal = adjacentLand(state, home);
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");
- this.assertEqual(network.orders.length, 0, "no order is sent without the budget");
- this.assertEqual(env.$("#confirm-title").text(), "Insufficient budget");
+ this.assertEqual(network.orders.length, 0, "no order is sent without the munitions");
+ 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();
} finally {
teardownDom(env);
diff --git a/tests/game_state_buildings_test.js b/tests/game_state_buildings_test.js
index 73e625b..d249a49 100644
--- a/tests/game_state_buildings_test.js
+++ b/tests/game_state_buildings_test.js
@@ -1,7 +1,7 @@
import { TestCase } from "./framework/test_case.js";
import { smallState, cityOf, gdpBuilding, grantBuilding } from "./framework/helpers.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 { DAYS_PER_YEAR } from "../shared/game_state/constants.js";
diff --git a/tests/industry_test.js b/tests/industry_test.js
new file mode 100644
index 0000000..e1bbc9d
--- /dev/null
+++ b/tests/industry_test.js
@@ -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"
+ );
+ }
+}
diff --git a/tests/money_test.js b/tests/money_test.js
index c4b8307..5b7658b 100644
--- a/tests/money_test.js
+++ b/tests/money_test.js
@@ -1,6 +1,5 @@
import { TestCase } from "./framework/test_case.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";
export class MoneyTest extends TestCase {
diff --git a/tests/political_map_test.js b/tests/political_map_test.js
index fffff84..895f140 100644
--- a/tests/political_map_test.js
+++ b/tests/political_map_test.js
@@ -1,7 +1,7 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.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 { politicalMode, POLITICAL_ZOOM } from "../client/js/map_view/constants.js";
import {
diff --git a/tests/regression_test.js b/tests/regression_test.js
index 30ff611..baf1a17 100644
--- a/tests/regression_test.js
+++ b/tests/regression_test.js
@@ -9,7 +9,6 @@ import {
TECHNOLOGIES,
TERRAIN_TILES,
MAP_CONFIG,
- ECONOMY,
TAXES,
BATTLE,
effectValue,
diff --git a/tests/siege_test.js b/tests/siege_test.js
index 46b2d12..9dd740f 100644
--- a/tests/siege_test.js
+++ b/tests/siege_test.js
@@ -1,7 +1,7 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.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 { MapView } from "../client/js/map_view.js";
@@ -232,24 +232,3 @@ function findCoastalClearCity(state, civ) {
}
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;
-}