Derived every description figure from the rule it describes

This commit is contained in:
2026-09-28 00:05:39 +02:00
parent c85f79fee2
commit 3c737e9ef9
13 changed files with 295 additions and 79 deletions
+8 -1
View File
@@ -89,7 +89,14 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
simulation: they surface in tooltips — city building cards and resource
producers (`client/js/ui/card.js` `tooltipSections`), spy operations and
minority policies (`game_screen/panels.js`, `modals/nation.js`) — so correct
them when the behaviour they describe changes.
them when the behaviour they describe changes. Never type a figure into that
prose: interpolate the field or constant the simulation uses (a
`get description()`/`get targetEffect()`, not a literal), and render it with
the shared formatters so the description quotes exactly what the game shows.
The amount formatters live in `shared/data/resources.js` (`formatEnergy`,
`formatResourceAmount`, re-exported by `shared/resources.js`); `text_format.js`
has `formatNumber`/`formatPercent`. `tests/descriptions_test.js` recomputes each
figure and fails when the prose drifts from its source.
- Keep aircraft out of the melee: `_battleTiles`/`_combatTarget` skip them and
their strikes are routed explicitly. Mirror the air rules (airport-only
rebasing, strike radius, ferry fuel) on both client and server, or prediction
+6 -6
View File
@@ -1,6 +1,12 @@
// Generated by scripts/generate-devlog.js from `git log`; do not edit.
// The pre-commit hook refreshes it so the main menu shows the latest commits.
export const DEVLOG = [
{
"hash": "c85f79f",
"date": "2026-09-27",
"subject": "Drew one bombardment arc per stack above the unit icons",
"body": ""
},
{
"hash": "4d3591c",
"date": "2026-09-27",
@@ -54,11 +60,5 @@ export const DEVLOG = [
"date": "2026-09-24",
"subject": "Flew aircraft and missiles straight and let strikes target fogged cities",
"body": ""
},
{
"hash": "36ac669",
"date": "2026-09-24",
"subject": "Let stacks split by shift-click and cross allied or at-war land",
"body": ""
}
];
+25 -6
View File
@@ -3,6 +3,13 @@
// the level too.
import { EFFECT_PRODUCTION, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
import { formatNumber, formatPercent } from "../text_format.js";
import { AIR_DEFENSE } from "./combat.js";
import { SATELLITE } from "./intelligence.js";
// Extra production lines a barracks or shipyard level adds. Shared by the
// simulation and the descriptions so the two never disagree.
export const PARALLEL_LINES_PER_LEVEL = 1;
// Buildings whose benefit is a rule rather than a stat effect. Kept as ids so
// the simulation and the UI can name them without magic strings.
@@ -29,7 +36,9 @@ export const BUILDINGS = [
{
id: "barracks",
name: "Barracks",
description: "Enables training infantry units. Each level allows one more parallel training.",
get description() {
return `Enables training infantry units. Each level allows ${PARALLEL_LINES_PER_LEVEL} more parallel training.`;
},
baseCost: 10_000_000,
costAlpha: 1.1,
refundFraction: 0.5,
@@ -40,7 +49,9 @@ export const BUILDINGS = [
{
id: "shelters",
name: "Wartime shelters",
description: "Each level shelters another 10% of the city's people from bombing, up to all of them. Extremely hard to destroy. Does not protect other buildings.",
get description() {
return `Each level shelters another ${formatPercent(this.civilianProtectionPerLevel)} of the city's people from bombing, up to all of them. Extremely hard to destroy. Does not protect other buildings.`;
},
baseCost: 100_000_000,
costAlpha: 2,
refundFraction: 0.5,
@@ -58,7 +69,9 @@ export const BUILDINGS = [
id: "aa_building",
advanced: true,
name: "Air defense",
description: "Fires on hostile aircraft and missiles that come within two tiles of this city.",
get description() {
return `Fires on hostile aircraft and missiles that come within ${AIR_DEFENSE.rangeTiles} tiles of this city.`;
},
baseCost: 1_000_000_000,
costAlpha: 2,
refundFraction: 0.5,
@@ -114,7 +127,9 @@ export const BUILDINGS = [
{
id: "port",
name: "Port",
description: "Warm water port. Adds 1000 production per person per adjacent sea tile per level, and brings in extra food.",
get description() {
return `Warm water port. Adds ${formatNumber(this.productionPerSeaTilePerLevel)} production per person per adjacent sea tile per level, and brings in extra food.`;
},
baseCost: 20_000_000,
costAlpha: 1.7,
refundFraction: 0.5,
@@ -144,7 +159,9 @@ export const BUILDINGS = [
{
id: "shipyard",
name: "Shipyard",
description: "Naval yards that build warships. Only a coastal city can raise one, and it must already have a port. Each level adds one parallel building queue.",
get description() {
return `Naval yards that build warships. Only a coastal city can raise one, and it must already have a port. Each level adds ${PARALLEL_LINES_PER_LEVEL} parallel building queue.`;
},
baseCost: 150_000_000,
costAlpha: 1.65,
refundFraction: 0.5,
@@ -172,7 +189,9 @@ export const BUILDINGS = [
id: "space_launch_center",
advanced: true,
name: "Space launch centre",
description: "A rocket complex. Each level lets the nation launch one spy satellite, which uncovers a random patch of the world every two days. Extremely expensive.",
get description() {
return `A rocket complex. Each level lets the nation launch ${SATELLITE.perLaunchCentreLevel} spy satellite, which uncovers a random patch of the world every ${SATELLITE.revealEveryDays} days. Extremely expensive.`;
},
baseCost: 10_000_000_000,
costAlpha: 2,
refundFraction: 0.5,
+6 -4
View File
@@ -63,8 +63,9 @@ export const TILE_IMPROVEMENTS = [
id: "radar_tower",
advanced: true,
name: "Radar tower",
description:
"Watches five tiles out. A ranged hit damages it and an enemy walking onto it captures it.",
get description() {
return `Watches ${this.vision} tiles out. A ranged hit damages it and an enemy walking onto it captures it.`;
},
buildCost: 60_000_000,
maintenance: 1000,
vision: 5,
@@ -76,8 +77,9 @@ export const TILE_IMPROVEMENTS = [
id: "coastal_cannon",
advanced: true,
name: "Coastal cannon",
description:
"Automatically fires on hostile naval units within two tiles. Only a coastal tile can carry one.",
get description() {
return `Automatically fires on hostile naval units within ${this.range} tiles. Only a coastal tile can carry one.`;
},
buildCost: 120_000_000,
maintenance: 2000,
range: 2,
+6 -2
View File
@@ -53,9 +53,12 @@ export const ESPIONAGE = {
// Spy satellites: a launch is free once the (very expensive) space launch centre
// stands, and each satellite uncovers a random patch of the world on a cadence.
// A centre sustains this many satellites for every level it has, one each by
// default.
export const SATELLITE = {
revealEveryDays: 2,
revealRadius: 2,
perLaunchCentreLevel: 1,
};
// `description` is the reference text the Intelligence tab shows, not a
@@ -72,8 +75,9 @@ export const SPY_OPERATIONS = [
{
id: SPY_SABOTAGE,
name: "Sabotage",
description:
"Cripple a tile's production for a week. The act may be pinned on another nation, or on no one at all. Spends the spies.",
get description() {
return `Cripple a tile's production for ${SABOTAGE.blockDays} days. The act may be pinned on another nation, or on no one at all. Spends the spies.`;
},
},
{
id: SPY_CITY,
+30 -6
View File
@@ -56,6 +56,18 @@ export const POPULARITY = {
pillage: 6,
};
// An opinion shift as the integer popularity points the policies are quoted in,
// so the wizard's estimates come straight from the applied effect.
export function opinionToPoints(opinion) {
return Math.round(opinion / POPULARITY.opinionPerPoint);
}
// A signed popularity figure with the typographic minus the catalogue uses.
function popularityPointsText(opinion) {
const points = opinionToPoints(opinion);
return `${points < 0 ? "\u2212" : "+"}${Math.abs(points)}`;
}
// A campaign costs nothing to start: it charges `defaultHourlyCost` for as long
// as it runs, like any other upkeep and shown in the budget. The player may
// raise or lower the spending up to `maxHourlyCost`; the opinion shift scales
@@ -102,16 +114,24 @@ export const POLICIES = [
name: "Champion a minority",
description:
"Proclaim the state the protector of an ethnicity. Its people everywhere warm to you, but your own other populations resent the favouritism.",
targetEffect: "+30 popularity among the chosen people",
politicalCost: "−16 popularity among every other population in your country",
get targetEffect() {
return `${popularityPointsText(POLICY_OPINION.championTarget)} popularity among the chosen people`;
},
get politicalCost() {
return `${popularityPointsText(POLICY_OPINION.championMajority)} popularity among every other population in your country`;
},
},
{
id: POLICY_PROTECT,
name: "Protect a minority",
description:
"Shield an ethnicity inside your borders. It raises their view of you at home and abroad, at some cost with your other populations.",
targetEffect: "+24 popularity among the chosen people",
politicalCost: "−8 popularity among every other population in your country",
get targetEffect() {
return `${popularityPointsText(POLICY_OPINION.protectTarget)} popularity among the chosen people`;
},
get politicalCost() {
return `${popularityPointsText(POLICY_OPINION.protectMajority)} popularity among every other population in your country`;
},
},
{
id: POLICY_EXPEL,
@@ -119,7 +139,9 @@ export const POLICIES = [
description:
"Force an ethnicity out of the country. Its people, in and outside your borders, will hate you for a long time, and they will press into your neighbours.",
targetEffect: "The people begin to leave for neighbouring countries",
politicalCost: "−80 popularity among the expelled people, remembered for a generation",
get politicalCost() {
return `${popularityPointsText(POLICY_OPINION.expelTarget)} popularity among the expelled people, remembered for a generation`;
},
},
{
id: POLICY_IMMIGRATION,
@@ -127,7 +149,9 @@ export const POLICIES = [
description:
"Invite an ethnicity in. They will come from lands where their government is unpopular, but every other population in your country dislikes the influx.",
targetEffect: "The chosen people migrate into your country",
politicalCost: "−10 popularity among every other population in your country",
get politicalCost() {
return `${popularityPointsText(POLICY_OPINION.immigrationOthers)} popularity among every other population in your country`;
},
},
];
+66 -17
View File
@@ -13,6 +13,7 @@ import {
EFFECT_LUXURY_EFFICIENCY,
EFFECT_HIGHTECH_EFFICIENCY,
} from "./effects.js";
import { groupDigits } from "../text_format.js";
// Energy is stored and moved as kWh. These keep the catalogue readable.
export const KWH_PER_MWH = 1_000;
@@ -112,6 +113,35 @@ export function resourceById(id) {
return RESOURCES[RESOURCE_INDEX[id]] || null;
}
// --------------------------------------------------------------- formatting --
//
// The canonical rendering of an amount, shared with the browser so a
// description quotes exactly what the game shows. Energy reads in kWh, MWh,
// GWh or TWh; a material in its own unit, stepping through kt/Mt, kct and ku.
export function formatEnergy(kwh) {
const value = Math.max(0, kwh);
if (value >= 1e9) return `${trim(value / 1e9)} TWh`;
if (value >= 1e6) return `${trim(value / 1e6)} GWh`;
if (value >= 1e3) return `${trim(value / 1e3)} MWh`;
return `${trim(value)} kWh`;
}
export function formatResourceAmount(id, value) {
if (id === "energy") return formatEnergy(value);
const amount = Math.max(0, value);
const unit = resourceById(id) ? resourceById(id).unit : "";
if (amount >= 1e12) return `${trim(amount / 1e12)} T${unit}`;
if (amount >= 1e9) return `${trim(amount / 1e9)} G${unit}`;
if (amount >= 1e6) return `${trim(amount / 1e6)} M${unit}`;
return `${trim(amount)} ${unit}`;
}
function trim(value) {
if (value >= 100) return groupDigits(Math.round(value));
if (value >= 10) return value.toFixed(1).replace(/\.0$/, "");
return value.toFixed(2).replace(/\.?0+$/, "");
}
// Global market prices, in euro per canonical unit. These are what a city pays
// when it cannot meet a need from its own store or a neighbour. Energy is
// deliberately cheap: the roadmap's energy-per-material ratios are large, so a
@@ -310,8 +340,9 @@ export const RESOURCE_BUILDINGS = [
{
id: "coal_power_plant",
name: "Coal power plant",
description:
"Burns coal to make 2,500 GWh of electricity a day. Cheap to raise, but the fuel bill never stops.",
get description() {
return `Burns coal to make ${formatEnergy(this.outputPerDay)} of electricity a day. Cheap to raise, but the fuel bill never stops.`;
},
resource: "energy",
outputPerDay: 250 * KWH_PER_GWH,
fuelEnergyPerDay: 250 * KWH_PER_GWH * 0.25,
@@ -323,8 +354,9 @@ export const RESOURCE_BUILDINGS = [
{
id: "natural_gas_power_plant",
name: "Natural gas power plant",
description:
"The cleanest fossil plant: 3,000 GWh a day for a higher fuel bill.",
get description() {
return `The cleanest fossil plant: ${formatEnergy(this.outputPerDay)} a day for a higher fuel bill.`;
},
resource: "energy",
outputPerDay: 300 * KWH_PER_GWH,
fuelEnergyPerDay: 300 * KWH_PER_GWH * 0.25,
@@ -336,7 +368,9 @@ export const RESOURCE_BUILDINGS = [
{
id: "oil_power_plant",
name: "Oil power plant",
description: "Burns oil for 2,700 GWh of electricity a day.",
get description() {
return `Burns oil for ${formatEnergy(this.outputPerDay)} of electricity a day.`;
},
resource: "energy",
outputPerDay: 270 * KWH_PER_GWH,
fuelEnergyPerDay: 270 * KWH_PER_GWH * 0.25,
@@ -348,8 +382,9 @@ export const RESOURCE_BUILDINGS = [
{
id: "solar_power_plant",
name: "Solar power plant",
description:
"A field of panels producing a steady 300 GWh a day with no fuel and no upkeep.",
get description() {
return `A field of panels producing a steady ${formatEnergy(this.outputPerDay)} a day with no fuel and no upkeep.`;
},
resource: "energy",
outputPerDay: 30 * KWH_PER_GWH,
energyPerDay: 0,
@@ -361,7 +396,9 @@ export const RESOURCE_BUILDINGS = [
{
id: "geothermal_plant",
name: "Geothermal plant",
description: "Taps the earth's heat for 600 GWh a day of free electricity.",
get description() {
return `Taps the earth's heat for ${formatEnergy(this.outputPerDay)} a day of free electricity.`;
},
resource: "energy",
outputPerDay: 60 * KWH_PER_GWH,
energyPerDay: 0,
@@ -373,7 +410,9 @@ export const RESOURCE_BUILDINGS = [
{
id: "wind_turbines",
name: "Wind turbines",
description: "A hilltop wind farm making 250 GWh a day for free.",
get description() {
return `A hilltop wind farm making ${formatEnergy(this.outputPerDay)} a day for free.`;
},
resource: "energy",
outputPerDay: 25 * KWH_PER_GWH,
energyPerDay: 0,
@@ -385,7 +424,9 @@ export const RESOURCE_BUILDINGS = [
{
id: "offshore_wind_turbines",
name: "Offshore wind turbines",
description: "Turbines out at sea making 500 GWh a day. Needs a coastal tile.",
get description() {
return `Turbines out at sea making ${formatEnergy(this.outputPerDay)} a day. Needs a coastal tile.`;
},
resource: "energy",
outputPerDay: 50 * KWH_PER_GWH,
energyPerDay: 0,
@@ -399,7 +440,9 @@ export const RESOURCE_BUILDINGS = [
id: "fusion_power_plant",
advanced: true,
name: "Fusion power plant",
description: "A late-game reactor producing 40 TWh of electricity a day.",
get description() {
return `A late-game reactor producing ${formatEnergy(this.outputPerDay)} of electricity a day.`;
},
resource: "energy",
outputPerDay: 4_000 * KWH_PER_GWH,
energyPerDay: 0,
@@ -416,8 +459,9 @@ export const RESOURCE_BUILDINGS = [
{
id: "steel_mill",
name: "Steel mill",
description:
"Smelts iron into 200,000 t of steel a day, spending 20 MWh of energy per tonne.",
get description() {
return `Smelts iron into ${formatResourceAmount(this.resource, this.outputPerDay)} of steel a day, spending ${formatEnergy(RESOURCE_RULES.energyPerSteelTonne)} of energy per tonne.`;
},
resource: "steel",
outputPerDay: 20_000,
energyPerDay: 2_000 * RESOURCE_RULES.energyPerSteelTonne,
@@ -428,7 +472,9 @@ export const RESOURCE_BUILDINGS = [
{
id: "gold_mine",
name: "Gold mine",
description: "Produces 200,000 carats of luxury a day, at 10 MWh a carat.",
get description() {
return `Produces ${formatResourceAmount(this.resource, this.outputPerDay)} of luxury a day, at ${formatEnergy(RESOURCE_RULES.energyPerLuxuryCarat.gold_mine)} a carat.`;
},
resource: "luxury",
outputPerDay: 20_000,
energyPerDay: 20_000 * RESOURCE_RULES.energyPerLuxuryCarat.gold_mine,
@@ -439,7 +485,9 @@ export const RESOURCE_BUILDINGS = [
{
id: "diamond_mine",
name: "Diamond mine",
description: "Produces 200,000 carats of luxury a day, at 5 MWh a carat.",
get description() {
return `Produces ${formatResourceAmount(this.resource, this.outputPerDay)} of luxury a day, at ${formatEnergy(RESOURCE_RULES.energyPerLuxuryCarat.diamond_mine)} a carat.`;
},
resource: "luxury",
outputPerDay: 20_000,
energyPerDay: 20_000 * RESOURCE_RULES.energyPerLuxuryCarat.diamond_mine,
@@ -451,8 +499,9 @@ export const RESOURCE_BUILDINGS = [
id: "chip_foundry",
advanced: true,
name: "Chip foundry",
description:
"Fabricates 50,000 units of high-tech a day, spending 10 MWh of energy per unit.",
get description() {
return `Fabricates ${formatResourceAmount(this.resource, this.outputPerDay)} of high-tech a day, spending ${formatEnergy(RESOURCE_RULES.energyPerHighTechUnit)} of energy per unit.`;
},
resource: "hightech",
outputPerDay: 5_000,
energyPerDay: 5_000 * RESOURCE_RULES.energyPerHighTechUnit,
+8 -2
View File
@@ -7,6 +7,8 @@
// each day), a `cityHpLossPerDay` override for when the unit stands in a city,
// and an `upkeepMultiplier` applied to the unit's upkeep while it is active.
import { formatPercent } from "../text_format.js";
export const STATUS_ENCIRCLED = "encircled";
export const STATUS_BESIEGED = "besieged";
export const STATUS_BATTLE = "battle";
@@ -16,7 +18,9 @@ export const STATUSES = [
id: STATUS_ENCIRCLED,
label: "Encircled",
icon: "encirclement.svg",
description: "Cut off: every adjacent land tile is held by a hostile unit and no friendly port supplies it by sea. Loses 10% of its maximum hit points each day, or 2% inside a city.",
get description() {
return `Cut off: every adjacent land tile is held by a hostile unit and no friendly port supplies it by sea. Loses ${formatPercent(this.hpLossPerDay)} of its maximum hit points each day, or ${formatPercent(this.cityHpLossPerDay)} inside a city.`;
},
hpLossPerDay: 0.10,
cityHpLossPerDay: 0.02,
},
@@ -30,7 +34,9 @@ export const STATUSES = [
id: STATUS_BATTLE,
label: "In battle",
icon: "icon_battle.svg",
description: "Fighting an enemy unit on the same tile. Upkeep is tripled and the unit cannot pillage while the battle rages.",
get description() {
return `Fighting an enemy unit on the same tile. Upkeep is multiplied by ${this.upkeepMultiplier} and the unit cannot pillage while the battle rages.`;
},
upkeepMultiplier: 3,
},
];
+3 -3
View File
@@ -11,7 +11,7 @@ import {
EFFECT_RAIL_SPEED,
} from "../data/effects.js";
import { CITY_GARRISON_HEAL_PER_HOUR } from "../data/combat.js";
import { BUILDING_MECHANIC } from "../data/buildings.js";
import { BUILDING_MECHANIC, PARALLEL_LINES_PER_LEVEL } from "../data/buildings.js";
import { technologyCost, technologyById } from "../data/technologies.js";
import { NEWS_TECHNOLOGY } from "../data/relations.js";
import {
@@ -1099,8 +1099,8 @@ export const economyMethods = {
// (units) and Shipyards (buildings) add. Each line works one entry at full
// capacity, so a higher-level city produces several things at once; the
// overflow of a finished entry carries into the next waiting one.
const unitLines = 1 + this._cityMechanicLevel(city, BUILDING_MECHANIC.PARALLEL_TRAINING);
const buildingLines = 1 + this._cityMechanicLevel(city, BUILDING_MECHANIC.PARALLEL_BUILDING);
const unitLines = 1 + PARALLEL_LINES_PER_LEVEL * this._cityMechanicLevel(city, BUILDING_MECHANIC.PARALLEL_TRAINING);
const buildingLines = 1 + PARALLEL_LINES_PER_LEVEL * this._cityMechanicLevel(city, BUILDING_MECHANIC.PARALLEL_BUILDING);
this._advanceProduction(queue, city, "unit", unitLines);
this._advanceProduction(queue, city, "building", buildingLines);
if (queue.length === 0) completed.push(cityId);
+1 -1
View File
@@ -330,7 +330,7 @@ export const intelligenceMethods = {
for (const [index, level] of Object.entries(city.buildings || {})) {
const proto = this.protoBuildings[index];
if (proto && proto.mechanic === BUILDING_MECHANIC.SPACE_LAUNCH) {
total += Number(level) || 0;
total += (Number(level) || 0) * SATELLITE.perLaunchCentreLevel;
}
}
}
+5 -31
View File
@@ -8,42 +8,16 @@ import {
RESOURCE_RULES,
RESOURCE_MARKET,
RESOURCE_MARKET_BASE,
resourceById,
formatEnergy,
formatResourceAmount,
} from "./data/resources.js";
import { EFFECT_RESEARCH } from "./data/effects.js";
import { groupDigits } from "./text_format.js";
const DAYS_PER_YEAR = 365;
// --------------------------------------------------------------- formatting --
// Energy reads in kWh, MWh, GWh or TWh, whichever keeps the figure short.
export function formatEnergy(kwh) {
const value = Math.max(0, kwh);
if (value >= 1e9) return `${trim(value / 1e9)} TWh`;
if (value >= 1e6) return `${trim(value / 1e6)} GWh`;
if (value >= 1e3) return `${trim(value / 1e3)} MWh`;
return `${trim(value)} kWh`;
}
// A material in its own friendly unit: tonnes step up through kt and Mt,
// carats through kct, high-tech through ku. The prefix and the divisor move
// together, so a million tonnes reads as 1 Mt rather than 1 kt.
export function formatResourceAmount(id, value) {
if (id === "energy") return formatEnergy(value);
const amount = Math.max(0, value);
const unit = resourceById(id) ? resourceById(id).unit : "";
if (amount >= 1e12) return `${trim(amount / 1e12)} T${unit}`;
if (amount >= 1e9) return `${trim(amount / 1e9)} G${unit}`;
if (amount >= 1e6) return `${trim(amount / 1e6)} M${unit}`;
return `${trim(amount)} ${unit}`;
}
function trim(value) {
if (value >= 100) return groupDigits(Math.round(value));
if (value >= 10) return value.toFixed(1).replace(/\.0$/, "");
return value.toFixed(2).replace(/\.?0+$/, "");
}
// The amount formatters live with the resource catalogue so its descriptions
// can quote the same figures; re-exported here for the existing callers.
export { formatEnergy, formatResourceAmount };
// --------------------------------------------------------------- per-person --
+10
View File
@@ -69,6 +69,16 @@ export function compact(value) {
return sign + text + suffixes[index];
}
// A fraction as a whole-number percentage, the unit most descriptions quote:
// 0.1 reads "10%". Callers pass the raw fraction the simulation applies so a
// description can never drift from the rule it describes.
export function formatPercent(fraction, { decimals = 0 } = {}) {
const value = Number(fraction) * 100;
const fixed = value.toFixed(decimals);
const text = decimals === 0 ? fixed : fixed.replace(/\.?0+$/, "");
return `${text}%`;
}
export function improvements(list) {
if (!list || list.length === 0) return "None";
return list.join(", ");
+121
View File
@@ -0,0 +1,121 @@
import { TestCase } from "./framework/test_case.js";
import {
RESOURCE_BUILDINGS,
RESOURCE_RULES,
TILE_IMPROVEMENTS,
STATUSES,
POLICY_OPINION,
opinionToPoints,
SPY_OPERATIONS,
AIR_DEFENSE,
SATELLITE,
SABOTAGE,
PARALLEL_LINES_PER_LEVEL,
buildingById,
policyById,
} from "../shared/data.js";
import { formatEnergy, formatResourceAmount } from "../shared/resources.js";
import { formatNumber, formatPercent } from "../shared/text_format.js";
// Every figure a description quotes must be derived from the same field the
// simulation applies, never typed in. These tests recompute the figure and
// assert the description shows it, so a rule change that leaves the prose stale
// fails here.
export class DescriptionsTest extends TestCase {
test_resource_building_descriptions_quote_their_output() {
for (const proto of RESOURCE_BUILDINGS) {
const output = formatResourceAmount(proto.resource, proto.outputPerDay);
this.assertTrue(
proto.description.includes(output),
`${proto.id} should quote ${output}: ${proto.description}`
);
}
}
test_material_producer_descriptions_quote_their_energy_ratio() {
const ratios = {
steel_mill: RESOURCE_RULES.energyPerSteelTonne,
gold_mine: RESOURCE_RULES.energyPerLuxuryCarat.gold_mine,
diamond_mine: RESOURCE_RULES.energyPerLuxuryCarat.diamond_mine,
chip_foundry: RESOURCE_RULES.energyPerHighTechUnit,
};
for (const [id, ratio] of Object.entries(ratios)) {
const proto = RESOURCE_BUILDINGS.find((b) => b.id === id);
const energy = formatEnergy(ratio);
this.assertTrue(
proto.description.includes(energy),
`${id} should quote ${energy} per unit: ${proto.description}`
);
}
}
test_building_descriptions_quote_their_rules() {
const shelters = buildingById("shelters");
const protection = formatPercent(shelters.civilianProtectionPerLevel);
this.assertTrue(shelters.description.includes(protection), `shelters quote ${protection}`);
const air = buildingById("aa_building");
this.assertTrue(air.description.includes(`${AIR_DEFENSE.rangeTiles} tiles`), "air defense range");
const port = buildingById("port");
const production = formatNumber(port.productionPerSeaTilePerLevel);
this.assertTrue(port.description.includes(production), `port quote ${production}`);
const space = buildingById("space_launch_center");
this.assertTrue(space.description.includes(`${SATELLITE.revealEveryDays} days`), "satellite cadence");
this.assertTrue(
space.description.includes(String(SATELLITE.perLaunchCentreLevel)),
"satellites per centre level"
);
for (const id of ["barracks", "shipyard"]) {
const proto = buildingById(id);
this.assertTrue(
proto.description.includes(String(PARALLEL_LINES_PER_LEVEL)),
`${id} quotes ${PARALLEL_LINES_PER_LEVEL} line(s)`
);
}
}
test_improvement_descriptions_quote_their_range() {
for (const [id, field] of [["radar_tower", "vision"], ["coastal_cannon", "range"]]) {
const proto = TILE_IMPROVEMENTS.find((i) => i.id === id);
this.assertTrue(
proto.description.includes(String(proto[field])),
`${id} quotes its ${field} (${proto[field]})`
);
}
}
test_status_descriptions_quote_their_effect() {
const encircled = STATUSES.find((s) => s.id === "encircled");
this.assertTrue(encircled.description.includes(formatPercent(encircled.hpLossPerDay)));
this.assertTrue(encircled.description.includes(formatPercent(encircled.cityHpLossPerDay)));
const battle = STATUSES.find((s) => s.id === "battle");
this.assertTrue(battle.description.includes(String(battle.upkeepMultiplier)));
}
test_policy_effects_quote_their_opinion_shift() {
const expected = [
["champion", "championTarget", "targetEffect"],
["champion", "championMajority", "politicalCost"],
["protect", "protectTarget", "targetEffect"],
["protect", "protectMajority", "politicalCost"],
["expel", "expelTarget", "politicalCost"],
["immigration", "immigrationOthers", "politicalCost"],
];
for (const [id, field, slot] of expected) {
const points = opinionToPoints(POLICY_OPINION[field]);
this.assertTrue(
policyById(id)[slot].includes(String(Math.abs(points))),
`${id}.${slot} should quote ${Math.abs(points)} points`
);
}
}
test_spy_operation_descriptions_quote_their_block() {
const sabotage = SPY_OPERATIONS.find((op) => op.id === "sabotage");
this.assertTrue(sabotage.description.includes(`${SABOTAGE.blockDays} days`));
}
}