Derived every description figure from the rule it describes
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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": ""
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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 --
|
||||||
|
|
||||||
|
|||||||
@@ -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(", ");
|
||||||
|
|||||||
@@ -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`));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user