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 simulation: they surface in tooltips — city building cards and resource
producers (`client/js/ui/card.js` `tooltipSections`), spy operations and producers (`client/js/ui/card.js` `tooltipSections`), spy operations and
minority policies (`game_screen/panels.js`, `modals/nation.js`) — so correct 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 - Keep aircraft out of the melee: `_battleTiles`/`_combatTarget` skip them and
their strikes are routed explicitly. Mirror the air rules (airport-only their strikes are routed explicitly. Mirror the air rules (airport-only
rebasing, strike radius, ferry fuel) on both client and server, or prediction 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. // 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. // The pre-commit hook refreshes it so the main menu shows the latest commits.
export const DEVLOG = [ export const DEVLOG = [
{
"hash": "c85f79f",
"date": "2026-09-27",
"subject": "Drew one bombardment arc per stack above the unit icons",
"body": ""
},
{ {
"hash": "4d3591c", "hash": "4d3591c",
"date": "2026-09-27", "date": "2026-09-27",
@@ -54,11 +60,5 @@ export const DEVLOG = [
"date": "2026-09-24", "date": "2026-09-24",
"subject": "Flew aircraft and missiles straight and let strikes target fogged cities", "subject": "Flew aircraft and missiles straight and let strikes target fogged cities",
"body": "" "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. // the level too.
import { EFFECT_PRODUCTION, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js"; 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 // 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. // the simulation and the UI can name them without magic strings.
@@ -29,7 +36,9 @@ export const BUILDINGS = [
{ {
id: "barracks", id: "barracks",
name: "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, baseCost: 10_000_000,
costAlpha: 1.1, costAlpha: 1.1,
refundFraction: 0.5, refundFraction: 0.5,
@@ -40,7 +49,9 @@ export const BUILDINGS = [
{ {
id: "shelters", id: "shelters",
name: "Wartime 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, baseCost: 100_000_000,
costAlpha: 2, costAlpha: 2,
refundFraction: 0.5, refundFraction: 0.5,
@@ -58,7 +69,9 @@ export const BUILDINGS = [
id: "aa_building", id: "aa_building",
advanced: true, advanced: true,
name: "Air defense", 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, baseCost: 1_000_000_000,
costAlpha: 2, costAlpha: 2,
refundFraction: 0.5, refundFraction: 0.5,
@@ -114,7 +127,9 @@ export const BUILDINGS = [
{ {
id: "port", id: "port",
name: "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, baseCost: 20_000_000,
costAlpha: 1.7, costAlpha: 1.7,
refundFraction: 0.5, refundFraction: 0.5,
@@ -144,7 +159,9 @@ export const BUILDINGS = [
{ {
id: "shipyard", id: "shipyard",
name: "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, baseCost: 150_000_000,
costAlpha: 1.65, costAlpha: 1.65,
refundFraction: 0.5, refundFraction: 0.5,
@@ -172,7 +189,9 @@ export const BUILDINGS = [
id: "space_launch_center", id: "space_launch_center",
advanced: true, advanced: true,
name: "Space launch centre", 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, baseCost: 10_000_000_000,
costAlpha: 2, costAlpha: 2,
refundFraction: 0.5, refundFraction: 0.5,
+6 -4
View File
@@ -63,8 +63,9 @@ export const TILE_IMPROVEMENTS = [
id: "radar_tower", id: "radar_tower",
advanced: true, advanced: true,
name: "Radar tower", name: "Radar tower",
description: get description() {
"Watches five tiles out. A ranged hit damages it and an enemy walking onto it captures it.", return `Watches ${this.vision} tiles out. A ranged hit damages it and an enemy walking onto it captures it.`;
},
buildCost: 60_000_000, buildCost: 60_000_000,
maintenance: 1000, maintenance: 1000,
vision: 5, vision: 5,
@@ -76,8 +77,9 @@ export const TILE_IMPROVEMENTS = [
id: "coastal_cannon", id: "coastal_cannon",
advanced: true, advanced: true,
name: "Coastal cannon", name: "Coastal cannon",
description: get description() {
"Automatically fires on hostile naval units within two tiles. Only a coastal tile can carry one.", return `Automatically fires on hostile naval units within ${this.range} tiles. Only a coastal tile can carry one.`;
},
buildCost: 120_000_000, buildCost: 120_000_000,
maintenance: 2000, maintenance: 2000,
range: 2, 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 // 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. // 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 = { export const SATELLITE = {
revealEveryDays: 2, revealEveryDays: 2,
revealRadius: 2, revealRadius: 2,
perLaunchCentreLevel: 1,
}; };
// `description` is the reference text the Intelligence tab shows, not a // `description` is the reference text the Intelligence tab shows, not a
@@ -72,8 +75,9 @@ export const SPY_OPERATIONS = [
{ {
id: SPY_SABOTAGE, id: SPY_SABOTAGE,
name: "Sabotage", name: "Sabotage",
description: get 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.", 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, id: SPY_CITY,
+30 -6
View File
@@ -56,6 +56,18 @@ export const POPULARITY = {
pillage: 6, 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 // 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 // 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 // raise or lower the spending up to `maxHourlyCost`; the opinion shift scales
@@ -102,16 +114,24 @@ export const POLICIES = [
name: "Champion a minority", name: "Champion a minority",
description: description:
"Proclaim the state the protector of an ethnicity. Its people everywhere warm to you, but your own other populations resent the favouritism.", "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", get targetEffect() {
politicalCost: "−16 popularity among every other population in your country", 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, id: POLICY_PROTECT,
name: "Protect a minority", name: "Protect a minority",
description: description:
"Shield an ethnicity inside your borders. It raises their view of you at home and abroad, at some cost with your other populations.", "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", get targetEffect() {
politicalCost: "−8 popularity among every other population in your country", 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, id: POLICY_EXPEL,
@@ -119,7 +139,9 @@ export const POLICIES = [
description: 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.", "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", 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, id: POLICY_IMMIGRATION,
@@ -127,7 +149,9 @@ export const POLICIES = [
description: 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.", "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", 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_LUXURY_EFFICIENCY,
EFFECT_HIGHTECH_EFFICIENCY, EFFECT_HIGHTECH_EFFICIENCY,
} from "./effects.js"; } from "./effects.js";
import { groupDigits } from "../text_format.js";
// Energy is stored and moved as kWh. These keep the catalogue readable. // Energy is stored and moved as kWh. These keep the catalogue readable.
export const KWH_PER_MWH = 1_000; export const KWH_PER_MWH = 1_000;
@@ -112,6 +113,35 @@ export function resourceById(id) {
return RESOURCES[RESOURCE_INDEX[id]] || null; 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 // 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 // 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 // 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", id: "coal_power_plant",
name: "Coal power plant", name: "Coal power plant",
description: get description() {
"Burns coal to make 2,500 GWh of electricity a day. Cheap to raise, but the fuel bill never stops.", return `Burns coal to make ${formatEnergy(this.outputPerDay)} of electricity a day. Cheap to raise, but the fuel bill never stops.`;
},
resource: "energy", resource: "energy",
outputPerDay: 250 * KWH_PER_GWH, outputPerDay: 250 * KWH_PER_GWH,
fuelEnergyPerDay: 250 * KWH_PER_GWH * 0.25, fuelEnergyPerDay: 250 * KWH_PER_GWH * 0.25,
@@ -323,8 +354,9 @@ export const RESOURCE_BUILDINGS = [
{ {
id: "natural_gas_power_plant", id: "natural_gas_power_plant",
name: "Natural gas power plant", name: "Natural gas power plant",
description: get description() {
"The cleanest fossil plant: 3,000 GWh a day for a higher fuel bill.", return `The cleanest fossil plant: ${formatEnergy(this.outputPerDay)} a day for a higher fuel bill.`;
},
resource: "energy", resource: "energy",
outputPerDay: 300 * KWH_PER_GWH, outputPerDay: 300 * KWH_PER_GWH,
fuelEnergyPerDay: 300 * KWH_PER_GWH * 0.25, fuelEnergyPerDay: 300 * KWH_PER_GWH * 0.25,
@@ -336,7 +368,9 @@ export const RESOURCE_BUILDINGS = [
{ {
id: "oil_power_plant", id: "oil_power_plant",
name: "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", resource: "energy",
outputPerDay: 270 * KWH_PER_GWH, outputPerDay: 270 * KWH_PER_GWH,
fuelEnergyPerDay: 270 * KWH_PER_GWH * 0.25, fuelEnergyPerDay: 270 * KWH_PER_GWH * 0.25,
@@ -348,8 +382,9 @@ export const RESOURCE_BUILDINGS = [
{ {
id: "solar_power_plant", id: "solar_power_plant",
name: "Solar power plant", name: "Solar power plant",
description: get description() {
"A field of panels producing a steady 300 GWh a day with no fuel and no upkeep.", return `A field of panels producing a steady ${formatEnergy(this.outputPerDay)} a day with no fuel and no upkeep.`;
},
resource: "energy", resource: "energy",
outputPerDay: 30 * KWH_PER_GWH, outputPerDay: 30 * KWH_PER_GWH,
energyPerDay: 0, energyPerDay: 0,
@@ -361,7 +396,9 @@ export const RESOURCE_BUILDINGS = [
{ {
id: "geothermal_plant", id: "geothermal_plant",
name: "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", resource: "energy",
outputPerDay: 60 * KWH_PER_GWH, outputPerDay: 60 * KWH_PER_GWH,
energyPerDay: 0, energyPerDay: 0,
@@ -373,7 +410,9 @@ export const RESOURCE_BUILDINGS = [
{ {
id: "wind_turbines", id: "wind_turbines",
name: "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", resource: "energy",
outputPerDay: 25 * KWH_PER_GWH, outputPerDay: 25 * KWH_PER_GWH,
energyPerDay: 0, energyPerDay: 0,
@@ -385,7 +424,9 @@ export const RESOURCE_BUILDINGS = [
{ {
id: "offshore_wind_turbines", id: "offshore_wind_turbines",
name: "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", resource: "energy",
outputPerDay: 50 * KWH_PER_GWH, outputPerDay: 50 * KWH_PER_GWH,
energyPerDay: 0, energyPerDay: 0,
@@ -399,7 +440,9 @@ export const RESOURCE_BUILDINGS = [
id: "fusion_power_plant", id: "fusion_power_plant",
advanced: true, advanced: true,
name: "Fusion power plant", 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", resource: "energy",
outputPerDay: 4_000 * KWH_PER_GWH, outputPerDay: 4_000 * KWH_PER_GWH,
energyPerDay: 0, energyPerDay: 0,
@@ -416,8 +459,9 @@ export const RESOURCE_BUILDINGS = [
{ {
id: "steel_mill", id: "steel_mill",
name: "Steel mill", name: "Steel mill",
description: get description() {
"Smelts iron into 200,000 t of steel a day, spending 20 MWh of energy per tonne.", return `Smelts iron into ${formatResourceAmount(this.resource, this.outputPerDay)} of steel a day, spending ${formatEnergy(RESOURCE_RULES.energyPerSteelTonne)} of energy per tonne.`;
},
resource: "steel", resource: "steel",
outputPerDay: 20_000, outputPerDay: 20_000,
energyPerDay: 2_000 * RESOURCE_RULES.energyPerSteelTonne, energyPerDay: 2_000 * RESOURCE_RULES.energyPerSteelTonne,
@@ -428,7 +472,9 @@ export const RESOURCE_BUILDINGS = [
{ {
id: "gold_mine", id: "gold_mine",
name: "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", resource: "luxury",
outputPerDay: 20_000, outputPerDay: 20_000,
energyPerDay: 20_000 * RESOURCE_RULES.energyPerLuxuryCarat.gold_mine, energyPerDay: 20_000 * RESOURCE_RULES.energyPerLuxuryCarat.gold_mine,
@@ -439,7 +485,9 @@ export const RESOURCE_BUILDINGS = [
{ {
id: "diamond_mine", id: "diamond_mine",
name: "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", resource: "luxury",
outputPerDay: 20_000, outputPerDay: 20_000,
energyPerDay: 20_000 * RESOURCE_RULES.energyPerLuxuryCarat.diamond_mine, energyPerDay: 20_000 * RESOURCE_RULES.energyPerLuxuryCarat.diamond_mine,
@@ -451,8 +499,9 @@ export const RESOURCE_BUILDINGS = [
id: "chip_foundry", id: "chip_foundry",
advanced: true, advanced: true,
name: "Chip foundry", name: "Chip foundry",
description: get description() {
"Fabricates 50,000 units of high-tech a day, spending 10 MWh of energy per unit.", return `Fabricates ${formatResourceAmount(this.resource, this.outputPerDay)} of high-tech a day, spending ${formatEnergy(RESOURCE_RULES.energyPerHighTechUnit)} of energy per unit.`;
},
resource: "hightech", resource: "hightech",
outputPerDay: 5_000, outputPerDay: 5_000,
energyPerDay: 5_000 * RESOURCE_RULES.energyPerHighTechUnit, 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, // 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. // 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_ENCIRCLED = "encircled";
export const STATUS_BESIEGED = "besieged"; export const STATUS_BESIEGED = "besieged";
export const STATUS_BATTLE = "battle"; export const STATUS_BATTLE = "battle";
@@ -16,7 +18,9 @@ export const STATUSES = [
id: STATUS_ENCIRCLED, id: STATUS_ENCIRCLED,
label: "Encircled", label: "Encircled",
icon: "encirclement.svg", 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, hpLossPerDay: 0.10,
cityHpLossPerDay: 0.02, cityHpLossPerDay: 0.02,
}, },
@@ -30,7 +34,9 @@ export const STATUSES = [
id: STATUS_BATTLE, id: STATUS_BATTLE,
label: "In battle", label: "In battle",
icon: "icon_battle.svg", 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, upkeepMultiplier: 3,
}, },
]; ];
+3 -3
View File
@@ -11,7 +11,7 @@ import {
EFFECT_RAIL_SPEED, EFFECT_RAIL_SPEED,
} from "../data/effects.js"; } from "../data/effects.js";
import { CITY_GARRISON_HEAL_PER_HOUR } from "../data/combat.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 { technologyCost, technologyById } from "../data/technologies.js";
import { NEWS_TECHNOLOGY } from "../data/relations.js"; import { NEWS_TECHNOLOGY } from "../data/relations.js";
import { import {
@@ -1099,8 +1099,8 @@ export const economyMethods = {
// (units) and Shipyards (buildings) add. Each line works one entry at full // (units) and Shipyards (buildings) add. Each line works one entry at full
// capacity, so a higher-level city produces several things at once; the // capacity, so a higher-level city produces several things at once; the
// overflow of a finished entry carries into the next waiting one. // overflow of a finished entry carries into the next waiting one.
const unitLines = 1 + this._cityMechanicLevel(city, BUILDING_MECHANIC.PARALLEL_TRAINING); const unitLines = 1 + PARALLEL_LINES_PER_LEVEL * this._cityMechanicLevel(city, BUILDING_MECHANIC.PARALLEL_TRAINING);
const buildingLines = 1 + this._cityMechanicLevel(city, BUILDING_MECHANIC.PARALLEL_BUILDING); const buildingLines = 1 + PARALLEL_LINES_PER_LEVEL * this._cityMechanicLevel(city, BUILDING_MECHANIC.PARALLEL_BUILDING);
this._advanceProduction(queue, city, "unit", unitLines); this._advanceProduction(queue, city, "unit", unitLines);
this._advanceProduction(queue, city, "building", buildingLines); this._advanceProduction(queue, city, "building", buildingLines);
if (queue.length === 0) completed.push(cityId); 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 || {})) { for (const [index, level] of Object.entries(city.buildings || {})) {
const proto = this.protoBuildings[index]; const proto = this.protoBuildings[index];
if (proto && proto.mechanic === BUILDING_MECHANIC.SPACE_LAUNCH) { 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_RULES,
RESOURCE_MARKET, RESOURCE_MARKET,
RESOURCE_MARKET_BASE, RESOURCE_MARKET_BASE,
resourceById, formatEnergy,
formatResourceAmount,
} from "./data/resources.js"; } from "./data/resources.js";
import { EFFECT_RESEARCH } from "./data/effects.js"; import { EFFECT_RESEARCH } from "./data/effects.js";
import { groupDigits } from "./text_format.js";
const DAYS_PER_YEAR = 365; const DAYS_PER_YEAR = 365;
// --------------------------------------------------------------- formatting -- // The amount formatters live with the resource catalogue so its descriptions
// can quote the same figures; re-exported here for the existing callers.
// Energy reads in kWh, MWh, GWh or TWh, whichever keeps the figure short. export { formatEnergy, formatResourceAmount };
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+$/, "");
}
// --------------------------------------------------------------- per-person -- // --------------------------------------------------------------- per-person --
+10
View File
@@ -69,6 +69,16 @@ export function compact(value) {
return sign + text + suffixes[index]; 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) { export function improvements(list) {
if (!list || list.length === 0) return "None"; if (!list || list.length === 0) return "None";
return list.join(", "); 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`));
}
}