Compare commits

...
7 Commits
Author SHA1 Message Date
adrien 575f08b64e Enlarged the stack group tick to fill its row 2026-09-28 10:22:00 +02:00
adrien a98cde4e48 Replaced the placeholder submarine icon 2026-09-28 10:16:09 +02:00
adrien da11e1d97d Showed the culture a propaganda campaign drains
The simple economy pays for propaganda out of the nation's culture stock and lapses a campaign it can no longer fund, but the tab quoted money and never named the store. Give culture the clapperboard icon as a non-tradeable resource like research, read the stock and the campaigns' daily drain out on the Propaganda tab, and price every campaign figure in culture when the simple economy runs.
2026-09-28 10:15:22 +02:00
adrien 9fc94a8e00 Rebuilt the technology panel as one lane-routed draggable graph 2026-09-28 01:06:07 +02:00
adrien 3c737e9ef9 Derived every description figure from the rule it describes 2026-09-28 00:05:39 +02:00
adrien c85f79fee2 Drew one bombardment arc per stack above the unit icons 2026-09-27 10:46:50 +02:00
adrien 4d3591c48f Corrected the catalogue descriptions and quoted every rate per day 2026-09-27 10:38:37 +02:00
45 changed files with 2048 additions and 503 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
+1
View File
@@ -0,0 +1 @@
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M2 2h508v508H2z" fill="#000000" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="2"></path><g class="" style="" transform="translate(0,0)"><path d="m419.682 26.2-8.66 2.452L32.915 135.81 55.27 214.7l386.77-109.608L419.683 26.2zm-12.41 22.224 9.074 32.014-41.086-22.942 32.013-9.072zM350.77 64.438l56.8 31.714-37.084 10.51-56.8-31.715 37.084-10.51zm-61.577 17.45 56.803 31.716-37.084 10.51-56.8-31.718 37.08-10.51zm-61.574 17.45 56.802 31.715-37.084 10.51-56.803-31.715 37.084-10.51zm-61.577 17.45 56.803 31.716-37.084 10.51-56.8-31.717 37.08-10.51zm-61.574 17.45 56.8 31.715-37.083 10.51-56.802-31.715 37.084-10.51zm-45.86 26.227 41.085 22.94-32.01 9.072-9.074-32.012zM55 215v274h402V215H55zm18 18h33.273L73 266.273V233zm58.727 0h38.546l-46 46H85.727l46-46zm64 0h38.546l-46 46h-38.546l46-46zm64 0h38.546l-46 46h-38.546l46-46zm64 0h38.546l-46 46h-38.546l46-46zm64 0h38.546l-46 46h-38.546l46-46zM439 245.727V279h-33.273L439 245.727zM73 297h366v174H73V297zm248.635 46.57-192.44.703.067 18 192.44-.703-.067-18zM130.7 391.33l-.134 17.998 92.707.703.137-18-92.71-.7zm127.155.7-.2 18 63.913.702.2-17.998-63.913-.703z" fill="#9013fe" fill-opacity="1"></path></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1 -5
View File
@@ -1,5 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">submarine</text>
</svg>
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M315 144v21.75L292 160l-16 80H164c-49.154 16.385-81.254 27.1-102.578 34.846L52 256l-16-16v44.918C20.047 292.31 20 296.316 20 304c0 7.712.05 11.717 16 19.162V368l16-16 9.352-18.703c21.094 7.734 52.752 18.418 101.072 34.703H372c160-16 160-128 0-128h-16v-64l-23-5.75V144h-18zM148 263h256v18H148v-18z" fill="#9b9b9b" fill-opacity="1" stroke="#4a4a4a" stroke-opacity="1" stroke-width="5"></path></g></svg>

Before

Width:  |  Height:  |  Size: 479 B

After

Width:  |  Height:  |  Size: 556 B

+203 -12
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; }
@@ -1198,10 +1200,11 @@ input:focus, select:focus { border-color: var(--accent); }
/* The tri-state group tick: selects or excludes the whole type in one click and
doubles as the group's readout (all / some / none). */
.stack-group-check {
width: 16px; height: 16px; flex: 0 0 auto; padding: 0;
width: 42px; height: 42px;
flex: 0 0 auto; padding: 0; box-sizing: border-box;
display: flex; align-items: center; justify-content: center;
border: 1px solid #46566e; border-radius: 4px; background: #101720;
color: #eaf2fc; font-size: 11px; line-height: 1; cursor: pointer;
border: 1px solid #46566e; border-radius: 6px; background: #101720;
color: #eaf2fc; font-size: 26px; line-height: 1; cursor: pointer;
}
.stack-group-check:hover { border-color: #6eafff; }
.stack-group-check.all { background: #3f6fa8; border-color: #6eafff; }
@@ -1701,14 +1704,198 @@ input:focus, select:focus { border-color: var(--accent); }
.row-card .budget-detail { margin-top: 4px; }
.row-card .budget-line { font-size: 12px; color: var(--muted); margin-top: 2px; }
/* Technology cards are the same building cards as the city tab, tiled across
the tab so many read at once, grouped by the theme sub-tabs. */
#technology-list {
display: grid;
grid-template-columns: repeat(auto-fill, 178px);
gap: 8px;
align-content: start;
/* The technology tree: one theme at a time, cards placed on a canvas with the
dependency links drawn into an SVG layer beneath them. */
.tech-research {
display: flex;
align-items: center;
gap: 14px;
margin: 4px 18px 8px;
padding: 9px 14px;
border-radius: 9px;
border: 1px solid var(--panel-border);
background: linear-gradient(160deg, rgba(40, 52, 70, 0.55), rgba(18, 24, 34, 0.7));
}
.tech-research.idle { color: var(--muted); }
.tech-research .tech-research-head { display: flex; align-items: center; gap: 14px; flex: 0 0 auto; }
.tech-research .tech-research-icon { width: 32px; height: 32px; flex: 0 0 auto; opacity: 0.95; }
.tech-research .tech-research-text { flex: 0 0 auto; min-width: 190px; }
.tech-research .tech-research-name { font-weight: 600; font-size: 13.5px; }
.tech-research .tech-research-sub { font-size: 11px; color: var(--muted); }
.tech-research .tech-research-cost { flex: 1 1 auto; color: var(--muted); font-variant-numeric: tabular-nums; }
.tech-research .tech-research-cost .res img { width: 12px; height: 12px; vertical-align: -2px; margin-right: 3px; }
.tech-research .tech-research-focus { flex: 0 0 auto; min-width: 96px; padding: 6px 16px; }
.tech-research .tech-research-bar {
position: relative;
flex: 1 1 auto;
height: 26px;
border-radius: 6px;
overflow: hidden;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(160, 190, 230, 0.25);
}
.tech-research .tech-research-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0;
background: linear-gradient(90deg, rgba(79, 140, 255, 0.75), rgba(127, 209, 255, 0.75));
}
.tech-research .tech-research-count {
position: relative;
display: block;
padding: 5px 10px;
text-align: right;
font-size: 11.5px;
font-variant-numeric: tabular-nums;
}
.tech-research .tech-research-count .res img { width: 12px; height: 12px; vertical-align: -2px; margin-right: 3px; }
#technology-list.tech-tree-body { overflow: hidden; }
.tech-pan { position: relative; width: 100%; height: 100%; overflow: hidden; cursor: grab; }
.tech-pan.dragging { cursor: grabbing; }
.tech-canvas { position: absolute; top: 0; left: 0; transform: translate(0, 0); }
.tech-links { position: absolute; inset: 0; pointer-events: none; overflow: visible; }
.tech-links path { fill: none; stroke: rgba(150, 190, 240, 0.5); stroke-width: 2; }
.tech-links circle { fill: #0a1220; stroke: rgba(150, 190, 240, 0.55); stroke-width: 2; }
/* A faint fade and chevron sit on every edge the tree still extends past, so
the panel reads as draggable without the clutter of a scrollbar. */
.tech-hint {
position: absolute;
z-index: 2;
pointer-events: none;
opacity: 0;
transition: opacity 220ms ease;
}
.tech-hint.show { opacity: 1; }
.tech-hint .tech-hint-arrow {
position: absolute;
display: block;
padding: 3px;
border: solid rgba(185, 205, 232, 0.75);
border-width: 0 2px 2px 0;
}
.tech-hint-top {
top: 0;
left: 0;
right: 0;
height: 64px;
background: linear-gradient(to bottom, rgba(10, 15, 22, 0.92), rgba(10, 15, 22, 0));
}
.tech-hint-top .tech-hint-arrow {
top: 10px;
left: 50%;
margin-left: -5px;
transform: rotate(-135deg);
animation: tech-hint-bob-up 1.7s ease-in-out infinite;
}
.tech-hint-bottom {
bottom: 0;
left: 0;
right: 0;
height: 64px;
background: linear-gradient(to top, rgba(10, 15, 22, 0.92), rgba(10, 15, 22, 0));
}
.tech-hint-bottom .tech-hint-arrow {
bottom: 10px;
left: 50%;
margin-left: -5px;
transform: rotate(45deg);
animation: tech-hint-bob-down 1.7s ease-in-out infinite;
}
.tech-hint-left {
top: 0;
bottom: 0;
left: 0;
width: 64px;
background: linear-gradient(to right, rgba(10, 15, 22, 0.92), rgba(10, 15, 22, 0));
}
.tech-hint-left .tech-hint-arrow {
left: 10px;
top: 50%;
margin-top: -5px;
transform: rotate(135deg);
animation: tech-hint-bob-left 1.7s ease-in-out infinite;
}
.tech-hint-right {
top: 0;
bottom: 0;
right: 0;
width: 64px;
background: linear-gradient(to left, rgba(10, 15, 22, 0.92), rgba(10, 15, 22, 0));
}
.tech-hint-right .tech-hint-arrow {
right: 10px;
top: 50%;
margin-top: -5px;
transform: rotate(-45deg);
animation: tech-hint-bob-right 1.7s ease-in-out infinite;
}
@keyframes tech-hint-bob-up { 50% { translate: 0 -5px; } }
@keyframes tech-hint-bob-down { 50% { translate: 0 5px; } }
@keyframes tech-hint-bob-left { 50% { translate: -5px 0; } }
@keyframes tech-hint-bob-right { 50% { translate: 5px 0; } }
.tech-card { position: absolute; padding: 0; overflow: hidden; border-left-width: 4px; }
.tech-card.theme-science { border-left-color: #5aa9ff; }
.tech-card.theme-economy { border-left-color: #e0b050; }
.tech-card.theme-society { border-left-color: #9a7bff; }
.tech-card.theme-military { border-left-color: #ff6b5e; }
/* The category key above the tree. */
.tech-legend {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
margin: 2px 18px 6px;
font-size: 11px;
color: var(--muted);
}
.tech-legend .tech-legend-item { display: flex; align-items: center; gap: 6px; }
.tech-legend .tech-legend-swatch {
width: 4px;
height: 14px;
border-radius: 2px;
}
.tech-card .card-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0;
background: linear-gradient(90deg, rgba(79, 140, 255, 0.5), rgba(127, 209, 255, 0.4));
}
.tech-card.built .card-fill {
background: linear-gradient(90deg, rgba(74, 160, 110, 0.5), rgba(143, 227, 168, 0.4));
}
.tech-card .card-body {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 4px;
height: 100%;
padding: 9px;
}
.tech-card .bt-level.good { color: #8fe3a8; }
.tech-card .bt-foot {
display: flex;
align-items: center;
gap: 4px;
margin-top: auto;
font-size: 10.5px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.tech-card .bt-foot img { width: 11px; height: 11px; vertical-align: -1px; }
.tech-card.clickable { cursor: pointer; }
.tech-card.locked::after { z-index: 2; }
.tech-card .free-button { width: 100%; font-size: 10.5px; padding: 3px 6px; }
/* Tracing an unavailable technology's outstanding requirements. */
.tech-card.warn.locked { opacity: 0.95; }
.tech-card.warn { border-color: #ffb56b; }
.tech-card.warn-self { border-color: #ff7a5c; box-shadow: 0 0 0 2px rgba(255, 122, 92, 0.55); }
.tech-links path.warn { stroke: #ffb56b; stroke-width: 2.6; stroke-dasharray: none; }
.tech-links circle.warn { stroke: #ffb56b; }
/* A focused technology turns its action button into the progress bar: the
committed share fills behind, while the "Focused" label and the points sit on
top. The disabled dimming is lifted so the bar stays legible. */
@@ -2077,6 +2264,9 @@ input:focus, select:focus { border-color: var(--accent); }
padding: 4px 6px;
}
.politics-readout { color: var(--muted); font-size: 12px; margin-left: 8px; }
.politics-card .res { display: inline; white-space: nowrap; }
.politics-card .res img { width: 13px; height: 13px; vertical-align: -2px; margin-right: 3px; }
.culture-readout { margin-left: 0; font-size: 13px; }
.politics-policy {
padding: 8px 0;
border-top: 1px solid rgba(255, 255, 255, 0.07);
@@ -2107,6 +2297,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; }
+5 -2
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>
@@ -370,8 +373,8 @@
</div>
<div class="tab-page" id="tab-technology">
<div id="technology-sub" class="modal-sub"></div>
<div id="technology-themes" class="subtabs"></div>
<div id="technology-list" class="modal-body"></div>
<div id="technology-research" class="tech-research"></div>
<div id="technology-list" class="modal-body tech-tree-body"></div>
</div>
<div class="tab-page" id="tab-diplomacy">
<div id="diplomacy-sub" class="modal-sub"></div>
+42 -42
View File
@@ -1,6 +1,48 @@
// 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": "a98cde4",
"date": "2026-09-28",
"subject": "Replaced the placeholder submarine icon",
"body": ""
},
{
"hash": "da11e1d",
"date": "2026-09-28",
"subject": "Showed the culture a propaganda campaign drains",
"body": "The simple economy pays for propaganda out of the nation's culture stock and lapses a campaign it can no longer fund, but the tab quoted money and never named the store. Give culture the clapperboard icon as a non-tradeable resource like research, read the stock and the campaigns' daily drain out on the Propaganda tab, and price every campaign figure in culture when the simple economy runs."
},
{
"hash": "9fc94a8",
"date": "2026-09-28",
"subject": "Rebuilt the technology panel as one lane-routed draggable graph",
"body": ""
},
{
"hash": "3c737e9",
"date": "2026-09-28",
"subject": "Derived every description figure from the rule it describes",
"body": ""
},
{
"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",
@@ -18,47 +60,5 @@ export const DEVLOG = [
"date": "2026-09-25",
"subject": "Showed the nation's resource pool in the simple economy's top panel",
"body": "The top bar now carries a two-by-two grid of the four storable goods, each a big icon, the grouped pool and an arrow reading production against consumption (purchases excluded); hovering a chip names both daily figures. The quantity column reserves room for 99 999 999 999 so the layout never shifts as stocks grow.\n\nformatResourceAmount paired each prefix with a divisor a thousand times too large, so a million tonnes printed as 1 kt; the steps now move together as kt, Mt, Gt and Tt."
},
{
"hash": "d5305da",
"date": "2026-09-25",
"subject": "Gave the world a simple economy: one pool per nation and a global market",
"body": "The simple economy is now the default and the old simulation is frozen as the hard model, selectable per game. A nation keeps one resource pool available in every region, fed by public production works rather than private per-region stores, and construction and training begin at once, paced by the region's spare power instead of gathering materials over days.\n\nMoney is euro only: a per-inhabitant daily tax funds a treasury spent in a global market, where buy and sell lots trade against the world stock and move its price. The hard economy keeps its per-nation currencies, central banks and exchange rates.\n\nSimple games skip the price-settling warmup and economic migration; the snapshot carries the economy model, the market and the pool figures the client draws. Added the matching test suites and the design briefs under docs/simple_economy."
},
{
"hash": "49613f2",
"date": "2026-09-24",
"subject": "Made training gather materials, fixed the trade graph, and banked money gifts",
"body": "Unit orders now gather steel and high-tech over the days before training, exactly as buildings do, and clicking a queued order reveals what it is gathering, its reserved budget and the soldiers it will draw.\n\nThe trade graph cached a producer-less node list under a stamp that already reflected the producers' stores, so every seeded mine stayed invisible to buyers and rare-good prices settled at a huge multiple of base; the storage nodes are now re-read after production.\n\nMoney gifts draw the giver's central-bank reserves in the denomination first, buy the rest from the issuing bank at the market rate, convert into the receiver's currency, and cannot buy from a bank their nation is at war with.\n\nPruned the finished design briefs (intelligence, stacks, air movement, politics performance, economy balance, training UI and FIXME) and refreshed the index and cross-references."
},
{
"hash": "afb23df",
"date": "2026-09-24",
"subject": "Cut the migration sweep's repeated lookups and population churn",
"body": ""
},
{
"hash": "a219cf6",
"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;
+20 -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),
@@ -2743,6 +2744,10 @@ export const panelMethods = {
localCiv,
totalHours: snapshot.totalHours || 0,
campaigns: snapshot.campaigns || [],
// The nation's culture stock, which the simple economy spends on the
// campaigns, and the model that decides which currency they are paid in.
culture: snapshot.culture || [],
economyModel: this.economyModel,
opinions: snapshot.opinions || { ethnic: [], government: [] },
spies: (snapshot.units || [])
.filter((unit) => unit.civ === localCiv && this._isSpy(unit))
+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.
+134 -187
View File
@@ -9,7 +9,6 @@ import {
POLICIES,
PROPAGANDA,
policyById,
TECH_THEMES,
technologyCost,
RESOURCES,
RESOURCE_IDS,
@@ -17,6 +16,7 @@ import {
MARKET_LOTS,
resourceById,
PRICE_INDEX_WINDOW_DAYS,
SIMPLE_ECONOMY,
TREATIES,
treatyById,
} from "../../../shared/data.js";
@@ -25,7 +25,8 @@ import { renderMonthlyBudget } from "./budget.js";
import { effectNodes, money, quotedMoney } from "./format.js";
import { bindTabs } from "./tabs.js";
import { ethnicityChart } from "../ui/ethnicity.js";
import { buildCard, researchNode, setCardMeta } from "../ui/card.js";
import { cultureNode, researchNode } from "../ui/card.js";
import { TechnologyTreeView } from "../ui/tech_tree_view.js";
import { resourceNode, valueNode } from "../ui/value.js";
import { lineChart } from "../ui/line_chart.js";
import { trendArrow } from "../ui/trend.js";
@@ -33,21 +34,13 @@ import { currencySymbol, currencyFor, rateToNational, formatMoney } from "../cur
import { HOURS_PER_DAY } from "../../../shared/game_state/constants.js";
import { MONTH_NAMES, unixTime } from "../../../shared/game_clock.js";
// Each research theme borrows the building-card icon of its field, so a
// technology card reads with the same visual grammar as a building.
const TECH_THEME_ICONS = {
science: "icon_university.svg",
economy: "icon_bank.svg",
society: "icon_museum.svg",
military: "icon_fortifications.svg",
};
export class NationModal {
constructor() {
this.$modal = $("#modal-nation");
this.$governmentSub = $("#government-sub");
this.$governmentList = $("#government-list");
this.$technologySub = $("#technology-sub");
this.$technologyStrip = $("#technology-research");
this.$technologyList = $("#technology-list");
this.$budgetSub = $("#budget-sub");
this.$budgetMonths = $("#budget-months");
@@ -101,9 +94,8 @@ export class NationModal {
this.currentGovernment = 0;
this.researched = new Set();
this.governmentRows = [];
this.technologyRows = [];
// Focused research: which theme tab is open and the current focus figures.
this.activeTheme = TECH_THEMES[0].id;
// The technology tree is drawn by a self-contained view, shared with the
// stand-alone catalogue preview; the modal only feeds it the snapshot.
this.techInfo = { focus: -1, progress: 0, repeatCounts: {} };
this.diplomacy = null;
this.diplomacySort = { key: "name", dir: 1 };
@@ -148,6 +140,13 @@ export class NationModal {
this.onConfirm = () => {};
this.$modal.find(".modal-close").on("click", () => this.close());
bindTabs(this.$modal, "tab-");
this.techTree = new TechnologyTreeView({
graph: this.$technologyList,
strip: this.$technologyStrip,
tipFor: (index) => this._technologyTip(index),
onFocusTechnology: (index) => this.onFocusTechnology(index),
onUnlockTechnology: (index) => this.onUnlockTechnology(index),
});
}
isShowing() {
@@ -195,11 +194,9 @@ export class NationModal {
// second set of buttons on top of the previous one, or the stale rows keep
// their old labels and disabled state and appear unresponsive.
this.$governmentList.empty();
this.$technologyList.empty();
this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number));
this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government"));
this.technologyRows = technologies.map((proto, index) => this._makeTechnologyCard(proto, index));
for (const row of this.technologyRows) this.$technologyList.append(row.card.$card);
this._buildTechnologyThemes();
this.techTree.render({ technologies: this.technologies, researched: this.researched, techInfo: this.techInfo });
this._buildResourceSubtabs();
this._buildIntelligenceSubtabs();
// Reopening the panel shows the server's rates, not an unsent edit left
@@ -215,31 +212,6 @@ export class NationModal {
this.$modal.removeClass("hidden");
}
// The technology tab's theme sub-tabs, built once per open. Switching a tab
// only shows and hides the rows that are already there.
_buildTechnologyThemes() {
const $themes = $("#technology-themes");
if (!$themes.length) return;
$themes.empty();
for (const theme of TECH_THEMES) {
const $button = $("<button type='button' class='subtab'></button>").text(theme.name);
if (theme.id === this.activeTheme) $button.addClass("active");
$button.on("click", () => {
this.activeTheme = theme.id;
$themes.find(".subtab").removeClass("active");
$button.addClass("active");
this._applyTechnologyFilter();
});
$themes.append($button);
}
}
_applyTechnologyFilter() {
for (const row of this.technologyRows) {
if (row.$card) row.$card.toggle(row.proto.theme === this.activeTheme);
}
}
// The Resources tab's sub-tabs: the nation's own stores and trade, and the
// world ranking of each commodity's major exporters and importers. Both views
// are rendered together; switching only shows and hides them. The simple
@@ -1494,82 +1466,26 @@ export class NationModal {
return { proto, index, kind, $name, $effects, $cost, $action, $card };
}
// One technology card in the shared building-card idiom: the theme icon and
// name up top with the research state below, the description and effect in
// the hover tooltip, and a Focus button carrying the research cost. A focused
// technology fills the card's progress bar instead.
_makeTechnologyCard(proto, index) {
const row = { proto, index, repeat: 0 };
const card = buildCard({
icon: TECH_THEME_ICONS[proto.theme] || "icon_university.svg",
name: proto.name,
status: "",
action: { label: "Focus" },
progress: true,
tip: () => this._technologyTip(row),
onAction: () => this.onFocusTechnology(index),
});
row.card = card;
row.$card = card.$card;
row.$name = card.$card.find(".bt-name");
row.$status = card.$status;
row.$action = card.$action;
row.$label = card.$label;
row.$meta = card.$meta;
row.$fill = card.$progressFill;
// A local testing game gains a second button that grants the technology at
// once, ignoring its cost and prerequisites.
if (this.techInfo && this.techInfo.testing) {
const $free = $("<button class='aero bt-free free-button'></button>")
.text("Unlock now")
.on("click", (event) => {
event.stopPropagation();
this.onUnlockTechnology(index);
});
card.$card.append($free);
row.$free = $free;
}
return row;
}
// Whether the viewer's nation holds the building a technology is gated
// behind, such as the uranium enrichment centre that unlocks nuclear weapons.
_hasTechBuilding(id) {
return !!(this.techInfo && this.techInfo.buildings && this.techInfo.buildings.has(id));
}
// The hover detail for a technology card: its description, what its next
// level yields, and what it still needs before it can be focused.
_technologyTip(row) {
const proto = row.proto;
// The hover detail for a technology card: its description and what its next
// level yields. Outstanding requirements are traced on the tree itself rather
// than listed here.
_technologyTip(index) {
const proto = this.technologies[index];
// A repeatable's next level is what is being weighed, so its diminishing
// gain is shown rather than the level already held.
const level = proto.repeatable ? row.repeat + 1 : 1;
const repeat = (this.techInfo.repeatCounts || {})[proto.id] || 0;
const level = proto.repeatable ? repeat + 1 : 1;
const sections = [];
if (proto.description) {
sections.push({ label: "Research", cls: "tip-produce", sign: "+", suffix: "", items: [{ text: proto.description }] });
}
sections.push({
label: proto.repeatable ? `Next level (${row.repeat + 1})` : "Effect",
label: proto.repeatable ? `Next level (${repeat + 1})` : "Effect",
cls: "tip-produce",
sign: "+",
suffix: "",
items: [{ node: effectNodes(proto.effects, level) }],
});
if (
row.$action &&
row.$action.prop("disabled") &&
!this.researched.has(row.index) &&
!prerequisitesMet(proto, this.researched, this.technologies, (id) => this._hasTechBuilding(id))
) {
const names = (proto.prerequisites || [])
.map((id) => {
const found = this.technologies.find((t) => t.id === id);
return found ? found.name : id;
})
.join(", ");
sections.push({ label: "Requires", cls: "tip-cost", sign: "−", suffix: "", items: [{ text: names }] });
}
return sections;
}
@@ -1600,8 +1516,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;
@@ -1628,60 +1544,7 @@ export class NationModal {
}
}
const repeatCounts = this.techInfo.repeatCounts || {};
for (const row of this.technologyRows) {
const proto = row.proto;
const repeat = repeatCounts[proto.id] || 0;
const focused = this.techInfo.focus === row.index;
row.repeat = repeat;
row.$name.text(proto.name);
row.$fill.css("width", "0%");
row.$action.removeClass("progressing");
row.$card.removeClass("built locked");
row.$status.removeClass("built");
row.$action.prop("disabled", false);
if (this.researched.has(row.index) && !proto.repeatable) {
row.$status.text("Researched").addClass("built");
row.$card.addClass("built");
row.$label.text("Researched");
row.$action.prop("disabled", true);
setCardMeta(row.$meta, {});
} else if (!prerequisitesMet(proto, this.researched, this.technologies, (id) => this._hasTechBuilding(id))) {
row.$status.text("Locked");
row.$card.addClass("locked");
row.$label.text("Locked");
row.$action.prop("disabled", true);
setCardMeta(row.$meta, {});
} else if (focused) {
// A focused technology fills the card's progress bar, with the committed
// and total points on the button.
const cost = technologyCost(proto, repeat);
const progress = this.techInfo.progress || 0;
const pct = cost > 0 ? Math.max(0, Math.min(100, (progress / cost) * 100)) : 0;
row.$status.text(repeat > 0 ? `Level ${repeat} · Researching` : "Researching").addClass("built");
row.$card.addClass("built");
row.$label.text("Focused");
row.$action.prop("disabled", true).addClass("progressing");
row.$fill.css("width", `${pct}%`);
setCardMeta(row.$meta, {});
row.$meta
.append(researchNode(progress))
.append(document.createTextNode(` / ${groupDigits(Math.round(cost))}`));
} else {
const cost = technologyCost(proto, repeat);
row.$status.text(repeat > 0 ? `Level ${repeat}` : "Not researched");
row.$label.text("Focus");
setCardMeta(row.$meta, {});
row.$meta.append(researchNode(cost));
}
// The testing unlock disappears once a one-off technology is held; a
// repeatable keeps it, to grant another level.
if (row.$free) {
row.$free.toggleClass("hidden", this.researched.has(row.index) && !proto.repeatable);
}
}
this._applyTechnologyFilter();
this.techTree.refresh({ researched: this.researched, techInfo: this.techInfo });
this._renderDiplomacy();
this._renderConflicts();
@@ -2235,6 +2098,7 @@ export class NationModal {
this._spyRows = new Map();
this._reportsSignature = null;
this.$intelligenceSpies = $("<div class='politics-card'></div>");
this.$intelligenceCulture = $("<div class='politics-card'></div>");
this.$intelligenceCampaigns = $("<div class='politics-card'></div>");
this.$intelligenceSpyView.append(
this._buildSpiesCard(),
@@ -2242,6 +2106,7 @@ export class NationModal {
this._buildSatellitesCard()
);
this.$intelligencePropagandaView.append(
this._buildCultureCard(),
this.$intelligenceCampaigns,
this._buildCampaignForm(civs)
);
@@ -2252,6 +2117,7 @@ export class NationModal {
this.$intelligenceSub.text(
"Your spies and the propaganda campaigns they serve."
);
this._fillCulture(info, localCiv);
this._fillSpies(info, localCiv);
this._fillReports(info);
this._fillSatellites(info);
@@ -2260,6 +2126,42 @@ export class NationModal {
this._syncCampaignForm();
}
// The culture readout: the simple economy funds propaganda from the culture
// stock, so the tab names the store and what the running campaigns drain from
// it each day. The hard economy pays campaigns from the treasury instead, so
// only the stock (soft power) is worth naming there.
_buildCultureCard() {
const $card = this.$intelligenceCulture;
$card.empty();
$card.append($("<div class='name'></div>").text("Culture"));
const $desc = $("<div class='desc'></div>").text(
this._campaignPaidInCulture()
? "Propaganda is paid for in culture, drained from this store each day; a " +
"campaign lapses when there is too little left to fund it."
: "Your stock of soft power. Campaigns are funded from the treasury."
);
this.$cultureReadout = $("<div class='politics-readout culture-readout'></div>");
$card.append($desc, this.$cultureReadout);
return $card;
}
_fillCulture(info, localCiv) {
if (!this.$cultureReadout || !this.$cultureReadout.length) return;
const culture = (info.culture && info.culture[localCiv]) || 0;
const upkeep = (info.campaigns || [])
.filter((campaign) => campaign.civ === localCiv)
.reduce((sum, campaign) => sum + (campaign.hourlyCost || 0), 0);
const $readout = this.$cultureReadout.empty().append(cultureNode(culture));
if (this._campaignPaidInCulture()) {
$readout
.append(document.createTextNode(" culture · Campaigns consume "))
.append(cultureNode(upkeep * HOURS_PER_DAY))
.append(document.createTextNode("/day"));
} else {
$readout.append(document.createTextNode(" culture"));
}
}
// The spy roster: one row per spy in the field, with a Locate button that
// selects it and brings the map to it. Rows are only rebuilt when the roster
// (or a spy's whereabouts) changes, so an arriving snapshot does not yank a
@@ -2474,19 +2376,51 @@ export class NationModal {
}
for (const campaign of campaigns) {
const row = this._campaignRows.get(campaign.id);
if (row) row.$cost.text(this._campaignCostText(info, campaign));
if (row) row.$cost.empty().append(this._campaignCostNode(info, campaign));
}
}
_campaignCostText(info, campaign) {
// Which currency propaganda is funded from: the simple economy drains the
// culture stock, the hard economy the treasury. The two models also price a
// campaign with their own figures, so the slider bounds come from here too.
_campaignPaidInCulture() {
return this.economyModel === "simple";
}
_campaignDefaultCost() {
return this._campaignPaidInCulture()
? SIMPLE_ECONOMY.campaign.defaultCulturePerHour
: PROPAGANDA.defaultHourlyCost;
}
_campaignMaxCost() {
return this._campaignPaidInCulture()
? SIMPLE_ECONOMY.campaign.maxCulturePerHour
: PROPAGANDA.maxHourlyCost;
}
// A campaign figure in its own currency: culture points with the clapperboard
// icon in the simple economy, quoted euros in the hard one. `amount` is
// already scaled to the period being shown.
_campaignAmountNode(amount) {
if (this._campaignPaidInCulture()) return cultureNode(amount);
return document.createTextNode(
`${currencySymbol()}${groupDigits(Math.round(this._quoted(amount)))}`
);
}
_campaignCostNode(info, campaign) {
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);
return (
`${currencySymbol()}${groupDigits(this._quoted(hourly))}/h · ${currencySymbol()}${groupDigits(Math.round(spent))} spent over ` +
`${days} day${days === 1 ? "" : "s"}`
);
const days = Math.floor(activeHours / HOURS_PER_DAY);
const $cost = $("<span class='politics-campaign-cost'></span>");
$cost
.append(this._campaignAmountNode(hourly * HOURS_PER_DAY))
.append(document.createTextNode("/day · "))
.append(this._campaignAmountNode(spent))
.append(document.createTextNode(` spent over ${days} day${days === 1 ? "" : "s"}`));
return $cost;
}
_buildCampaignRow(info, localCiv, campaign) {
@@ -2507,26 +2441,32 @@ export class NationModal {
if (campaign.civ === localCiv) {
const $slider = $("<input type='range' class='politics-slider'>").attr({
min: 0,
max: PROPAGANDA.maxHourlyCost,
step: 1000,
max: this._campaignMaxCost(),
step: this._campaignPaidInCulture() ? 1 : 1000,
value: campaign.hourlyCost,
});
const $spend = $("<span class='politics-readout'></span>").text(
`${currencySymbol()}${groupDigits(this._quoted(campaign.hourlyCost))}/h`
);
$slider.on("input", () =>
$spend.text(`${currencySymbol()}${groupDigits(this._quoted(Number($slider.val())))}/h`)
);
const spendNode = (hourly) =>
$("<span class='politics-readout'></span>")
.append(this._campaignAmountNode(hourly * HOURS_PER_DAY))
.append(document.createTextNode("/day"));
let $spend = spendNode(campaign.hourlyCost);
$slider.on("input", () => {
const next = spendNode(Number($slider.val()));
$spend.replaceWith(next);
$spend = next;
});
$slider.on("change", () => {
const amount = Number($slider.val());
if (amount <= 0) {
// 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`);
const next = spendNode(campaign.hourlyCost);
$spend.replaceWith(next);
$spend = next;
this.onConfirm(
"Stop this campaign?",
`Cutting the spending to ${currencySymbol()}0 will stop the campaign.`,
`Cutting the spending to ${this._campaignPaidInCulture() ? "0 culture" : `${currencySymbol()}0`} will stop the campaign.`,
() => this.onCancelCampaign(campaign.id)
);
} else {
@@ -2556,17 +2496,21 @@ export class NationModal {
$card.append($("<div class='name'></div>").text("Launch a propaganda campaign"));
$card.append(
$("<div class='desc'></div>").text(
"Spend money to warm or cool a population's view of another people or government. " +
"Your cultural impact decides how strongly the message lands, and the last " +
"few popularity points are the hardest to win."
`Spend ${this._campaignPaidInCulture() ? "culture" : "money"} to warm or cool a ` +
"population's view of another people or government. Your cultural impact " +
"decides how strongly the message lands, and the last few popularity points " +
"are the hardest to win."
)
);
$card.append(
$("<div class='desc politics-cost'></div>").text(
`Costs ${currencySymbol()}${groupDigits(this._quoted(PROPAGANDA.defaultHourlyCost))}/h to begin; ` +
"adjust the spending on an active campaign to make it land harder or softer."
const $beginCost = $("<div class='desc politics-cost'></div>").append(
document.createTextNode("Costs "),
this._campaignAmountNode(this._campaignDefaultCost() * HOURS_PER_DAY),
document.createTextNode(
"/day to begin; adjust the spending on an active campaign to make it land " +
"harder or softer."
)
);
$card.append($beginCost);
const $observer = this._civSelect(civs, campaign.observer);
const $kind = $("<select class='politics-select'></select>").append(
$("<option value='government'></option>").text("their government"),
@@ -2677,6 +2621,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 +2646,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 +2665,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(
+26 -1
View File
@@ -47,6 +47,22 @@ export function researchNode(amount, { sign = "", decimals = 0 } = {}) {
return $span;
}
// A culture-point figure with its clapperboard icon, standing in for the word
// "culture" the way the erlenmeyer stands in for research. The simple economy
// spends culture on propaganda, so the campaign panels read with the same
// grammar as the research figures.
export function cultureNode(amount, { sign = "", decimals = 0 } = {}) {
const proto = resourceById("culture");
const $span = $("<span class='res culture-points'></span>");
if (proto) {
$span.append($("<img>").attr("src", `assets/${proto.icon}`).attr("alt", proto.name));
}
const value = Number(amount) || 0;
const text = decimals > 0 ? value.toFixed(decimals) : groupDigits(Math.round(value));
$span.append(document.createTextNode(`${sign}${text}`));
return $span;
}
// An inline resource bill for a card: a muted label, then one icon-and-amount
// per material, then an optional suffix. Used for a unit's daily upkeep, which
// is paid in materials rather than money.
@@ -60,18 +76,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;
@@ -91,8 +111,13 @@ export function hideCardTooltip() {
export function showCardTooltip($anchor, sections) {
hideCardTooltip();
if (!$anchor || !$anchor.length) return;
if (!sections || !sections.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) {
+273
View File
@@ -0,0 +1,273 @@
// The technology tree's geometry and pure helpers.
//
// Every technology shares one graph: a technology's column is its distance from
// a root (the longest chain of prerequisites behind it), and each column holds
// every technology of that tier.
//
// Links are routed orthogonally so that no two runs ever lie on top of each
// other: each edge leaves its parent's right edge at its own port, travels down
// a vertical lane unique to its gutter, crosses a horizontal track unique to its
// row gap, then enters the child's left edge at its own port. That costs some
// width (the gutters widen with the number of lanes) but keeps every dependency
// readable even when a node has many parents or children.
// Each research theme borrows the building-card icon of its field, so a
// technology card reads with the same visual grammar as a building. The colour
// tints the card's left edge and keys the legend above the tree.
export const TECH_THEMES = {
science: { name: "Science", color: "#5aa9ff", icon: "icon_university.svg" },
economy: { name: "Economy", color: "#e0b050", icon: "icon_bank.svg" },
society: { name: "Society", color: "#9a7bff", icon: "icon_museum.svg" },
military: { name: "Military", color: "#ff6b5e", icon: "icon_fortifications.svg" },
};
export const TECH_THEME_ORDER = ["science", "economy", "society", "military"];
export function techTheme(id) {
return TECH_THEMES[id] || TECH_THEMES.science;
}
// Repeatable technologies carry a Roman-numeral level up to X; beyond that a
// plain number is used (exponential costs make it unreachable in practice).
const ROMAN = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"];
export function romanNumeral(n) {
return ROMAN[n] || String(n);
}
export const TECH_LAYOUT = {
W: 196,
H: 76,
GX: 36,
GY: 18,
PADX: 16,
PADY: 20,
LANE: 12,
LANE_MARGIN: 12,
TRACK: 12,
TRACK_MARGIN: 9,
};
// A technology's tier: one past the deepest prerequisite.
function depthOf(index, technologies, byId, cache) {
if (cache.has(index)) return cache.get(index);
let depth = 0;
for (const id of technologies[index].prerequisites || []) {
const parent = byId.get(id);
if (parent !== undefined) depth = Math.max(depth, 1 + depthOf(parent, technologies, byId, cache));
}
cache.set(index, depth);
return depth;
}
// Positions every card and routes every prerequisite link. The returned
// `placed` map gives each technology's column and pixel box; `edges` carries the
// link's polyline points (ready for `chamferPath`) and the dot where it meets
// the child.
export function layoutTechnologies(technologies) {
const { W, H, GX, GY: BASE_GY, PADX, PADY, LANE, LANE_MARGIN, TRACK, TRACK_MARGIN } = TECH_LAYOUT;
const byId = new Map(technologies.map((t, i) => [t.id, i]));
const cache = new Map();
const byDepth = new Map();
technologies.forEach((t, index) => {
const d = depthOf(index, technologies, byId, cache);
if (!byDepth.has(d)) byDepth.set(d, []);
byDepth.get(d).push(index);
});
// Rows within a column: order children by where their parents sit, so links
// cross as little as possible, then by name for a stable layout.
const rowOf = new Map();
const colRow = new Map();
const depths = [...byDepth.keys()].sort((a, b) => a - b);
for (const d of depths) {
const group = byDepth.get(d);
group.sort((a, b) => {
const key = (index) => {
const rows = (technologies[index].prerequisites || [])
.map((id) => rowOf.get(byId.get(id)))
.filter((r) => r !== undefined);
return rows.length ? rows.reduce((x, y) => x + y, 0) / rows.length : 0;
};
return key(a) - key(b) || technologies[a].name.localeCompare(technologies[b].name);
});
group.forEach((index, row) => {
rowOf.set(index, row);
colRow.set(index, { col: d, row });
});
}
const numCols = depths.length ? depths[depths.length - 1] + 1 : 0;
let maxRow = 0;
for (const pos of colRow.values()) maxRow = Math.max(maxRow, pos.row);
// Every prerequisite link, with the columns and rows at each end.
const edges = [];
technologies.forEach((tech, to) => {
for (const id of tech.prerequisites || []) {
const from = byId.get(id);
if (from === undefined || !colRow.has(from) || !colRow.has(to)) continue;
const a = colRow.get(from);
const b = colRow.get(to);
edges.push({ from, to, fromCol: a.col, fromRow: a.row, toCol: b.col, toRow: b.row, span: b.col - a.col });
}
});
edges.sort(
(a, b) =>
a.fromCol - b.fromCol || a.fromRow - b.fromRow || a.toCol - b.toCol || a.toRow - b.toRow
);
// Vertical lanes live in the gutter between two columns: links leaving a
// column near its left side, links arriving at the next column near its right
// side. Wide enough gutters keep those lanes apart.
const numGutters = Math.max(0, numCols - 1);
const parentRuns = Array.from({ length: numGutters }, () => []);
const childRuns = Array.from({ length: numGutters }, () => []);
for (const e of edges) {
if (e.span > 1) parentRuns[e.fromCol].push(e);
if (e.toCol >= 1) childRuns[e.toCol - 1].push(e);
}
const gutterWidth = [];
for (let g = 0; g < numGutters; g++) {
parentRuns[g].sort((a, b) => a.fromRow - b.fromRow || a.toCol - b.toCol || a.toRow - b.toRow);
// Child runs are ordered so the lane closest to the child belongs to the
// link whose port sits lowest. That keeps siblings from criss-crossing as
// their stubs leave one parent.
childRuns[g].sort((a, b) => b.toRow - a.toRow || a.fromCol - b.fromCol || a.fromRow - b.fromRow);
const lanes = parentRuns[g].length + childRuns[g].length;
gutterWidth[g] = lanes ? Math.max(GX, 2 * LANE_MARGIN + lanes * LANE) : GX;
}
const colX = [];
let x = PADX;
for (let c = 0; c < numCols; c++) {
colX[c] = x;
x += W + (gutterWidth[c] || 0);
}
const width = x + PADX;
// Horizontal tracks: one per spanning link, spread across the row gap just
// below its parent. The gap grows when several links fan out from one row.
const gapTracks = new Map();
for (const e of edges) {
if (e.span <= 1) continue;
const k = e.fromRow + 1;
gapTracks.set(k, (gapTracks.get(k) || 0) + 1);
}
const maxTracks = Math.max(1, ...gapTracks.values());
const GY = Math.max(BASE_GY, (maxTracks - 1) * TRACK + 2 * TRACK_MARGIN);
const rowY = (row) => PADY + row * (H + GY);
const gapCenter = (k) => PADY + k * (H + GY) - GY / 2;
const trackSeen = new Map();
for (const e of edges) {
if (e.span <= 1) continue;
const k = e.fromRow + 1;
const n = gapTracks.get(k);
const i = trackSeen.get(k) || 0;
trackSeen.set(k, i + 1);
e.trackY = n === 1 ? gapCenter(k) : gapCenter(k) + (i - (n - 1) / 2) * TRACK;
}
// Attach a lane x to each vertical run.
for (let g = 0; g < numGutters; g++) {
const left = colX[g] + W;
const right = colX[g + 1];
parentRuns[g].forEach((e, i) => {
e.parentLaneX = left + LANE_MARGIN + (i + 0.5) * LANE;
});
childRuns[g].forEach((e, i) => {
e.childLaneX = right - LANE_MARGIN - (i + 0.5) * LANE;
});
}
// A card has a single port each way: every dependency leaves the middle of
// its right edge and every dependency arrives at the middle of its left edge,
// so a fan of links spreads from one point rather than a row of stubs.
const midY = (row) => rowY(row) + H / 2;
for (const e of edges) {
e.outY = midY(e.fromRow);
e.inY = midY(e.toRow);
}
const placed = new Map();
for (const [index, pos] of colRow) {
placed.set(index, { col: pos.col, row: pos.row, x: colX[pos.col], y: rowY(pos.row) });
}
// Out of the parent's port, along its lane, across a track (only when the
// child is more than one column away), down the child's lane, into its port.
for (const e of edges) {
const parent = placed.get(e.from);
const child = placed.get(e.to);
const start = parent.x + W;
const end = child.x;
if (e.span <= 1) {
e.points = [[start, e.outY], [e.childLaneX, e.outY], [e.childLaneX, e.inY], [end, e.inY]];
} else {
e.points = [
[start, e.outY],
[e.parentLaneX, e.outY],
[e.parentLaneX, e.trackY],
[e.childLaneX, e.trackY],
[e.childLaneX, e.inY],
[end, e.inY],
];
}
e.dot = { x: end, y: e.inY };
}
const height = PADY * 2 + (maxRow + 1) * H + maxRow * GY;
return {
nodes: technologies.map((_, index) => ({ index })),
byId,
placed,
edges,
maxRow,
width,
height,
};
}
// A path of axis-aligned runs with each 90-degree corner cut by a short
// 45-degree chamfer.
export function chamferPath(points, size = 7) {
const pts = points.filter(
(p, i, a) => i === 0 || Math.hypot(p[0] - a[i - 1][0], p[1] - a[i - 1][1]) > 0.5
);
if (pts.length < 2) return "";
let d = `M ${pts[0][0]} ${pts[0][1]}`;
for (let i = 1; i < pts.length - 1; i++) {
const p = pts[i], prev = pts[i - 1], next = pts[i + 1];
const cross = (p[0] - prev[0]) * (next[1] - p[1]) - (p[1] - prev[1]) * (next[0] - p[0]);
if (Math.abs(cross) < 0.001) { d += ` L ${p[0]} ${p[1]}`; continue; }
const l1 = Math.hypot(p[0] - prev[0], p[1] - prev[1]);
const l2 = Math.hypot(next[0] - p[0], next[1] - p[1]);
const c = Math.min(size, l1 / 2, l2 / 2);
const u1 = [(prev[0] - p[0]) / l1, (prev[1] - p[1]) / l1];
const u2 = [(next[0] - p[0]) / l2, (next[1] - p[1]) / l2];
d += ` L ${p[0] + u1[0] * c} ${p[1] + u1[1] * c} L ${p[0] + u2[0] * c} ${p[1] + u2[1] * c}`;
}
const last = pts[pts.length - 1];
return `${d} L ${last[0]} ${last[1]}`;
}
// Every prerequisite still outstanding for the technology at `index`, at any
// depth: the trace of what stands between the nation and this research. A
// prerequisite gated behind a missing building counts as outstanding too.
export function unmetPrerequisites(index, technologies, researched, hasBuilding) {
const byId = new Map(technologies.map((t, i) => [t.id, i]));
const unmet = new Set();
const walk = (at) => {
for (const id of technologies[at].prerequisites || []) {
const parent = byId.get(id);
if (parent === undefined) continue;
const proto = technologies[parent];
const buildingMissing = !!proto.requiresBuilding && !hasBuilding(proto.requiresBuilding);
if (!researched.has(parent) || buildingMissing) {
if (!unmet.has(parent)) {
unmet.add(parent);
walk(parent);
}
}
}
};
walk(index);
return unmet;
}
+459
View File
@@ -0,0 +1,459 @@
// The technology panel's tree: a draggable graph of cards and routed
// prerequisite links, plus the focus/progress strip above it.
//
// It is deliberately independent of the nation modal so the same view backs
// both the game and the stand-alone development page used when the technology
// catalogue is edited. The host supplies the data and the copy (a tooltip
// builder and the two order callbacks); everything else lives here.
import { technologyCost } from "../../../shared/data.js";
import { prerequisitesMet } from "../../../shared/rules.js";
import { groupDigits } from "../../../shared/text_format.js";
import { researchNode, showCardTooltip, hideCardTooltip } from "./card.js";
import {
TECH_THEMES,
TECH_THEME_ORDER,
techTheme,
TECH_LAYOUT,
romanNumeral,
layoutTechnologies,
chamferPath,
unmetPrerequisites,
} from "./tech_tree.js";
const EMPTY_TECH_INFO = {
focus: -1,
progress: 0,
progressByTech: {},
repeatCounts: {},
buildings: new Set(),
testing: false,
};
let nextInstance = 0;
export class TechnologyTreeView {
constructor({ graph, strip, tipFor, onFocusTechnology, onUnlockTechnology, assetBase }) {
// A namespace unique to this instance so two trees on one page (the game
// and the development preview do not share a page, but be safe) do not
// unbind each other's drag listeners.
this._ns = `.techpan${nextInstance++}`;
this.$graph = graph;
this.$strip = strip;
this.tipFor = tipFor || (() => []);
this.onFocusTechnology = onFocusTechnology || (() => {});
this.onUnlockTechnology = onUnlockTechnology || (() => {});
// Where the theme icons live, relative to the hosting page.
this.assetBase = assetBase || "assets/";
this.technologies = [];
this.researched = new Set();
this.techInfo = EMPTY_TECH_INFO;
this.cards = new Map();
this.edges = [];
this.layout = null;
this.selected = null;
this.hover = null;
this._panMoved = false;
}
// Rebuild the whole tree from the catalogue. The layout only depends on the
// catalogue, so this runs when the panel opens; `refresh` repaints states in
// place afterwards.
render({ technologies, researched, techInfo }) {
if (technologies) this.technologies = technologies;
if (researched) this.researched = researched;
if (techInfo) this.techInfo = techInfo;
const $graph = this.$graph;
if (!$graph || !$graph.length) return;
hideCardTooltip();
$(document).off(this._ns);
$(window).off(this._ns);
$graph.empty();
this.cards = new Map();
this.edges = [];
this.selected = null;
this.hover = null;
this._panMoved = false;
this.$pan = null;
this.$hints = null;
this._panState = null;
if (!this.technologies.length) {
this.layout = null;
this._renderLegend();
return;
}
const layout = layoutTechnologies(this.technologies);
this.layout = layout;
this._renderLegend();
const { W, H } = TECH_LAYOUT;
const $pan = $("<div class='tech-pan'></div>");
const $canvas = $("<div class='tech-canvas'></div>")
.css({ width: `${layout.width}px`, height: `${layout.height}px` });
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", "tech-links");
svg.setAttribute("width", layout.width);
svg.setAttribute("height", layout.height);
$canvas.append(svg);
// Links first, so a card always paints over the line that reaches it.
for (const edge of layout.edges) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", chamferPath(edge.points));
path.dataset.from = edge.from;
path.dataset.to = edge.to;
svg.appendChild(path);
const dot = document.createElementNS("http://www.w3.org/2000/svg", "circle");
dot.setAttribute("cx", edge.dot.x);
dot.setAttribute("cy", edge.dot.y);
dot.setAttribute("r", 3);
dot.dataset.from = edge.from;
dot.dataset.to = edge.to;
svg.appendChild(dot);
this.edges.push({ from: edge.from, to: edge.to, $path: $(path), $dot: $(dot) });
}
for (const node of layout.nodes) {
const proto = this.technologies[node.index];
const pos = layout.placed.get(node.index);
const $card = $("<div class='building-tile tech-card'></div>")
.attr("data-index", node.index)
.addClass(`theme-${proto.theme}`)
.css({ left: `${pos.x}px`, top: `${pos.y}px`, width: `${W}px`, height: `${H}px` });
const $fill = $("<div class='card-fill'></div>");
const $status = $("<div class='bt-level'></div>");
const $foot = $("<div class='bt-foot'></div>");
const $body = $("<div class='card-body'></div>").append(
$("<div class='bt-top'></div>").append(
$("<img class='building-icon'>").attr("src", `${this.assetBase}${techTheme(proto.theme).icon}`).attr("alt", proto.name),
$("<div class='bt-id'></div>").append(
$("<div class='bt-name'></div>").text(proto.name),
$status
)
),
$foot
);
$card.append($fill, $body);
const ref = { index: node.index, proto, $card, $status, $fill, $foot, $free: null };
if (this.techInfo && this.techInfo.testing) {
ref.$free = $("<button class='aero free-button'></button>").text("Unlock now")
.on("click", (event) => { event.stopPropagation(); this.onUnlockTechnology(node.index); });
$card.append(ref.$free);
}
$card.on("mouseenter", () => {
showCardTooltip($card, this.tipFor(node.index));
if (!this._canFocus(node.index) && !this.researched.has(node.index)) {
this.hover = node.index;
this._applyHighlight(node.index);
}
});
$card.on("mouseleave", () => {
hideCardTooltip();
if (this.hover === node.index) this.hover = null;
if (this.selected !== null) this._applyHighlight(this.selected);
else this._clearHighlight();
});
$card.on("click", () => {
// A drag that happened to start on a card must not also select it.
if (this._panMoved) { this._panMoved = false; return; }
this.selected = this.selected === node.index ? null : node.index;
this.hover = null;
this.refresh();
});
this.cards.set(node.index, ref);
$canvas.append($card);
}
$pan.append($canvas);
// Faint fades and chevrons on whichever edges still hide part of the tree,
// so the panel reads as pannable without a scrollbar.
this.$pan = $pan;
this.$hints = {};
for (const side of ["top", "bottom", "left", "right"]) {
const $hint = $(`<div class='tech-hint tech-hint-${side}'></div>`)
.append("<span class='tech-hint-arrow'></span>");
this.$hints[side] = $hint;
$pan.append($hint);
}
$graph.append($pan);
this._bindPan($pan, $canvas, layout.width, layout.height);
this.refresh();
}
// The category key that sits between the focus strip and the tree. It is a
// sibling of the pannable area, so it stays put while the tree is dragged.
_renderLegend() {
if (this.$legend) this.$legend.remove();
this.$legend = null;
if (!this.$graph || !this.$graph.length || !this.technologies.length) return;
const $legend = $("<div class='tech-legend'></div>");
for (const id of TECH_THEME_ORDER) {
const theme = TECH_THEMES[id];
if (!theme) continue;
$legend.append(
$("<span class='tech-legend-item'></span>")
.attr("data-theme", id)
.append(
$("<span class='tech-legend-swatch'></span>").css("background", theme.color),
document.createTextNode(theme.name)
)
);
}
this.$legend = $legend;
this.$graph.before($legend);
}
// Drag anywhere on the tree to pan it, rather than a scrollbar.
_bindPan($pan, $canvas, width, height) {
let x = 0, y = 0, moved = false;
let dragging = false, startX = 0, startY = 0, startL = 0, startT = 0;
this._panState = { x: 0, y: 0, width, height };
const apply = () => {
$canvas.css("transform", `translate(${x}px, ${y}px)`);
this._panState.x = x;
this._panState.y = y;
this._updateScrollHints();
};
const clamp = () => {
const vw = $pan.width(), vh = $pan.height();
x = Math.max(Math.min(0, vw - width), Math.min(0, x));
y = Math.max(Math.min(0, vh - height), Math.min(0, y));
};
$pan.on("mousedown", (event) => {
if (event.button !== 0) return;
dragging = true;
moved = false;
startX = event.pageX;
startY = event.pageY;
startL = x;
startT = y;
$pan.addClass("dragging");
event.preventDefault();
});
$(document).on(`mousemove${this._ns}`, (event) => {
if (!dragging) return;
x = startL + (event.pageX - startX);
y = startT + (event.pageY - startY);
if (Math.abs(event.pageX - startX) > 4 || Math.abs(event.pageY - startY) > 4) moved = true;
clamp();
apply();
});
$(document).on(`mouseup${this._ns}`, () => {
if (!dragging) return;
dragging = false;
// Leave the flag for the click that follows, so a drag is not a selection.
this._panMoved = moved;
$pan.removeClass("dragging");
setTimeout(() => { this._panMoved = false; }, 0);
});
$(window).on(`resize${this._ns}`, () => { clamp(); apply(); });
$pan.on("click", (event) => {
if (moved) return;
if ($(event.target).closest(".tech-card").length) return;
this.selected = null;
this.refresh();
});
apply();
}
// Show a fade + chevron on every edge where the tree extends past the view.
_updateScrollHints() {
const hints = this.$hints;
const $pan = this.$pan;
const state = this._panState;
if (!hints || !$pan || !state || !$pan.length) return;
const vw = $pan.width(), vh = $pan.height();
const { x, y, width, height } = state;
const wide = width - vw > 1;
const tall = height - vh > 1;
hints.left.toggleClass("show", wide && x < -1);
hints.right.toggleClass("show", wide && x > vw - width + 1);
hints.top.toggleClass("show", tall && y < -1);
hints.bottom.toggleClass("show", tall && y > vh - height + 1);
}
_hasBuilding(id) {
return !!(this.techInfo && this.techInfo.buildings && this.techInfo.buildings.has(id));
}
// Whether a technology can be focused now: not already held (unless it is
// repeatable) and with every prerequisite met.
_canFocus(index) {
const proto = this.technologies[index];
if (!proto) return false;
if (this.researched.has(index) && !proto.repeatable) return false;
return prerequisitesMet(proto, this.researched, this.technologies, (id) => this._hasBuilding(id));
}
// Repaint every card and the focus strip from the current snapshot.
refresh({ researched, techInfo } = {}) {
if (researched) this.researched = researched;
if (techInfo) this.techInfo = techInfo;
if (!this.layout) {
this._renderStrip();
return;
}
const repeatCounts = this.techInfo.repeatCounts || {};
for (const ref of this.cards.values()) {
const proto = ref.proto;
const repeat = repeatCounts[proto.id] || 0;
const focusable = this._canFocus(ref.index);
const done = this.researched.has(ref.index) && !proto.repeatable;
const focused = this.techInfo.focus === ref.index;
const points = (this.techInfo.progressByTech || {})[ref.index] || 0;
const cost = technologyCost(proto, repeat);
const pct = cost > 0 ? Math.min(1, points / cost) : 0;
const fill = proto.repeatable ? Math.min(1, (repeat + pct) / 10) * 100 : pct * 100;
ref.$card.removeClass("built locked researching clickable");
ref.$card.toggleClass("clickable", focusable);
ref.$fill.css("width", `${fill}%`);
ref.$foot.empty();
if (done) {
ref.$card.addClass("built");
ref.$status.text("Researched").addClass("good");
ref.$foot.text("Complete");
} else if (!focusable) {
ref.$card.addClass("locked");
ref.$status.text("Locked").removeClass("good");
ref.$foot.append(researchNode(cost));
} else if (focused) {
ref.$card.addClass("researching");
ref.$status
.text(repeat > 0 ? `Level ${romanNumeral(repeat)} · Researching` : "Researching")
.addClass("good");
ref.$foot
.append(researchNode(points))
.append(document.createTextNode(` / ${groupDigits(Math.round(cost))}`));
} else {
ref.$status
.text(repeat > 0 ? `Level ${romanNumeral(repeat)}` : "Not researched")
.removeClass("good");
ref.$foot.append(researchNode(cost));
}
if (ref.$free) ref.$free.toggleClass("hidden", done);
}
this._renderStrip();
const traced = this.selected !== null ? this.selected : this.hover;
if (traced !== null && traced !== undefined) this._applyHighlight(traced);
else this._clearHighlight();
}
// The focus + progress strip above the tree. It follows the selected card
// (offering a Focus button for a technology that can be researched) and falls
// back to the technology already being researched.
_renderStrip() {
const $strip = this.$strip;
if (!$strip || !$strip.length) return;
const selected = this.selected;
if (selected !== null && selected !== undefined && this.technologies[selected]) {
this._renderSelectedStrip($strip, selected);
return;
}
const focus = this.techInfo.focus;
const proto = focus >= 0 ? this.technologies[focus] : null;
if (!proto) {
$strip.addClass("idle").empty()
.append($("<span class='tech-research-idle'></span>").text("No research focus — select a technology."));
return;
}
$strip.removeClass("idle").empty();
const repeat = (this.techInfo.repeatCounts || {})[proto.id] || 0;
const points = this.techInfo.progress || 0;
const cost = technologyCost(proto, repeat);
const pct = cost > 0 ? Math.max(0, Math.min(100, (points / cost) * 100)) : 0;
$strip
.append(this._stripHead(proto, proto.repeatable ? `Researching toward Level ${romanNumeral(repeat + 1)}` : "Researching"))
.append(
$("<div class='tech-research-bar'></div>").append(
$("<div class='tech-research-fill'></div>").css("width", `${pct}%`),
$("<span class='tech-research-count'></span>")
.append(researchNode(points))
.append(document.createTextNode(` / ${groupDigits(Math.round(cost))}`))
)
);
}
_renderSelectedStrip($strip, index) {
const proto = this.technologies[index];
const focusable = this._canFocus(index);
const focused = this.techInfo.focus === index;
const held = this.researched.has(index) && !proto.repeatable;
$strip.removeClass("idle").empty();
const sub = held ? "Researched" : focused ? "Researching" : focusable ? "Ready to research" : "Locked";
$strip.append(this._stripHead(proto, sub));
if (focused) {
const repeat = (this.techInfo.repeatCounts || {})[proto.id] || 0;
const points = this.techInfo.progress || 0;
const cost = technologyCost(proto, repeat);
const pct = cost > 0 ? Math.max(0, Math.min(100, (points / cost) * 100)) : 0;
$strip.append(
$("<div class='tech-research-bar'></div>").append(
$("<div class='tech-research-fill'></div>").css("width", `${pct}%`),
$("<span class='tech-research-count'></span>")
.append(researchNode(points))
.append(document.createTextNode(` / ${groupDigits(Math.round(cost))}`))
)
);
} else {
const repeat = (this.techInfo.repeatCounts || {})[proto.id] || 0;
$strip.append(
$("<div class='tech-research-cost'></div>").append(
researchNode(technologyCost(proto, repeat)),
document.createTextNode(held ? " complete" : focusable ? " to research" : " — prerequisites missing")
)
);
}
if (focusable && !focused) {
$strip.append(
$("<button type='button' class='aero tech-research-focus'></button>").text("Focus")
.on("click", () => {
// Show the commitment at once; a snapshot confirms it a moment later.
this.techInfo = {
...this.techInfo,
focus: index,
progress: (this.techInfo.progressByTech || {})[index] || 0,
};
this.selected = null;
this.onFocusTechnology(index);
this.refresh();
})
);
}
}
_stripHead(proto, sub) {
return $("<span class='tech-research-head'></span>").append(
$("<img class='tech-research-icon'>").attr("src", `${this.assetBase}${techTheme(proto.theme).icon}`).attr("alt", proto.name),
$("<div class='tech-research-text'></div>").append(
$("<div class='tech-research-name'></div>").text(proto.name),
$("<div class='tech-research-sub'></div>").text(sub)
)
);
}
// Trace every outstanding requirement of a locked card, and the links
// between them, so a chain of missing research reads at a glance.
_applyHighlight(index) {
this._clearHighlight();
const ref = this.cards.get(index);
if (ref) ref.$card.addClass("warn-self");
const unmet = unmetPrerequisites(index, this.technologies, this.researched, (id) => this._hasBuilding(id));
for (const i of unmet) {
const card = this.cards.get(i);
if (card) card.$card.addClass("warn");
}
for (const edge of this.edges) {
if (unmet.has(edge.from) && (edge.to === index || unmet.has(edge.to))) {
edge.$path.addClass("warn");
edge.$dot.addClass("warn");
}
}
}
_clearHighlight() {
for (const ref of this.cards.values()) ref.$card.removeClass("warn warn-self");
for (const edge of this.edges || []) {
edge.$path.removeClass("warn");
edge.$dot.removeClass("warn");
}
}
}
+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`;
},
},
];
+80 -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;
@@ -107,11 +108,54 @@ export const RESEARCH = {
colour: "#a142b6",
};
// Culture is the nation's store of soft power: produced by its cultural
// buildings and spent, in the simple economy, on propaganda campaigns. Like
// research it is a special resource -- it has no market, no stockpile tile and
// never moves -- so it is kept out of the tradeable catalogue and shown only
// with its clapperboard icon.
export const CULTURE = {
id: "culture",
name: "Culture",
unit: "",
icon: "icon_culture.svg",
colour: "#9a7bff",
};
export function resourceById(id) {
if (id === RESEARCH.id) return RESEARCH;
if (id === CULTURE.id) return CULTURE;
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 +354,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 +368,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 +382,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 +396,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 +410,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 +424,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 +438,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 +454,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 +473,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 +486,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 +499,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 +513,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,
},
];
+14 -29
View File
@@ -25,24 +25,9 @@ import {
effect,
} from "./effects.js";
// The tabs the technology panel groups research under.
export const TECH_THEMES = [
{ id: "science", name: "Science" },
{ id: "economy", name: "Economy" },
{ id: "society", name: "Society" },
{ id: "military", name: "Military" },
];
// The lowest tier: every technology below is a root, available from the start.
// Research is focused, so nothing is unlocked by a single gateway technology.
export const TECHNOLOGIES = [
{
id: "scientific_method",
name: "Scientific Method",
description: "A disciplined approach to experiment and evidence. Lays the groundwork for every later technology.",
theme: "science",
cost: 20,
prerequisites: [],
effects: [effect(EFFECT_RESEARCH, 1.0, 1.0), effect(EFFECT_PRODUCTION, 0.02, 1.0)],
},
{
id: "industrial_automation",
name: "Industrial Automation",
@@ -50,7 +35,7 @@ export const TECHNOLOGIES = [
theme: "economy",
repeatable: true,
cost: 60,
prerequisites: ["scientific_method"],
prerequisites: [],
effects: [effect(EFFECT_PRODUCTION, 0.08, 1.0)],
},
{
@@ -59,7 +44,7 @@ export const TECHNOLOGIES = [
description: "Clean generation cuts waste and lifts national production.",
theme: "economy",
cost: 80,
prerequisites: ["scientific_method"],
prerequisites: [],
effects: [effect(EFFECT_PRODUCTION, 0.03, 1.0)],
},
{
@@ -68,13 +53,13 @@ export const TECHNOLOGIES = [
description: "Machines that reason in superposition. A step change in the pace of discovery.",
theme: "science",
cost: 120,
prerequisites: ["scientific_method"],
prerequisites: [],
effects: [effect(EFFECT_RESEARCH, 4.0, 1.0)],
},
{
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"],
@@ -97,7 +82,7 @@ export const TECHNOLOGIES = [
theme: "military",
// 300,000 research points: a project a whole economy bends toward.
cost: 3_000,
prerequisites: ["scientific_method"],
prerequisites: [],
// The technology is unlocked by the nation's first uranium enrichment
// centre; without one it cannot be focused at all.
requiresBuilding: "uranium_enrichment_center",
@@ -109,7 +94,7 @@ export const TECHNOLOGIES = [
description: "Professional services for espionage and covert action. Unlocks the spy and its operations abroad.",
theme: "society",
cost: 250,
prerequisites: ["scientific_method"],
prerequisites: [],
// Unlocks the spy unit rather than carrying a numeric effect of its own.
unlocks: true,
effects: [],
@@ -136,7 +121,7 @@ export const TECHNOLOGIES = [
cost: 40,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
prerequisites: [],
effects: [effect(EFFECT_INFANTRY_ATTACK, 0.01, 1.0)],
},
{
@@ -147,7 +132,7 @@ export const TECHNOLOGIES = [
cost: 40,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
prerequisites: [],
effects: [effect(EFFECT_INFANTRY_DEFENSE, 0.01, 1.0)],
},
{
@@ -158,7 +143,7 @@ export const TECHNOLOGIES = [
cost: 50,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
prerequisites: [],
effects: [effect(EFFECT_ARTILLERY_ATTACK, 0.01, 1.0)],
},
{
@@ -169,7 +154,7 @@ export const TECHNOLOGIES = [
cost: 50,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
prerequisites: [],
effects: [effect(EFFECT_CONSTRUCTION_SPEED, 0.01, 1.0)],
},
{
@@ -180,7 +165,7 @@ export const TECHNOLOGIES = [
cost: 50,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
prerequisites: [],
effects: [effect(EFFECT_RAIL_SPEED, 0.01, 1.0)],
},
@@ -217,7 +202,7 @@ export const TECHNOLOGIES = [
cost: 50,
repeatable: true,
repeatCostGrowth: 0.03,
prerequisites: ["scientific_method"],
prerequisites: [],
effects: [effect(EFFECT_FOOD_EFFICIENCY, 0.05, 1.0)],
},
{
+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(", ");
+1 -1
View File
@@ -19,7 +19,7 @@ export class DataTest extends TestCase {
this.assertEqual(protoUnitById("modern_infantry"), PROTO_UNITS.find((u) => u.id === "modern_infantry"));
this.assertEqual(buildingById("barracks"), BUILDINGS.find((b) => b.id === "barracks"));
this.assertEqual(governmentById("republic"), GOVERNMENTS[0]);
this.assertEqual(technologyById("scientific_method"), TECHNOLOGIES[0]);
this.assertEqual(technologyById("industrial_automation"), TECHNOLOGIES[0]);
this.assertNull(protoUnitById("nope"));
this.assertNull(buildingById("nope"));
this.assertNull(governmentById("nope"));
+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`));
}
}
+43 -10
View File
@@ -7,7 +7,7 @@ import { compact, groupDigits } from "../shared/text_format.js";
import { tileImprovementResourceCost } from "../shared/resources.js";
import { quotedBuildCost, marketPricesFrom } from "../client/js/modals/format.js";
import { parseKey } from "../shared/hex.js";
import { smallState, adjacentLand, grantBuilding, stripPorts } from "./framework/helpers.js";
import { smallState, simpleState, adjacentLand, grantBuilding, stripPorts } from "./framework/helpers.js";
function stubNetwork() {
return {
@@ -147,7 +147,11 @@ export class GameScreenTest extends TestCase {
env.$("#civ-flag").click();
this.assertFalse(env.$("#modal-nation").hasClass("hidden"));
this.assertSize(env.$("#government-list .row-card"), 3);
this.assertSize(env.$("#technology-list .building-tile"), TECHNOLOGIES.length);
this.assertSize(
env.$("#technology-list .tech-card"),
TECHNOLOGIES.length,
"the whole technology graph is drawn"
);
screen.leave();
} finally {
teardownDom(env);
@@ -959,7 +963,7 @@ export class GameScreenTest extends TestCase {
screen.enter();
const state = smallState();
state.budgets.set(0, 1.0e15);
const method = state.technologies.findIndex((t) => t.id === "scientific_method");
const method = state.technologies.findIndex((t) => t.id === "renewable_energy");
state.requestFocusTechnology(0, method);
state._addResearch(0, state.getTechnologyCost(0, method));
screen.onState(state.snapshot(0));
@@ -1389,14 +1393,12 @@ export class GameScreenTest extends TestCase {
screen.onState(smallState().snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab=technology]").click();
const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method");
env.$("#technology-list .building-tile")
.eq(method)
.find(".bt-upgrade")
.click();
const quantum = TECHNOLOGIES.findIndex((t) => t.id === "quantum_computing");
env.$(`#technology-list .tech-card[data-index="${quantum}"]`).click();
env.$("#technology-research .tech-research-focus").click();
this.assertEqual(network.orders[0], {
type: "focus_technology",
technology: method,
technology: quantum,
});
screen.leave();
} finally {
@@ -1596,6 +1598,37 @@ export class GameScreenTest extends TestCase {
}
}
async test_propaganda_tab_shows_the_culture_drain() {
const env = await setupDom();
try {
const state = simpleState();
state.culture.set(0, 12_345);
state.requestCampaign(0, 0, "government", 1, 1);
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
screen.onState(state.snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='intelligence']").click();
showIntelligenceSubtab(env, "Propaganda");
const $culture = env.$("#intelligence-propaganda-view .culture-readout");
this.assertSize($culture, 1, "the propaganda tab reads out the culture stock");
this.assertTrue($culture.text().includes("12\u00a0345"), "it names the current culture");
this.assertTrue($culture.text().includes("192"), "and what the campaigns consume");
const src = $culture.find("img").first().attr("src");
this.assertTrue(/icon_culture\.svg$/.test(src || ""), "with the culture icon");
const $cost = env.$("#intelligence-propaganda-view .politics-campaign-cost");
this.assertSize($cost, 1, "the running campaign reports its cost");
this.assertGreater($cost.find("img").length, 0, "in culture points");
this.assertFalse($cost.text().includes("\u20ac"), "not in money");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_politics_tab_sends_campaign_and_policy_orders() {
const env = await setupDom();
try {
@@ -2291,7 +2324,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,
+1 -1
View File
@@ -343,7 +343,7 @@ export class GameStateBuildingsTest extends TestCase {
const index = this.addBuilding(state, building);
this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
// Research only accumulates into a focused technology, so focus one.
const method = state.technologies.findIndex((t) => t.id === "scientific_method");
const method = state.technologies.findIndex((t) => t.id === "renewable_energy");
this.assertTrue(state.requestFocusTechnology(0, method));
this.assertEqual(state.getTechnologyProgress(0), 0);
state.advanceHour();
+1 -1
View File
@@ -55,7 +55,7 @@ export class GameStateGovernmentTest extends TestCase {
state.budgets.set(0, BIG_BUDGET);
const technocracy = state.governments.findIndex((g) => g.id === "technocracy");
this.assertTrue(state.requestSetGovernment(0, technocracy));
const method = state.technologies.findIndex((t) => t.id === "scientific_method");
const method = state.technologies.findIndex((t) => t.id === "renewable_energy");
this.assertTrue(state.requestFocusTechnology(0, method));
this.assertEqual(state.getTechnologyProgress(0), 0);
state.advanceHour();
+30 -25
View File
@@ -1,6 +1,12 @@
import { TestCase } from "./framework/test_case.js";
import { smallState, stripPorts } from "./framework/helpers.js";
import { TECHNOLOGIES, EFFECT_INFANTRY_ATTACK, EFFECT_CONSTRUCTION_SPEED } from "../shared/data.js";
import {
TECHNOLOGIES,
EFFECT_INFANTRY_ATTACK,
EFFECT_CONSTRUCTION_SPEED,
EFFECT_PRODUCTION,
EFFECT_RESEARCH,
} from "../shared/data.js";
import { NEWS_TECHNOLOGY } from "../shared/data/relations.js";
export class GameStateTechnologyTest extends TestCase {
@@ -18,11 +24,11 @@ export class GameStateTechnologyTest extends TestCase {
}
test_catalogue_links_prerequisites() {
const method = TECHNOLOGIES.find((t) => t.id === "scientific_method");
this.assertEmpty(method.prerequisites);
this.assertGreater(method.cost, 0);
const automation = TECHNOLOGIES.find((t) => t.id === "industrial_automation");
this.assertEqual(automation.prerequisites, ["scientific_method"]);
const root = TECHNOLOGIES.find((t) => t.id === "industrial_automation");
this.assertEmpty(root.prerequisites, "the lowest tier needs nothing");
this.assertGreater(root.cost, 0);
const space = TECHNOLOGIES.find((t) => t.id === "space_program");
this.assertEqual(space.prerequisites, ["industrial_automation", "quantum_computing"]);
}
test_every_civilisation_starts_with_nothing_researched() {
@@ -34,7 +40,7 @@ export class GameStateTechnologyTest extends TestCase {
test_research_points_fill_the_focused_technology() {
const state = smallState();
const index = this.index(state, "scientific_method");
const index = this.index(state, "quantum_computing");
this.assertTrue(state.requestFocusTechnology(0, index), "the focus is accepted");
const cost = state.getTechnologyCost(0, index);
// Points flow in as they are created; just short of the cost leaves it in
@@ -52,7 +58,7 @@ export class GameStateTechnologyTest extends TestCase {
// A treasury deep in the red does not stop a nation focusing or accruing
// research: the shortfall is central-bank debt, not a spending limit.
state.budgets.set(0, -1_000_000);
const index = this.index(state, "scientific_method");
const index = this.index(state, "quantum_computing");
this.assertTrue(state.requestFocusTechnology(0, index), "the focus is accepted in debt");
state._addResearch(0, 5);
this.assertApprox(state.getTechnologyProgress(0), 5, 1e-9, "points still accrue");
@@ -61,7 +67,6 @@ export class GameStateTechnologyTest extends TestCase {
test_switching_focus_keeps_committed_progress() {
const state = smallState();
this.complete(state, "scientific_method");
const weapons = this.index(state, "infantry_weapons");
const armour = this.index(state, "body_armour");
this.assertTrue(state.requestFocusTechnology(0, weapons));
@@ -82,7 +87,7 @@ export class GameStateTechnologyTest extends TestCase {
test_nothing_is_researched_without_a_focus() {
const state = smallState();
const index = this.index(state, "scientific_method");
const index = this.index(state, "quantum_computing");
// Points created with nothing focused are simply not stored anywhere.
state._addResearch(0, state.getTechnologyCost(0, index) * 10);
this.assertFalse(state.isResearched(0, index));
@@ -104,38 +109,41 @@ export class GameStateTechnologyTest extends TestCase {
test_prerequisites_unlock_the_tree() {
const state = smallState();
const space = this.index(state, "space_program");
this.assertFalse(state.requestFocusTechnology(0, space), "locked before its prerequisites");
const automation = this.index(state, "industrial_automation");
this.assertFalse(state.requestFocusTechnology(0, automation), "locked before its prerequisite");
this.complete(state, "scientific_method");
this.assertFalse(state.isResearched(0, automation), "a prerequisite is not auto-granted");
this.complete(state, "industrial_automation");
this.assertTrue(state.isResearched(0, automation));
this.assertFalse(state.canResearchTechnology(0, space), "one prerequisite is not enough");
this.complete(state, "quantum_computing");
this.assertTrue(state.requestFocusTechnology(0, this.index(state, "space_program")));
this.assertTrue(state.requestFocusTechnology(0, space), "both prerequisites met");
}
test_technology_effects_are_folded_into_gdp() {
const state = smallState();
stripPorts(state, 0);
const index = this.index(state, "renewable_energy");
const added = TECHNOLOGIES[index].effects.find((e) => e.stat === EFFECT_PRODUCTION).baseValue;
const base = state.getNationalModifiers(0)[EFFECT_PRODUCTION] || 0;
const before = state.getPlayerGdp(0);
this.complete(state, "scientific_method");
this.assertApprox(state.getPlayerGdp(0), (before * 1.07) / 1.05, 0.01);
this.complete(state, "renewable_energy");
this.assertApprox(state.getPlayerGdp(0), (before * (1 + base + added)) / (1 + base), 0.01);
}
test_researched_technology_adds_its_research_rate() {
const withTech = smallState();
const without = smallState();
this.complete(withTech, "scientific_method");
this.complete(withTech, "quantum_computing");
this.assertApprox(
withTech.viewerStats(0).researchRate - without.viewerStats(0).researchRate,
1.0,
4.0,
1e-9
);
}
test_snapshot_exposes_technologies_and_progress() {
const state = smallState();
const index = this.complete(state, "scientific_method");
const index = this.complete(state, "quantum_computing");
const snapshot = state.snapshot(0);
this.assertSize(snapshot.technologies, state.technologies.length);
this.assertSize(snapshot.researched, state.civilisations.length);
@@ -145,7 +153,6 @@ export class GameStateTechnologyTest extends TestCase {
test_repeatable_research_costs_two_percent_more_each_level() {
const state = smallState();
state.researched.get(0).add(this.index(state, "scientific_method"));
const index = this.index(state, "infantry_weapons");
const base = state.getTechnologyCost(0, index);
this.assertTrue(state.requestFocusTechnology(0, index), "first level");
@@ -165,7 +172,6 @@ export class GameStateTechnologyTest extends TestCase {
test_repeatable_effects_stack_with_each_level() {
const state = smallState();
state.researched.get(0).add(this.index(state, "scientific_method"));
const weapons = this.index(state, "infantry_weapons");
const construction = this.index(state, "construction_technology");
for (let level = 0; level < 2; level++) {
@@ -182,10 +188,10 @@ export class GameStateTechnologyTest extends TestCase {
test_repeatable_research_notifies_without_news() {
const state = smallState();
this.complete(state, "scientific_method");
this.complete(state, "quantum_computing");
this.complete(state, "body_armour");
const notices = state.news.filter((entry) => entry.type === NEWS_TECHNOLOGY);
const normalNotice = notices.find((entry) => entry.technology === "scientific_method");
const normalNotice = notices.find((entry) => entry.technology === "quantum_computing");
const repeatNotice = notices.find((entry) => entry.technology === "body_armour");
this.assertNotNull(normalNotice);
this.assertFalse(!!normalNotice.repeatable, "a discovery is a real news event");
@@ -204,7 +210,6 @@ export class GameStateTechnologyTest extends TestCase {
test_completing_a_repeatable_keeps_it_focused() {
const state = smallState();
state.researched.get(0).add(this.index(state, "scientific_method"));
const index = this.complete(state, "infantry_weapons");
this.assertEqual(state.getTechnologyRepeatCount(0, index), 1, "one level done");
this.assertEqual(state.getFocusedTechnology(0), index, "it stays focused");
@@ -216,7 +221,7 @@ export class GameStateTechnologyTest extends TestCase {
test_research_costs_a_hundred_times_the_catalogue_price() {
const state = smallState();
const index = this.index(state, "scientific_method");
const index = this.index(state, "quantum_computing");
const proto = TECHNOLOGIES[index];
this.assertEqual(state.getTechnologyCost(0, index), proto.cost * 100);
}
+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"
);
+61 -53
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();
@@ -448,25 +451,24 @@ export class NationModalTest extends TestCase {
};
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6);
this.assertSize(env.$("#government-list .row-card"), GOVERNMENTS.length);
this.assertSize(env.$("#technology-list .building-tile"), TECHNOLOGIES.length);
const governmentRows = env.$("#government-list .row-card");
this.assertTrue(governmentRows.eq(0).find(".actions button").prop("disabled"), "current government is locked");
governmentRows.eq(1).find(".actions button").click();
this.assertEqual(government, 1);
// A prerequisite-gated technology starts locked.
const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method");
const automation = TECHNOLOGIES.findIndex((t) => t.id === "industrial_automation");
const techRows = env.$("#technology-list .building-tile");
this.assertEqual(techRows.eq(automation).find(".bt-upgrade").text(), "Locked");
// A gated technology starts locked; researching its prerequisite unlocks
// it, then selecting it offers a Focus button in the strip.
const quantum = TECHNOLOGIES.findIndex((t) => t.id === "quantum_computing");
const litho = TECHNOLOGIES.findIndex((t) => t.id === "advanced_lithography");
const card = (index) => env.$(`#technology-list .tech-card[data-index="${index}"]`);
this.assertTrue(card(litho).hasClass("locked"), "a gated technology starts locked");
// Researching its prerequisite unlocks it.
modal.refresh(0, new Set([method]), 1.0e9, 1.0e15, 1.0e6);
this.assertTrue(techRows.eq(method).find(".bt-upgrade").text().startsWith("Researched"));
this.assertTrue(techRows.eq(automation).find(".bt-upgrade").text().startsWith("Focus"));
techRows.eq(automation).find(".bt-upgrade").click();
this.assertEqual(technology, automation);
modal.refresh(0, new Set([quantum]), 1.0e9, 1.0e15, 1.0e6);
this.assertFalse(card(litho).hasClass("locked"), "the prerequisite unlocks it");
card(litho).click();
env.$("#technology-research .tech-research-focus").click();
this.assertEqual(technology, litho);
env.$("#modal-nation .tab[data-tab=technology]").click();
this.assertTrue(env.$("#tab-technology").hasClass("active"));
@@ -479,21 +481,25 @@ export class NationModalTest extends TestCase {
}
}
async test_technology_theme_tabs_filter_the_list() {
async test_the_technology_tree_draws_every_technology() {
const env = await setupDom();
try {
const modal = new NationModal();
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6);
const $themes = env.$("#technology-themes .subtab");
this.assertGreater($themes.length, 1, "the technologies are split into themes");
const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method");
this.assertSize(env.$("#technology-list .tech-card"), TECHNOLOGIES.length, "one card per technology");
this.assertSize(env.$("#technology-list .tech-pan"), 1, "the tree is draggable");
for (const side of ["top", "bottom", "left", "right"]) {
this.assertSize(env.$(`#technology-list .tech-hint-${side} .tech-hint-arrow`), 1, `a ${side} pan clue is present`);
}
const themed = env.$("#technology-list .tech-card").filter((_, el) =>
el.classList.contains(`theme-${TECHNOLOGIES[Number(el.dataset.index)].theme}`)
);
this.assertSize(themed, TECHNOLOGIES.length, "every card carries its theme's colour");
this.assertSize(env.$(".tech-legend .tech-legend-item"), 4, "the category legend keys every theme");
const quantum = TECHNOLOGIES.findIndex((t) => t.id === "quantum_computing");
const automation = TECHNOLOGIES.findIndex((t) => t.id === "industrial_automation");
const rows = env.$("#technology-list .building-tile");
this.assertNotEqual(rows.eq(method).css("display"), "none", "science is the opening theme");
this.assertEqual(rows.eq(automation).css("display"), "none", "other themes start hidden");
$themes.filter((_index, element) => env.$(element).text() === "Economy").trigger("click");
this.assertEqual(rows.eq(method).css("display"), "none", "science is hidden");
this.assertNotEqual(rows.eq(automation).css("display"), "none", "the economy theme shows");
const card = (index) => env.$(`#technology-list .tech-card[data-index="${index}"]`);
this.assertTrue(card(quantum).length > 0 && card(automation).length > 0, "every theme shares the graph");
modal.close();
} finally {
teardownDom(env);
@@ -504,23 +510,22 @@ export class NationModalTest extends TestCase {
const env = await setupDom();
try {
const modal = new NationModal();
const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method");
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set([method]), 1.0e9, 1.0e15, 1.0e6, {}, null, [], null, {
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, {}, null, [], null, {
focus: -1,
progress: 0,
repeatCounts: { infantry_weapons: 3 },
});
const index = TECHNOLOGIES.findIndex((t) => t.id === "infantry_weapons");
const card = env.$("#technology-list .building-tile").eq(index);
this.assertTrue(card.find(".bt-level").text().includes("Level 3"), "the level is shown");
const $meta = card.find(".bt-meta");
const card = env.$(`#technology-list .tech-card[data-index="${index}"]`);
this.assertTrue(card.find(".bt-level").text().includes("Level III"), "the level is Roman");
const $foot = card.find(".bt-foot");
this.assertEqual(
$meta.find("img[src$='icon_research.svg']").length,
$foot.find("img[src$='icon_research.svg']").length,
1,
"the erlenmeyer icon stands in for the research unit"
);
this.assertTrue($meta.text().length > 0, `the next level is priced: ${$meta.text()}`);
this.assertFalse($meta.text().includes("RP"), "no text 'RP' unit is left");
this.assertTrue($foot.text().length > 0, `the next level is priced: ${$foot.text()}`);
this.assertFalse($foot.text().includes("RP"), "no text 'RP' unit is left");
modal.close();
} finally {
teardownDom(env);
@@ -615,21 +620,22 @@ export class NationModalTest extends TestCase {
const env = await setupDom();
try {
const modal = new NationModal();
const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method");
const quantum = TECHNOLOGIES.findIndex((t) => t.id === "quantum_computing");
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 5, {}, null, [], null, {
focus: method,
progress: 10,
focus: quantum,
progress: 6000,
progressByTech: { [quantum]: 6000 },
repeatCounts: {},
});
const card = env.$("#technology-list .building-tile").eq(method);
this.assertTrue(card.find(".bt-upgrade .bt-progress-fill").length > 0, "the button is the bar");
this.assertNotEqual(card.find(".bt-upgrade .bt-progress-fill").css("width"), "0%", "the bar is filled");
this.assertEqual(
card.find(".bt-meta").text(),
`10 / ${groupDigits(technologyCost(TECHNOLOGIES[method]))}`,
"X / Y beside it"
const $strip = env.$("#technology-research");
this.assertTrue($strip.find(".tech-research-fill").length > 0, "the strip carries the bar");
this.assertNotEqual($strip.find(".tech-research-fill").css("width"), "0%", "the bar is filled");
this.assertTrue(
$strip.find(".tech-research-count").text().includes(`${groupDigits(6000)} / ${groupDigits(technologyCost(TECHNOLOGIES[quantum]))}`),
"the committed and total points are shown beside it"
);
this.assertEqual(card.find(".bt-up-label").text(), "Focused", "no cost on the button");
const card = env.$(`#technology-list .tech-card[data-index="${quantum}"]`);
this.assertTrue(card.hasClass("researching"), "the focused card is marked");
modal.close();
} finally {
teardownDom(env);
@@ -642,7 +648,7 @@ export class NationModalTest extends TestCase {
const modal = new NationModal();
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 5);
const infantry = TECHNOLOGIES.findIndex((t) => t.id === "infantry_weapons");
env.$("#technology-list .building-tile").eq(infantry).find(".bt-upgrade").trigger("mouseenter");
env.$(`#technology-list .tech-card[data-index="${infantry}"]`).trigger("mouseenter");
this.assertTrue(
env.$(".card-tooltip").text().includes("+1.0% infantry attack"),
"the effect is in the hover detail"
@@ -664,7 +670,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 {
@@ -681,15 +688,16 @@ export class NationModalTest extends TestCase {
technology = index;
};
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 0);
const firstCount = env.$("#technology-list .tech-card").length;
modal.close();
// Reopen with enough research to afford the first technology.
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6);
this.assertSize(env.$("#government-list .row-card"), GOVERNMENTS.length, "no stacked governments");
this.assertSize(env.$("#technology-list .building-tile"), TECHNOLOGIES.length, "no stacked technologies");
const button = env.$("#technology-list .building-tile").first().find(".bt-upgrade");
this.assertFalse(button.prop("disabled"), "the rebuilt button reflects the new budget");
button.click();
this.assertEqual(technology, 0);
this.assertSize(env.$("#technology-list .tech-card"), firstCount, "no stacked technologies");
const first = env.$("#technology-list .tech-card").first();
first.click();
env.$("#technology-research .tech-research-focus").click();
this.assertEqual(technology, Number(first.attr("data-index")));
} finally {
teardownDom(env);
}
+1 -1
View File
@@ -365,7 +365,7 @@ export class ResourceModelTest extends TestCase {
state.budgets.set(0, 1e15);
const city = cityOf(state, 0);
grantBuilding(state, city, "research_lab");
const tech = state.technologies.findIndex((t) => t.id === "scientific_method");
const tech = state.technologies.findIndex((t) => t.id === "renewable_energy");
this.assertTrue(state.requestFocusTechnology(0, tech));
const governmentRate = state.getCivGovernmentModifiers(0)[EFFECT_RESEARCH] || 0;
// With a store of high-tech the lab's one point an hour comes through.
+5 -5
View File
@@ -80,21 +80,21 @@ export class RulesTest extends TestCase {
}
test_prerequisites_met() {
const method = TECHNOLOGIES.find((t) => t.id === "scientific_method");
this.assertEmpty(method.prerequisites);
this.assertTrue(prerequisitesMet(method, new Set(), TECHNOLOGIES), "a root tech needs nothing");
const root = TECHNOLOGIES.find((t) => t.id === "industrial_automation");
this.assertEmpty(root.prerequisites);
this.assertTrue(prerequisitesMet(root, new Set(), TECHNOLOGIES), "a root tech needs nothing");
const space = TECHNOLOGIES.find((t) => t.id === "space_program");
const index = (id) => TECHNOLOGIES.findIndex((t) => t.id === id);
this.assertFalse(prerequisitesMet(space, new Set(), TECHNOLOGIES), "missing all prerequisites");
this.assertFalse(
prerequisitesMet(space, new Set([index("scientific_method"), index("industrial_automation")]), TECHNOLOGIES),
prerequisitesMet(space, new Set([index("industrial_automation")]), TECHNOLOGIES),
"still missing one"
);
this.assertTrue(
prerequisitesMet(
space,
new Set([index("scientific_method"), index("industrial_automation"), index("quantum_computing")]),
new Set([index("industrial_automation"), index("quantum_computing")]),
TECHNOLOGIES
),
"all prerequisites met"
+187
View File
@@ -0,0 +1,187 @@
import { TestCase } from "./framework/test_case.js";
import { TECHNOLOGIES } from "../shared/data.js";
import {
romanNumeral,
layoutTechnologies,
chamferPath,
unmetPrerequisites,
TECH_LAYOUT,
} from "../client/js/ui/tech_tree.js";
const indexOf = (id) => TECHNOLOGIES.findIndex((t) => t.id === id);
// Every axis-aligned run of every link, as {x1,y1,x2,y2,label,port}. The first
// and last run are the port stubs, which several links sharing a card port are
// allowed to overlap (they fan out of, or converge into, one point).
function segments(layout) {
const runs = [];
for (const e of layout.edges) {
const label = `${TECHNOLOGIES[e.from].id}->${TECHNOLOGIES[e.to].id}`;
for (let i = 1; i < e.points.length; i++) {
const [x1, y1] = e.points[i - 1];
const [x2, y2] = e.points[i];
if (Math.hypot(x2 - x1, y2 - y1) < 0.5) continue;
const port = i === 1 || i === e.points.length - 1;
runs.push({ x1, y1, x2, y2, label, port });
}
}
return runs;
}
const horizontal = (s) => Math.abs(s.y1 - s.y2) < 0.01;
const vertical = (s) => Math.abs(s.x1 - s.x2) < 0.01;
export class TechTreeTest extends TestCase {
test_repeatable_levels_are_roman_up_to_ten() {
this.assertEqual(romanNumeral(1), "I");
this.assertEqual(romanNumeral(4), "IV");
this.assertEqual(romanNumeral(9), "IX");
this.assertEqual(romanNumeral(10), "X");
// Beyond the catalogue's practical reach a plain number is used.
this.assertEqual(romanNumeral(11), "11");
}
test_column_is_the_distance_from_a_root() {
const layout = layoutTechnologies(TECHNOLOGIES);
this.assertEqual(layout.placed.get(indexOf("industrial_automation")).col, 0, "a root opens column 0");
this.assertEqual(layout.placed.get(indexOf("quantum_computing")).col, 0);
this.assertEqual(layout.placed.get(indexOf("space_program")).col, 1, "one step from a root");
this.assertEqual(layout.placed.get(indexOf("fusion_power")).col, 2, "two steps from a root");
// Every technology of a tier shares its column.
for (const [index, pos] of layout.placed) {
const roots = TECHNOLOGIES[index].prerequisites;
if (roots.length) {
const parents = roots.map((id) => layout.placed.get(indexOf(id)).col);
this.assertEqual(pos.col, Math.max(...parents) + 1, `${TECHNOLOGIES[index].id} sits one past its deepest parent`);
} else {
this.assertEqual(pos.col, 0, `${TECHNOLOGIES[index].id} is a root`);
}
}
}
test_every_prerequisite_sits_to_the_left() {
const layout = layoutTechnologies(TECHNOLOGIES);
for (const [index, pos] of layout.placed) {
for (const id of TECHNOLOGIES[index].prerequisites) {
const parent = layout.placed.get(indexOf(id));
this.assertTrue(parent.x < pos.x, `${TECHNOLOGIES[index].id} follows ${id}`);
}
}
}
test_every_prerequisite_has_a_routed_link() {
const layout = layoutTechnologies(TECHNOLOGIES);
let expected = 0;
for (const tech of TECHNOLOGIES) expected += tech.prerequisites.length;
this.assertSize(layout.edges, expected, "one link per prerequisite");
for (const e of layout.edges) {
const parent = layout.placed.get(e.from);
const child = layout.placed.get(e.to);
this.assertEqual(e.points[0][0], parent.x + TECH_LAYOUT.W, "the link leaves the parent's right edge");
const last = e.points[e.points.length - 1];
this.assertEqual(last[0], child.x, "the link enters the child's left edge");
for (let i = 1; i < e.points.length; i++) {
const a = e.points[i - 1];
const b = e.points[i];
this.assertTrue(
Math.abs(a[0] - b[0]) < 0.5 || Math.abs(a[1] - b[1]) < 0.5,
"every run is axis-aligned"
);
}
}
}
test_links_are_chamfered_without_curves() {
const layout = layoutTechnologies(TECHNOLOGIES);
const edge = layout.edges.find(
(e) => TECHNOLOGIES[e.from].id === "quantum_computing" && TECHNOLOGIES[e.to].id === "space_program"
);
const d = chamferPath(edge.points);
this.assertTrue(d.startsWith("M "), "the path starts with a move");
this.assertFalse(d.includes("NaN"), "no coordinate is missing");
this.assertFalse(d.includes("C "), "there is no bezier curve");
}
// The whole point of the lane routing: distinct dependencies must never be
// drawn on top of each other. The short stubs that fan out of a shared card
// port are the one exception, allowed to lie along the same line.
test_no_two_links_overlap() {
const layout = layoutTechnologies(TECHNOLOGIES);
const runs = segments(layout);
for (let i = 0; i < runs.length; i++) {
for (let j = i + 1; j < runs.length; j++) {
const a = runs[i];
const b = runs[j];
if (a.port || b.port) continue;
if (horizontal(a) && horizontal(b) && Math.abs(a.y1 - b.y1) < 0.01) {
const lo = Math.max(Math.min(a.x1, a.x2), Math.min(b.x1, b.x2));
const hi = Math.min(Math.max(a.x1, a.x2), Math.max(b.x1, b.x2));
this.assertTrue(hi - lo < 0.5, `${a.label} overlaps ${b.label}`);
}
if (vertical(a) && vertical(b) && Math.abs(a.x1 - b.x1) < 0.01) {
const lo = Math.max(Math.min(a.y1, a.y2), Math.min(b.y1, b.y2));
const hi = Math.min(Math.max(a.y1, a.y2), Math.max(b.y1, b.y2));
this.assertTrue(hi - lo < 0.5, `${a.label} overlaps ${b.label}`);
}
}
}
}
test_every_card_has_a_single_port_each_way() {
const layout = layoutTechnologies(TECHNOLOGIES);
const { W, H } = TECH_LAYOUT;
for (const e of layout.edges) {
const parent = layout.placed.get(e.from);
const child = layout.placed.get(e.to);
const start = e.points[0];
const end = e.points[e.points.length - 1];
this.assertEqual(start[0], parent.x + W, "the link leaves the parent's right edge");
this.assertEqual(start[1], parent.y + H / 2, "every outgoing link leaves the middle of the right edge");
this.assertEqual(end[0], child.x, "the link enters the child's left edge");
this.assertEqual(end[1], child.y + H / 2, "every incoming link arrives at the middle of the left edge");
}
}
test_no_link_passes_behind_a_card() {
const layout = layoutTechnologies(TECHNOLOGIES);
const { W, H } = TECH_LAYOUT;
for (const run of segments(layout)) {
for (const [index, pos] of layout.placed) {
const left = pos.x, right = pos.x + W;
const top = pos.y, bottom = pos.y + H;
if (horizontal(run)) {
const xlo = Math.min(run.x1, run.x2);
const xhi = Math.max(run.x1, run.x2);
const crosses = run.y1 > top + 0.5 && run.y1 < bottom - 0.5 &&
xhi > left + 0.5 && xlo < right - 0.5;
this.assertFalse(crosses, `${run.label} crosses ${TECHNOLOGIES[index].id}`);
} else {
const ylo = Math.min(run.y1, run.y2);
const yhi = Math.max(run.y1, run.y2);
const crosses = run.x1 > left + 0.5 && run.x1 < right - 0.5 &&
yhi > top + 0.5 && ylo < bottom - 0.5;
this.assertFalse(crosses, `${run.label} crosses ${TECHNOLOGIES[index].id}`);
}
}
}
}
test_unmet_prerequisites_recurse_to_the_roots() {
const hasBuilding = () => false;
const deep = unmetPrerequisites(indexOf("fusion_power"), TECHNOLOGIES, new Set(), hasBuilding);
this.assertTrue(deep.has(indexOf("space_program")));
this.assertTrue(deep.has(indexOf("quantum_computing")));
this.assertTrue(deep.has(indexOf("industrial_automation")), "the chain is followed to the roots");
// A met prerequisite stops the walk: its own requirements are satisfied.
const shallow = unmetPrerequisites(
indexOf("fusion_power"),
TECHNOLOGIES,
new Set([indexOf("industrial_automation")]),
hasBuilding
);
this.assertFalse(shallow.has(indexOf("industrial_automation")));
this.assertTrue(shallow.has(indexOf("space_program")));
this.assertTrue(shallow.has(indexOf("quantum_computing")));
}
}
+1 -1
View File
@@ -263,7 +263,7 @@ export class TreatyTest extends TestCase {
test_joint_research_shares_points_but_not_military_ones() {
const state = smallState();
this.assertTrue(sign(state, 0, 1, TREATY_JOINT_RESEARCH));
const civil = state.technologies.findIndex((t) => t.id === "scientific_method");
const civil = state.technologies.findIndex((t) => t.id === "renewable_energy");
const military = state.technologies.findIndex((t) => t.id === "body_armour");
state.focusTechnology.set(0, civil);
state.focusTechnology.set(1, civil);
-1
View File
@@ -69,7 +69,6 @@ export class WmdTest extends TestCase {
test_nuclear_weapons_research_needs_an_enrichment_centre() {
const state = smallState();
const index = techIndex(state, "nuclear_weapons");
research(state, 0, "scientific_method");
this.assertFalse(
state.canResearchTechnology(0, index),
"the bomb is locked without a uranium enrichment centre"
+108
View File
@@ -0,0 +1,108 @@
<!doctype html>
<!--
Development preview for the technology tree.
It mounts the very same TechnologyTreeView the game uses, against the real
catalogue, so a technology added or rewired in shared/data/technologies.js
shows up here exactly as it will in the nation panel.
Serve the repository root (e.g. the game server, or
`python3 -m http.server` from the repo root) and open /tools/tech-tree.html.
Relative paths assume the page stays under client/ and shared/.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>Technology tree — preview</title>
<style>html, body { height: 100%; margin: 0; }</style>
<link rel="stylesheet" href="../client/css/style.css">
</head>
<body>
<div id="modal-nation" class="modal">
<div class="modal-card">
<div class="modal-head">
<span>Technology tree — preview</span>
</div>
<div class="tab-page active" id="tab-technology">
<div id="technology-sub" class="modal-sub">
<label style="cursor:pointer">
<input type="checkbox" id="tech-preview-testing"> Testing game (Unlock now buttons)
</label>
</div>
<div id="technology-research" class="tech-research"></div>
<div id="technology-list" class="modal-body tech-tree-body"></div>
</div>
</div>
</div>
<script src="../client/vendor/jquery.min.js"></script>
<script type="module">
import { TechnologyTreeView } from "../client/js/ui/tech_tree_view.js";
import { effectNodes } from "../client/js/modals/format.js";
import { TECHNOLOGIES } from "../shared/data.js";
const indexOf = (id) => TECHNOLOGIES.findIndex((t) => t.id === id);
// A sample nation so every card state is visible: a few technologies held, one
// being researched, and repeatable ones at various levels.
const researched = new Set(
["renewable_energy", "intelligence_agencies", "industrial_automation", "infantry_weapons", "construction_technology"].map(indexOf)
);
const focus = indexOf("quantum_computing");
const techInfo = {
focus,
progress: 6000,
progressByTech: { [focus]: 6000 },
repeatCounts: {
industrial_automation: 4,
infantry_weapons: 2,
construction_technology: 10,
advanced_metallurgy: 1,
},
buildings: new Set(),
testing: false,
};
const tipFor = (index) => {
const proto = TECHNOLOGIES[index];
const repeat = techInfo.repeatCounts[proto.id] || 0;
const level = proto.repeatable ? repeat + 1 : 1;
const sections = [];
if (proto.description) {
sections.push({ label: "Research", cls: "tip-produce", sign: "+", suffix: "", items: [{ text: proto.description }] });
}
sections.push({
label: proto.repeatable ? `Next level (${repeat + 1})` : "Effect",
cls: "tip-produce",
sign: "+",
suffix: "",
items: [{ node: effectNodes(proto.effects, level) }],
});
return sections;
};
const view = new TechnologyTreeView({
graph: $("#technology-list"),
strip: $("#technology-research"),
assetBase: "../client/assets/",
tipFor,
onFocusTechnology: (index) => {
techInfo.focus = index;
techInfo.progress = techInfo.progressByTech[index] || 0;
view.refresh({ researched, techInfo });
},
onUnlockTechnology: (index) => {
researched.add(index);
view.refresh({ researched, techInfo });
},
});
const draw = () => view.render({ technologies: TECHNOLOGIES, researched, techInfo });
draw();
document.getElementById("tech-preview-testing").addEventListener("change", (event) => {
techInfo.testing = event.target.checked;
draw();
});
</script>
</body>
</html>