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
|
||||
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
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -53,9 +53,12 @@ export const ESPIONAGE = {
|
||||
|
||||
// Spy satellites: a launch is free once the (very expensive) space launch centre
|
||||
// stands, and each satellite uncovers a random patch of the world on a cadence.
|
||||
// A centre sustains this many satellites for every level it has, one each by
|
||||
// default.
|
||||
export const SATELLITE = {
|
||||
revealEveryDays: 2,
|
||||
revealRadius: 2,
|
||||
perLaunchCentreLevel: 1,
|
||||
};
|
||||
|
||||
// `description` is the reference text the Intelligence tab shows, not a
|
||||
@@ -72,8 +75,9 @@ export const SPY_OPERATIONS = [
|
||||
{
|
||||
id: SPY_SABOTAGE,
|
||||
name: "Sabotage",
|
||||
description:
|
||||
"Cripple a tile's production for a week. The act may be pinned on another nation, or on no one at all. Spends the spies.",
|
||||
get description() {
|
||||
return `Cripple a tile's production for ${SABOTAGE.blockDays} days. The act may be pinned on another nation, or on no one at all. Spends the spies.`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: SPY_CITY,
|
||||
|
||||
+30
-6
@@ -56,6 +56,18 @@ export const POPULARITY = {
|
||||
pillage: 6,
|
||||
};
|
||||
|
||||
// An opinion shift as the integer popularity points the policies are quoted in,
|
||||
// so the wizard's estimates come straight from the applied effect.
|
||||
export function opinionToPoints(opinion) {
|
||||
return Math.round(opinion / POPULARITY.opinionPerPoint);
|
||||
}
|
||||
|
||||
// A signed popularity figure with the typographic minus the catalogue uses.
|
||||
function popularityPointsText(opinion) {
|
||||
const points = opinionToPoints(opinion);
|
||||
return `${points < 0 ? "\u2212" : "+"}${Math.abs(points)}`;
|
||||
}
|
||||
|
||||
// A campaign costs nothing to start: it charges `defaultHourlyCost` for as long
|
||||
// as it runs, like any other upkeep and shown in the budget. The player may
|
||||
// raise or lower the spending up to `maxHourlyCost`; the opinion shift scales
|
||||
@@ -102,16 +114,24 @@ export const POLICIES = [
|
||||
name: "Champion a minority",
|
||||
description:
|
||||
"Proclaim the state the protector of an ethnicity. Its people everywhere warm to you, but your own other populations resent the favouritism.",
|
||||
targetEffect: "+30 popularity among the chosen people",
|
||||
politicalCost: "−16 popularity among every other population in your country",
|
||||
get targetEffect() {
|
||||
return `${popularityPointsText(POLICY_OPINION.championTarget)} popularity among the chosen people`;
|
||||
},
|
||||
get politicalCost() {
|
||||
return `${popularityPointsText(POLICY_OPINION.championMajority)} popularity among every other population in your country`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: POLICY_PROTECT,
|
||||
name: "Protect a minority",
|
||||
description:
|
||||
"Shield an ethnicity inside your borders. It raises their view of you at home and abroad, at some cost with your other populations.",
|
||||
targetEffect: "+24 popularity among the chosen people",
|
||||
politicalCost: "−8 popularity among every other population in your country",
|
||||
get targetEffect() {
|
||||
return `${popularityPointsText(POLICY_OPINION.protectTarget)} popularity among the chosen people`;
|
||||
},
|
||||
get politicalCost() {
|
||||
return `${popularityPointsText(POLICY_OPINION.protectMajority)} popularity among every other population in your country`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: POLICY_EXPEL,
|
||||
@@ -119,7 +139,9 @@ export const POLICIES = [
|
||||
description:
|
||||
"Force an ethnicity out of the country. Its people, in and outside your borders, will hate you for a long time, and they will press into your neighbours.",
|
||||
targetEffect: "The people begin to leave for neighbouring countries",
|
||||
politicalCost: "−80 popularity among the expelled people, remembered for a generation",
|
||||
get politicalCost() {
|
||||
return `${popularityPointsText(POLICY_OPINION.expelTarget)} popularity among the expelled people, remembered for a generation`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: POLICY_IMMIGRATION,
|
||||
@@ -127,7 +149,9 @@ export const POLICIES = [
|
||||
description:
|
||||
"Invite an ethnicity in. They will come from lands where their government is unpopular, but every other population in your country dislikes the influx.",
|
||||
targetEffect: "The chosen people migrate into your country",
|
||||
politicalCost: "−10 popularity among every other population in your country",
|
||||
get politicalCost() {
|
||||
return `${popularityPointsText(POLICY_OPINION.immigrationOthers)} popularity among every other population in your country`;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+66
-17
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -330,7 +330,7 @@ export const intelligenceMethods = {
|
||||
for (const [index, level] of Object.entries(city.buildings || {})) {
|
||||
const proto = this.protoBuildings[index];
|
||||
if (proto && proto.mechanic === BUILDING_MECHANIC.SPACE_LAUNCH) {
|
||||
total += Number(level) || 0;
|
||||
total += (Number(level) || 0) * SATELLITE.perLaunchCentreLevel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-31
@@ -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 --
|
||||
|
||||
|
||||
@@ -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(", ");
|
||||
|
||||
@@ -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