From 3c737e9ef902e2d56856578019a44e0a4edbc239 Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Mon, 28 Sep 2026 00:05:39 +0200 Subject: [PATCH] Derived every description figure from the rule it describes --- AGENTS.md | 9 ++- client/js/devlog_data.js | 12 +-- shared/data/buildings.js | 31 ++++++-- shared/data/improvements.js | 10 ++- shared/data/intelligence.js | 8 +- shared/data/politics.js | 36 +++++++-- shared/data/resources.js | 83 +++++++++++++++----- shared/data/statuses.js | 10 ++- shared/game_state/economy.js | 6 +- shared/game_state/intelligence.js | 2 +- shared/resources.js | 36 ++------- shared/text_format.js | 10 +++ tests/descriptions_test.js | 121 ++++++++++++++++++++++++++++++ 13 files changed, 295 insertions(+), 79 deletions(-) create mode 100644 tests/descriptions_test.js diff --git a/AGENTS.md b/AGENTS.md index d6f81f2..777b032 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/client/js/devlog_data.js b/client/js/devlog_data.js index 7720f0d..3f51de2 100644 --- a/client/js/devlog_data.js +++ b/client/js/devlog_data.js @@ -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": "" } ]; diff --git a/shared/data/buildings.js b/shared/data/buildings.js index 5f4c9b6..ad858f5 100644 --- a/shared/data/buildings.js +++ b/shared/data/buildings.js @@ -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, diff --git a/shared/data/improvements.js b/shared/data/improvements.js index bc5ebdc..d1676ea 100644 --- a/shared/data/improvements.js +++ b/shared/data/improvements.js @@ -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, diff --git a/shared/data/intelligence.js b/shared/data/intelligence.js index 70d43d4..4fe3562 100644 --- a/shared/data/intelligence.js +++ b/shared/data/intelligence.js @@ -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, diff --git a/shared/data/politics.js b/shared/data/politics.js index 65c9567..21c8de4 100644 --- a/shared/data/politics.js +++ b/shared/data/politics.js @@ -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`; + }, }, ]; diff --git a/shared/data/resources.js b/shared/data/resources.js index 04d3919..8c99534 100644 --- a/shared/data/resources.js +++ b/shared/data/resources.js @@ -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, diff --git a/shared/data/statuses.js b/shared/data/statuses.js index 4a62f89..d5c4eee 100644 --- a/shared/data/statuses.js +++ b/shared/data/statuses.js @@ -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, }, ]; diff --git a/shared/game_state/economy.js b/shared/game_state/economy.js index 6dc5344..970386d 100644 --- a/shared/game_state/economy.js +++ b/shared/game_state/economy.js @@ -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); diff --git a/shared/game_state/intelligence.js b/shared/game_state/intelligence.js index 7362de2..8a1eea3 100644 --- a/shared/game_state/intelligence.js +++ b/shared/game_state/intelligence.js @@ -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; } } } diff --git a/shared/resources.js b/shared/resources.js index f46b64d..2308e7f 100644 --- a/shared/resources.js +++ b/shared/resources.js @@ -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 -- diff --git a/shared/text_format.js b/shared/text_format.js index 0c04ad8..c216b05 100644 --- a/shared/text_format.js +++ b/shared/text_format.js @@ -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(", "); diff --git a/tests/descriptions_test.js b/tests/descriptions_test.js new file mode 100644 index 0000000..9462ede --- /dev/null +++ b/tests/descriptions_test.js @@ -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`)); + } +}