Compare commits

...
3 Commits
31 changed files with 495 additions and 163 deletions
+19
View File
@@ -78,6 +78,25 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
fragmented and large frames (`server/websocket.js`). Do not lower the buffer
limits without checking the snapshot path.
- Indent `.js` files with 2 spaces. `.editorconfig` only enforces UTF-8.
- The simulation ticks hourly, but the player reads rates per day: money income
and upkeep, research and culture effects, campaign spending and the opinion
drift are all scaled by `HOURS_PER_DAY` where they are shown
(`client/js/modals/format.js` `signedMoney`/`effectNodes`, `budget.js`,
`nation.js`, and the approval tooltip in `game_screen/panels.js`). Write new
rate copy per day, and leave durations (build time, cooldowns, endurance) in
hours.
- `description` fields under `shared/data/` are player-facing and must match the
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. 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
+5 -2
View File
@@ -328,8 +328,9 @@ input:focus, select:focus { border-color: var(--accent); }
}
/* A ranged unit's bombardment: a red dashed parabolic arc from the battery to
the target and a red ring around the target. Drawn on the layer below the
entity icons so the units themselves stay on top. */
the target and a red ring around the target. The arc and its shell live on
`layer-strike-arcs`, above the entity icons, so a shell is never hidden behind
the battery that fired it; the ring stays on `layer-targets`, below the icons. */
.target-arcs, .targeting-arcs {
position: absolute;
left: 0;
@@ -1106,6 +1107,7 @@ input:focus, select:focus { border-color: var(--accent); }
.card-tooltip .tip-consume,
.card-tooltip .tip-cost { color: #e2c08a; }
.card-tooltip .tip-none { color: var(--muted); }
.card-tooltip .tip-desc { color: var(--text); line-height: 1.35; }
.card-tooltip .res { display: inline; white-space: nowrap; }
.card-tooltip .res img { width: 13px; height: 13px; vertical-align: -2px; margin-right: 3px; }
@@ -2107,6 +2109,7 @@ input:focus, select:focus { border-color: var(--accent); }
}
.politics-estimate { margin-top: 8px; font-size: 13px; }
.politics-cost { color: #f0b060; }
.politics-desc { color: var(--muted); font-size: 12px; }
.politics-wizard .politics-controls { margin-top: 0; }
.ethnic-trend { font-weight: 700; margin-left: 2px; }
.trend-up { color: #6ee787; }
+3
View File
@@ -103,6 +103,9 @@
<div class="layer" id="layer-targeting"></div>
<div class="layer" id="layer-resources"></div>
<div class="layer" id="layer-entities"></div>
<!-- The ballistic arcs and shells of standing bombardments ride above
the unit icons; the target rings stay in layer-targets below them. -->
<div class="layer" id="layer-strike-arcs"></div>
<div class="layer city-label-layer" id="layer-labels"></div>
</div>
</div>
+18 -18
View File
@@ -1,6 +1,24 @@
// 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",
"subject": "Corrected the catalogue descriptions and quoted every rate per day",
"body": ""
},
{
"hash": "26e5141",
"date": "2026-09-25",
"subject": "Split the resource tooltip into aligned production and consumption lines",
"body": ""
},
{
"hash": "0a1c2d0",
"date": "2026-09-25",
@@ -42,23 +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": ""
},
{
"hash": "cc5c57c",
"date": "2026-09-24",
"subject": "Gave the world spies, treaties and nuclear weapons",
"body": ""
},
{
"hash": "54efd66",
"date": "2026-09-24",
"subject": "Grouped the unit stack by type and let ranged units pick a target",
"body": ""
}
];
+1 -1
View File
@@ -448,7 +448,7 @@ export class GameScreen {
!this._isFriendlyAirport(coords)) {
this.confirmModal.show(
"No runway",
"Aircraft may only fly to one of your cities with an airport, or attack an enemy unit.",
"Aircraft may only fly to one of your cities with an airport, or strike a hostile unit or city.",
null
);
return;
+16 -15
View File
@@ -25,6 +25,7 @@ import {
ECONOMY,
EFFECT_PRODUCTION,
APPROVAL,
POPULARITY,
effectValue,
pillageSteps,
ROADS,
@@ -47,7 +48,7 @@ import {
import { defenseFactors, unitDefense, productionFactors, productionMultiplier, popularityDisplay } from "../../../shared/rules.js";
import { formatResourceAmount, foodNeedPerDay, protoUpkeep, tileImprovementResourceCost } from "../../../shared/resources.js";
import { dateString, hoursInNextMonth } from "../../../shared/game_clock.js";
import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
import { DEFAULT_UNIT_SPEED, HOURS_PER_DAY } from "../../../shared/game_state/constants.js";
import { flightDistanceTiles } from "../../../shared/hex.js";
import { ethnicityChart } from "../ui/ethnicity.js";
import { isEconomicMode } from "../map_view/economic.js";
@@ -518,19 +519,13 @@ export const panelMethods = {
let hint = "Not yet available";
if (operation.id === SPY_SABOTAGE) {
enabled = this._canSabotage(units);
hint = enabled
? "Cripple this tile's production for a week (spends the spies)"
: "Move the spy onto foreign land";
hint = enabled ? operation.description : "Move the spy onto foreign land";
} else if (operation.id === SPY_CITY) {
enabled = this._spyActionTarget(SPY_CITY, units);
hint = enabled
? "Read a neighbouring foreign city (spends the spies)"
: "Move the spy beside a foreign city";
hint = enabled ? operation.description : "Move the spy beside a foreign city";
} else if (operation.id === SPY_COMMS) {
enabled = this._spyActionTarget(SPY_COMMS, units);
hint = enabled
? "Intercept a neighbouring capital's messages (spends the spies)"
: "Move the spy beside a foreign capital";
hint = enabled ? operation.description : "Move the spy beside a foreign capital";
}
const active = armed === operation.id;
$button
@@ -1001,15 +996,17 @@ export const panelMethods = {
const popularityPoints = stats.popularity || 0;
const popularity = popularityDisplay(popularityPoints);
this._updateApprovalStat(popularity);
// The people drift one point an hour back to neutral, so the estimate is
// simply how many points away they are; zero is already there.
// The people drift `POPULARITY.driftPerHour` points back to neutral every
// hour, so the estimate is how many points away they are spread over that
// daily rate; zero is already there.
const driftPerDay = POPULARITY.driftPerHour * HOURS_PER_DAY;
const pointsAway = Math.abs(popularityPoints);
this._setStatTooltip(
"stat-approval",
pointsAway <= 0
? "Popularity is exactly neutral"
: `Neutrality in about ${pointsAway} h (~${Math.ceil(pointsAway / 24)} d) ` +
"at the current drift of one point an hour"
: `Neutrality in about ${Math.ceil(pointsAway / driftPerDay)} d ` +
`at the current drift of ${driftPerDay} points a day`
);
// Popularity doubles its arrow past ten percentage points.
this._updateStatArrow(
@@ -1677,7 +1674,7 @@ export const panelMethods = {
$el.append(
$("<div class='site-material-label stalled'></div>").text(
"The market is above the budgeted prices. " +
"The budget rises 1% a day, doubling each day, until the materials can be bought."
"The budget is raised to meet them, then the materials are bought."
)
);
} else {
@@ -1829,7 +1826,11 @@ export const panelMethods = {
const materials = tileImprovementResourceCost(improvement);
const bill = this._resourceItems(materials);
const price = quotedBuildCost(improvement.buildCost, materials, prices);
// A resource producer's reference text sits above its output; the military
// works carry theirs as the produce line.
const desc = improvement.resource ? improvement.description || "" : "";
return tooltipSections({
desc,
produce,
produceSuffix: improvement.resource ? "/day" : "",
consume: this._improvementUpkeepItems(improvement),
+13
View File
@@ -78,6 +78,18 @@ export class MapView {
// graph while the population map is active.
this.migrationGraph = { links: [] };
this.$entities = layers.entities;
// The ballistic arc and its shell of every standing bombardment, drawn on
// their own layer above the entity icons so a shell is never hidden behind
// the battery that fired it. The target rings stay in `$targets`, below the
// icons.
this.$strikeArcs = layers.strikeArcs && layers.strikeArcs.length
? layers.strikeArcs
: $("#layer-strike-arcs");
if (!this.$strikeArcs.length) {
this.$strikeArcs = $('<div id="layer-strike-arcs" class="layer"></div>');
if (this.$entities && this.$entities.length) this.$entities.after(this.$strikeArcs);
else this.$world.append(this.$strikeArcs);
}
// City names live on their own layer above the entities so they are never
// hidden behind a unit or city marker.
this.$labels = layers.labels && layers.labels.length
@@ -109,6 +121,7 @@ export class MapView {
for (const $layer of [
this.$terrain, this.$roads, this.$borders, this.$fog,
this.$highlight, this.$paths, this.$targets, this.$targeting, this.$resources,
this.$strikeArcs,
]) {
if ($layer && $layer.length) $layer.addClass("tilted");
}
+25 -12
View File
@@ -265,8 +265,9 @@ export const motionMethods = {
// a parabola rather than along the ground; an aircraft's strike is no ballistic
// launch, so its line stays straight. The dashes march and a glowing shell
// travels the line so the direction is unmistakable. They stay visible so an
// ongoing barrage cannot be forgotten, and sit on a layer above the terrain but
// below the unit and city icons.
// ongoing barrage cannot be forgotten. The arc and shell ride a layer above the
// unit and city icons so a shell is never hidden behind the battery that fired
// it; the ring stays on the layer below them.
//
// The overlay is rebuilt at most every TARGET_REFRESH_MS and immediately when
// its contents change; the per-strike target lookup goes through one index of
@@ -274,18 +275,28 @@ export const motionMethods = {
// units). The nodes are reused, so an unchanged number of barrages allocates
// nothing.
_drawStrikeTargets(force = false) {
if (!this.$targets || !this.$targets.length) return;
if (!this.$targets || !this.$targets.length ||
!this.$strikeArcs || !this.$strikeArcs.length) return;
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
const tick = Math.floor(now / TARGET_REFRESH_MS);
// Collect the standing barrages and a signature that changes the moment a
// barrage appears, moves or is selected, so those edits never wait for the
// next tick.
const strikes = [];
// next tick. A stack fires as one, so batteries sharing an origin tile and a
// target are grouped and drawn as a single arc and ring.
const groups = new Map();
let signature = "";
for (const [id, data] of this._unitData) {
if (!data.strikeTarget) continue;
strikes.push(id);
signature += `${id}:${data.strikeTarget[0]},${data.strikeTarget[1]};`;
const motion = this._unitMotion.get(id);
const origin = motion ? this._motionTile(motion) : { x: data.coords[0], y: data.coords[1] };
const pair = `${key(origin.x, origin.y)}>${key(data.strikeTarget[0], data.strikeTarget[1])}`;
let group = groups.get(pair);
if (!group) {
group = { ids: [], target: data.strikeTarget };
groups.set(pair, group);
}
group.ids.push(id);
}
signature += `|${[...this._selectedUnitIds].join(",")}`;
if (!force && tick === this._strikeTick && signature === this._strikeSignature) return;
@@ -293,7 +304,7 @@ export const motionMethods = {
this._strikeSignature = signature;
const views = this._targetViews;
if (strikes.length === 0) {
if (groups.size === 0) {
this._clearStrikeTargets();
return;
}
@@ -303,14 +314,16 @@ export const motionMethods = {
const targetUnits = this._targetUnitIndex();
const ns = "http://www.w3.org/2000/svg";
const seen = new Set();
for (const id of strikes) {
for (const group of groups.values()) {
const id = group.ids[0];
const data = this._unitData.get(id);
if (!data) continue;
const target = { x: data.strikeTarget[0], y: data.strikeTarget[1] };
const target = { x: group.target[0], y: group.target[1] };
const targetId = targetUnits.get(key(target.x, target.y));
// The selected attacker or target stands out from the standing barrages
// the player is not looking at.
const selected = this._selectedUnitIds.has(id) ||
// the player is not looking at; a stack stands out when any of its
// batteries is selected.
const selected = group.ids.some((unitId) => this._selectedUnitIds.has(unitId)) ||
(targetId !== undefined && this._selectedUnitIds.has(targetId));
const fromPoint = this._unitRenderPosition(id);
if (!fromPoint) continue;
@@ -377,7 +390,7 @@ export const motionMethods = {
if (this._targetSvg && this._targetSvg.parentNode) return this._targetSvg;
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", "target-arcs");
this.$targets[0].appendChild(svg);
this.$strikeArcs[0].appendChild(svg);
this._targetSvg = svg;
return svg;
},
+1
View File
@@ -722,6 +722,7 @@ export const terrainMethods = {
{ source: this.$highlight, interactive: false },
{ source: this.$paths, interactive: false },
{ source: this.$targets, interactive: false },
{ source: this.$strikeArcs, interactive: false },
{ source: this.$targeting, interactive: false },
{ source: this.$resources, interactive: false },
{ source: this.$politicalLabels, interactive: false },
+4 -3
View File
@@ -1,5 +1,5 @@
// The budget table shared by the nation and city panels. Every row is an item
// and its hourly amount, income in green and every cost in red, with the
// and its daily amount, income in green and every cost in red, with the
// projected total on the last row. Building types are rows that expand to their
// per-city cost. `breakdown` mirrors the shape both the national and the
// regional economy use, so one renderer serves them both.
@@ -88,7 +88,8 @@ export function renderBudget($list, breakdown = {}, { expanded = new Set(), onTo
}
// What the nation spent buying each resource, and earned selling it to other
// countries, over the last day, shown as an hourly average.
// countries, over the last day, shown as a daily figure. The record holds the
// day's total, so it is divided back to an hourly rate for `signedMoney`.
if (resourceMoney.length > 0) {
$body.append(sectionRow("Resource trade"));
for (const entry of resourceMoney) {
@@ -103,7 +104,7 @@ export function renderBudget($list, breakdown = {}, { expanded = new Set(), onTo
}
}
// The real resource bills of the last day, as hourly averages, in their own
// The real resource bills of the last day, as daily figures, in their own
// section so they are not confused with the forecast upkeep rows above.
const resourceCosts = breakdown.resourceCosts || [];
if (resourceCosts.length > 0) {
+2 -1
View File
@@ -138,7 +138,7 @@ export class CityModal {
);
for (const row of this.buildingRows) this.$buildingGrid.append(row.card.$card);
this.refreshBuildings(view.budget || 0, view.levels || {}, this.gdp, {});
this.$budgetSub.text(`Region net +${currencySymbol()}0/h`);
this.$budgetSub.text(`Region net +${currencySymbol()}0/day`);
this.renderBudget(null);
this.renderPeople(
view.ethnicMakeup || [],
@@ -804,6 +804,7 @@ export class CityModal {
const bill = this._resourceItems(materials);
const price = this._quotedCost(cost, row.proto, row.level + row.pending);
return tooltipSections({
desc: row.proto.description || "",
produce: effect && effect !== "No effect" ? [{ node: buildingEffectNodes(row.proto, shownLevel) }] : [],
consume: this._resourceItems(upkeep),
consumeSuffix: "/day",
+8 -6
View File
@@ -1,6 +1,7 @@
// Shared formatting helpers for the modal panels.
import { groupDigits } from "../../../shared/text_format.js";
import { HOURS_PER_DAY } from "../../../shared/game_clock.js";
import { policyById, resourceById, treatyById } from "../../../shared/data.js";
import { marketPriceIndex, resourceMarketValue } from "../../../shared/resources.js";
import { formatMoneySigned, currencySymbol } from "../currency.js";
@@ -34,9 +35,9 @@ export function formatEffect(effect, level) {
case "production":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% production`;
case "research":
return `${value >= 0 ? "+" : ""}${value.toFixed(2)} research/h`;
return `${value >= 0 ? "+" : ""}${(value * HOURS_PER_DAY).toFixed(2)} research/day`;
case "culture":
return `${value >= 0 ? "+" : ""}${value.toFixed(2)} culture/h`;
return `${value >= 0 ? "+" : ""}${(value * HOURS_PER_DAY).toFixed(2)} culture/day`;
case "infantry_attack":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% infantry attack`;
case "infantry_defense":
@@ -81,7 +82,7 @@ export function buildingEffectText(proto, level) {
return `−${saved}% civilian bombing losses · very hard to destroy`;
}
case "air_defense":
return "Fires one volley at hostile aircraft over the city";
return "Fires on hostile aircraft and missiles within two tiles";
case "port_production": {
const per = (proto.productionPerSeaTilePerLevel || 0) * level;
const food = (proto.foodPerSeaTilePerLevel || 0) * level * 100;
@@ -104,8 +105,8 @@ export function effectNodes(effects, level) {
if (index > 0) $frag.append(document.createTextNode(" · "));
if (effect.stat === "research") {
const value = level <= 0 ? 0 : effect.baseValue * Math.pow(level, effect.exponent);
$frag.append(researchNode(value, { sign: value >= 0 ? "+" : "", decimals: 2 }));
$frag.append(document.createTextNode("/h"));
$frag.append(researchNode(value * HOURS_PER_DAY, { sign: value >= 0 ? "+" : "", decimals: 2 }));
$frag.append(document.createTextNode("/day"));
} else {
$frag.append(document.createTextNode(formatEffect(effect, level)));
}
@@ -122,8 +123,9 @@ export function buildingEffectNodes(proto, level) {
export const SOURCE_LABELS = { building: "building", government: "government", technology: "research" };
// A signed money rate, given per hour by the model and shown per day.
export function signedMoney(value) {
return `${formatMoneySigned(value)}/h`;
return `${formatMoneySigned(value * HOURS_PER_DAY)}/day`;
}
// A signed money amount with no period suffix, for the monthly budget report.
+15 -8
View File
@@ -1600,8 +1600,8 @@ export class NationModal {
this.$technologySub
.empty()
.append(document.createTextNode("Research: "))
.append(researchNode(researchRate || 0, { sign: "+", decimals: 1 }))
.append(document.createTextNode("/h"));
.append(researchNode((researchRate || 0) * HOURS_PER_DAY, { sign: "+", decimals: 1 }))
.append(document.createTextNode("/day"));
const fin = finances || {};
this._treasury = budget;
@@ -2482,9 +2482,9 @@ export class NationModal {
const hourly = campaign.hourlyCost || 0;
const activeHours = Math.max(0, (info.totalHours || 0) - (campaign.startedHours || 0));
const spent = hourly * activeHours;
const days = Math.floor(activeHours / 24);
const days = Math.floor(activeHours / HOURS_PER_DAY);
return (
`${currencySymbol()}${groupDigits(this._quoted(hourly))}/h · ${currencySymbol()}${groupDigits(Math.round(spent))} spent over ` +
`${currencySymbol()}${groupDigits(Math.round(this._quoted(hourly) * HOURS_PER_DAY))}/day · ${currencySymbol()}${groupDigits(Math.round(spent))} spent over ` +
`${days} day${days === 1 ? "" : "s"}`
);
}
@@ -2512,10 +2512,12 @@ export class NationModal {
value: campaign.hourlyCost,
});
const $spend = $("<span class='politics-readout'></span>").text(
`${currencySymbol()}${groupDigits(this._quoted(campaign.hourlyCost))}/h`
`${currencySymbol()}${groupDigits(Math.round(this._quoted(campaign.hourlyCost) * HOURS_PER_DAY))}/day`
);
$slider.on("input", () =>
$spend.text(`${currencySymbol()}${groupDigits(this._quoted(Number($slider.val())))}/h`)
$spend.text(
`${currencySymbol()}${groupDigits(Math.round(this._quoted(Number($slider.val())) * HOURS_PER_DAY))}/day`
)
);
$slider.on("change", () => {
const amount = Number($slider.val());
@@ -2523,7 +2525,9 @@ export class NationModal {
// Reverting to the last known spend keeps the control honest until
// the player confirms the stop.
$slider.val(campaign.hourlyCost);
$spend.text(`${currencySymbol()}${groupDigits(this._quoted(campaign.hourlyCost))}/h`);
$spend.text(
`${currencySymbol()}${groupDigits(Math.round(this._quoted(campaign.hourlyCost) * HOURS_PER_DAY))}/day`
);
this.onConfirm(
"Stop this campaign?",
`Cutting the spending to ${currencySymbol()}0 will stop the campaign.`,
@@ -2563,7 +2567,7 @@ export class NationModal {
);
$card.append(
$("<div class='desc politics-cost'></div>").text(
`Costs ${currencySymbol()}${groupDigits(this._quoted(PROPAGANDA.defaultHourlyCost))}/h to begin; ` +
`Costs ${currencySymbol()}${groupDigits(Math.round(this._quoted(PROPAGANDA.defaultHourlyCost) * HOURS_PER_DAY))}/day to begin; ` +
"adjust the spending on an active campaign to make it land harder or softer."
)
);
@@ -2677,6 +2681,7 @@ export class NationModal {
const $eth = this._civSelect(civs, policy.ethnicity);
this.$policyType = $type;
this.$policyEth = $eth;
this.$policyDesc = $("<div class='politics-estimate politics-desc'></div>");
this.$policyTarget = $("<div class='politics-estimate'></div>");
this.$policyCost = $("<div class='politics-estimate politics-cost'></div>");
this.$policyDuplicate = $("<div class='politics-estimate politics-penalty'></div>");
@@ -2701,6 +2706,7 @@ export class NationModal {
$controls.append($("<div class='politics-field'></div>").append(this.$policyEnact));
this.$politicsWizard.append(
$controls,
this.$policyDesc,
this.$policyTarget,
this.$policyCost,
this.$policyDuplicate
@@ -2719,6 +2725,7 @@ export class NationModal {
const type = String(this.$policyType.val());
const ethnicity = Number(this.$policyEth.val());
const proto = policyById(type);
this.$policyDesc.text(proto ? proto.description : "");
this.$policyTarget.text(proto ? `Target: ${proto.targetEffect}` : "");
this.$policyCost.text(proto ? `Political cost: ${proto.politicalCost}` : "");
const duplicate = active.some(
+9 -1
View File
@@ -60,18 +60,22 @@ export function resourceBill(items, { label = "", sign = "−", suffix = "" } =
// The three tooltip sections from a work's flows. Each entry is a resource
// { id, amount } or free text { text }; free text is shown exactly as written.
// `desc` is an optional prose line shown first, with no section heading.
export function tooltipSections({
desc = "",
produce = [],
produceSuffix = "",
consume = [],
consumeSuffix = "",
cost = [],
} = {}) {
return [
const sections = [
{ label: "Produces", cls: "tip-produce", sign: "+", suffix: produceSuffix, items: produce },
{ label: "Consumes", cls: "tip-consume", sign: "−", suffix: consumeSuffix, items: consume },
{ label: "Costs", cls: "tip-cost", sign: "−", suffix: "", items: cost },
];
if (desc) sections.unshift({ desc });
return sections;
}
let $tooltip = null;
@@ -93,6 +97,10 @@ export function showCardTooltip($anchor, sections) {
if (!$anchor || !$anchor.length) return;
$tooltip = $("<div class='card-tooltip'></div>");
for (const section of sections) {
if (section.desc) {
$tooltip.append($("<div class='tip-section tip-desc'></div>").text(section.desc));
continue;
}
const $section = $("<div class='tip-section'></div>");
$section.append($("<div class='tip-head'></div>").text(section.label));
if (!section.items || section.items.length === 0) {
+26 -7
View File
@@ -3,6 +3,13 @@
// the level too.
import { EFFECT_PRODUCTION, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
import { formatNumber, formatPercent } from "../text_format.js";
import { AIR_DEFENSE } from "./combat.js";
import { SATELLITE } from "./intelligence.js";
// Extra production lines a barracks or shipyard level adds. Shared by the
// simulation and the descriptions so the two never disagree.
export const PARALLEL_LINES_PER_LEVEL = 1;
// Buildings whose benefit is a rule rather than a stat effect. Kept as ids so
// the simulation and the UI can name them without magic strings.
@@ -13,7 +20,7 @@ export const BUILDING_MECHANIC = {
PARALLEL_BUILDING: "parallel_building",
// Cuts civilian deaths from bombing, and is very hard to knock down.
CIVILIAN_SHELTER: "civilian_shelter",
// Fires one volley at hostile aircraft passing over the city.
// Fires on hostile aircraft and missiles that enter a two-tile corridor.
AIR_DEFENSE: "air_defense",
// Flat production per head for the city's region, per adjacent sea tile.
PORT_PRODUCTION: "port_production",
@@ -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 reduces civilian life loss from bombing by 10%. 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: "Damages hostile aircraft that fly above 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,
+1 -1
View File
@@ -7,7 +7,7 @@ export const GOVERNMENTS = [
{
id: "republic",
name: "Republic",
description: "Rule by elected representatives. A balanced government that spreads its support across the economy, trade and the sciences.",
description: "Rule by elected representatives. A balanced government that spreads its support across the economy and the sciences.",
baseCost: 0,
costGdpFraction: 0.02,
effects: [effect(EFFECT_PRODUCTION, 0.05, 1.0), effect(EFFECT_RESEARCH, 1.0, 1.0)],
+6 -4
View File
@@ -63,8 +63,9 @@ export const TILE_IMPROVEMENTS = [
id: "radar_tower",
advanced: true,
name: "Radar tower",
description:
"Watches five tiles out. A ranged hit damages it and an enemy walking onto it captures it.",
get description() {
return `Watches ${this.vision} tiles out. A ranged hit damages it and an enemy walking onto it captures it.`;
},
buildCost: 60_000_000,
maintenance: 1000,
vision: 5,
@@ -76,8 +77,9 @@ export const TILE_IMPROVEMENTS = [
id: "coastal_cannon",
advanced: true,
name: "Coastal cannon",
description:
"Automatically fires on hostile warships within two tiles. Only a coastal tile can carry one.",
get description() {
return `Automatically fires on hostile naval units within ${this.range} tiles. Only a coastal tile can carry one.`;
},
buildCost: 120_000_000,
maintenance: 2000,
range: 2,
+6 -2
View File
@@ -53,9 +53,12 @@ export const ESPIONAGE = {
// Spy satellites: a launch is free once the (very expensive) space launch centre
// stands, and each satellite uncovers a random patch of the world on a cadence.
// A centre sustains this many satellites for every level it has, one each by
// default.
export const SATELLITE = {
revealEveryDays: 2,
revealRadius: 2,
perLaunchCentreLevel: 1,
};
// `description` is the reference text the Intelligence tab shows, not a
@@ -72,8 +75,9 @@ export const SPY_OPERATIONS = [
{
id: SPY_SABOTAGE,
name: "Sabotage",
description:
"Cripple a tile's production for a week. The act may be pinned on another nation, or on no one at all. Spends the spies.",
get description() {
return `Cripple a tile's production for ${SABOTAGE.blockDays} days. The act may be pinned on another nation, or on no one at all. Spends the spies.`;
},
},
{
id: SPY_CITY,
+30 -6
View File
@@ -56,6 +56,18 @@ export const POPULARITY = {
pillage: 6,
};
// An opinion shift as the integer popularity points the policies are quoted in,
// so the wizard's estimates come straight from the applied effect.
export function opinionToPoints(opinion) {
return Math.round(opinion / POPULARITY.opinionPerPoint);
}
// A signed popularity figure with the typographic minus the catalogue uses.
function popularityPointsText(opinion) {
const points = opinionToPoints(opinion);
return `${points < 0 ? "\u2212" : "+"}${Math.abs(points)}`;
}
// A campaign costs nothing to start: it charges `defaultHourlyCost` for as long
// as it runs, like any other upkeep and shown in the budget. The player may
// raise or lower the spending up to `maxHourlyCost`; the opinion shift scales
@@ -102,16 +114,24 @@ export const POLICIES = [
name: "Champion a minority",
description:
"Proclaim the state the protector of an ethnicity. Its people everywhere warm to you, but your own other populations resent the favouritism.",
targetEffect: "+30 popularity among the chosen people",
politicalCost: "−16 popularity among every other population in your country",
get targetEffect() {
return `${popularityPointsText(POLICY_OPINION.championTarget)} popularity among the chosen people`;
},
get politicalCost() {
return `${popularityPointsText(POLICY_OPINION.championMajority)} popularity among every other population in your country`;
},
},
{
id: POLICY_PROTECT,
name: "Protect a minority",
description:
"Shield an ethnicity inside your borders. It raises their view of you at home and abroad, at some cost with your other populations.",
targetEffect: "+24 popularity among the chosen people",
politicalCost: "−8 popularity among every other population in your country",
get targetEffect() {
return `${popularityPointsText(POLICY_OPINION.protectTarget)} popularity among the chosen people`;
},
get politicalCost() {
return `${popularityPointsText(POLICY_OPINION.protectMajority)} popularity among every other population in your country`;
},
},
{
id: POLICY_EXPEL,
@@ -119,7 +139,9 @@ export const POLICIES = [
description:
"Force an ethnicity out of the country. Its people, in and outside your borders, will hate you for a long time, and they will press into your neighbours.",
targetEffect: "The people begin to leave for neighbouring countries",
politicalCost: "−80 popularity among the expelled people, remembered for a generation",
get politicalCost() {
return `${popularityPointsText(POLICY_OPINION.expelTarget)} popularity among the expelled people, remembered for a generation`;
},
},
{
id: POLICY_IMMIGRATION,
@@ -127,7 +149,9 @@ export const POLICIES = [
description:
"Invite an ethnicity in. They will come from lands where their government is unpopular, but every other population in your country dislikes the influx.",
targetEffect: "The chosen people migrate into your country",
politicalCost: "−10 popularity among every other population in your country",
get politicalCost() {
return `${popularityPointsText(POLICY_OPINION.immigrationOthers)} popularity among every other population in your country`;
},
},
];
+66 -17
View File
@@ -13,6 +13,7 @@ import {
EFFECT_LUXURY_EFFICIENCY,
EFFECT_HIGHTECH_EFFICIENCY,
} from "./effects.js";
import { groupDigits } from "../text_format.js";
// Energy is stored and moved as kWh. These keep the catalogue readable.
export const KWH_PER_MWH = 1_000;
@@ -112,6 +113,35 @@ export function resourceById(id) {
return RESOURCES[RESOURCE_INDEX[id]] || null;
}
// --------------------------------------------------------------- formatting --
//
// The canonical rendering of an amount, shared with the browser so a
// description quotes exactly what the game shows. Energy reads in kWh, MWh,
// GWh or TWh; a material in its own unit, stepping through kt/Mt, kct and ku.
export function formatEnergy(kwh) {
const value = Math.max(0, kwh);
if (value >= 1e9) return `${trim(value / 1e9)} TWh`;
if (value >= 1e6) return `${trim(value / 1e6)} GWh`;
if (value >= 1e3) return `${trim(value / 1e3)} MWh`;
return `${trim(value)} kWh`;
}
export function formatResourceAmount(id, value) {
if (id === "energy") return formatEnergy(value);
const amount = Math.max(0, value);
const unit = resourceById(id) ? resourceById(id).unit : "";
if (amount >= 1e12) return `${trim(amount / 1e12)} T${unit}`;
if (amount >= 1e9) return `${trim(amount / 1e9)} G${unit}`;
if (amount >= 1e6) return `${trim(amount / 1e6)} M${unit}`;
return `${trim(amount)} ${unit}`;
}
function trim(value) {
if (value >= 100) return groupDigits(Math.round(value));
if (value >= 10) return value.toFixed(1).replace(/\.0$/, "");
return value.toFixed(2).replace(/\.?0+$/, "");
}
// Global market prices, in euro per canonical unit. These are what a city pays
// when it cannot meet a need from its own store or a neighbour. Energy is
// deliberately cheap: the roadmap's energy-per-material ratios are large, so a
@@ -310,8 +340,9 @@ export const RESOURCE_BUILDINGS = [
{
id: "coal_power_plant",
name: "Coal power plant",
description:
"Burns coal to make 250 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: 300 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 270 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 30 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 60 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 25 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 50 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 4 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 20,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 20,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 20,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 5000 units of high-tech a day, spending 100 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,
+9 -3
View File
@@ -7,6 +7,8 @@
// each day), a `cityHpLossPerDay` override for when the unit stands in a city,
// and an `upkeepMultiplier` applied to the unit's upkeep while it is active.
import { formatPercent } from "../text_format.js";
export const STATUS_ENCIRCLED = "encircled";
export const STATUS_BESIEGED = "besieged";
export const STATUS_BATTLE = "battle";
@@ -16,7 +18,9 @@ export const STATUSES = [
id: STATUS_ENCIRCLED,
label: "Encircled",
icon: "encirclement.svg",
description: "Cut off from every allied city while a hostile unit is adjacent. 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,
},
@@ -24,13 +28,15 @@ export const STATUSES = [
id: STATUS_BESIEGED,
label: "Besieged",
icon: "encirclement.svg",
description: "Cut off from every other allied city while a hostile unit is adjacent.",
description: "Cut off: every adjacent land tile is held by a hostile unit and no friendly port supplies it by sea.",
},
{
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,
},
];
+1 -1
View File
@@ -74,7 +74,7 @@ export const TECHNOLOGIES = [
{
id: "space_program",
name: "Space Program",
description: "Reaching orbit inspires the nation and spins off technology across the economy.",
description: "Reaching orbit inspires the nation, lifting its culture and its industry.",
theme: "science",
cost: 300,
prerequisites: ["industrial_automation", "quantum_computing"],
+2 -2
View File
@@ -26,7 +26,7 @@ export const TREATIES = [
id: TREATY_ALLIANCE,
name: "Alliance",
description:
"The signatories cannot go to war with each other; attacking one declares war on all.",
"The signatories spare each other's forces; attacking one breaks the pact and draws its allies into the war.",
},
{
id: TREATY_FREE_TRADE,
@@ -48,7 +48,7 @@ export const TREATIES = [
{
id: TREATY_GIVE_RESOURCES,
name: "Give resources",
description: "Transfers stores of steel, high-tech, luxury, food or energy.",
description: "Transfers stores of steel, high-tech, luxury or food.",
payload: "resources",
},
{
+3 -3
View File
@@ -11,7 +11,7 @@ import {
EFFECT_RAIL_SPEED,
} from "../data/effects.js";
import { CITY_GARRISON_HEAL_PER_HOUR } from "../data/combat.js";
import { BUILDING_MECHANIC } from "../data/buildings.js";
import { BUILDING_MECHANIC, PARALLEL_LINES_PER_LEVEL } from "../data/buildings.js";
import { technologyCost, technologyById } from "../data/technologies.js";
import { NEWS_TECHNOLOGY } from "../data/relations.js";
import {
@@ -1099,8 +1099,8 @@ export const economyMethods = {
// (units) and Shipyards (buildings) add. Each line works one entry at full
// capacity, so a higher-level city produces several things at once; the
// overflow of a finished entry carries into the next waiting one.
const unitLines = 1 + this._cityMechanicLevel(city, BUILDING_MECHANIC.PARALLEL_TRAINING);
const buildingLines = 1 + this._cityMechanicLevel(city, BUILDING_MECHANIC.PARALLEL_BUILDING);
const unitLines = 1 + PARALLEL_LINES_PER_LEVEL * this._cityMechanicLevel(city, BUILDING_MECHANIC.PARALLEL_TRAINING);
const buildingLines = 1 + PARALLEL_LINES_PER_LEVEL * this._cityMechanicLevel(city, BUILDING_MECHANIC.PARALLEL_BUILDING);
this._advanceProduction(queue, city, "unit", unitLines);
this._advanceProduction(queue, city, "building", buildingLines);
if (queue.length === 0) completed.push(cityId);
+1 -1
View File
@@ -330,7 +330,7 @@ export const intelligenceMethods = {
for (const [index, level] of Object.entries(city.buildings || {})) {
const proto = this.protoBuildings[index];
if (proto && proto.mechanic === BUILDING_MECHANIC.SPACE_LAUNCH) {
total += Number(level) || 0;
total += (Number(level) || 0) * SATELLITE.perLaunchCentreLevel;
}
}
}
+5 -31
View File
@@ -8,42 +8,16 @@ import {
RESOURCE_RULES,
RESOURCE_MARKET,
RESOURCE_MARKET_BASE,
resourceById,
formatEnergy,
formatResourceAmount,
} from "./data/resources.js";
import { EFFECT_RESEARCH } from "./data/effects.js";
import { groupDigits } from "./text_format.js";
const DAYS_PER_YEAR = 365;
// --------------------------------------------------------------- formatting --
// Energy reads in kWh, MWh, GWh or TWh, whichever keeps the figure short.
export function formatEnergy(kwh) {
const value = Math.max(0, kwh);
if (value >= 1e9) return `${trim(value / 1e9)} TWh`;
if (value >= 1e6) return `${trim(value / 1e6)} GWh`;
if (value >= 1e3) return `${trim(value / 1e3)} MWh`;
return `${trim(value)} kWh`;
}
// A material in its own friendly unit: tonnes step up through kt and Mt,
// carats through kct, high-tech through ku. The prefix and the divisor move
// together, so a million tonnes reads as 1 Mt rather than 1 kt.
export function formatResourceAmount(id, value) {
if (id === "energy") return formatEnergy(value);
const amount = Math.max(0, value);
const unit = resourceById(id) ? resourceById(id).unit : "";
if (amount >= 1e12) return `${trim(amount / 1e12)} T${unit}`;
if (amount >= 1e9) return `${trim(amount / 1e9)} G${unit}`;
if (amount >= 1e6) return `${trim(amount / 1e6)} M${unit}`;
return `${trim(amount)} ${unit}`;
}
function trim(value) {
if (value >= 100) return groupDigits(Math.round(value));
if (value >= 10) return value.toFixed(1).replace(/\.0$/, "");
return value.toFixed(2).replace(/\.?0+$/, "");
}
// The amount formatters live with the resource catalogue so its descriptions
// can quote the same figures; re-exported here for the existing callers.
export { formatEnergy, formatResourceAmount };
// --------------------------------------------------------------- per-person --
+10
View File
@@ -69,6 +69,16 @@ export function compact(value) {
return sign + text + suffixes[index];
}
// A fraction as a whole-number percentage, the unit most descriptions quote:
// 0.1 reads "10%". Callers pass the raw fraction the simulation applies so a
// description can never drift from the rule it describes.
export function formatPercent(fraction, { decimals = 0 } = {}) {
const value = Number(fraction) * 100;
const fixed = value.toFixed(decimals);
const text = decimals === 0 ? fixed : fixed.replace(/\.?0+$/, "");
return `${text}%`;
}
export function improvements(list) {
if (!list || list.length === 0) return "None";
return list.join(", ");
+121
View File
@@ -0,0 +1,121 @@
import { TestCase } from "./framework/test_case.js";
import {
RESOURCE_BUILDINGS,
RESOURCE_RULES,
TILE_IMPROVEMENTS,
STATUSES,
POLICY_OPINION,
opinionToPoints,
SPY_OPERATIONS,
AIR_DEFENSE,
SATELLITE,
SABOTAGE,
PARALLEL_LINES_PER_LEVEL,
buildingById,
policyById,
} from "../shared/data.js";
import { formatEnergy, formatResourceAmount } from "../shared/resources.js";
import { formatNumber, formatPercent } from "../shared/text_format.js";
// Every figure a description quotes must be derived from the same field the
// simulation applies, never typed in. These tests recompute the figure and
// assert the description shows it, so a rule change that leaves the prose stale
// fails here.
export class DescriptionsTest extends TestCase {
test_resource_building_descriptions_quote_their_output() {
for (const proto of RESOURCE_BUILDINGS) {
const output = formatResourceAmount(proto.resource, proto.outputPerDay);
this.assertTrue(
proto.description.includes(output),
`${proto.id} should quote ${output}: ${proto.description}`
);
}
}
test_material_producer_descriptions_quote_their_energy_ratio() {
const ratios = {
steel_mill: RESOURCE_RULES.energyPerSteelTonne,
gold_mine: RESOURCE_RULES.energyPerLuxuryCarat.gold_mine,
diamond_mine: RESOURCE_RULES.energyPerLuxuryCarat.diamond_mine,
chip_foundry: RESOURCE_RULES.energyPerHighTechUnit,
};
for (const [id, ratio] of Object.entries(ratios)) {
const proto = RESOURCE_BUILDINGS.find((b) => b.id === id);
const energy = formatEnergy(ratio);
this.assertTrue(
proto.description.includes(energy),
`${id} should quote ${energy} per unit: ${proto.description}`
);
}
}
test_building_descriptions_quote_their_rules() {
const shelters = buildingById("shelters");
const protection = formatPercent(shelters.civilianProtectionPerLevel);
this.assertTrue(shelters.description.includes(protection), `shelters quote ${protection}`);
const air = buildingById("aa_building");
this.assertTrue(air.description.includes(`${AIR_DEFENSE.rangeTiles} tiles`), "air defense range");
const port = buildingById("port");
const production = formatNumber(port.productionPerSeaTilePerLevel);
this.assertTrue(port.description.includes(production), `port quote ${production}`);
const space = buildingById("space_launch_center");
this.assertTrue(space.description.includes(`${SATELLITE.revealEveryDays} days`), "satellite cadence");
this.assertTrue(
space.description.includes(String(SATELLITE.perLaunchCentreLevel)),
"satellites per centre level"
);
for (const id of ["barracks", "shipyard"]) {
const proto = buildingById(id);
this.assertTrue(
proto.description.includes(String(PARALLEL_LINES_PER_LEVEL)),
`${id} quotes ${PARALLEL_LINES_PER_LEVEL} line(s)`
);
}
}
test_improvement_descriptions_quote_their_range() {
for (const [id, field] of [["radar_tower", "vision"], ["coastal_cannon", "range"]]) {
const proto = TILE_IMPROVEMENTS.find((i) => i.id === id);
this.assertTrue(
proto.description.includes(String(proto[field])),
`${id} quotes its ${field} (${proto[field]})`
);
}
}
test_status_descriptions_quote_their_effect() {
const encircled = STATUSES.find((s) => s.id === "encircled");
this.assertTrue(encircled.description.includes(formatPercent(encircled.hpLossPerDay)));
this.assertTrue(encircled.description.includes(formatPercent(encircled.cityHpLossPerDay)));
const battle = STATUSES.find((s) => s.id === "battle");
this.assertTrue(battle.description.includes(String(battle.upkeepMultiplier)));
}
test_policy_effects_quote_their_opinion_shift() {
const expected = [
["champion", "championTarget", "targetEffect"],
["champion", "championMajority", "politicalCost"],
["protect", "protectTarget", "targetEffect"],
["protect", "protectMajority", "politicalCost"],
["expel", "expelTarget", "politicalCost"],
["immigration", "immigrationOthers", "politicalCost"],
];
for (const [id, field, slot] of expected) {
const points = opinionToPoints(POLICY_OPINION[field]);
this.assertTrue(
policyById(id)[slot].includes(String(Math.abs(points))),
`${id}.${slot} should quote ${Math.abs(points)} points`
);
}
}
test_spy_operation_descriptions_quote_their_block() {
const sabotage = SPY_OPERATIONS.find((op) => op.id === "sabotage");
this.assertTrue(sabotage.description.includes(`${SABOTAGE.blockDays} days`));
}
}
+1 -1
View File
@@ -2291,7 +2291,7 @@ export class GameScreenTest extends TestCase {
.map((_, el) => env.$(el).text())
.get()
.join("|");
this.assertTrue(labels.includes("doubling"), `the automatic rise is explained: ${labels}`);
this.assertTrue(labels.includes("raised to meet them"), `the automatic rise is explained: ${labels}`);
this.assertFalse(labels.includes("Increase budget"), `no raise is offered: ${labels}`);
this.assertEqual(
env.$("#tile-works .site-warning").length,
+58 -11
View File
@@ -289,10 +289,10 @@ export class MapViewRenderTest extends MapViewFixture {
try {
map.setSelectedUnit(jet.id);
map.animate(0);
const d = env.$("#layer-targets .target-line").get(0).getAttribute("d");
const d = env.$("#layer-strike-arcs .target-line").get(0).getAttribute("d");
this.assertTrue(d.includes(" L "), "the air strike is a straight segment");
this.assertTrue(!d.includes("Q"), "no ballistic Bezier for an aircraft");
this.assertSize(env.$("#layer-targets .target-shell"), 1, "a shell still travels the line");
this.assertSize(env.$("#layer-strike-arcs .target-shell"), 1, "a shell still travels the line");
this.assertSize(env.$("#layer-targets .target-ring"), 1, "the target is ringed");
} finally {
teardownDom(env);
@@ -316,28 +316,33 @@ export class MapViewRenderTest extends MapViewFixture {
try {
map.setSelectedUnit(artillery.id);
map.animate(0);
const path = env.$("#layer-targets .target-line").get(0);
const path = env.$("#layer-strike-arcs .target-line").get(0);
const d = path.getAttribute("d");
this.assertTrue(d.includes("Q"), "the barrage is drawn as a curve, not a straight line");
// The control point is lifted above both ends, so the shell arcs upwards.
const startY = Number(d.split(" ")[2]);
const controlY = Number(d.split(" ")[5]);
this.assertLess(controlY, startY, "the arc rises above the battery");
this.assertSize(env.$("#layer-targets .target-shell"), 1, "a shell travels the arc");
this.assertSize(env.$("#layer-strike-arcs .target-shell"), 1, "a shell travels the arc");
this.assertSize(env.$("#layer-targets .target-ring"), 1, "the target is ringed");
this.assertSize(
env.$("#layer-targets .target-selected"),
2,
env.$("#layer-strike-arcs .target-selected"),
1,
"a selected barrage stands out"
);
this.assertSize(
env.$("#layer-targets .target-selected"),
1,
"the selected target ring stands out"
);
// A standing barrage stays visible even when nothing is selected.
map.clearSelection();
map.animate(0);
this.assertSize(env.$("#layer-targets .target-line"), 1, "the line stays drawn");
this.assertSize(env.$("#layer-strike-arcs .target-line"), 1, "the line stays drawn");
this.assertSize(env.$("#layer-targets .target-ring"), 1, "the ring stays drawn");
this.assertSize(
env.$("#layer-targets .target-selected"),
env.$("#layer-strike-arcs .target-selected"),
0,
"nothing is highlighted without a selection"
);
@@ -346,6 +351,48 @@ export class MapViewRenderTest extends MapViewFixture {
}
}
async test_a_stacked_bombardment_draws_one_arc_and_ring() {
const state = smallState();
const spot = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c));
const artilleryProto = state.protoUnits.find((p) => p.id === "artillery");
const first = state._spawnUnit(spot, 0, artilleryProto);
const second = state._spawnUnit(spot, 0, artilleryProto);
const goal = state.topology
.neighbours(spot.x, spot.y)
.find((n) => state._isLand(n) && !state.unitAt(n));
state._spawnUnit(goal, 1, state.protoUnits[0]);
this.assertTrue(
state.requestBombard([first.id, second.id], goal),
"the whole stack opens fire"
);
const { env, map } = await this.build(state);
try {
map.setSelectedUnit(first.id);
map.animate(0);
this.assertSize(
env.$("#layer-strike-arcs .target-line"),
1,
"a stacked barrage draws a single arc"
);
this.assertSize(
env.$("#layer-targets .target-ring"),
1,
"a stacked barrage rings the target once"
);
// Selecting the other battery keeps the one shared arc highlighted.
map.setSelectedUnit(second.id);
map.animate(0);
this.assertSize(
env.$("#layer-strike-arcs .target-selected"),
1,
"the shared arc stays highlighted"
);
} finally {
teardownDom(env);
}
}
async test_city_name_carries_a_production_progress_bar() {
const state = smallState();
state.budgets.set(0, 1.0e15);
@@ -456,15 +503,15 @@ export class MapViewRenderTest extends MapViewFixture {
return [];
};
map._drawStrikeTargets(true);
this.assertGreater(env.$("#layer-targets .target-line").length, 1, "the barrages are drawn");
this.assertGreater(env.$("#layer-strike-arcs .target-line").length, 1, "the barrages are drawn");
this.assertEqual(idScans, 0, "no per-strike army scan");
this.assertEqual(occupantScans, 0, "no per-strike occupant scan");
// A redraw reuses the existing nodes instead of rebuilding them.
const firstPath = env.$("#layer-targets .target-line").get(0);
const firstPath = env.$("#layer-strike-arcs .target-line").get(0);
map._drawStrikeTargets(true);
this.assertEqual(
env.$("#layer-targets .target-line").get(0),
env.$("#layer-strike-arcs .target-line").get(0),
firstPath,
"the arc nodes are reused across frames"
);
+10 -6
View File
@@ -34,7 +34,10 @@ export class CityModalTest extends TestCase {
1,
"the lab's science output carries the erlenmeyer icon"
);
this.assertFalse($tip.text().includes("research"), "the unit word is replaced by the icon");
this.assertFalse(
$tip.find(".tip-produce").text().includes("research"),
"the unit word is replaced by the icon"
);
modal.close();
} finally {
teardownDom(env);
@@ -287,13 +290,13 @@ export class CityModalTest extends TestCase {
};
modal.show({ id: 1, name: "Paris" }, { budget: 1.0e15, buildings: BUILDINGS, levels: {} });
modal.renderBudget(breakdown);
this.assertTrue(env.$("#city-budget-sub").text().includes("+¤635/h"), "the region net is shown");
this.assertTrue(env.$("#city-budget-sub").text().includes("+¤15 240/day"), "the region net is shown");
const text = env.$("#city-budget-list").text();
this.assertTrue(text.includes("+¤1 035/h"), "region tax income is green");
this.assertTrue(text.includes("+¤24 840/day"), "region tax income is green");
this.assertTrue(text.includes("Bank in Paris (building) +5.0%"), "a building bonus is listed");
this.assertTrue(text.includes("−¤400/h"), "the building upkeep is listed");
this.assertTrue(text.includes("−¤9 600/day"), "the building upkeep is listed");
this.assertTrue(text.includes("TOTAL"), "a total row is present");
this.assertTrue(text.includes("+¤635/h"), "the total carries the region net");
this.assertTrue(text.includes("+¤15 240/day"), "the total carries the region net");
this.assertFalse(text.includes("No units"), "unit upkeep is national, not regional");
env.$("#city-budget-list tr.budget-group .budget-name").click();
@@ -664,7 +667,8 @@ export class NationModalTest extends TestCase {
1,
"the rate carries the erlenmeyer icon"
);
this.assertTrue($sub.text().includes("2.5"), "the rate is shown");
this.assertTrue($sub.text().includes("60.0"), "the per-day rate is shown");
this.assertTrue($sub.text().includes("/day"), "the rate is stated per day");
this.assertFalse($sub.text().includes("RP"), "no text 'RP' unit is left");
modal.close();
} finally {