Compare commits
7
Commits
26e5141ca2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
575f08b64e | ||
|
|
a98cde4e48 | ||
|
|
da11e1d97d | ||
|
|
9fc94a8e00 | ||
|
|
3c737e9ef9 | ||
|
|
c85f79fee2 | ||
|
|
4d3591c48f |
@@ -78,6 +78,25 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
|
||||
fragmented and large frames (`server/websocket.js`). Do not lower the buffer
|
||||
limits without checking the snapshot path.
|
||||
- Indent `.js` files with 2 spaces. `.editorconfig` only enforces UTF-8.
|
||||
- The simulation ticks hourly, but the player reads rates per day: money income
|
||||
and upkeep, research and culture effects, campaign spending and the opinion
|
||||
drift are all scaled by `HOURS_PER_DAY` where they are shown
|
||||
(`client/js/modals/format.js` `signedMoney`/`effectNodes`, `budget.js`,
|
||||
`nation.js`, and the approval tooltip in `game_screen/panels.js`). Write new
|
||||
rate copy per day, and leave durations (build time, cooldowns, endurance) in
|
||||
hours.
|
||||
- `description` fields under `shared/data/` are player-facing and must match the
|
||||
simulation: they surface in tooltips — city building cards and resource
|
||||
producers (`client/js/ui/card.js` `tooltipSections`), spy operations and
|
||||
minority policies (`game_screen/panels.js`, `modals/nation.js`) — so correct
|
||||
them when the behaviour they describe changes. Never type a figure into that
|
||||
prose: interpolate the field or constant the simulation uses (a
|
||||
`get description()`/`get targetEffect()`, not a literal), and render it with
|
||||
the shared formatters so the description quotes exactly what the game shows.
|
||||
The amount formatters live in `shared/data/resources.js` (`formatEnergy`,
|
||||
`formatResourceAmount`, re-exported by `shared/resources.js`); `text_format.js`
|
||||
has `formatNumber`/`formatPercent`. `tests/descriptions_test.js` recomputes each
|
||||
figure and fails when the prose drifts from its source.
|
||||
- Keep aircraft out of the melee: `_battleTiles`/`_combatTarget` skip them and
|
||||
their strikes are routed explicitly. Mirror the air rules (airport-only
|
||||
rebasing, strike radius, ferry fuel) on both client and server, or prediction
|
||||
|
||||
@@ -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 +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
@@ -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
@@ -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
@@ -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": ""
|
||||
}
|
||||
];
|
||||
|
||||
@@ -448,7 +448,7 @@ export class GameScreen {
|
||||
!this._isFriendlyAirport(coords)) {
|
||||
this.confirmModal.show(
|
||||
"No runway",
|
||||
"Aircraft may only fly to one of your cities with an airport, or attack an enemy unit.",
|
||||
"Aircraft may only fly to one of your cities with an airport, or strike a hostile unit or city.",
|
||||
null
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ECONOMY,
|
||||
EFFECT_PRODUCTION,
|
||||
APPROVAL,
|
||||
POPULARITY,
|
||||
effectValue,
|
||||
pillageSteps,
|
||||
ROADS,
|
||||
@@ -47,7 +48,7 @@ import {
|
||||
import { defenseFactors, unitDefense, productionFactors, productionMultiplier, popularityDisplay } from "../../../shared/rules.js";
|
||||
import { formatResourceAmount, foodNeedPerDay, protoUpkeep, tileImprovementResourceCost } from "../../../shared/resources.js";
|
||||
import { dateString, hoursInNextMonth } from "../../../shared/game_clock.js";
|
||||
import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
|
||||
import { DEFAULT_UNIT_SPEED, HOURS_PER_DAY } from "../../../shared/game_state/constants.js";
|
||||
import { flightDistanceTiles } from "../../../shared/hex.js";
|
||||
import { ethnicityChart } from "../ui/ethnicity.js";
|
||||
import { isEconomicMode } from "../map_view/economic.js";
|
||||
@@ -518,19 +519,13 @@ export const panelMethods = {
|
||||
let hint = "Not yet available";
|
||||
if (operation.id === SPY_SABOTAGE) {
|
||||
enabled = this._canSabotage(units);
|
||||
hint = enabled
|
||||
? "Cripple this tile's production for a week (spends the spies)"
|
||||
: "Move the spy onto foreign land";
|
||||
hint = enabled ? operation.description : "Move the spy onto foreign land";
|
||||
} else if (operation.id === SPY_CITY) {
|
||||
enabled = this._spyActionTarget(SPY_CITY, units);
|
||||
hint = enabled
|
||||
? "Read a neighbouring foreign city (spends the spies)"
|
||||
: "Move the spy beside a foreign city";
|
||||
hint = enabled ? operation.description : "Move the spy beside a foreign city";
|
||||
} else if (operation.id === SPY_COMMS) {
|
||||
enabled = this._spyActionTarget(SPY_COMMS, units);
|
||||
hint = enabled
|
||||
? "Intercept a neighbouring capital's messages (spends the spies)"
|
||||
: "Move the spy beside a foreign capital";
|
||||
hint = enabled ? operation.description : "Move the spy beside a foreign capital";
|
||||
}
|
||||
const active = armed === operation.id;
|
||||
$button
|
||||
@@ -1001,15 +996,17 @@ export const panelMethods = {
|
||||
const popularityPoints = stats.popularity || 0;
|
||||
const popularity = popularityDisplay(popularityPoints);
|
||||
this._updateApprovalStat(popularity);
|
||||
// The people drift one point an hour back to neutral, so the estimate is
|
||||
// simply how many points away they are; zero is already there.
|
||||
// The people drift `POPULARITY.driftPerHour` points back to neutral every
|
||||
// hour, so the estimate is how many points away they are spread over that
|
||||
// daily rate; zero is already there.
|
||||
const driftPerDay = POPULARITY.driftPerHour * HOURS_PER_DAY;
|
||||
const pointsAway = Math.abs(popularityPoints);
|
||||
this._setStatTooltip(
|
||||
"stat-approval",
|
||||
pointsAway <= 0
|
||||
? "Popularity is exactly neutral"
|
||||
: `Neutrality in about ${pointsAway} h (~${Math.ceil(pointsAway / 24)} d) ` +
|
||||
"at the current drift of one point an hour"
|
||||
: `Neutrality in about ${Math.ceil(pointsAway / driftPerDay)} d ` +
|
||||
`at the current drift of ${driftPerDay} points a day`
|
||||
);
|
||||
// Popularity doubles its arrow past ten percentage points.
|
||||
this._updateStatArrow(
|
||||
@@ -1677,7 +1674,7 @@ export const panelMethods = {
|
||||
$el.append(
|
||||
$("<div class='site-material-label stalled'></div>").text(
|
||||
"The market is above the budgeted prices. " +
|
||||
"The budget rises 1% a day, doubling each day, until the materials can be bought."
|
||||
"The budget is raised to meet them, then the materials are bought."
|
||||
)
|
||||
);
|
||||
} else {
|
||||
@@ -1829,7 +1826,11 @@ export const panelMethods = {
|
||||
const materials = tileImprovementResourceCost(improvement);
|
||||
const bill = this._resourceItems(materials);
|
||||
const price = quotedBuildCost(improvement.buildCost, materials, prices);
|
||||
// A resource producer's reference text sits above its output; the military
|
||||
// works carry theirs as the produce line.
|
||||
const desc = improvement.resource ? improvement.description || "" : "";
|
||||
return tooltipSections({
|
||||
desc,
|
||||
produce,
|
||||
produceSuffix: improvement.resource ? "/day" : "",
|
||||
consume: this._improvementUpkeepItems(improvement),
|
||||
@@ -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))
|
||||
|
||||
@@ -78,6 +78,18 @@ export class MapView {
|
||||
// graph while the population map is active.
|
||||
this.migrationGraph = { links: [] };
|
||||
this.$entities = layers.entities;
|
||||
// The ballistic arc and its shell of every standing bombardment, drawn on
|
||||
// their own layer above the entity icons so a shell is never hidden behind
|
||||
// the battery that fired it. The target rings stay in `$targets`, below the
|
||||
// icons.
|
||||
this.$strikeArcs = layers.strikeArcs && layers.strikeArcs.length
|
||||
? layers.strikeArcs
|
||||
: $("#layer-strike-arcs");
|
||||
if (!this.$strikeArcs.length) {
|
||||
this.$strikeArcs = $('<div id="layer-strike-arcs" class="layer"></div>');
|
||||
if (this.$entities && this.$entities.length) this.$entities.after(this.$strikeArcs);
|
||||
else this.$world.append(this.$strikeArcs);
|
||||
}
|
||||
// City names live on their own layer above the entities so they are never
|
||||
// hidden behind a unit or city marker.
|
||||
this.$labels = layers.labels && layers.labels.length
|
||||
@@ -109,6 +121,7 @@ export class MapView {
|
||||
for (const $layer of [
|
||||
this.$terrain, this.$roads, this.$borders, this.$fog,
|
||||
this.$highlight, this.$paths, this.$targets, this.$targeting, this.$resources,
|
||||
this.$strikeArcs,
|
||||
]) {
|
||||
if ($layer && $layer.length) $layer.addClass("tilted");
|
||||
}
|
||||
|
||||
@@ -265,8 +265,9 @@ export const motionMethods = {
|
||||
// a parabola rather than along the ground; an aircraft's strike is no ballistic
|
||||
// launch, so its line stays straight. The dashes march and a glowing shell
|
||||
// travels the line so the direction is unmistakable. They stay visible so an
|
||||
// ongoing barrage cannot be forgotten, and sit on a layer above the terrain but
|
||||
// below the unit and city icons.
|
||||
// ongoing barrage cannot be forgotten. The arc and shell ride a layer above the
|
||||
// unit and city icons so a shell is never hidden behind the battery that fired
|
||||
// it; the ring stays on the layer below them.
|
||||
//
|
||||
// The overlay is rebuilt at most every TARGET_REFRESH_MS and immediately when
|
||||
// its contents change; the per-strike target lookup goes through one index of
|
||||
@@ -274,18 +275,28 @@ export const motionMethods = {
|
||||
// units). The nodes are reused, so an unchanged number of barrages allocates
|
||||
// nothing.
|
||||
_drawStrikeTargets(force = false) {
|
||||
if (!this.$targets || !this.$targets.length) return;
|
||||
if (!this.$targets || !this.$targets.length ||
|
||||
!this.$strikeArcs || !this.$strikeArcs.length) return;
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
const tick = Math.floor(now / TARGET_REFRESH_MS);
|
||||
// Collect the standing barrages and a signature that changes the moment a
|
||||
// barrage appears, moves or is selected, so those edits never wait for the
|
||||
// next tick.
|
||||
const strikes = [];
|
||||
// next tick. A stack fires as one, so batteries sharing an origin tile and a
|
||||
// target are grouped and drawn as a single arc and ring.
|
||||
const groups = new Map();
|
||||
let signature = "";
|
||||
for (const [id, data] of this._unitData) {
|
||||
if (!data.strikeTarget) continue;
|
||||
strikes.push(id);
|
||||
signature += `${id}:${data.strikeTarget[0]},${data.strikeTarget[1]};`;
|
||||
const motion = this._unitMotion.get(id);
|
||||
const origin = motion ? this._motionTile(motion) : { x: data.coords[0], y: data.coords[1] };
|
||||
const pair = `${key(origin.x, origin.y)}>${key(data.strikeTarget[0], data.strikeTarget[1])}`;
|
||||
let group = groups.get(pair);
|
||||
if (!group) {
|
||||
group = { ids: [], target: data.strikeTarget };
|
||||
groups.set(pair, group);
|
||||
}
|
||||
group.ids.push(id);
|
||||
}
|
||||
signature += `|${[...this._selectedUnitIds].join(",")}`;
|
||||
if (!force && tick === this._strikeTick && signature === this._strikeSignature) return;
|
||||
@@ -293,7 +304,7 @@ export const motionMethods = {
|
||||
this._strikeSignature = signature;
|
||||
|
||||
const views = this._targetViews;
|
||||
if (strikes.length === 0) {
|
||||
if (groups.size === 0) {
|
||||
this._clearStrikeTargets();
|
||||
return;
|
||||
}
|
||||
@@ -303,14 +314,16 @@ export const motionMethods = {
|
||||
const targetUnits = this._targetUnitIndex();
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
const seen = new Set();
|
||||
for (const id of strikes) {
|
||||
for (const group of groups.values()) {
|
||||
const id = group.ids[0];
|
||||
const data = this._unitData.get(id);
|
||||
if (!data) continue;
|
||||
const target = { x: data.strikeTarget[0], y: data.strikeTarget[1] };
|
||||
const target = { x: group.target[0], y: group.target[1] };
|
||||
const targetId = targetUnits.get(key(target.x, target.y));
|
||||
// The selected attacker or target stands out from the standing barrages
|
||||
// the player is not looking at.
|
||||
const selected = this._selectedUnitIds.has(id) ||
|
||||
// the player is not looking at; a stack stands out when any of its
|
||||
// batteries is selected.
|
||||
const selected = group.ids.some((unitId) => this._selectedUnitIds.has(unitId)) ||
|
||||
(targetId !== undefined && this._selectedUnitIds.has(targetId));
|
||||
const fromPoint = this._unitRenderPosition(id);
|
||||
if (!fromPoint) continue;
|
||||
@@ -377,7 +390,7 @@ export const motionMethods = {
|
||||
if (this._targetSvg && this._targetSvg.parentNode) return this._targetSvg;
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("class", "target-arcs");
|
||||
this.$targets[0].appendChild(svg);
|
||||
this.$strikeArcs[0].appendChild(svg);
|
||||
this._targetSvg = svg;
|
||||
return svg;
|
||||
},
|
||||
|
||||
@@ -722,6 +722,7 @@ export const terrainMethods = {
|
||||
{ source: this.$highlight, interactive: false },
|
||||
{ source: this.$paths, interactive: false },
|
||||
{ source: this.$targets, interactive: false },
|
||||
{ source: this.$strikeArcs, interactive: false },
|
||||
{ source: this.$targeting, interactive: false },
|
||||
{ source: this.$resources, interactive: false },
|
||||
{ source: this.$politicalLabels, interactive: false },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// The budget table shared by the nation and city panels. Every row is an item
|
||||
// and its hourly amount, income in green and every cost in red, with the
|
||||
// and its daily amount, income in green and every cost in red, with the
|
||||
// projected total on the last row. Building types are rows that expand to their
|
||||
// per-city cost. `breakdown` mirrors the shape both the national and the
|
||||
// regional economy use, so one renderer serves them both.
|
||||
@@ -88,7 +88,8 @@ export function renderBudget($list, breakdown = {}, { expanded = new Set(), onTo
|
||||
}
|
||||
|
||||
// What the nation spent buying each resource, and earned selling it to other
|
||||
// countries, over the last day, shown as an hourly average.
|
||||
// countries, over the last day, shown as a daily figure. The record holds the
|
||||
// day's total, so it is divided back to an hourly rate for `signedMoney`.
|
||||
if (resourceMoney.length > 0) {
|
||||
$body.append(sectionRow("Resource trade"));
|
||||
for (const entry of resourceMoney) {
|
||||
@@ -103,7 +104,7 @@ export function renderBudget($list, breakdown = {}, { expanded = new Set(), onTo
|
||||
}
|
||||
}
|
||||
|
||||
// The real resource bills of the last day, as hourly averages, in their own
|
||||
// The real resource bills of the last day, as daily figures, in their own
|
||||
// section so they are not confused with the forecast upkeep rows above.
|
||||
const resourceCosts = breakdown.resourceCosts || [];
|
||||
if (resourceCosts.length > 0) {
|
||||
|
||||
@@ -138,7 +138,7 @@ export class CityModal {
|
||||
);
|
||||
for (const row of this.buildingRows) this.$buildingGrid.append(row.card.$card);
|
||||
this.refreshBuildings(view.budget || 0, view.levels || {}, this.gdp, {});
|
||||
this.$budgetSub.text(`Region net +${currencySymbol()}0/h`);
|
||||
this.$budgetSub.text(`Region net +${currencySymbol()}0/day`);
|
||||
this.renderBudget(null);
|
||||
this.renderPeople(
|
||||
view.ethnicMakeup || [],
|
||||
@@ -804,6 +804,7 @@ export class CityModal {
|
||||
const bill = this._resourceItems(materials);
|
||||
const price = this._quotedCost(cost, row.proto, row.level + row.pending);
|
||||
return tooltipSections({
|
||||
desc: row.proto.description || "",
|
||||
produce: effect && effect !== "No effect" ? [{ node: buildingEffectNodes(row.proto, shownLevel) }] : [],
|
||||
consume: this._resourceItems(upkeep),
|
||||
consumeSuffix: "/day",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Shared formatting helpers for the modal panels.
|
||||
|
||||
import { groupDigits } from "../../../shared/text_format.js";
|
||||
import { HOURS_PER_DAY } from "../../../shared/game_clock.js";
|
||||
import { policyById, resourceById, treatyById } from "../../../shared/data.js";
|
||||
import { marketPriceIndex, resourceMarketValue } from "../../../shared/resources.js";
|
||||
import { formatMoneySigned, currencySymbol } from "../currency.js";
|
||||
@@ -34,9 +35,9 @@ export function formatEffect(effect, level) {
|
||||
case "production":
|
||||
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% production`;
|
||||
case "research":
|
||||
return `${value >= 0 ? "+" : ""}${value.toFixed(2)} research/h`;
|
||||
return `${value >= 0 ? "+" : ""}${(value * HOURS_PER_DAY).toFixed(2)} research/day`;
|
||||
case "culture":
|
||||
return `${value >= 0 ? "+" : ""}${value.toFixed(2)} culture/h`;
|
||||
return `${value >= 0 ? "+" : ""}${(value * HOURS_PER_DAY).toFixed(2)} culture/day`;
|
||||
case "infantry_attack":
|
||||
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% infantry attack`;
|
||||
case "infantry_defense":
|
||||
@@ -81,7 +82,7 @@ export function buildingEffectText(proto, level) {
|
||||
return `−${saved}% civilian bombing losses · very hard to destroy`;
|
||||
}
|
||||
case "air_defense":
|
||||
return "Fires one volley at hostile aircraft over the city";
|
||||
return "Fires on hostile aircraft and missiles within two tiles";
|
||||
case "port_production": {
|
||||
const per = (proto.productionPerSeaTilePerLevel || 0) * level;
|
||||
const food = (proto.foodPerSeaTilePerLevel || 0) * level * 100;
|
||||
@@ -104,8 +105,8 @@ export function effectNodes(effects, level) {
|
||||
if (index > 0) $frag.append(document.createTextNode(" · "));
|
||||
if (effect.stat === "research") {
|
||||
const value = level <= 0 ? 0 : effect.baseValue * Math.pow(level, effect.exponent);
|
||||
$frag.append(researchNode(value, { sign: value >= 0 ? "+" : "", decimals: 2 }));
|
||||
$frag.append(document.createTextNode("/h"));
|
||||
$frag.append(researchNode(value * HOURS_PER_DAY, { sign: value >= 0 ? "+" : "", decimals: 2 }));
|
||||
$frag.append(document.createTextNode("/day"));
|
||||
} else {
|
||||
$frag.append(document.createTextNode(formatEffect(effect, level)));
|
||||
}
|
||||
@@ -122,8 +123,9 @@ export function buildingEffectNodes(proto, level) {
|
||||
|
||||
export const SOURCE_LABELS = { building: "building", government: "government", technology: "research" };
|
||||
|
||||
// A signed money rate, given per hour by the model and shown per day.
|
||||
export function signedMoney(value) {
|
||||
return `${formatMoneySigned(value)}/h`;
|
||||
return `${formatMoneySigned(value * HOURS_PER_DAY)}/day`;
|
||||
}
|
||||
|
||||
// A signed money amount with no period suffix, for the monthly budget report.
|
||||
|
||||
+134
-187
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,13 @@
|
||||
// the level too.
|
||||
|
||||
import { EFFECT_PRODUCTION, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
|
||||
import { formatNumber, formatPercent } from "../text_format.js";
|
||||
import { AIR_DEFENSE } from "./combat.js";
|
||||
import { SATELLITE } from "./intelligence.js";
|
||||
|
||||
// Extra production lines a barracks or shipyard level adds. Shared by the
|
||||
// simulation and the descriptions so the two never disagree.
|
||||
export const PARALLEL_LINES_PER_LEVEL = 1;
|
||||
|
||||
// Buildings whose benefit is a rule rather than a stat effect. Kept as ids so
|
||||
// the simulation and the UI can name them without magic strings.
|
||||
@@ -13,7 +20,7 @@ export const BUILDING_MECHANIC = {
|
||||
PARALLEL_BUILDING: "parallel_building",
|
||||
// Cuts civilian deaths from bombing, and is very hard to knock down.
|
||||
CIVILIAN_SHELTER: "civilian_shelter",
|
||||
// Fires one volley at hostile aircraft passing over the city.
|
||||
// Fires on hostile aircraft and missiles that enter a two-tile corridor.
|
||||
AIR_DEFENSE: "air_defense",
|
||||
// Flat production per head for the city's region, per adjacent sea tile.
|
||||
PORT_PRODUCTION: "port_production",
|
||||
@@ -29,7 +36,9 @@ export const BUILDINGS = [
|
||||
{
|
||||
id: "barracks",
|
||||
name: "Barracks",
|
||||
description: "Enables training infantry units. Each level allows one more parallel training.",
|
||||
get description() {
|
||||
return `Enables training infantry units. Each level allows ${PARALLEL_LINES_PER_LEVEL} more parallel training.`;
|
||||
},
|
||||
baseCost: 10_000_000,
|
||||
costAlpha: 1.1,
|
||||
refundFraction: 0.5,
|
||||
@@ -40,7 +49,9 @@ export const BUILDINGS = [
|
||||
{
|
||||
id: "shelters",
|
||||
name: "Wartime shelters",
|
||||
description: "Each level reduces civilian life loss from bombing by 10%. Extremely hard to destroy. Does not protect other buildings.",
|
||||
get description() {
|
||||
return `Each level shelters another ${formatPercent(this.civilianProtectionPerLevel)} of the city's people from bombing, up to all of them. Extremely hard to destroy. Does not protect other buildings.`;
|
||||
},
|
||||
baseCost: 100_000_000,
|
||||
costAlpha: 2,
|
||||
refundFraction: 0.5,
|
||||
@@ -58,7 +69,9 @@ export const BUILDINGS = [
|
||||
id: "aa_building",
|
||||
advanced: true,
|
||||
name: "Air defense",
|
||||
description: "Damages hostile aircraft that fly above this city.",
|
||||
get description() {
|
||||
return `Fires on hostile aircraft and missiles that come within ${AIR_DEFENSE.rangeTiles} tiles of this city.`;
|
||||
},
|
||||
baseCost: 1_000_000_000,
|
||||
costAlpha: 2,
|
||||
refundFraction: 0.5,
|
||||
@@ -114,7 +127,9 @@ export const BUILDINGS = [
|
||||
{
|
||||
id: "port",
|
||||
name: "Port",
|
||||
description: "Warm water port. Adds 1000 production per person per adjacent sea tile per level, and brings in extra food.",
|
||||
get description() {
|
||||
return `Warm water port. Adds ${formatNumber(this.productionPerSeaTilePerLevel)} production per person per adjacent sea tile per level, and brings in extra food.`;
|
||||
},
|
||||
baseCost: 20_000_000,
|
||||
costAlpha: 1.7,
|
||||
refundFraction: 0.5,
|
||||
@@ -144,7 +159,9 @@ export const BUILDINGS = [
|
||||
{
|
||||
id: "shipyard",
|
||||
name: "Shipyard",
|
||||
description: "Naval yards that build warships. Only a coastal city can raise one, and it must already have a port. Each level adds one parallel building queue.",
|
||||
get description() {
|
||||
return `Naval yards that build warships. Only a coastal city can raise one, and it must already have a port. Each level adds ${PARALLEL_LINES_PER_LEVEL} parallel building queue.`;
|
||||
},
|
||||
baseCost: 150_000_000,
|
||||
costAlpha: 1.65,
|
||||
refundFraction: 0.5,
|
||||
@@ -172,7 +189,9 @@ export const BUILDINGS = [
|
||||
id: "space_launch_center",
|
||||
advanced: true,
|
||||
name: "Space launch centre",
|
||||
description: "A rocket complex. Each level lets the nation launch one spy satellite, which uncovers a random patch of the world every two days. Extremely expensive.",
|
||||
get description() {
|
||||
return `A rocket complex. Each level lets the nation launch ${SATELLITE.perLaunchCentreLevel} spy satellite, which uncovers a random patch of the world every ${SATELLITE.revealEveryDays} days. Extremely expensive.`;
|
||||
},
|
||||
baseCost: 10_000_000_000,
|
||||
costAlpha: 2,
|
||||
refundFraction: 0.5,
|
||||
|
||||
@@ -7,7 +7,7 @@ export const GOVERNMENTS = [
|
||||
{
|
||||
id: "republic",
|
||||
name: "Republic",
|
||||
description: "Rule by elected representatives. A balanced government that spreads its support across the economy, trade and the sciences.",
|
||||
description: "Rule by elected representatives. A balanced government that spreads its support across the economy and the sciences.",
|
||||
baseCost: 0,
|
||||
costGdpFraction: 0.02,
|
||||
effects: [effect(EFFECT_PRODUCTION, 0.05, 1.0), effect(EFFECT_RESEARCH, 1.0, 1.0)],
|
||||
|
||||
@@ -63,8 +63,9 @@ export const TILE_IMPROVEMENTS = [
|
||||
id: "radar_tower",
|
||||
advanced: true,
|
||||
name: "Radar tower",
|
||||
description:
|
||||
"Watches five tiles out. A ranged hit damages it and an enemy walking onto it captures it.",
|
||||
get description() {
|
||||
return `Watches ${this.vision} tiles out. A ranged hit damages it and an enemy walking onto it captures it.`;
|
||||
},
|
||||
buildCost: 60_000_000,
|
||||
maintenance: 1000,
|
||||
vision: 5,
|
||||
@@ -76,8 +77,9 @@ export const TILE_IMPROVEMENTS = [
|
||||
id: "coastal_cannon",
|
||||
advanced: true,
|
||||
name: "Coastal cannon",
|
||||
description:
|
||||
"Automatically fires on hostile warships within two tiles. Only a coastal tile can carry one.",
|
||||
get description() {
|
||||
return `Automatically fires on hostile naval units within ${this.range} tiles. Only a coastal tile can carry one.`;
|
||||
},
|
||||
buildCost: 120_000_000,
|
||||
maintenance: 2000,
|
||||
range: 2,
|
||||
|
||||
@@ -53,9 +53,12 @@ export const ESPIONAGE = {
|
||||
|
||||
// Spy satellites: a launch is free once the (very expensive) space launch centre
|
||||
// stands, and each satellite uncovers a random patch of the world on a cadence.
|
||||
// A centre sustains this many satellites for every level it has, one each by
|
||||
// default.
|
||||
export const SATELLITE = {
|
||||
revealEveryDays: 2,
|
||||
revealRadius: 2,
|
||||
perLaunchCentreLevel: 1,
|
||||
};
|
||||
|
||||
// `description` is the reference text the Intelligence tab shows, not a
|
||||
@@ -72,8 +75,9 @@ export const SPY_OPERATIONS = [
|
||||
{
|
||||
id: SPY_SABOTAGE,
|
||||
name: "Sabotage",
|
||||
description:
|
||||
"Cripple a tile's production for a week. The act may be pinned on another nation, or on no one at all. Spends the spies.",
|
||||
get description() {
|
||||
return `Cripple a tile's production for ${SABOTAGE.blockDays} days. The act may be pinned on another nation, or on no one at all. Spends the spies.`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: SPY_CITY,
|
||||
|
||||
+30
-6
@@ -56,6 +56,18 @@ export const POPULARITY = {
|
||||
pillage: 6,
|
||||
};
|
||||
|
||||
// An opinion shift as the integer popularity points the policies are quoted in,
|
||||
// so the wizard's estimates come straight from the applied effect.
|
||||
export function opinionToPoints(opinion) {
|
||||
return Math.round(opinion / POPULARITY.opinionPerPoint);
|
||||
}
|
||||
|
||||
// A signed popularity figure with the typographic minus the catalogue uses.
|
||||
function popularityPointsText(opinion) {
|
||||
const points = opinionToPoints(opinion);
|
||||
return `${points < 0 ? "\u2212" : "+"}${Math.abs(points)}`;
|
||||
}
|
||||
|
||||
// A campaign costs nothing to start: it charges `defaultHourlyCost` for as long
|
||||
// as it runs, like any other upkeep and shown in the budget. The player may
|
||||
// raise or lower the spending up to `maxHourlyCost`; the opinion shift scales
|
||||
@@ -102,16 +114,24 @@ export const POLICIES = [
|
||||
name: "Champion a minority",
|
||||
description:
|
||||
"Proclaim the state the protector of an ethnicity. Its people everywhere warm to you, but your own other populations resent the favouritism.",
|
||||
targetEffect: "+30 popularity among the chosen people",
|
||||
politicalCost: "−16 popularity among every other population in your country",
|
||||
get targetEffect() {
|
||||
return `${popularityPointsText(POLICY_OPINION.championTarget)} popularity among the chosen people`;
|
||||
},
|
||||
get politicalCost() {
|
||||
return `${popularityPointsText(POLICY_OPINION.championMajority)} popularity among every other population in your country`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: POLICY_PROTECT,
|
||||
name: "Protect a minority",
|
||||
description:
|
||||
"Shield an ethnicity inside your borders. It raises their view of you at home and abroad, at some cost with your other populations.",
|
||||
targetEffect: "+24 popularity among the chosen people",
|
||||
politicalCost: "−8 popularity among every other population in your country",
|
||||
get targetEffect() {
|
||||
return `${popularityPointsText(POLICY_OPINION.protectTarget)} popularity among the chosen people`;
|
||||
},
|
||||
get politicalCost() {
|
||||
return `${popularityPointsText(POLICY_OPINION.protectMajority)} popularity among every other population in your country`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: POLICY_EXPEL,
|
||||
@@ -119,7 +139,9 @@ export const POLICIES = [
|
||||
description:
|
||||
"Force an ethnicity out of the country. Its people, in and outside your borders, will hate you for a long time, and they will press into your neighbours.",
|
||||
targetEffect: "The people begin to leave for neighbouring countries",
|
||||
politicalCost: "−80 popularity among the expelled people, remembered for a generation",
|
||||
get politicalCost() {
|
||||
return `${popularityPointsText(POLICY_OPINION.expelTarget)} popularity among the expelled people, remembered for a generation`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: POLICY_IMMIGRATION,
|
||||
@@ -127,7 +149,9 @@ export const POLICIES = [
|
||||
description:
|
||||
"Invite an ethnicity in. They will come from lands where their government is unpopular, but every other population in your country dislikes the influx.",
|
||||
targetEffect: "The chosen people migrate into your country",
|
||||
politicalCost: "−10 popularity among every other population in your country",
|
||||
get politicalCost() {
|
||||
return `${popularityPointsText(POLICY_OPINION.immigrationOthers)} popularity among every other population in your country`;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+80
-17
@@ -13,6 +13,7 @@ import {
|
||||
EFFECT_LUXURY_EFFICIENCY,
|
||||
EFFECT_HIGHTECH_EFFICIENCY,
|
||||
} from "./effects.js";
|
||||
import { groupDigits } from "../text_format.js";
|
||||
|
||||
// Energy is stored and moved as kWh. These keep the catalogue readable.
|
||||
export const KWH_PER_MWH = 1_000;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -26,7 +26,7 @@ export const TREATIES = [
|
||||
id: TREATY_ALLIANCE,
|
||||
name: "Alliance",
|
||||
description:
|
||||
"The signatories cannot go to war with each other; attacking one declares war on all.",
|
||||
"The signatories spare each other's forces; attacking one breaks the pact and draws its allies into the war.",
|
||||
},
|
||||
{
|
||||
id: TREATY_FREE_TRADE,
|
||||
@@ -48,7 +48,7 @@ export const TREATIES = [
|
||||
{
|
||||
id: TREATY_GIVE_RESOURCES,
|
||||
name: "Give resources",
|
||||
description: "Transfers stores of steel, high-tech, luxury, food or energy.",
|
||||
description: "Transfers stores of steel, high-tech, luxury or food.",
|
||||
payload: "resources",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
EFFECT_RAIL_SPEED,
|
||||
} from "../data/effects.js";
|
||||
import { CITY_GARRISON_HEAL_PER_HOUR } from "../data/combat.js";
|
||||
import { BUILDING_MECHANIC } from "../data/buildings.js";
|
||||
import { BUILDING_MECHANIC, PARALLEL_LINES_PER_LEVEL } from "../data/buildings.js";
|
||||
import { technologyCost, technologyById } from "../data/technologies.js";
|
||||
import { NEWS_TECHNOLOGY } from "../data/relations.js";
|
||||
import {
|
||||
@@ -1099,8 +1099,8 @@ export const economyMethods = {
|
||||
// (units) and Shipyards (buildings) add. Each line works one entry at full
|
||||
// capacity, so a higher-level city produces several things at once; the
|
||||
// overflow of a finished entry carries into the next waiting one.
|
||||
const unitLines = 1 + this._cityMechanicLevel(city, BUILDING_MECHANIC.PARALLEL_TRAINING);
|
||||
const buildingLines = 1 + this._cityMechanicLevel(city, BUILDING_MECHANIC.PARALLEL_BUILDING);
|
||||
const unitLines = 1 + PARALLEL_LINES_PER_LEVEL * this._cityMechanicLevel(city, BUILDING_MECHANIC.PARALLEL_TRAINING);
|
||||
const buildingLines = 1 + PARALLEL_LINES_PER_LEVEL * this._cityMechanicLevel(city, BUILDING_MECHANIC.PARALLEL_BUILDING);
|
||||
this._advanceProduction(queue, city, "unit", unitLines);
|
||||
this._advanceProduction(queue, city, "building", buildingLines);
|
||||
if (queue.length === 0) completed.push(cityId);
|
||||
|
||||
@@ -330,7 +330,7 @@ export const intelligenceMethods = {
|
||||
for (const [index, level] of Object.entries(city.buildings || {})) {
|
||||
const proto = this.protoBuildings[index];
|
||||
if (proto && proto.mechanic === BUILDING_MECHANIC.SPACE_LAUNCH) {
|
||||
total += Number(level) || 0;
|
||||
total += (Number(level) || 0) * SATELLITE.perLaunchCentreLevel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-31
@@ -8,42 +8,16 @@ import {
|
||||
RESOURCE_RULES,
|
||||
RESOURCE_MARKET,
|
||||
RESOURCE_MARKET_BASE,
|
||||
resourceById,
|
||||
formatEnergy,
|
||||
formatResourceAmount,
|
||||
} from "./data/resources.js";
|
||||
import { EFFECT_RESEARCH } from "./data/effects.js";
|
||||
import { groupDigits } from "./text_format.js";
|
||||
|
||||
const DAYS_PER_YEAR = 365;
|
||||
|
||||
// --------------------------------------------------------------- formatting --
|
||||
|
||||
// Energy reads in kWh, MWh, GWh or TWh, whichever keeps the figure short.
|
||||
export function formatEnergy(kwh) {
|
||||
const value = Math.max(0, kwh);
|
||||
if (value >= 1e9) return `${trim(value / 1e9)} TWh`;
|
||||
if (value >= 1e6) return `${trim(value / 1e6)} GWh`;
|
||||
if (value >= 1e3) return `${trim(value / 1e3)} MWh`;
|
||||
return `${trim(value)} kWh`;
|
||||
}
|
||||
|
||||
// A material in its own friendly unit: tonnes step up through kt and Mt,
|
||||
// carats through kct, high-tech through ku. The prefix and the divisor move
|
||||
// together, so a million tonnes reads as 1 Mt rather than 1 kt.
|
||||
export function formatResourceAmount(id, value) {
|
||||
if (id === "energy") return formatEnergy(value);
|
||||
const amount = Math.max(0, value);
|
||||
const unit = resourceById(id) ? resourceById(id).unit : "";
|
||||
if (amount >= 1e12) return `${trim(amount / 1e12)} T${unit}`;
|
||||
if (amount >= 1e9) return `${trim(amount / 1e9)} G${unit}`;
|
||||
if (amount >= 1e6) return `${trim(amount / 1e6)} M${unit}`;
|
||||
return `${trim(amount)} ${unit}`;
|
||||
}
|
||||
|
||||
function trim(value) {
|
||||
if (value >= 100) return groupDigits(Math.round(value));
|
||||
if (value >= 10) return value.toFixed(1).replace(/\.0$/, "");
|
||||
return value.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
// The amount formatters live with the resource catalogue so its descriptions
|
||||
// can quote the same figures; re-exported here for the existing callers.
|
||||
export { formatEnergy, formatResourceAmount };
|
||||
|
||||
// --------------------------------------------------------------- per-person --
|
||||
|
||||
|
||||
@@ -69,6 +69,16 @@ export function compact(value) {
|
||||
return sign + text + suffixes[index];
|
||||
}
|
||||
|
||||
// A fraction as a whole-number percentage, the unit most descriptions quote:
|
||||
// 0.1 reads "10%". Callers pass the raw fraction the simulation applies so a
|
||||
// description can never drift from the rule it describes.
|
||||
export function formatPercent(fraction, { decimals = 0 } = {}) {
|
||||
const value = Number(fraction) * 100;
|
||||
const fixed = value.toFixed(decimals);
|
||||
const text = decimals === 0 ? fixed : fixed.replace(/\.?0+$/, "");
|
||||
return `${text}%`;
|
||||
}
|
||||
|
||||
export function improvements(list) {
|
||||
if (!list || list.length === 0) return "None";
|
||||
return list.join(", ");
|
||||
|
||||
+1
-1
@@ -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"));
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user