Compare commits
3
Commits
26e5141ca2
...
3c737e9ef9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c737e9ef9 | ||
|
|
c85f79fee2 | ||
|
|
4d3591c48f |
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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": ""
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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`));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user