Compare commits

...
3 Commits
Author SHA1 Message Date
adrien aed6ba94f2 Rolled the central bank's commodity chart over the last two years
The index is still 100 at January 2000's prices, but the chart now plots only the last twenty-four monthly samples and its end labels follow the window, so the graph stops growing as the game runs. A test covers the 24-month window and its end labels.
2026-09-23 00:03:23 +02:00
adrien 2d36685ca0 Moved tile works into a management panel and put every building on one card
The city buildings tab and the HUD's tile panel now draw the same card: an icon, name and status, a bottom-anchored button carrying the estimated price and build time, a quiet remove control and a hatched locked state, with the produce/consume/cost detail in a hover tooltip. The shared card lives in client/js/ui/card.js.

The tile dock is now a summary — description, production, people and their ethnic makeup, GDP and the improvements list — with a Management button that opens a full-size panel like the city and nation panels. Its Improvements tab holds the grouped Military, Transportation and Economic cards, so Road and Railway sit with the other works. City buildings sit in a two-row horizontal scroller with the built ones first, and the city panel now matches the nation panel's size.

AGENTS.md now asks for a minimal visual preview to be shown before a player-visible change is committed.
2026-09-22 23:51:48 +02:00
adrien 7ea5373b60 Moved the economy onto resources and surfaced each building's material bill
Rebalanced the resource rules so nothing is priced primarily in money: steel, electricity and high-tech lead every construction bill, chip foundries are seeded everywhere instead of being hand-placed, and cities open with stores of steel and high-tech. City building rows now lead with the resource bill and keep the money as an aside, and the region's resources expand into the buildings that demand them. Pulled the graph and cost maths into a framework-free shared/economy_graph.js, quoted currency through one helper, and gave every data table the same striping and row highlight.
2026-09-22 23:31:36 +02:00
29 changed files with 1187 additions and 536 deletions
+11
View File
@@ -26,6 +26,17 @@ everything else (transport included) is hand-written.
There is no linter, formatter, or CI. Do not invent commands beyond these.
### Visual changes
- A change that alters what the player sees is reviewed by eye before it is
committed. Build a **minimal standalone HTML example** of the change first —
the isolated element and each of its states, not a whole mocked-up screen —
open it in a browser and show it to the user. Only commit once the user has
seen and approved the preview.
- Keep the examples small and self-contained: link the stylesheet and copy in
the few assets they need. A short gallery of one small example per visual
change is the right size. Do not add the examples to the repository.
### Local testing mode
- The server creates its game at startup; add `--testing` to
+167 -37
View File
@@ -867,13 +867,8 @@ input:focus, select:focus { border-color: var(--accent); }
padding: 12px 18px;
}
/* Tile improvements, grouped under a category heading. */
.tile-improvement-actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
/* Tile improvements, grouped under a category heading, in the management
panel. */
.improvement-group { display: flex; flex-direction: column; gap: 4px; }
.improvement-category {
font-size: 10px;
@@ -885,30 +880,145 @@ input:focus, select:focus { border-color: var(--accent); }
.improvement-btn { font-size: 12px; }
.improvement-btn.current { opacity: 0.6; }
.improvement-btn.remove { border-color: rgba(242, 114, 114, 0.6); }
/* Buildable improvements are square icons with the cost written beneath. */
.improvement-icon-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
width: 64px;
padding: 5px 4px;
}
.improvement-icon { width: 40px; height: 40px; display: block; }
.improvement-cost {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 0 3px;
font-size: 10px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.cost-res { display: inline-flex; align-items: center; gap: 2px; }
.cost-icon { width: 12px; height: 12px; vertical-align: middle; }
.cost-money { color: var(--muted); }
/* The building and improvement card: icon, name and status up top, a
bottom-anchored action button carrying the estimated price and build time,
and a quiet remove control. What the work produces, consumes and costs is in
the hover tooltip so the card itself stays bare. Shared by the city
buildings tab and the HUD's tile improvement panel. */
.building-scroller { overflow-x: auto; overflow-y: hidden; padding-bottom: 8px; }
.building-grid {
display: grid;
grid-template-rows: repeat(2, auto);
grid-auto-flow: column;
grid-auto-columns: 178px;
gap: 8px;
width: max-content;
}
.building-tile {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
width: 178px;
padding: 9px;
border-radius: 9px;
border: 1px solid rgba(150, 190, 240, 0.35);
background: linear-gradient(180deg, rgba(90, 130, 190, 0.3), rgba(35, 55, 90, 0.55));
}
.building-tile:hover { filter: brightness(1.15); }
.building-tile.built { border-color: rgba(143, 227, 168, 0.6); }
.building-tile.locked { opacity: 0.6; filter: none; }
/* An unavailable work is hatched, so a locked card reads at a glance. */
.building-tile.locked::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: repeating-linear-gradient(
45deg,
rgba(255, 255, 255, 0.14) 0 2px,
transparent 2px 8px
);
}
.bt-top { display: flex; align-items: center; gap: 8px; padding-right: 16px; }
.bt-top .building-icon { width: 34px; height: 34px; flex: 0 0 auto; opacity: 0.95; }
.bt-id { min-width: 0; }
.building-tile .bt-name { font-weight: 600; font-size: 12.5px; line-height: 1.15; }
.building-tile .bt-level { font-size: 10.5px; color: var(--muted); }
.building-tile .bt-level.built { color: #8fe3a8; }
.building-tile .bt-upgrade {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 6px;
width: 100%;
margin-top: auto;
padding: 4px 8px;
font-size: 11.5px;
text-align: left;
}
.building-tile .bt-up-label { font-weight: 600; }
.building-tile .bt-meta {
font-size: 10px;
font-weight: 400;
color: var(--muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* The money is an estimate, so it is italic and marked with a tilde. */
.building-tile .bt-price { font-style: italic; }
.building-tile .bt-free { width: 100%; font-size: 10.5px; padding: 3px 6px; }
.building-tile .bt-downgrade {
position: absolute;
top: 5px;
right: 5px;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
line-height: 1;
font-size: 11px;
border-radius: 6px;
background: transparent;
border: 1px solid transparent;
color: var(--muted);
opacity: 0.6;
}
.building-tile .bt-downgrade:hover:not(:disabled) {
opacity: 1;
color: #ff9a9a;
background: rgba(255, 255, 255, 0.06);
border-color: rgba(242, 114, 114, 0.5);
}
.building-tile .bt-downgrade:disabled { opacity: 0.2; }
/* The card's hover tooltip. Fixed to the body so a scrolling card list never
clips it. */
.card-tooltip {
position: fixed;
z-index: 1000;
pointer-events: none;
max-width: 300px;
padding: 8px 10px;
border-radius: 9px;
background: var(--panel-bg);
border: 1px solid var(--panel-border);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.7);
font-size: 11.5px;
color: var(--text);
}
.card-tooltip .tip-section + .tip-section {
margin-top: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 6px;
}
.card-tooltip .tip-head {
font-size: 9.5px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 2px;
}
.card-tooltip .tip-line {
display: flex;
align-items: center;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.card-tooltip .tip-produce { color: #8fe3a8; }
.card-tooltip .tip-consume,
.card-tooltip .tip-cost { color: #e2c08a; }
.card-tooltip .tip-none { color: var(--muted); }
.card-tooltip .res { display: inline-flex; align-items: center; gap: 3px; }
.card-tooltip .res img { width: 13px; height: 13px; }
/* The tile panel leads with what the tile actually produces. */
#tile-production { margin: 4px 0 6px; display: flex; flex-direction: column; gap: 2px; }
.tile-prod-row {
@@ -1153,7 +1263,8 @@ input:focus, select:focus { border-color: var(--accent); }
instead of covering it, so the approval figure stays in view while they are
open. The 104px clears the summary's 12px offset plus its padded height. */
#modal-city,
#modal-nation {
#modal-nation,
#modal-tile {
align-items: flex-start;
padding: 104px 16px 16px;
}
@@ -1348,7 +1459,9 @@ input:focus, select:focus { border-color: var(--accent); }
.row-card .name { font-weight: 600; }
.row-card .desc { color: var(--muted); font-size: 12px; margin: 3px 0; }
.row-card .effects { font-size: 12px; color: #bcd4ff; }
.row-card .consume { font-size: 12px; color: #e2c08a; margin-top: 2px; }
.row-card .cost { font-size: 12px; color: var(--muted); margin-top: 3px; }
.row-card .cost .cost-money { color: var(--text); }
.row-card .actions { display: flex; flex-direction: column; gap: 6px; justify-content: center; }
.row-card .actions .aero { min-width: 170px; }
.row-card .value {
@@ -1415,6 +1528,21 @@ input:focus, select:focus { border-color: var(--accent); }
.economy-table td { padding: 5px 8px; text-align: left; border-bottom: 1px solid rgba(255, 255, 255, 0.08); }
.economy-table th { color: var(--muted); font-weight: 600; font-size: 11px; }
.economy-table .numeric { text-align: right; font-variant-numeric: tabular-nums; }
/* Every data table reads the same way: alternate-row shading and a highlight on
the row under the pointer. Later, more specific rules (a local nation, a
sorted header) may override these. */
.economy-table tbody tr:nth-child(even),
.diplomacy-table tbody tr:nth-child(even),
.budget-table tbody tr:nth-child(even) { background: rgba(255, 255, 255, 0.045); }
.economy-table tbody tr:hover,
.diplomacy-table tbody tr:hover,
.budget-table tbody tr:hover { background: rgba(110, 175, 255, 0.12); }
/* A resource row that expands into its per-building demand. */
.resource-caret { display: inline-block; width: 12px; color: var(--muted); }
.economy-table td.clickable { cursor: pointer; }
.economy-table td.clickable:hover { color: var(--accent); }
.resource-demand td { padding-top: 2px; padding-bottom: 6px; }
.demand-line { font-size: 12px; color: var(--muted); padding: 1px 0 1px 14px; }
.central-bank-rate { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
.central-bank-rate label { color: var(--muted); }
.central-bank-rate input { width: 90px; }
@@ -1538,15 +1666,17 @@ input:focus, select:focus { border-color: var(--accent); }
.training-queue .queue-row { padding: 8px 12px; }
.training-queue .queue-row .actions .aero { min-width: 110px; }
/* The city modal keeps a fixed size whatever tab is open, so switching between
train, buildings, people and budget never resizes the card. The unit buttons
sit in a fixed-height box that scrolls when there are more units than fit;
the queue below is the flexible part and scrolls too, so adding units never
stretches the card or pushes the buttons out of reach. */
#modal-city .modal-card {
width: 680px;
max-width: calc(100vw - 32px);
height: min(80vh, 740px);
/* The city and tile panels keep a fixed size whatever tab is open, so switching
between train, buildings, people and budget never resizes the card. They match
the nation panel so the three read as the same surface. The unit buttons sit in
a fixed-height box that scrolls when there are more units than fit; the queue
below is the flexible part and scrolls too, so adding units never stretches
the card or pushes the buttons out of reach. */
#modal-city .modal-card,
#modal-tile .modal-card {
width: 90vw;
max-width: 1600px;
height: min(80vh, 900px);
max-height: calc(100vh - 120px);
}
#modal-city #city-train-list {
+21 -1
View File
@@ -151,7 +151,9 @@
<div id="tile-gdp"></div>
<div id="tile-improvements"></div>
<div id="tile-works"></div>
<div id="tile-improvement-actions" class="tile-improvement-actions"></div>
<div class="row">
<button id="tile-manage" class="aero hidden">Management</button>
</div>
</div>
<div id="city-info" class="info-section hidden">
<div id="city-name" class="info-title"></div>
@@ -264,6 +266,24 @@
</div>
</div>
<!-- Tile management panel: the works one tile can carry, opened from the
tile summary's Management button. -->
<div id="modal-tile" class="modal hidden">
<div class="modal-card">
<div class="modal-head">
<span id="tile-manage-title">Tile</span>
<button class="modal-close aero">✕</button>
</div>
<div class="tabs">
<button class="tab active" data-tab="improvements">Improvements</button>
</div>
<div class="tab-page active" id="tile-tab-improvements">
<div id="tile-manage-sub" class="modal-sub"></div>
<div id="tile-manage-improvements" class="modal-body"></div>
</div>
</div>
</div>
<!-- Generic confirmation modal -->
<div id="modal-confirm" class="modal hidden">
<div class="modal-card modal-card-small">
+12
View File
@@ -27,6 +27,18 @@ export function currencySymbol() {
return symbol;
}
// A money amount in the viewer's currency, with the digits grouped by
// non-breaking spaces. Every panel quotes currency through here, so the figures
// read the same everywhere.
export function formatMoney(value) {
return `${symbol}${groupDigits(Math.round(value))}`;
}
// A signed money amount, for budget lines that show a movement.
export function formatMoneySigned(value) {
return `${value < 0 ? "−" : "+"}${formatMoney(Math.abs(value))}`;
}
export function viewerCurrencyCiv() {
return viewer;
}
+18 -18
View File
@@ -1,6 +1,24 @@
// Generated by scripts/generate-devlog.js from `git log`; do not edit.
// The pre-commit hook refreshes it so the main menu shows the latest commits.
export const DEVLOG = [
{
"hash": "2d36685",
"date": "2026-09-22",
"subject": "Moved tile works into a management panel and put every building on one card",
"body": "The city buildings tab and the HUD's tile panel now draw the same card: an icon, name and status, a bottom-anchored button carrying the estimated price and build time, a quiet remove control and a hatched locked state, with the produce/consume/cost detail in a hover tooltip. The shared card lives in client/js/ui/card.js.\n\nThe tile dock is now a summary — description, production, people and their ethnic makeup, GDP and the improvements list — with a Management button that opens a full-size panel like the city and nation panels. Its Improvements tab holds the grouped Military, Transportation and Economic cards, so Road and Railway sit with the other works. City buildings sit in a two-row horizontal scroller with the built ones first, and the city panel now matches the nation panel's size.\n\nAGENTS.md now asks for a minimal visual preview to be shown before a player-visible change is committed."
},
{
"hash": "7ea5373",
"date": "2026-09-22",
"subject": "Moved the economy onto resources and surfaced each building's material bill",
"body": "Rebalanced the resource rules so nothing is priced primarily in money: steel, electricity and high-tech lead every construction bill, chip foundries are seeded everywhere instead of being hand-placed, and cities open with stores of steel and high-tech. City building rows now lead with the resource bill and keep the money as an aside, and the region's resources expand into the buildings that demand them. Pulled the graph and cost maths into a framework-free shared/economy_graph.js, quoted currency through one helper, and gave every data table the same striping and row highlight."
},
{
"hash": "1324128",
"date": "2026-09-22",
"subject": "Showed idle trade routes and flagged migrant flows on the economic maps",
"body": "The delivery graph now ships every route the viewer's own storage nodes could trade over, not just the legs that ran, and keeps exports as well as imports so goods leaving the nation appear. The map draws the idle routes grey beneath the active white legs, with a key entry for them.\n\nThe population map replaces its bare flow arrows with the arrows plus a disc textured with the origin nation's flag drifting from city to city, mirroring the merchandise that rides the trade graph."
},
{
"hash": "aa726ff",
"date": "2026-09-22",
@@ -42,23 +60,5 @@ export const DEVLOG = [
"date": "2026-09-22",
"subject": "Let central banks work their reserves and cleared the landlocked famines",
"body": "Central banks now redeem the national money foreign banks hold, sell reserves to defend a weak rate, and cover part of strategic imports from reserves. Five repeatable technologies raise each commodity's output per unit of energy. Hostile troops on a road or sea lane sever trade, power and unit supply. Middlemen buy food to resell to neighbours at a markup, and starving regions buy before those topping up their buffers, which clears the landlocked famines. The economic map's trade graph gains a colour legend."
},
{
"hash": "3e429cc",
"date": "2026-09-22",
"subject": "Gave production buildings their own economy and fed island regions",
"body": "Production buildings are now independent private-sector agents: they keep what their output sells for, run only to the demand they can actually sell, and spend that cash on steel and high-tech upgrades when their demand grows. Grid power is a paid private flow between plants and their consumers, and regions synthesise the food their own harvest and the reachable market cannot supply.\n\nIsland regions no longer famine: a region with no reachable supplier grows its whole shortfall from energy, and grid power now feeds cities and food before the converters, so a famine never waits on a factory.\n\nBundles the in-progress tree cleanup that was already present in the working tree."
},
{
"hash": "238985b",
"date": "2026-09-22",
"subject": "Started a famine when a region's food runs out",
"body": ""
},
{
"hash": "b6287d7",
"date": "2026-09-22",
"subject": "Replaced the GDP tax with national sales and tariff taxes",
"body": ""
}
];
+3 -1
View File
@@ -8,7 +8,7 @@
// snapshot plumbing and the order flow.
import { MapView } from "./map_view.js";
import { CityModal, NationModal, ConfirmModal, NewsModal, MapModesModal, CapabilityModal } from "./modals.js";
import { CityModal, NationModal, TileModal, ConfirmModal, NewsModal, MapModesModal, CapabilityModal } from "./modals.js";
import { GameFeed } from "./feed.js";
import { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.js";
import { bootLog } from "./loading.js";
@@ -102,6 +102,7 @@ export class GameScreen {
);
this.cityModal = new CityModal();
this.nationModal = new NationModal();
this.tileModal = new TileModal();
this.confirmModal = new ConfirmModal();
this.newsModal = new NewsModal();
this.mapModesModal = new MapModesModal();
@@ -535,6 +536,7 @@ export class GameScreen {
const modalOpen = $(".modal").not(".hidden").length > 0;
this.cityModal.close();
this.nationModal.close();
this.tileModal.close();
this.confirmModal.close();
this.newsModal.close();
this.mapModesModal.close();
+186 -201
View File
@@ -32,7 +32,7 @@ import {
tileImprovementById,
} from "../../../shared/data.js";
import { defenseFactors, unitDefense, productionFactors, productionMultiplier, popularityDisplay } from "../../../shared/rules.js";
import { formatEnergy, formatResourceAmount, foodNeedPerDay, protoUpkeep, tileImprovementResourceCost } from "../../../shared/resources.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 { ethnicityChart } from "../ui/ethnicity.js";
@@ -40,10 +40,12 @@ import { isEconomicMode } from "../map_view/economic.js";
import { isWaterTile } from "../map_view/textures.js";
import { audio } from "../audio.js";
import { currencySymbol, currencyFor, rateToNational, moneyText } from "../currency.js";
import { buildCard, tooltipSections, hideCardTooltip } from "../ui/card.js";
export const panelMethods = {
_bindHud() {
$("#city-manage").on("click", () => this._openCityModal());
$("#tile-manage").on("click", () => this._openTileManageModal());
$("#civ-flag").on("click", () => this._openNationModal());
$("#news-button").on("click", () => this._openNewsModal());
$("#map-modes-button").on("click", (event) => {
@@ -952,7 +954,7 @@ export const panelMethods = {
this._renderTileWorks(coords);
this._renderCityProduction(city);
// Only a land tile the player controls can carry a new improvement. A city
// improves itself, so it never offers the build buttons.
// improves itself, so it never offers the build cards.
const buildable = !city &&
owner === this.localCiv &&
owner >= 0 &&
@@ -963,7 +965,52 @@ export const panelMethods = {
: this.map.roads.has(k)
? "road"
: null;
this._renderImprovementActions(coords, buildable, current);
// The summary only points at the management panel; the building cards live
// there, so they never overflow the small dock.
$("#tile-manage").toggleClass("hidden", !buildable);
if (this.tileModal.isShowing(coords)) {
this.tileModal.setSummary(this._tileManageSummary(coords));
this._renderImprovementActions(coords, buildable, current);
} else if (this.tileModal.isOpen()) {
// The inspected tile changed out from under an open panel.
this.tileModal.close();
}
},
// Opens the management panel for the inspected tile and fills it with the
// buildable works, which the HUD summary has no room for.
_openTileManageModal() {
const coords = this._inspectedTile;
if (!coords) return;
const summary = this._tileManageSummary(coords);
this.tileModal.show(coords, summary);
this._renderTileInfo(coords);
},
// The title and one-line summary shown atop the management panel.
_tileManageSummary(coords) {
const k = `${coords.x},${coords.y}`;
const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1;
const tile = this.map.getTile(coords);
const city = (this.snapshot.cities || []).find(
(c) => c.coords[0] === coords.x && c.coords[1] === coords.y
);
const regionCity = (this.snapshot.cities || []).find(
(c) => c.id === this._tileRegion.get(k)
);
const improvements = Array.from(
new Set([
...(this.map.railways.has(k) ? ["Railway"] : this.map.roads.has(k) ? [ROADS.improvementName] : []),
...(city ? (city.improvements || []).filter(
(name) => name !== ROADS.improvementName && name !== "Railway"
) : []),
])
);
return {
title: this._tileDescription(coords, owner, tile, regionCity),
sub: `Tile (${coords.x}, ${coords.y})` +
(improvements.length ? ` · ${fmtImprovements(improvements)}` : " · No improvements yet"),
};
},
// The construction site on this tile. A site gathering materials shows a
@@ -1116,101 +1163,100 @@ export const panelMethods = {
);
},
// A square icon button for one buildable improvement: the type's icon with
// its cost beneath, and the name (plus upkeep and build time) in the tooltip.
// A `costLabel` override replaces the resource cost (used by the free button).
_improvementButton(improvement, { className, costLabel, title, disabled, current, prices } = {}) {
const classes = `aero improvement-btn improvement-icon-btn ${className || ""}`;
const $button = $(`<button type='button' class='${classes}'></button>`)
.attr("title", title)
.prop("disabled", disabled)
.toggleClass("current", current);
$button.append(
$("<img class='improvement-icon'>")
.attr("src", `assets/${improvement.icon}`)
.attr("alt", improvement.name)
);
if (costLabel !== undefined) {
$button.append($("<span class='improvement-cost'></span>").text(costLabel));
} else {
$button.append(this._improvementCost(improvement, prices));
}
return $button;
},
// The cost line under an improvement icon: the steel, electricity and
// high-tech it spends, each with its resource icon, then the full price at
// today's market (build cost plus the material bill) in parentheses.
_improvementCost(improvement, prices) {
const costs = tileImprovementResourceCost(improvement);
const $cost = $("<span class='improvement-cost'></span>");
const items = [];
for (const id of ["steel", "energy", "hightech"]) {
if (!(costs[id] > 0)) continue;
const proto = resourceById(id);
items.push(
$("<span class='cost-res'></span>").append(
$("<img class='cost-icon'>")
.attr("src", `assets/${proto.icon}`)
.attr("alt", proto.name)
.attr("title", proto.name),
$("<span></span>").text(formatResourceAmount(id, costs[id]))
)
);
}
items.push(
$("<span class='cost-money'></span>").text(
`(${currencySymbol()}${compact(quotedBuildCost(improvement.buildCost, costs, prices))})`
)
);
items.forEach((item, index) => {
if (index > 0) $cost.append(" ");
$cost.append(item);
// One card per buildable work, in the shared card idiom. The price and build
// time sit on the button; the output, upkeep and material bill are in the
// hover tooltip.
_improvementCard(improvement, opts) {
const built = !!opts.built;
const locked = !!opts.locked;
const gdp = opts.gdp || 0;
const buildHours = ECONOMY.buildingHours(gdp, improvement.buildCost) / (1 + (opts.speed || 0));
const materials = tileImprovementResourceCost(improvement);
const card = buildCard({
icon: improvement.icon,
name: improvement.name,
status: locked ? `Requires ${opts.lockedName}` : (built ? "Built" : "Not built"),
statusBuilt: built,
built,
locked,
action: {
label: built ? "Rebuild" : "Build",
money: opts.disabled ? null : quotedBuildCost(improvement.buildCost, materials, opts.prices),
hours: hours(buildHours),
disabled: !!opts.disabled,
},
remove: {
label: "✕",
title: `Remove ${improvement.name}`,
enabled: built,
},
tip: () => this._improvementTip(improvement),
onAction: opts.onAction,
onRemove: opts.onRemove,
});
return $cost;
},
// The same cost as plain text, for a tooltip: the resource amounts then the
// market price.
_improvementCostText(improvement, prices) {
const costs = tileImprovementResourceCost(improvement);
const parts = [];
for (const id of ["steel", "energy", "hightech"]) {
if (costs[id] > 0) {
parts.push(`${formatResourceAmount(id, costs[id])} ${resourceById(id).name.toLowerCase()}`);
}
card.$action.addClass("improvement-btn").addClass(opts.className || "");
if (card.$remove) card.$remove.addClass("improvement-btn remove");
// A local test game may raise transport for free and at once.
if (opts.free && !built) {
const $free = $("<button class='aero bt-free free-button'></button>")
.text(`Free · ${improvement.name}`)
.on("click", opts.onFree);
card.$card.append($free);
}
parts.push(`(${currencySymbol()}${compact(quotedBuildCost(improvement.buildCost, costs, prices))})`);
return parts.join(", ");
return card.$card;
},
// The daily resource upkeep of a tile work, for its tooltip. Transport wears
// per tile rather than as a share of its cost.
_improvementUpkeepText(improvement) {
// The hover detail for an improvement card: what it yields, what it wears out
// each day and the materials its construction bills in.
_improvementTip(improvement) {
const produce = [];
if (improvement.resource) {
produce.push({ id: improvement.resource, amount: resourceBuildingOutputAt(improvement, 1) });
} else if (transportImprovement(improvement.id)) {
const faster = Math.round((1 / improvement.movementCostMultiplier - 1) * 100);
produce.push({ text: `+${faster}% movement on this tile` });
} else if (improvement.description) {
produce.push({ text: improvement.description });
}
return tooltipSections({
produce,
produceSuffix: improvement.resource ? "/day" : "",
consume: this._improvementUpkeepItems(improvement),
consumeSuffix: "/day",
cost: this._resourceItems(tileImprovementResourceCost(improvement)),
});
},
// The daily upkeep of a work as tooltip items. Transport wears per tile rather
// than as a share of its cost.
_improvementUpkeepItems(improvement) {
if (transportImprovement(improvement.id)) {
const steel = RESOURCE_RULES.transportUpkeepSteel[improvement.id] || 0;
return `${formatResourceAmount("steel", steel)} steel`;
return steel > 0 ? [{ id: "steel", amount: steel }] : [];
}
const upkeep = protoUpkeep(improvement, improvement.buildCost || 0);
const parts = [];
for (const id of ["steel", "energy", "hightech"]) {
if (upkeep[id] > 0) {
parts.push(`${formatResourceAmount(id, upkeep[id])} ${resourceById(id).name.toLowerCase()}`);
}
}
return parts.length > 0 ? parts.join(", ") : "no upkeep";
return this._resourceItems(protoUpkeep(improvement, improvement.buildCost || 0));
},
// One square icon button per improvement, grouped under a category heading:
// military first, then transportation, then economic (resource producers),
// plus a control to remove the one already standing on the tile. Shown only
// on a controlled land tile. Money never disables a button: a nation funds
// any order by borrowing from its central bank, so what it cannot yet pay for
// simply becomes debt. While a work is under way every button is hidden, and
// the only control left is the cancel drawn with the works progress.
// The positive entries of a resource bill, as tooltip items.
_resourceItems(resources) {
const items = [];
for (const id of ["steel", "energy", "hightech"]) {
if (resources && resources[id] > 0) items.push({ id, amount: resources[id] });
}
return items;
},
// One card per improvement, grouped under a category heading: military first,
// then transportation, then economic (resource producers). Drawn into the
// management panel, which has the room the HUD summary does not. Money never
// disables a card: a nation funds any order by borrowing from its central
// bank, so what it cannot yet pay for simply becomes debt. While a work is
// under way every card is hidden, and the only control left is the cancel
// drawn with the works progress.
_renderImprovementActions(coords, buildable, current) {
const $row = $("#tile-improvement-actions");
const $row = $("#tile-manage-improvements");
if (!$row.length) return;
hideCardTooltip();
$row.empty();
if (!buildable) return;
const testing = !!this.testing;
@@ -1218,141 +1264,79 @@ export const panelMethods = {
const speed = (this.snapshot.viewerStats || {}).constructionSpeed || 0;
const prices = marketPricesFrom(this.snapshot);
const standing = this.map.tileImprovements.get(`${coords.x},${coords.y}`) || null;
const standingProto = standing ? tileImprovementById(standing.id) : null;
const isResource = !!(standingProto && standingProto.resource);
// A work already under way on this tile blocks another order; the player may
// still cancel it from here.
const pending = ((this.snapshot.constructionSites || [])[this.localCiv] || []).some(
(site) => site.coords[0] === coords.x && site.coords[1] === coords.y
);
if (pending) return;
const shared = { coords, prices, gdp, speed, standing };
// Military tile works. They cost resources and take production time, so the
// tooltip names the expected hours alongside the upkeep. A tile with a work
// under way offers none of them, only the transport controls and a cancel.
// Military tile works. A tile that already carries a work offers none of
// them, only the removal control on the one that stands.
const military = [];
if (!pending) {
for (const improvement of TILE_IMPROVEMENTS) {
if (improvement.coastal && !this._tileIsCoastal(coords)) continue;
const built = !!(standing && standing.id === improvement.id);
const buildHours = ECONOMY.buildingHours(gdp, improvement.buildCost) / (1 + speed);
const title = standing && !built
? `${improvement.name} — another improvement already stands here`
: `${improvement.name} — ${this._improvementCostText(improvement, prices)} · ` +
`${this._improvementUpkeepText(improvement)}/day · ${hours(buildHours)} to build`;
const $button = this._improvementButton(improvement, {
className: "tile-btn",
title,
disabled: !!standing,
current: built,
prices,
});
$button.on("click", () => this._buildTileImprovement(coords, improvement.id));
military.push($button);
}
for (const improvement of TILE_IMPROVEMENTS) {
if (improvement.coastal && !this._tileIsCoastal(coords)) continue;
military.push(this._improvementCard(improvement, {
...shared,
className: "tile-btn",
built: !!(standing && standing.id === improvement.id),
disabled: !!standing,
onAction: () => this._buildTileImprovement(coords, improvement.id),
onRemove: () => this._removeTileImprovement(coords),
}));
}
// Every button disappears while a work is under way: a tile under
// construction offers nothing but the cancel control drawn with its
// progress above, so an accidental click cannot start a second order.
// Cancelling restores the full set.
// Transportation: roads and railways, bought outright and laid at once.
// Transportation: roads and railways, bought outright and laid at once. A
// second one may replace the first, so only the one already down is greyed.
const transport = [];
if (!pending) {
for (const improvement of TRANSPORT_IMPROVEMENTS) {
const isCurrent = current === improvement.id;
const title = isCurrent
? `${improvement.name} — already built on this tile`
: `${improvement.name} — ${this._improvementCostText(improvement, prices)} · ` +
`${this._improvementUpkeepText(improvement)}/day upkeep`;
const $button = this._improvementButton(improvement, {
className: "transport-btn",
title,
disabled: isCurrent,
current: isCurrent,
prices,
});
$button.on("click", () => this._buildImprovement(coords, improvement.id, false));
transport.push($button);
// A local test game may raise it for free and at once.
if (testing && !isCurrent) {
const $free = this._improvementButton(improvement, {
className: "free-button",
costLabel: "free",
title: `${improvement.name} — build free and instantly (testing)`,
disabled: false,
current: false,
});
$free.on("click", () => this._buildImprovement(coords, improvement.id, true));
transport.push($free);
}
}
if (current) {
const proto = transportImprovement(current);
const name = proto ? proto.name : current;
transport.push(
$("<button type='button' class='aero improvement-btn remove'></button>")
.text(`Remove ${name}`)
.on("click", () => this._removeImprovement(coords))
);
}
for (const improvement of TRANSPORT_IMPROVEMENTS) {
const isCurrent = current === improvement.id;
transport.push(this._improvementCard(improvement, {
...shared,
className: "transport-btn",
built: isCurrent,
disabled: isCurrent,
free: testing,
onAction: () => this._buildImprovement(coords, improvement.id, false),
onFree: () => this._buildImprovement(coords, improvement.id, true),
onRemove: () => this._removeImprovement(coords),
}));
}
// Economic: resource producers. They live on their own land, take
// production time and feed the delivery graph; only one may stand on a
// tile.
// tile. Some are locked behind a technology (the fusion plant).
const economic = [];
if (!isResource && !pending) {
for (const producer of RESOURCE_BUILDINGS) {
if (producer.coastal && !this._tileIsCoastal(coords)) continue;
const built = !!(standing && standing.id === producer.id);
const buildHours = ECONOMY.buildingHours(gdp, producer.buildCost) / (1 + speed);
// Some producers are locked behind a technology (the fusion plant).
const techIndex = producer.requiresTechnology
? TECHNOLOGIES.findIndex((tech) => tech.id === producer.requiresTechnology)
: -1;
const locked = techIndex >= 0 && !this._researched.has(techIndex);
const out = producer.resource === "energy"
? formatEnergy(producer.outputPerDay)
: `${compact(producer.outputPerDay)} ${producer.resource}/day`;
const title = locked
? `${producer.name} — requires ${TECHNOLOGIES[techIndex].name}`
: standing && !built
? `${producer.name} — another building already stands here`
: `${producer.name} — ${out} · ${this._improvementCostText(producer, prices)} · ` +
`${hours(buildHours)} to build`;
const $button = this._improvementButton(producer, {
className: "tile-btn resource-btn",
title,
disabled: !!standing || locked,
current: built,
prices,
});
$button.on("click", () => this._buildTileImprovement(coords, producer.id));
economic.push($button);
}
}
if (standing && !pending) {
const name = standingProto ? standingProto.name : standing.id;
const $remove = $("<button type='button' class='aero improvement-btn remove'></button>")
.text(`Remove ${name}`)
.on("click", () => this._removeTileImprovement(coords));
// A standing work is removed from its own category.
if (isResource) economic.push($remove);
else military.push($remove);
for (const producer of RESOURCE_BUILDINGS) {
if (producer.coastal && !this._tileIsCoastal(coords)) continue;
const techIndex = producer.requiresTechnology
? TECHNOLOGIES.findIndex((tech) => tech.id === producer.requiresTechnology)
: -1;
const locked = techIndex >= 0 && !this._researched.has(techIndex);
economic.push(this._improvementCard(producer, {
...shared,
className: "resource-btn",
built: !!(standing && standing.id === producer.id),
disabled: !!standing || locked,
locked,
lockedName: techIndex >= 0 ? TECHNOLOGIES[techIndex].name : "",
onAction: () => this._buildTileImprovement(coords, producer.id),
onRemove: () => this._removeTileImprovement(coords),
}));
}
for (const group of [
{ label: "Military", buttons: military },
{ label: "Transportation", buttons: transport },
{ label: "Economic", buttons: economic },
{ label: "Military", cards: military },
{ label: "Transportation", cards: transport },
{ label: "Economic", cards: economic },
]) {
if (group.buttons.length === 0) continue;
if (group.cards.length === 0) continue;
const $group = $("<div class='improvement-group'></div>");
$group.append($("<div class='improvement-category'></div>").text(group.label));
const $buttons = $("<div class='improvement-row'></div>");
for (const $button of group.buttons) $buttons.append($button);
$group.append($buttons);
const $cards = $("<div class='improvement-row'></div>");
for (const card of group.cards) $cards.append(card);
$group.append($cards);
$row.append($group);
}
},
@@ -1728,6 +1712,7 @@ export const panelMethods = {
const unitVisible = !$("#unit-info").hasClass("hidden");
$("#tile-panel").toggleClass("hidden", !tileVisible);
$("#unit-panel").toggleClass("hidden", !unitVisible);
if (!tileVisible) hideCardTooltip();
},
_unit(id) {
+1
View File
@@ -8,6 +8,7 @@
export { CityModal } from "./modals/city.js";
export { NationModal } from "./modals/nation.js";
export { TileModal } from "./modals/tile.js";
export { ConfirmModal } from "./modals/confirm.js";
export { NewsModal } from "./modals/news.js";
export { MapModesModal } from "./modals/map_modes.js";
+152 -61
View File
@@ -4,16 +4,22 @@
import { groupDigits, hours } from "../../../shared/text_format.js";
import { ECONOMY, BUILDINGS, resourceById, RESOURCE_IDS } from "../../../shared/data.js";
import { constructionResourceCost, formatResourceAmount } from "../../../shared/resources.js";
import { constructionResourceCost, formatResourceAmount, protoUpkeep } from "../../../shared/resources.js";
import {
buildingBuildCost,
buildingUpkeep,
} from "../../../shared/rules.js";
import { buildingEffectText, signedMoney, quotedBuildCost } from "./format.js";
import { renderBudget } from "./budget.js";
import { bindTabs } from "./tabs.js";
import { ethnicityChart } from "../ui/ethnicity.js";
import { currencySymbol } from "../currency.js";
import {
buildCard,
setCardMeta,
tooltipSections,
CARD_RESOURCES,
hideCardTooltip,
} from "../ui/card.js";
import { currencySymbol, formatMoney } from "../currency.js";
export class CityModal {
constructor() {
@@ -57,8 +63,15 @@ export class CityModal {
this._trainableSignature = null;
this.buildingNames = new Map(BUILDINGS.map((b, index) => [index, b.name]));
this.buildingRows = [];
// The grid the building cards live in, and the built-first order last drawn
// so a snapshot that changes nothing does not reshuffle the cards.
this.$buildingGrid = null;
this._buildingOrderSignature = null;
// Building types whose upkeep breakdown is expanded on the budget tab.
this.expandedBudgetGroups = new Set();
// Resources whose per-building demand breakdown is expanded on the
// Resources tab.
this.expandedResources = new Set();
this._lastBreakdown = {};
this.onTrain = () => {};
@@ -79,6 +92,7 @@ export class CityModal {
// Opens the panel for `city` and lays out every tab in one pass. `view`
// carries { budget, gdp, trainable, buildings, levels, queue }.
show(city, view = {}) {
hideCardTooltip();
this.cityId = city.id;
this.gdp = view.gdp || 0;
this.coastal = !!view.coastal;
@@ -90,9 +104,15 @@ export class CityModal {
this.renderQueue(view.queue || []);
this.$trainStatus.addClass("hidden");
this.$buildingsList.empty();
this.buildingRows = (view.buildings || []).map((proto, index) =>
this._makeBuildingRow(proto, index)
this.$buildingGrid = $("<div class='building-grid'></div>");
this.$buildingsList.append(
$("<div class='building-scroller'></div>").append(this.$buildingGrid)
);
this._buildingOrderSignature = null;
this.buildingRows = (view.buildings || []).map((proto, index) =>
this._makeBuildingCard(proto, index)
);
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.renderBudget(null);
@@ -191,24 +211,26 @@ export class CityModal {
}
// The region's material reserves: how much of each resource it holds, what
// that reserve is worth at today's market, and how much its private sector
// draws down each day. Energy is a flow, so it has no reserve of its own.
// that reserve is worth at today's market, and how much its people and its
// buildings draw each day. Every resource expands into the buildings that
// demanded it and where in the region they stand. Energy is a flow, so it has
// no reserve of its own.
renderResources(resources) {
if (!this.$resourcesList || !this.$resourcesList.length) return;
this.$resourcesList.empty();
const info = resources || {};
const stock = info.stock || {};
const consumption = info.consumption || {};
const demand = info.demand || {};
const value = info.value || 0;
if (this.$resourcesSub && this.$resourcesSub.length) {
this.$resourcesSub.text(
`Reserves worth ${currencySymbol()}${groupDigits(Math.round(value))}`
);
this.$resourcesSub.text(`Reserves worth ${formatMoney(value)}`);
}
this.$resourcesList.append(
$("<div class='desc'></div>").text(
"The region's private sector holds these reserves and draws them down as " +
"its people and buildings consume them. Energy is a flow -- it has no reserve."
"its people and buildings consume them. Expand a resource to see which " +
"buildings demand it. Energy is a flow -- it has no reserve."
)
);
const $table = $("<table class='resources-table economy-table'></table>");
@@ -226,7 +248,22 @@ export class CityModal {
const storable = proto ? proto.storable : false;
const held = stock[id] || 0;
const price = this.prices[id] || 0;
const row = $("<tr></tr>").append($("<td></td>").text(name));
const consumers = (demand[id] || []).slice().sort((a, b) => b.amount - a.amount);
const open = this.expandedResources.has(id);
const row = $("<tr></tr>");
if (consumers.length > 0) {
const $name = $("<td class='clickable'></td>");
$name.append($("<span class='resource-caret'></span>").text(open ? "▾" : "▸"));
$name.append(document.createTextNode(` ${name}`));
$name.on("click", () => {
if (this.expandedResources.has(id)) this.expandedResources.delete(id);
else this.expandedResources.add(id);
this.renderResources(resources);
});
row.append($name);
} else {
row.append($("<td></td>").text(name));
}
row.append(
$("<td class='numeric'></td>").text(storable ? formatResourceAmount(id, held) : "—")
);
@@ -234,11 +271,25 @@ export class CityModal {
$("<td class='numeric'></td>").text(formatResourceAmount(id, consumption[id] || 0))
);
row.append(
$("<td class='numeric'></td>").text(
storable ? `${currencySymbol()}${groupDigits(Math.round(held * price))}` : "—"
)
$("<td class='numeric'></td>").text(storable ? formatMoney(held * price) : "—")
);
$body.append(row);
if (open) {
const $detail = $("<tr class='resource-demand'></tr>");
const $cell = $("<td colspan='4'></td>");
for (const consumer of consumers) {
const where = consumer.coords
? ` (${consumer.coords[0]}, ${consumer.coords[1]})`
: "";
$cell.append(
$("<div class='demand-line'></div>").text(
`${consumer.name}${where} · ${formatResourceAmount(id, consumer.amount)}/day`
)
);
}
$detail.append($cell);
$body.append($detail);
}
}
$table.append($head, $body);
this.$resourcesList.append($table);
@@ -453,33 +504,56 @@ export class CityModal {
// ------------------------------------------------------------- buildings --
_makeBuildingRow(proto, index) {
const $card = $("<div class='row-card'></div>");
const $icon = proto.icon
? $("<img class='building-icon' />").attr({ src: `assets/${proto.icon}`, alt: proto.name })
: null;
const $info = $("<div class='info'></div>");
const $name = $("<div class='name'></div>");
const $desc = $("<div class='desc'></div>").text(proto.description);
const $effects = $("<div class='effects'></div>");
const $cost = $("<div class='cost'></div>");
$info.append($name, $desc, $effects, $cost);
const $actions = $("<div class='actions'></div>");
const $upgrade = $("<button class='aero'></button>");
const $downgrade = $("<button class='aero'></button>");
$upgrade.on("click", () => this.onBuild(this.cityId, index));
$downgrade.on("click", () => this.onDemolish(this.cityId, index));
$actions.append($upgrade, $downgrade);
// One card per building, in the shared card idiom. The card keeps only the
// icon, name and level; the produce/consume/organisation figures live in the
// hover tooltip and the price on the action button. `row` is mutated by
// refreshBuildings, and the tooltip reads it when opened so it always quotes
// the level currently on show.
_makeBuildingCard(proto, index) {
const row = { proto, index, level: 0, pending: 0, built: false, requirement: null };
row.card = buildCard({
icon: proto.icon,
name: proto.name,
status: "Level 0",
action: { label: "▲" },
remove: { label: "▾", title: "Downgrade", enabled: false },
tip: () => this._buildingTip(row),
onAction: () => this.onBuild(this.cityId, index),
onRemove: () => this.onDemolish(this.cityId, index),
});
// In a local test game, a second button raises the next level for free.
const $free = this.testing ? $("<button class='aero free-button'></button>") : null;
if ($free) {
$free.on("click", () => this.onFreeBuild(this.cityId, index));
$actions.append($free);
if (this.testing) {
row.$free = $("<button class='aero bt-free free-button'></button>")
.on("click", () => this.onFreeBuild(this.cityId, index));
row.card.$card.append(row.$free);
}
if ($icon) $card.append($icon);
$card.append($info, $actions);
this.$buildingsList.append($card);
return { proto, index, $name, $effects, $cost, $upgrade, $downgrade, $free };
return row;
}
// The hover detail for a building card: what it makes at the level it is
// buying, what it wears out each day there, and the materials its next order
// spends.
_buildingTip(row) {
const shownLevel = Math.max(row.level + row.pending, 1);
const effect = buildingEffectText(row.proto, shownLevel);
const upkeep = protoUpkeep(row.proto, buildingBuildCost(row.proto, shownLevel - 1));
const cost = buildingBuildCost(row.proto, row.level + row.pending);
const materials = constructionResourceCost(row.proto, row.level + row.pending, cost);
return tooltipSections({
produce: effect && effect !== "No effect" ? [{ text: effect }] : [],
consume: this._resourceItems(upkeep),
consumeSuffix: "/day",
cost: this._resourceItems(materials),
});
}
// The positive entries of a resource bill, as tooltip items.
_resourceItems(resources) {
const items = [];
for (const id of CARD_RESOURCES) {
if (resources && resources[id] > 0) items.push({ id, amount: resources[id] });
}
return items;
}
// Why a building cannot be raised here, or null when it can. Ports need the
@@ -498,40 +572,56 @@ export class CityModal {
refreshBuildings(budget, levels, gdp = 0, queued = {}) {
this._levels = levels || {};
this.$buildingsBudget.text(`Budget: ${currencySymbol()}${groupDigits(Math.round(budget))}`);
this.$buildingsBudget.text(`Budget: ${formatMoney(budget)}`);
for (const row of this.buildingRows) {
const level = levels[row.index] || 0;
const pending = queued[row.index] || 0;
// The next order is priced off the level the queued ones will reach, so
// an upgrade can cost more than the completed level alone suggests.
const nextLevel = level + pending;
const built = level > 0;
const requirement = this._buildRequirement(row.proto);
row.level = level;
row.pending = pending;
row.built = built;
row.requirement = requirement;
const levelText = pending > 0 ? `Level ${level} (+${pending} queued)` : `Level ${level}`;
row.$name.text(`${row.proto.name} — ${levelText}`);
row.$effects.text(buildingEffectText(row.proto, level > 0 ? level : Math.max(nextLevel, 1)));
const parts = [];
if (level > 0) parts.push(`Upkeep ${currencySymbol()}${groupDigits(Math.round(buildingUpkeep(row.proto, level)))}/h`);
row.card.$card.toggleClass("built", built).toggleClass("locked", !!requirement);
row.card.$status
.toggleClass("built", built && !requirement)
.text(requirement || levelText);
row.card.$action.prop("disabled", !!requirement);
const cost = buildingBuildCost(row.proto, nextLevel);
const duration = ECONOMY.buildingHours(gdp, cost) * this._constructionTimeFactor();
parts.push(`Build cost ${currencySymbol()}${groupDigits(this._quotedCost(cost, row.proto, nextLevel))} · ${hours(duration)}`);
const requirement = this._buildRequirement(row.proto);
if (requirement) parts.push(requirement);
row.$cost.text(parts.join(" · "));
row.$upgrade
.text(`Upgrade ${currencySymbol()}${groupDigits(this._quotedCost(cost, row.proto, nextLevel))}`)
.prop("disabled", !!requirement);
setCardMeta(row.card.$meta, {
money: requirement ? null : this._quotedCost(cost, row.proto, nextLevel),
hours: hours(duration),
});
if (row.card.$remove) {
// Downgrading returns nothing, so the control is only enabled once the
// building actually stands.
row.card.$remove.prop("disabled", !built);
}
if (row.$free) {
// Free does not ignore placement rules (a port still needs the sea).
row.$free
.text(`Free & instant — ${row.proto.name}`)
.prop("disabled", !!requirement);
}
if (level > 0) {
// Downgrading returns nothing, so the button names no price.
row.$downgrade.text("Downgrade").prop("disabled", false);
} else {
row.$downgrade.text("Not built").prop("disabled", true);
row.$free.text(`Free · ${row.proto.name}`).prop("disabled", !!requirement);
}
}
this._sortBuildingCards();
}
// Buildings already standing lead the grid, so what a city owns is always in
// view. The order is only rewritten when a building's standing changes, so the
// horizontal scroll is not disturbed on every snapshot.
_sortBuildingCards() {
const signature = this.buildingRows.map((row) => (row.built ? "1" : "0")).join("");
if (signature === this._buildingOrderSignature) return;
this._buildingOrderSignature = signature;
const ordered = this.buildingRows
.slice()
.sort((a, b) => (b.built ? 1 : 0) - (a.built ? 1 : 0));
for (const row of ordered) this.$buildingGrid.append(row.card.$card);
}
// ---------------------------------------------------------------- budget --
@@ -549,6 +639,7 @@ export class CityModal {
}
close() {
hideCardTooltip();
this.$modal.addClass("hidden");
this.cityId = 0;
}
+3 -3
View File
@@ -3,7 +3,7 @@
import { groupDigits } from "../../../shared/text_format.js";
import { policyById, resourceById } from "../../../shared/data.js";
import { marketPriceIndex, resourceMarketValue } from "../../../shared/resources.js";
import { currencySymbol } from "../currency.js";
import { formatMoneySigned } from "../currency.js";
// The world market's current prices, from the snapshot's market listing.
export function marketPricesFrom(snapshot) {
@@ -98,12 +98,12 @@ export function buildingEffectText(proto, level) {
export const SOURCE_LABELS = { building: "building", government: "government", technology: "research" };
export function signedMoney(value) {
return `${value < 0 ? "−" : "+"}${currencySymbol()}${groupDigits(Math.round(Math.abs(value)))}/h`;
return `${formatMoneySigned(value)}/h`;
}
// A signed money amount with no period suffix, for the monthly budget report.
export function money(value) {
return `${value < 0 ? "−" : "+"}${currencySymbol()}${groupDigits(Math.round(Math.abs(value)))}`;
return formatMoneySigned(value);
}
export function percent(value) {
+13 -2
View File
@@ -26,6 +26,10 @@ import { currencySymbol, currencyFor, rateToNational } from "../currency.js";
import { HOURS_PER_DAY } from "../../../shared/game_state/constants.js";
import { MONTH_NAMES } from "../../../shared/game_clock.js";
// The central bank charts a rolling window of the last two years, so its
// monthly commodity index stays 24 points instead of growing with the game.
const PRICE_INDEX_WINDOW_MONTHS = 24;
export class NationModal {
constructor() {
this.$modal = $("#modal-nation");
@@ -746,7 +750,8 @@ export class NationModal {
// The commodity price index chart and the per-commodity inflation table. Each
// series is a commodity's world price rebased to 100 in January 2000, with the
// live point appended so the current month reads to today.
// live point appended so the current month reads to today. Only the last two
// years are plotted, so the window rolls instead of the chart growing.
_renderCommodityChart(money) {
const $container = this.$economyCentralBank.find(".central-bank-prices");
if (!$container.length) return;
@@ -754,6 +759,8 @@ export class NationModal {
const inflation = new Map((money.resourceInflation || []).map((entry) => [entry.id, entry]));
const series = [];
const rows = [];
// The month the plotted window starts in, as an offset from January 2000.
let windowStart = 0;
for (const [id, prices] of history) {
const resource = resourceById(id);
if (!resource || !prices || prices.length === 0) continue;
@@ -761,9 +768,11 @@ export class NationModal {
const values = prices.map((price) => (price / base) * 100);
const info = inflation.get(id) || { index: 100, monthly: 0, yearly: 0 };
if (Math.abs(info.index - values[values.length - 1]) > 1e-6) values.push(info.index);
windowStart = Math.max(windowStart, values.length - PRICE_INDEX_WINDOW_MONTHS);
series.push({ label: resource.name, colour: resource.colour, values });
rows.push({ resource, info });
}
for (const entry of series) entry.values = entry.values.slice(windowStart);
$container.empty();
$container.append($("<div class='subhead'></div>").text("Commodity prices"));
$container.append(
@@ -773,7 +782,9 @@ export class NationModal {
);
if (series.length === 0) return;
const options = {
xLabel: (i) => `${MONTH_NAMES[i % 12].slice(0, 3)} ${2000 + Math.floor(i / 12)}`,
xLabel: (i) =>
`${MONTH_NAMES[(windowStart + i) % 12].slice(0, 3)} ` +
`${2000 + Math.floor((windowStart + i) / 12)}`,
};
// The panel scrolls, but the chart must not swallow it: size it to the
// panel's width and cap it at half the visible height, so the inflation
+53
View File
@@ -0,0 +1,53 @@
// The tile management panel: a full-size modal, modelled on the city and nation
// panels, holding the works a controlled tile can carry. The HUD's tile summary
// only points at it, so the building cards themselves live here where they have
// room. The cards are built by the game screen and drawn into #tile-manage-improvements.
import { bindTabs } from "./tabs.js";
import { hideCardTooltip } from "../ui/card.js";
export class TileModal {
constructor() {
this.$modal = $("#modal-tile");
this.$title = $("#tile-manage-title");
this.$sub = $("#tile-manage-sub");
this.$list = $("#tile-manage-improvements");
// The tile the panel was opened for, so a snapshot only refreshes the tile
// whose cards are on show.
this.coords = null;
this.$modal.find(".modal-close").on("click", () => this.close());
bindTabs(this.$modal, "tile-tab-");
}
isShowing(coords) {
if (this.$modal.hasClass("hidden") || !this.coords || !coords) return false;
return this.coords.x === coords.x && this.coords.y === coords.y;
}
isOpen() {
return !this.$modal.hasClass("hidden");
}
// Opens the panel for a tile. The cards are rendered afterwards by the game
// screen, which owns the orders they send.
show(coords, { title = "Tile", sub = "" } = {}) {
this.coords = { x: coords.x, y: coords.y };
this.$title.text(title);
this.$sub.text(sub);
this.$list.empty();
this.$modal.removeClass("hidden");
}
// Refreshes the title and summary without disturbing the cards below.
setSummary({ title, sub } = {}) {
if (title != null) this.$title.text(title);
if (sub != null) this.$sub.text(sub);
}
close() {
hideCardTooltip();
this.$modal.addClass("hidden");
this.coords = null;
}
}
+149
View File
@@ -0,0 +1,149 @@
// The building / improvement card. The city buildings tab and the HUD's tile
// improvement panel draw one card per type: an icon, a name and a status up
// top, then an action button carrying only the estimated price and the build
// time. What the work produces, consumes and costs lives in a hover tooltip, so
// the card itself stays bare. Shared by both panels so they read the same.
import { resourceById } from "../../../shared/data.js";
import { formatResourceAmount } from "../../../shared/resources.js";
import { formatMoney } from "../currency.js";
// The materials a construction bills in, in reading order.
export const CARD_RESOURCES = ["steel", "energy", "hightech"];
// A resource amount with its commodity icon. `sign` marks the direction of the
// flow: "+" for output, "−" for a cost.
function resourceLine(id, amount, sign) {
const proto = resourceById(id);
const $span = $("<span class='res'></span>");
if (proto) {
$span.append($("<img>").attr("src", `assets/${proto.icon}`).attr("alt", proto.name));
}
$span.append(document.createTextNode(`${sign}${formatResourceAmount(id, amount)}`));
return $span;
}
// 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.
export function tooltipSections({
produce = [],
produceSuffix = "",
consume = [],
consumeSuffix = "",
cost = [],
} = {}) {
return [
{ 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 },
];
}
let $tooltip = null;
// Removes any open card tooltip. Called on mouseleave and whenever the panel
// rebuilds, so a stale tooltip never lingers over fresh content.
export function hideCardTooltip() {
if ($tooltip) {
$tooltip.remove();
$tooltip = null;
}
}
// Opens the tooltip beside `$anchor`, flipping left or up when it would run off
// the viewport. It is fixed to the body, so a scrolling card list never clips
// it.
export function showCardTooltip($anchor, sections) {
hideCardTooltip();
if (!$anchor || !$anchor.length) return;
$tooltip = $("<div class='card-tooltip'></div>");
for (const section of sections) {
const $section = $("<div class='tip-section'></div>");
$section.append($("<div class='tip-head'></div>").text(section.label));
if (!section.items || section.items.length === 0) {
$section.append($("<div class='tip-line tip-none'></div>").text("nothing"));
} else {
for (const item of section.items) {
const $line = $("<div></div>").addClass(`tip-line ${section.cls}`);
if (item.id) $line.append(resourceLine(item.id, item.amount, section.sign));
else $line.text(item.text);
if (section.suffix) $line.append(document.createTextNode(section.suffix));
$section.append($line);
}
}
$tooltip.append($section);
}
$("body").append($tooltip);
const rect = $anchor[0].getBoundingClientRect();
$tooltip.css({ left: rect.right + 10, top: rect.top });
const tip = $tooltip[0].getBoundingClientRect();
if (tip.right > window.innerWidth - 8 && tip.width) {
$tooltip.css("left", rect.left - tip.width - 10);
}
if (tip.bottom > window.innerHeight - 8) {
$tooltip.css("top", Math.max(8, window.innerHeight - tip.height - 8));
}
}
// Puts the action button's money and time in its meta line. The money is an
// estimate, so it is italic and prefixed with a tilde; nothing is shown when
// the work has no price (locked or already standing).
export function setCardMeta($meta, { money = null, hours = "" } = {}) {
$meta.empty();
if (money == null) return;
$meta.append($("<span class='bt-price'></span>").text(`~${formatMoney(money)}`));
if (hours) $meta.append(document.createTextNode(` · ${hours}`));
}
// Builds a card. `spec` carries:
// icon, name, status, statusBuilt, built, locked
// action { label, money, hours, disabled }
// remove { label, title, enabled } (omit for no remove control)
// tip () => tooltip sections
// onAction, onRemove
// It returns the pieces the caller keeps to refresh the card in place.
export function buildCard(spec) {
const $card = $("<div class='building-tile'></div>");
if (spec.built) $card.addClass("built");
if (spec.locked) $card.addClass("locked");
const $top = $("<div class='bt-top'></div>");
if (spec.icon) {
$top.append(
$("<img class='building-icon'>").attr("src", `assets/${spec.icon}`).attr("alt", spec.name)
);
}
const $id = $("<div class='bt-id'></div>");
$id.append($("<div class='bt-name'></div>").text(spec.name));
const $status = $("<div class='bt-level'></div>")
.text(spec.status || "")
.toggleClass("built", !!spec.statusBuilt);
$id.append($status);
$top.append($id);
$card.append($top);
const action = spec.action || {};
const $action = $("<button class='aero bt-upgrade'></button>").prop("disabled", !!action.disabled);
$action.append($("<span class='bt-up-label'></span>").text(action.label || ""));
const $meta = $("<span class='bt-meta'></span>");
setCardMeta($meta, action);
$action.append($meta);
$card.append($action);
if (spec.onAction) $action.on("click", spec.onAction);
if (spec.tip) {
$action.on("mouseenter", () => showCardTooltip($action, spec.tip()));
$action.on("mouseleave", hideCardTooltip);
}
let $remove = null;
if (spec.remove) {
$remove = $("<button class='aero bt-downgrade'></button>")
.text(spec.remove.label || "✕")
.attr("title", spec.remove.title || "")
.prop("disabled", !spec.remove.enabled);
if (spec.onRemove) $remove.on("click", spec.onRemove);
$card.append($remove);
}
return { $card, $status, $action, $label: $action.find(".bt-up-label"), $meta, $remove };
}
-5
View File
@@ -97,11 +97,6 @@ export const MAP_CONFIG = {
// leaves exactly this many landmasses.
minContinents: 3,
connectOceans: true,
// How many nations open with a chip foundry, the only high-tech works the
// opening economy ever gets. They are spread across distinct nations, drawn
// from the world seed, and never the same nation twice. A nation with no land
// to host one simply goes without.
startingChipFabs: 2,
citiesPerCiv: 10,
// Minimum distance between any two cities during generation (never below 5).
citySpacing: 5,
+10 -7
View File
@@ -162,7 +162,7 @@ export const RESOURCE_RULES = {
gold_mine: 10 * KWH_PER_MWH, // 10 MWh per carat
diamond_mine: 5 * KWH_PER_MWH, // 5 MWh per carat
},
energyPerHighTechUnit: 100 * KWH_PER_MWH, // 100 MWh per unit
energyPerHighTechUnit: 10 * KWH_PER_MWH, // 10 MWh per unit
// Science buildings spend one high-tech unit for every research point.
highTechPerResearchPoint: 1,
@@ -176,12 +176,15 @@ export const RESOURCE_RULES = {
ship: 50, // 50 kWh per tonne per tile
},
// Steel every construction spends per euro of its money cost. At 1/10000 a
// 100 M building needs 10,000 t of steel.
steelPerBudgetEuro: 1 / 10_000,
// Steel every construction spends per euro of its money cost. At 1/1000 a
// 100 M building needs 100,000 t of steel. The steel bill is deliberately an
// order of magnitude larger than the cash price, so nothing is bought with
// money alone: the state pays with materials it has, and the currency is only
// the settlement it falls back on.
steelPerBudgetEuro: 1 / 1_000,
// The electricity a tile work draws as it is built, per euro of its money
// cost. It is roughly the power embodied in the steel it spends: steel costs
// 20 MWh a tonne and 1 t per 10,000 euro, so building is powered as well as
// 20 MWh a tonne and 1 t per 1,000 euro, so building is powered as well as
// supplied. Tile improvements pay this up front.
constructionEnergyPerBudgetEuro: 2, // kWh per euro
// High-tech a science or advanced building needs to be raised, per level.
@@ -382,9 +385,9 @@ export const RESOURCE_BUILDINGS = [
id: "steel_mill",
name: "Steel mill",
description:
"Smelts iron into 2000 t of steel a day, spending 20 MWh of energy per tonne.",
"Smelts iron into 20,000 t of steel a day, spending 20 MWh of energy per tonne.",
resource: "steel",
outputPerDay: 2_000,
outputPerDay: 20_000,
energyPerDay: 2_000 * RESOURCE_RULES.energyPerSteelTonne,
buildCost: 800_000_000,
maintenance: 10_000,
+76
View File
@@ -0,0 +1,76 @@
// The economic graph toolkit: a framework-free traversal over an abstract
// transport graph, and the transport-energy costs that weight its edges. It
// knows nothing about tiles, roads, ports or nations -- the caller supplies the
// adjacency and the per-step cost -- so the same engine serves the trade
// network, the delivery graph and any other economic spread over the map.
//
// Keeping this apart from `game_state/resources.js` means the simulation
// describes *how* its world is wired, while this module owns *how* to walk a
// graph and price a leg.
import { RESOURCE_RULES, resourceById } from "./data/resources.js";
// --------------------------------------------------------------- traversal --
// Breadth-first spread from `start` over an abstract graph, in rings of
// increasing distance. `neighbours(node)` returns the outward edges of a node,
// each `{ node, cost }`; a directed graph is just a `neighbours` that omits the
// reverse edge. The traversal stops at `maxDistance` steps.
//
// `accept(node, { distance, cost })` is called for every newly reached node
// (never the start) and decides whether it joins the result; returning false
// still lets the search continue through it. The result is the accepted nodes
// with their accumulated `distance` (steps) and `cost` (sum of edge costs),
// sorted by cost.
export function traverse(start, { neighbours, id = (node) => node, accept = null, maxDistance = Infinity }) {
const results = [];
const visited = new Set([id(start)]);
let frontier = [{ node: start, distance: 0, cost: 0 }];
while (frontier.length > 0) {
const next = [];
for (const current of frontier) {
if (current.distance >= maxDistance) continue;
for (const edge of neighbours(current.node)) {
const key = id(edge.node);
if (visited.has(key)) continue;
visited.add(key);
const reached = {
node: edge.node,
distance: current.distance + 1,
cost: current.cost + (edge.cost || 0),
};
if (!accept || accept(edge.node, reached)) results.push(reached);
next.push(reached);
}
}
frontier = next;
}
results.sort((a, b) => a.cost - b.cost);
return results;
}
// ---------------------------------------------------------------- delivery --
// The kWh of transport energy one material tonne costs over `tiles` tiles in
// the given mode. A material tonne and an energy tonne (10 GWh) share the
// formula, so material deliveries and energy deliveries are priced alike.
export function deliveryEnergyKwh(tonnes, tiles, mode, tileMovementCost = 1) {
const d = RESOURCE_RULES.deliveryEnergy;
const perTile = mode === "train"
? d.train
: mode === "ship"
? d.ship
: mode === "road"
? d.truckRoad
: d.truckOffRoad * Math.max(tileMovementCost, 0);
return tonnes * Math.max(tiles, 0) * perTile;
}
// The energy cost of delivering `amount` of a resource over `tiles`. Every
// resource carries a mass per canonical unit, so a carat or a chip is charged
// for the tonnes it actually weighs rather than as if it were a tonne.
export function deliveryEnergyForResource(id, amount, tiles, mode, tileMovementCost = 1) {
const proto = resourceById(id);
const tonnes = amount * (proto ? proto.tonnesPerUnit : 1);
return deliveryEnergyKwh(tonnes, tiles, mode, tileMovementCost);
}
+143 -86
View File
@@ -33,11 +33,11 @@ import {
producerEnergyPerDay,
foodSynthesisEnergy,
synthesisedFoodFor,
deliveryEnergyForResource,
nextMarketPrice,
protoUpkeep,
constructionResourceCost as sharedConstructionResourceCost,
} from "../resources.js";
import { deliveryEnergyForResource, traverse } from "../economy_graph.js";
import { buildingBuildCost } from "../rules.js";
import { Random } from "../rng.js";
import { TILE_IMPROVEMENT_HP, HOURS_PER_DAY } from "./constants.js";
@@ -50,13 +50,14 @@ const SUPPLIER_RESERVE_DAYS = 2;
// The producers a fresh nation may be seeded with. Energy uses the large
// fossil plants so a nation needs few works to light its industry; the luxury
// pick is randomised between the two mines. High-tech is deliberately absent:
// a chip foundry is not covered by these passes but placed by hand for the two
// nations that open with one (see `_seedResourceBuildings`). Fusion, locked
// behind research, never appears either.
// pick is randomised between the two mines. High-tech is an industry like any
// other: a nation short of it places chip foundries just as it places mills and
// mines, and the power pass then builds the grid to run them. Fusion, locked
// behind research, never appears.
const SEED_PRODUCERS = {
steel: ["steel_mill"],
luxury: ["diamond_mine", "gold_mine"],
hightech: ["chip_foundry"],
energy: ["coal_power_plant", "natural_gas_power_plant", "oil_power_plant"],
};
// How many top-up passes the start-of-game seeding makes before it gives up on
@@ -68,6 +69,11 @@ const SEED_MAX_PASSES = 10;
// because it scales with GDP, which the market moves.
const SEED_MATERIAL_MARGIN = 1.05;
const SEED_ENERGY_MARGIN = 1.15;
// The steel and high-tech a city opens holding, on top of its people's own
// staples. Construction is settled in steel, so the opening store is sized to
// let a fresh region raise its first works before any producer comes online.
const OPENING_STEEL_STORE = 200_000;
const OPENING_HIGHTECH_STORE = 500;
function emptyStock() {
return { steel: 0, food: 0, luxury: 0, hightech: 0 };
@@ -207,15 +213,17 @@ export const resourceMethods = {
return record;
},
// Every city opens with most of a month of food, a little steel and luxury,
// and some high-tech, so an early game with no producer yet can still build.
// Every city opens with most of a month of food, a working store of steel and
// luxury, and some high-tech, so an early game with no producer yet can still
// build. Steel is stocked deeply because construction is paid in steel, not
// cash: the opening store is sized to cover a first building's material bill.
_seedCityResources(city) {
const k = key(city.coords.x, city.coords.y);
const stock = emptyStock();
stock.food = foodMonthlyTarget(city.population) * 0.75;
stock.steel = steelNeedPerDay(city.population) * 45;
stock.steel = steelNeedPerDay(city.population) * 45 + OPENING_STEEL_STORE;
stock.luxury = luxuryNeedPerDay(city.population) * 45;
stock.hightech = 500;
stock.hightech = OPENING_HIGHTECH_STORE;
this.resourceStock.set(k, stock);
},
@@ -285,16 +293,10 @@ export const resourceMethods = {
}
claims.push({ civ, spots, byComponent });
}
// Two nations open with a chip foundry -- the only high-tech the opening
// world makes -- so early research is a prize two powers share. The pair is
// drawn from the world seed and never the same nation twice; a nation with
// no land to host one simply goes without.
const fabCount = Math.max(0, this.mapConfig?.startingChipFabs ?? 2);
const fabCandidates = fabCount > 0 ? claims.filter((claim) => claim.spots.length > 0) : [];
rng.shuffle(fabCandidates);
const fabCivs = new Set(fabCandidates.slice(0, fabCount).map((claim) => claim.civ));
// The covering passes below seat every industry a nation is short of,
// high-tech included, so no resource gets a special hand-placed start.
for (const claim of claims) {
this._layStartingIndustry(rng, components, foodEnergy, touchesNetwork, claim, fabCivs.has(claim.civ));
this._layStartingIndustry(rng, components, foodEnergy, touchesNetwork, claim);
}
this._gdpPerCapitaCache.clear();
},
@@ -304,7 +306,7 @@ export const resourceMethods = {
// stops as soon as a pass changes nothing. A work off the transport network
// gets a one-tile road spur where it can, so its output can actually reach a
// city.
_layStartingIndustry(rng, components, foodEnergy, touchesNetwork, { civ, spots, byComponent }, chipFab = false) {
_layStartingIndustry(rng, components, foodEnergy, touchesNetwork, { civ, spots, byComponent }) {
const used = new Set();
let cursor = 0;
const takeSpot = (component) => {
@@ -346,13 +348,10 @@ export const resourceMethods = {
}
return laid;
};
// A chip foundry is laid before the covering passes so the power seeding
// sees its draw and builds the grid needed to run it.
if (chipFab) place(tileImprovementById("chip_foundry"), undefined, 1);
for (let pass = 0; pass < SEED_MAX_PASSES; pass++) {
let changed = false;
const deficits = this._resourceSeedDeficits(civ);
for (const id of ["steel", "luxury"]) {
for (const id of ["steel", "luxury", "hightech"]) {
const target = deficits[id] * SEED_MATERIAL_MARGIN;
if (target <= 0) continue;
const proto = this._pickSeedProducer(rng, id);
@@ -387,10 +386,17 @@ export const resourceMethods = {
const population = this.getPlayerPopulation(civ);
const upkeep = this.getCivUpkeepResources(civ);
const made = this._civResourceProduction(civ);
// A nation with no high-tech works of its own is treated as short one chip
// foundry, so every power opens the game with a high-tech industry just as
// it opens with steel mills and mines. Once one stands, the real deficit
// takes over.
const hightechFloor = made.hightech > 0
? 0
: tileImprovementById("chip_foundry").outputPerDay / SEED_MATERIAL_MARGIN;
return {
steel: steelNeedPerDay(population) + (upkeep.steel || 0) - made.steel,
luxury: luxuryNeedPerDay(population) - made.luxury,
hightech: (upkeep.hightech || 0) - made.hightech,
hightech: (upkeep.hightech || 0) - made.hightech + hightechFloor,
};
},
@@ -573,29 +579,77 @@ export const resourceMethods = {
},
// What one region holds and draws per day, for its city panel: the reserve of
// every storable resource, that reserve's market value, and the private
// sector's daily consumption -- the people's staples, the region's building
// upkeep and the grid power its industry pulls.
// every storable resource, that reserve's market value, the private sector's
// daily consumption, and a per-building breakdown of who demanded what. The
// breakdown is inverted by resource -- `demand[id]` is a list of
// `{ name, coords, amount }` consumers -- so the panel can expand any
// resource into the buildings that drew it.
getRegionResourceInfo(city) {
const economy = this.getCityEconomy(city);
const population = economy.population;
const store = this.getCityResourceStock(city);
const consumption = {
const consumers = [];
const addConsumer = (name, coords, consume) => {
consumers.push({ name, coords, consume });
};
addConsumer("People", city.coords, {
food: foodNeedPerDay(population),
steel: steelNeedPerDay(population),
luxury: luxuryNeedPerDay(population),
hightech: 0,
energy: cityEnergyPerDay(economy.gdp),
};
});
// The region's baseline electric appetite, from its yearly GDP.
const baseline = cityEnergyPerDay(economy.gdp);
if (baseline > 0) addConsumer("Region grid", city.coords, { energy: baseline });
// Every building raised in the city wears out its daily materials.
for (const [index, level] of Object.entries(city.buildings || {})) {
if (level <= 0) continue;
const proto = this.protoBuildings[Number(index)];
if (!proto) continue;
const costs = this.buildingUpkeepResources(proto, level);
consumption.steel += costs.steel || 0;
consumption.energy += costs.energy || 0;
consumption.hightech += costs.hightech || 0;
const up = this.buildingUpkeepResources(proto, level);
addConsumer(proto.name, city.coords, {
steel: up.steel || 0,
energy: up.energy || 0,
hightech: up.hightech || 0,
});
}
// Every production building standing in this region draws power and wears
// its own materials, wherever it sits.
for (const [k, id] of this.tileImprovements) {
if (this.tileImprovementOwner.get(k) !== city.civ) continue;
const proto = tileImprovementById(id);
if (!isResourceTileBuilding(proto)) continue;
const coords = parseKey(k);
if (this.regionCityAt(coords) !== city) continue;
const up = protoUpkeep(proto, proto.buildCost || 0);
const energy = this.resourceBuildingInputAt(k, proto, "energyPerDay") +
this.resourceBuildingInputAt(k, proto, "fuelEnergyPerDay");
addConsumer(proto.name, coords, {
steel: up.steel || 0,
hightech: up.hightech || 0,
energy: energy + (up.energy || 0),
});
}
const consumption = { food: 0, steel: 0, luxury: 0, hightech: 0, energy: 0 };
const demand = {};
for (const id of RESOURCE_IDS) demand[id] = [];
for (const consumer of consumers) {
for (const id of RESOURCE_IDS) {
const amount = consumer.consume[id] || 0;
if (!(amount > 0)) continue;
consumption[id] += amount;
demand[id].push({
name: consumer.name,
coords: [consumer.coords.x, consumer.coords.y],
amount,
});
}
}
const stock = {};
let value = 0;
for (const id of STORABLE_RESOURCE_IDS) {
@@ -603,7 +657,7 @@ export const resourceMethods = {
stock[id] = amount;
value += amount * this.getResourcePrice(id);
}
return { stock, consumption, value };
return { stock, consumption, value, demand };
},
// The region's unmet resource needs over the last day: for each resource the
@@ -2140,66 +2194,69 @@ export const resourceMethods = {
},
// The storage nodes a city can haul from over the transport network: a
// breadth-first search over land and sea, capped at RESOURCE_TRADE_RADIUS
// breadth-first spread over land and sea, capped at RESOURCE_TRADE_RADIUS
// tiles, that hands every reachable node to `accept`. `resource` only names
// the commodity charged for the haul; the transport rules themselves are
// commodity-independent, so the delivery graph passes one to price its rides.
// The traversal itself lives in the economy-graph toolkit; this method only
// describes the network it walks.
_tradeReachable(city, nodes, allowSea, accept, resource = "steel") {
const results = [];
const byKey = new Map(nodes.map((node) => [key(node.coords.x, node.coords.y), node]));
const origin = key(city.coords.x, city.coords.y);
const blocked = this._hostileOccupied(city.civ);
const visited = new Set([origin]);
// Every delivery runs over the transport network: railways and roads on
// land, shipping lanes across water (which need a working port).
const queue = [{ coords: city.coords, tiles: 0, energy: 0 }];
while (queue.length > 0) {
const current = queue.shift();
const here = byKey.get(key(current.coords.x, current.coords.y));
if (here && key(here.coords.x, here.coords.y) !== origin) {
const reached = traverse(city.coords, {
id: (coords) => key(coords.x, coords.y),
maxDistance: RESOURCE_TRADE_RADIUS,
neighbours: (coords) => this._tradeNeighbours(city, coords, allowSea, resource, blocked),
accept: (coords) => {
const here = byKey.get(key(coords.x, coords.y));
if (!here || key(here.coords.x, here.coords.y) === origin) return false;
const hostile = here.civ >= 0 && here.civ !== city.civ && this.isAtWar(city.civ, here.civ);
if (!hostile && accept(here)) {
results.push({
...here,
tiles: current.tiles,
routeEnergyPerTonne: current.energy,
});
}
}
if (current.tiles >= RESOURCE_TRADE_RADIUS) continue;
for (const neighbour of this._neighbours(current.coords)) {
const nk = key(neighbour.x, neighbour.y);
if (visited.has(nk) || blocked.has(nk)) continue;
const tile = this.tiles[nk];
if (!tile || !(tile.terrainClass === "Land" || tile.terrainClass === "Sea" ||
tile.terrainClass === "Ice")) continue;
const sea = tile.terrainClass === "Sea";
let mode;
if (sea) {
// A shipping lane needs a working port to load and unload.
if (!allowSea) continue;
mode = "ship";
} else {
// Ground legs must run on a road or railway; there is no off-road
// trucking in the trade network.
if (this.railways.has(nk)) mode = "train";
else if (this.roads.has(nk)) mode = "road";
else continue;
// Goods never cross the land of a nation you are at war with.
const owner = this.civAt(neighbour);
if (owner >= 0 && owner !== city.civ && this.isAtWar(city.civ, owner)) continue;
}
const stepEnergy = deliveryEnergyForResource(resource, 1, 1, mode, tile.movementCostMultiplier || 1);
visited.add(nk);
queue.push({
coords: neighbour,
tiles: current.tiles + 1,
energy: current.energy + stepEnergy,
});
return !hostile && accept(here);
},
});
return reached.map((entry) => ({
...byKey.get(key(entry.node.x, entry.node.y)),
tiles: entry.distance,
routeEnergyPerTonne: entry.cost,
}));
},
// The passable outward edges of one tile for the trade network: land legs
// must run on a road or railway, sea legs cross open water and need a working
// port, and goods never cross the land of a nation you are at war with. Each
// edge is priced in the transport energy one tonne of `resource` burns over
// it, so the graph is commodity-aware while the traversal is not.
_tradeNeighbours(city, coords, allowSea, resource, blocked) {
const edges = [];
for (const neighbour of this._neighbours(coords)) {
const nk = key(neighbour.x, neighbour.y);
if (blocked.has(nk)) continue;
const tile = this.tiles[nk];
if (!tile || !(tile.terrainClass === "Land" || tile.terrainClass === "Sea" ||
tile.terrainClass === "Ice")) continue;
const sea = tile.terrainClass === "Sea";
let mode;
if (sea) {
// A shipping lane needs a working port to load and unload.
if (!allowSea) continue;
mode = "ship";
} else {
// Ground legs must run on a road or railway; there is no off-road
// trucking in the trade network.
if (this.railways.has(nk)) mode = "train";
else if (this.roads.has(nk)) mode = "road";
else continue;
// Goods never cross the land of a nation you are at war with.
const owner = this.civAt(neighbour);
if (owner >= 0 && owner !== city.civ && this.isAtWar(city.civ, owner)) continue;
}
edges.push({
node: neighbour,
cost: deliveryEnergyForResource(resource, 1, 1, mode, tile.movementCostMultiplier || 1),
});
}
results.sort((a, b) => a.routeEnergyPerTonne - b.routeEnergyPerTonne);
return results;
return edges;
},
// Every supplier-to-city route among a nation's own storage nodes, whether or
+2 -26
View File
@@ -11,6 +11,7 @@ import {
resourceById,
} from "./data/resources.js";
import { EFFECT_RESEARCH } from "./data/effects.js";
import { groupDigits } from "./text_format.js";
const DAYS_PER_YEAR = 365;
@@ -44,7 +45,7 @@ export function formatResourceAmount(id, value) {
}
function trim(value) {
if (value >= 100) return String(Math.round(value));
if (value >= 100) return groupDigits(Math.round(value));
if (value >= 10) return value.toFixed(1).replace(/\.0$/, "");
return value.toFixed(2).replace(/\.?0+$/, "");
}
@@ -210,31 +211,6 @@ export function producerEnergyPerDay(id, outputPerDay) {
return 0;
}
// ---------------------------------------------------------------- delivery --
// The kWh of transport energy one material tonne costs over `tiles` tiles in the
// given mode. A material tonne and an energy tonne (10 GWh) share the formula.
export function deliveryEnergyKwh(tonnes, tiles, mode, tileMovementCost = 1) {
const d = RESOURCE_RULES.deliveryEnergy;
const perTile = mode === "train"
? d.train
: mode === "ship"
? d.ship
: mode === "road"
? d.truckRoad
: d.truckOffRoad * Math.max(tileMovementCost, 0);
return tonnes * Math.max(tiles, 0) * perTile;
}
// The energy cost of delivering `amount` of a resource over `tiles`. Every
// resource carries a mass per canonical unit, so a carat or a chip is charged
// for the tonnes it actually weighs rather than as if it were a tonne.
export function deliveryEnergyForResource(id, amount, tiles, mode, tileMovementCost = 1) {
const proto = resourceById(id);
const tonnes = amount * (proto ? proto.tonnesPerUnit : 1);
return deliveryEnergyKwh(tonnes, tiles, mode, tileMovementCost);
}
// ----------------------------------------------------------------- market --
// Moves a price toward what the day's world-wide supply and demand imply. A
+3 -1
View File
@@ -1,6 +1,8 @@
// Small formatting helpers shared by the UI, ported from
// scripts/classes/text_format.gd.
// Groups a number's digits in threes. The separators are non-breaking spaces,
// so a money or resource figure never wraps in the middle of a number.
export function groupDigits(value) {
const truncated = Math.trunc(value);
const digits = String(Math.abs(truncated));
@@ -9,7 +11,7 @@ export function groupDigits(value) {
for (let i = digits.length - 1; i >= 0; i--) {
grouped = digits[i] + grouped;
count += 1;
if (count % 3 === 0 && i > 0) grouped = " " + grouped;
if (count % 3 === 0 && i > 0) grouped = "\u00a0" + grouped;
}
return (truncated < 0 ? "-" : "") + grouped;
}
+2
View File
@@ -112,6 +112,8 @@ export class AirTest extends TestCase {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const city = coastalCity(state, 0);
// Construction is settled in materials, so stock the city before ordering.
state.getCityResourceStock(city).steel = 5_000_000;
const jet = protoIndex(state, "jet_fighter");
const battleship = protoIndex(state, "battleship");
this.assertFalse(state.requestTrain(city.id, jet), "no airport, no aircraft");
+1 -1
View File
@@ -119,7 +119,7 @@ export class ConflictsModalTest extends TestCase {
this.assertFalse(detail.hasClass("hidden"), "clicking a conflict reveals its details");
const text = detail.text();
this.assertTrue(text.includes("Conflict started 1 January 2000, 00:00"));
this.assertTrue(text.includes("France — Military: 12 · Civilians: 3 400"));
this.assertTrue(text.includes("France — Military: 12 · Civilians: 3\u00a0400"));
this.assertTrue(text.includes("Britain — Military: 7 · Civilians: 900"));
modal.close();
} finally {
+1 -1
View File
@@ -32,7 +32,7 @@ export class DataTest extends TestCase {
// One work stands for a whole industry: the authored output is multiplied
// tenfold so fewer of them stand on the map.
const mill = RESOURCE_BUILDINGS.find((b) => b.id === "steel_mill");
this.assertApprox(mill.outputPerDay, 20_000, 1e-6);
this.assertApprox(mill.outputPerDay, 200_000, 1e-6);
}
test_unknown_lookups_return_null() {
+48 -36
View File
@@ -3,7 +3,7 @@ import { setupDom, teardownDom } from "./framework/dom.js";
import { GameScreen } from "../client/js/game_screen.js";
import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, TRANSPORT_IMPROVEMENTS } from "../shared/data.js";
import { hoursInNextMonth } from "../shared/game_clock.js";
import { compact } from "../shared/text_format.js";
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";
@@ -208,7 +208,7 @@ export class GameScreenTest extends TestCase {
screen.map.onCitySelected(capital.id);
env.$("#city-manage").click();
this.assertFalse(env.$("#modal-city").hasClass("hidden"));
this.assertSize(env.$("#city-buildings-list .row-card"), BUILDINGS.length);
this.assertSize(env.$("#city-buildings-list .building-tile"), BUILDINGS.length);
screen.onState(state.snapshot(0));
this.assertFalse(env.$("#modal-city").hasClass("hidden"));
@@ -1290,7 +1290,7 @@ export class GameScreenTest extends TestCase {
const $button = env.$("#unit-pillage");
this.assertFalse($button.hasClass("hidden"), "the pillage button shows");
const tooltip = $button.attr("data-tooltip") || "";
this.assertTrue(tooltip.includes("19 000"), `forecast removes 19k: ${tooltip}`);
this.assertTrue(tooltip.includes("19\u00a0000"), `forecast removes 19k: ${tooltip}`);
this.assertTrue(tooltip.includes("0.49"), "forecast shows the GDP multiplier");
this.assertTrue(tooltip.includes("+10") && tooltip.includes("+9"), "forecast shows both heals");
@@ -1546,19 +1546,29 @@ export class GameScreenTest extends TestCase {
});
this.assertNotNull(coords, "the player owns a bare land tile");
screen._showTile(coords);
const options = env.$("#tile-improvement-actions .transport-btn");
env.$("#tile-manage").click();
const options = env.$("#tile-manage-improvements .transport-btn");
this.assertEqual(options.length, 2, "road and railway are listed");
this.assertFalse(options.eq(0).prop("disabled"), "the road option is clickable");
this.assertFalse(options.eq(1).prop("disabled"), "the railway option is clickable");
// Each is a square icon with its name in the tooltip and its resource and
// money cost beneath.
// Each card shows the work's icon and name, while the resource bill is in
// the hover tooltip and the estimated price sits on the button.
const roadCard = options.eq(0).closest(".building-tile");
this.assertTrue(
options.eq(0).find("img.improvement-icon").attr("src").endsWith("icon_road.svg"),
"the road button shows its icon"
roadCard.find("img.building-icon").attr("src").endsWith("icon_road.svg"),
"the road card shows its icon"
);
const costText = options.eq(0).find(".improvement-cost").text();
this.assertTrue(costText.includes("5000 t"), `the steel cost is shown: ${costText}`);
this.assertTrue(costText.includes("100 GWh"), `the energy cost is shown: ${costText}`);
this.assertTrue(roadCard.find(".bt-name").text() === "Road", "the card names the work");
options.eq(0).trigger("mouseenter");
const tipCost = env.$(".card-tooltip .tip-cost").text();
this.assertTrue(tipCost.includes("50\u00a0000 t"), `the steel cost is shown: ${tipCost}`);
this.assertTrue(tipCost.includes("100 GWh"), `the energy cost is shown: ${tipCost}`);
this.assertEqual(
env.$(".card-tooltip .tip-cost img").length,
2,
"steel and energy carry resource icons"
);
options.eq(0).trigger("mouseleave");
// The money figure is the build cost plus the material bill at today's
// market prices, not the raw build cost.
const roadProto = TRANSPORT_IMPROVEMENTS.find((i) => i.id === "road");
@@ -1568,17 +1578,8 @@ export class GameScreenTest extends TestCase {
marketPricesFrom(screen.snapshot)
);
this.assertTrue(
costText.includes(`£${compact(expectedCost)}`),
`the market price is shown: ${costText}`
);
this.assertEqual(
options.eq(0).find(".improvement-cost img.cost-icon").length,
2,
"steel and energy carry resource icons"
);
this.assertTrue(
options.eq(0).attr("title").startsWith("Road"),
"the name is the tooltip"
options.eq(0).find(".bt-price").text().includes(groupDigits(Math.round(expectedCost))),
`the market price is shown: ${options.eq(0).find(".bt-price").text()}`
);
options.eq(0).click();
const order = network.orders[network.orders.length - 1];
@@ -1597,10 +1598,11 @@ export class GameScreenTest extends TestCase {
state.tileImprovementHp.delete(roadKey);
screen.onState(state.snapshot(0));
screen._showTile({ x: road[0], y: road[1] });
const again = env.$("#tile-improvement-actions .transport-btn").not(".remove");
env.$("#tile-manage").click();
const again = env.$("#tile-manage-improvements .transport-btn");
this.assertTrue(again.eq(0).prop("disabled"), "the built road is greyed out");
this.assertFalse(again.eq(1).prop("disabled"), "the railway upgrade stays available");
const $remove = env.$("#tile-improvement-actions .improvement-btn.remove");
const $remove = env.$("#tile-manage-improvements .improvement-btn.remove:not(:disabled)");
this.assertSize($remove, 1, "a remove control is offered");
$remove.click();
this.assertEqual(network.orders[network.orders.length - 1], {
@@ -1627,7 +1629,8 @@ export class GameScreenTest extends TestCase {
});
this.assertNotNull(coords, "the player owns a bare land tile");
screen._showTile(coords);
const labels = env.$("#tile-improvement-actions .improvement-category")
env.$("#tile-manage").click();
const labels = env.$("#tile-manage-improvements .improvement-category")
.map((_, el) => env.$(el).text())
.get();
this.assertEqual(
@@ -1635,7 +1638,7 @@ export class GameScreenTest extends TestCase {
"Military,Transportation,Economic",
"the categories are labelled in order"
);
const firstClasses = env.$("#tile-improvement-actions .improvement-row")
const firstClasses = env.$("#tile-manage-improvements .improvement-row")
.map((_, el) => env.$(el).find(".improvement-btn").first().attr("class"))
.get();
this.assertTrue(firstClasses[0].includes("tile-btn"), "military works come first");
@@ -1663,11 +1666,12 @@ export class GameScreenTest extends TestCase {
});
this.assertNotNull(coords, "the player owns a bare land tile");
screen._showTile(coords);
env.$("#tile-manage").click();
this.assertFalse(
env.$("#tile-improvement-actions .transport-btn").eq(0).prop("disabled"),
env.$("#tile-manage-improvements .transport-btn").eq(0).prop("disabled"),
"transport stays clickable in debt"
);
const military = env.$("#tile-improvement-actions .improvement-row")
const military = env.$("#tile-manage-improvements .improvement-row")
.eq(0).find(".tile-btn").first();
this.assertFalse(military.prop("disabled"), "a military work stays clickable in debt");
screen.leave();
@@ -1695,10 +1699,11 @@ export class GameScreenTest extends TestCase {
);
screen.onState(state.snapshot(0));
screen._showTile(coords);
env.$("#tile-manage").click();
// Every improvement button is gone while the work runs.
this.assertSize(env.$("#tile-improvement-actions .improvement-group"), 0, "nothing is left");
this.assertSize(env.$("#tile-improvement-actions .tile-btn"), 0, "no military or economic works");
this.assertSize(env.$("#tile-improvement-actions .transport-btn"), 0, "not even transport");
this.assertSize(env.$("#tile-manage-improvements .improvement-group"), 0, "nothing is left");
this.assertSize(env.$("#tile-manage-improvements .tile-btn"), 0, "no military or economic works");
this.assertSize(env.$("#tile-manage-improvements .transport-btn"), 0, "not even transport");
// The only control is the cancel beside the progress.
const $cancel = env.$("#tile-works .site-actions button");
this.assertSize($cancel, 1, "the work can still be cancelled");
@@ -1833,17 +1838,24 @@ export class GameScreenTest extends TestCase {
this.assertNotNull(coords, "the player owns a bare land tile");
screen.onState(state.snapshot(0));
screen._showTile(coords);
const before = env.$("#tile-improvement-actions .transport-btn").eq(0)
.find(".improvement-cost").text();
env.$("#tile-manage").click();
const before = env.$("#tile-manage-improvements .transport-btn").eq(0)
.find(".bt-price").text();
// A jump in the world steel price raises the quoted price, without
// changing the amount of steel shown.
state.setResourcePrice("steel", state.getResourcePrice("steel") * 3);
screen.onState(state.snapshot(0));
screen._showTile(coords);
const after = env.$("#tile-improvement-actions .transport-btn").eq(0)
.find(".improvement-cost").text();
const after = env.$("#tile-manage-improvements .transport-btn").eq(0)
.find(".bt-price").text();
this.assertNotEqual(before, after, `the quote moved: ${before} -> ${after}`);
this.assertTrue(after.includes("5000 t"), "the steel amount is unchanged");
// The steel amount itself is unchanged, in the tooltip.
env.$("#tile-manage-improvements .transport-btn").eq(0).trigger("mouseenter");
this.assertTrue(
env.$(".card-tooltip .tip-cost").text().includes("50\u00a0000 t"),
"the steel amount is unchanged"
);
env.$("#tile-manage-improvements .transport-btn").eq(0).trigger("mouseleave");
screen.leave();
} finally {
teardownDom(env);
+4
View File
@@ -80,6 +80,9 @@ export class GameStateBuildingsTest extends TestCase {
solo.budgets.set(0, BIG_BUDGET);
const soloIndex = this.addBuilding(solo, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 }));
const soloCity = cityOf(solo, 0);
// Construction is settled in materials; stock the city so both queued
// levels can gather without depending on the transport network.
solo.getCityResourceStock(soloCity).steel = 5_000_000;
solo.requestBuild(soloCity.id, soloIndex);
solo.requestBuild(soloCity.id, soloIndex);
const sequential = this.hoursUntilIdle(solo);
@@ -88,6 +91,7 @@ export class GameStateBuildingsTest extends TestCase {
yard.budgets.set(0, BIG_BUDGET);
const yardIndex = this.addBuilding(yard, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 }));
const yardCity = cityOf(yard, 0);
yard.getCityResourceStock(yardCity).steel = 5_000_000;
grantBuilding(yard, yardCity, "shipyard");
yard.requestBuild(yardCity.id, yardIndex);
yard.requestBuild(yardCity.id, yardIndex);
+3 -2
View File
@@ -237,11 +237,12 @@ export class PowerMarketTest extends TestCase {
state._converterProductionScale(k, proto, agent), 1,
"a building with no history runs flat out"
);
agent.sales = [4_000, 4_000, 4_000, 4_000];
agent.sales = [proto.outputPerDay * 0.2, proto.outputPerDay * 0.2,
proto.outputPerDay * 0.2, proto.outputPerDay * 0.2];
state._ensureResourceStock(k).steel = 1e9;
const scale = state._converterProductionScale(k, proto, agent);
this.assertLess(scale, 1, "a full store eases off");
this.assertApprox(scale, 4_000 / proto.outputPerDay, 1e-3);
this.assertApprox(scale, 0.2, 1e-3);
}
test_a_converter_sells_out_and_then_grows() {
+1 -1
View File
@@ -99,7 +99,7 @@ export class MapModesTest extends TestCase {
this.assertFalse(env.$("#map-legend").hasClass("hidden"), "the legend is shown");
this.assertEqual(env.$("#map-legend-title").text(), "GDP per capita", "the legend is titled");
this.assertEqual(env.$("#map-legend-min").text(), "¤100", "the legend shows the minimum");
this.assertEqual(env.$("#map-legend-max").text(), "¤1 234", "the legend shows the maximum");
this.assertEqual(env.$("#map-legend-max").text(), "¤1\u00a0234", "the legend shows the maximum");
// Every land tile carries the top-of-range colour, so the fill is live.
const top = economicColour(1234, 100, 1234);
let painted = false;
+74 -23
View File
@@ -133,16 +133,16 @@ export class CityModalTest extends TestCase {
demolished = [cityId, index];
};
modal.show({ id: 3, name: "Lyon" }, { budget: 1.0e15, buildings: BUILDINGS, levels: {} });
this.assertSize(env.$("#city-buildings-list .row-card"), BUILDINGS.length);
const first = env.$("#city-buildings-list .row-card").first();
this.assertTrue(first.find(".name").text().includes("Level 0"));
this.assertFalse(first.find(".actions button").first().prop("disabled"));
first.find(".actions button").first().click();
this.assertSize(env.$("#city-buildings-list .building-tile"), BUILDINGS.length);
const first = env.$("#city-buildings-list .building-tile").first();
this.assertTrue(first.find(".bt-level").text().includes("Level 0"));
this.assertFalse(first.find(".bt-upgrade").prop("disabled"));
first.find(".bt-upgrade").click();
this.assertEqual(built, [3, 0]);
modal.refreshBuildings(1.0e15, { 0: 1 });
this.assertTrue(first.find(".name").text().includes("Level 1"));
first.find(".actions button").eq(1).click();
this.assertTrue(first.find(".bt-level").text().includes("Level 1"));
first.find(".bt-downgrade").click();
this.assertEqual(demolished, [3, 0]);
modal.close();
this.assertTrue(env.$("#modal-city").hasClass("hidden"));
@@ -156,11 +156,11 @@ export class CityModalTest extends TestCase {
try {
const modal = new CityModal();
modal.show({ id: 1, name: "X" }, { budget: 1.0e15, buildings: BUILDINGS, levels: {}, gdp: 1.0e9 });
const first = env.$("#city-buildings-list .row-card").first();
const plain = first.find(".cost").text();
const first = env.$("#city-buildings-list .building-tile").first();
const plain = first.find(".bt-meta").text();
modal.refreshBuildings(1.0e15, {}, 1.0e9, { 0: 1 });
const queued = first.find(".cost").text();
this.assertTrue(first.find(".name").text().includes("queued"), "the pending level is shown");
const queued = first.find(".bt-meta").text();
this.assertTrue(first.find(".bt-level").text().includes("queued"), "the pending level is shown");
this.assertNotEqual(queued, plain, "the next order is priced off the queued level");
} finally {
teardownDom(env);
@@ -172,7 +172,7 @@ export class CityModalTest extends TestCase {
try {
const modal = new CityModal();
modal.show({ id: 1, name: "X" }, { budget: 0, buildings: BUILDINGS, levels: {} });
const upgrade = env.$("#city-buildings-list .row-card").first().find(".actions button").first();
const upgrade = env.$("#city-buildings-list .building-tile").first().find(".bt-upgrade");
// The government can always pay, running an overdraft, so cost never
// disables the button; only an unmet requirement does.
this.assertFalse(upgrade.prop("disabled"));
@@ -214,7 +214,7 @@ export class CityModalTest extends TestCase {
modal.renderBudget(breakdown);
this.assertTrue(env.$("#city-budget-sub").text().includes("+¤635/h"), "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("+¤1 035/h"), "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("TOTAL"), "a total row is present");
@@ -292,7 +292,7 @@ export class CityModalTest extends TestCase {
const text = env.$("#city-economy-list").text();
this.assertTrue(text.includes("French Franc"), "the national currency is listed");
this.assertTrue(text.includes("British Pound"), "a foreign holding is listed");
this.assertTrue(env.$("#city-economy-sub").text().includes("4 250"), "the total value shows");
this.assertTrue(env.$("#city-economy-sub").text().includes("4 250"), "the total value shows");
this.assertTrue(env.$("#city-economy-sub").text().includes("Debt"), "the central-bank debt shows");
} finally {
teardownDom(env);
@@ -318,7 +318,7 @@ export class CityModalTest extends TestCase {
env.$("#modal-city .tab[data-tab=resources]").click();
this.assertTrue(env.$("#city-tab-resources").hasClass("active"));
this.assertTrue(
env.$("#city-resources-sub").text().includes("123 456"),
env.$("#city-resources-sub").text().includes("123 456"),
"the reserve value shows"
);
const text = env.$("#city-resources-list").text();
@@ -350,7 +350,7 @@ export class CityModalTest extends TestCase {
const text = env.$("#city-people-list").text();
this.assertTrue(text.includes("Resource shortages"), "the shortage section shows");
this.assertTrue(text.includes("Steel"), "the short resource is named");
this.assertTrue(text.includes("1200"), "the amount it is short by is shown");
this.assertTrue(text.includes("1\u00a0200"), "the amount it is short by is shown");
this.assertTrue(text.includes("40%"), "the share of need is shown");
} finally {
teardownDom(env);
@@ -651,7 +651,7 @@ export class NationModalTest extends TestCase {
const text = env.$("#budget-list").text();
this.assertTrue(text.includes("National sales tax"), "the month's taxes are shown");
this.assertTrue(text.includes("+¤1 035"), "the tax take is shown");
this.assertTrue(text.includes("+¤1 035"), "the tax take is shown");
this.assertTrue(text.includes("Resource sales"), "resource sales are shown");
this.assertTrue(text.includes("Building upkeep"), "a building category is listed");
this.assertTrue(text.includes("−¤700"), "the rightmost column is the market value");
@@ -938,7 +938,7 @@ export class NationModalTest extends TestCase {
this.assertTrue(env.$("#tab-economy").hasClass("active"));
const summary = env.$("#economy-summary").text();
this.assertTrue(summary.includes("Paris"), "the region is listed");
this.assertTrue(summary.includes("5 000"), "its private cash is shown");
this.assertTrue(summary.includes("5 000"), "its private cash is shown");
env.$("#economy-subtabs .subtab[data-view=taxes]").click();
const taxesText = env.$("#economy-taxes").text();
@@ -954,13 +954,13 @@ export class NationModalTest extends TestCase {
"the per-resource override is shown"
);
this.assertTrue(
env.$("#economy-sub").text().includes("1 600"), "the day's total take is shown"
env.$("#economy-sub").text().includes("1 600"), "the day's total take is shown"
);
env.$("#economy-subtabs .subtab[data-view=centralbank]").click();
const bank = env.$("#economy-central-bank").text();
this.assertTrue(bank.includes("British Pound"), "the foreign reserve is listed");
this.assertTrue(bank.includes("987 654"), "the outstanding loans are listed");
this.assertTrue(bank.includes("987\u00a0654"), "the outstanding loans are listed");
this.assertTrue(bank.includes("Commodity inflation"), "the inflation indicator is shown");
this.assertTrue(env.$("#cb-rate").length === 1, "the rate input is present");
@@ -971,8 +971,8 @@ export class NationModalTest extends TestCase {
env.$("#economy-subtabs .subtab[data-view=consumption]").click();
const consumption = env.$("#economy-consumption").text();
this.assertTrue(consumption.includes("Steel"), "the consumed resource is listed");
this.assertTrue(consumption.includes("1200"), "its daily consumption is shown");
this.assertTrue(consumption.includes("180 000"), "what was spent abroad is shown");
this.assertTrue(consumption.includes("1\u00a0200"), "its daily consumption is shown");
this.assertTrue(consumption.includes("180\u00a0000"), "what was spent abroad is shown");
this.assertTrue(consumption.includes("40"), "what was sold abroad is shown");
env.$("#economy-subtabs .subtab[data-view=currency]").click();
@@ -984,7 +984,7 @@ export class NationModalTest extends TestCase {
this.assertTrue(currency.includes("Paris"), "a home region holding it is listed");
this.assertTrue(currency.includes("London"), "a foreign region holding it is listed");
this.assertTrue(currency.includes("Central bank of Britain"), "a foreign bank is listed");
this.assertTrue(currency.includes("7 934"), "the total in circulation is shown");
this.assertTrue(currency.includes("7 934"), "the total in circulation is shown");
} finally {
teardownDom(env);
}
@@ -1055,6 +1055,57 @@ export class NationModalTest extends TestCase {
teardownDom(env);
}
}
async test_central_bank_chart_rolls_a_two_year_window() {
const env = await setupDom();
try {
const modal = new NationModal();
// Thirty monthly openings, so six must fall off the twenty-four month
// window. The live index matches the last sample, so no point is appended.
const energy = Array.from({ length: 30 }, (_, i) => 100 + i * 10);
const money = {
viewerCiv: 0,
currency: { code: "FRF", symbol: "₣", name: "French Franc" },
currencies: [{ civ: 0, code: "FRF", symbol: "₣", name: "French Franc", value: 1 }],
exchangeRates: [{ civ: 0, code: "FRF", symbol: "₣", name: "French Franc", rate: 1 }],
centralBank: { rate: 0.02, reserves: [], loans: 0 },
taxes: {
sales: { rate: 0.2, resources: {} },
export: { rate: 0.05, resources: {} },
import: { rate: 0.05, resources: {} },
},
takeTax: { sales: 0, export: 0, import: 0 },
commodityInflation: 0,
currencyHoldings: null,
privateCash: 0,
cities: [],
resourcePriceHistory: [["energy", energy]],
resourceInflation: [{ id: "energy", index: 390, monthly: 0.03, yearly: 0.25 }],
};
const resourcesInfo = {
summary: {
stock: {}, need: {}, production: {}, prices: {},
consumed: { steel: 0, food: 0, luxury: 0, hightech: 0, energy: 0 },
external: { buy: {}, sell: {} },
},
market: [],
trade: [],
};
modal.show(
GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6,
{ months: [], cities: [] }, null, [], null, null, resourcesInfo, money
);
env.$("#modal-nation .tab[data-tab=economy]").click();
env.$("#economy-subtabs .subtab[data-view=centralbank]").click();
const points = env.$("#economy-central-bank .line-chart-line").attr("points") || "";
this.assertEqual(points.trim().split(/\s+/).length, 24, "the window holds 24 months");
const labels = env.$("#economy-central-bank .line-chart-label");
this.assertEqual(labels.eq(-2).text(), "Jul 2000", "the window starts two years back");
this.assertEqual(labels.eq(-1).text(), "Jun 2002", "the window ends at the latest month");
} finally {
teardownDom(env);
}
}
}
export class ResourceTrendTest extends TestCase {
+26 -20
View File
@@ -17,10 +17,9 @@ import {
luxuryNeedPerDay,
foodMonthlyTarget,
cityEnergyPerDay,
deliveryEnergyKwh,
deliveryEnergyForResource,
nextMarketPrice,
} from "../shared/resources.js";
import { deliveryEnergyKwh, deliveryEnergyForResource } from "../shared/economy_graph.js";
function freeLandTile(state, civ = 0) {
for (const coords of state._territoryByCiv.get(civ) || []) {
@@ -234,9 +233,14 @@ export class ResourceModelTest extends TestCase {
this.assertApprox(info.value, expected);
const population = state.getCityEconomy(city).population;
this.assertApprox(info.consumption.food, foodNeedPerDay(population));
this.assertApprox(info.consumption.steel, steelNeedPerDay(population));
this.assertApprox(info.consumption.luxury, luxuryNeedPerDay(population));
this.assertTrue(info.consumption.energy > 0, "grid demand draws power");
// The people's own steel need is named in the breakdown, and any industry
// in the region adds its wear on top.
const people = info.demand.steel.find((entry) => entry.name === "People");
this.assertNotNull(people, "the people are a named steel consumer");
this.assertApprox(people.amount, steelNeedPerDay(population));
this.assertTrue(info.consumption.steel >= steelNeedPerDay(population) - 1e-6);
}
test_city_stats_ship_the_region_resources() {
@@ -272,7 +276,10 @@ export class ResourceModelTest extends TestCase {
gdp += stats.gdp;
population += stats.population;
}
this.assertApprox(snapshot.viewerStats.gdpPerCapita, gdp / population, 1e-6);
// The national figure is the exact GDP over the exact population; the
// regional figures ship rounded populations, so allow a person's worth of
// rounding drift in the per-capita comparison.
this.assertApprox(snapshot.viewerStats.gdpPerCapita, gdp / population, 0.01);
}
test_construction_draws_steel_from_the_city_before_the_market() {
@@ -602,23 +609,23 @@ export class ResourceModelTest extends TestCase {
this.assertNotEqual(describe(first), describe(other), "a different seed lays out different works");
}
test_two_nations_open_with_a_chip_foundry_apart() {
const state = smallState(["france", "britain", "poland"], 12345, { startingChipFabs: 2 });
const fabs = [];
test_every_nation_opens_with_a_chip_foundry() {
// High-tech is seeded like any other industry: a nation with no works of
// its own opens with a chip foundry, on the transport network, and the
// layout is drawn from the world seed.
const state = smallState(["france", "britain", "poland"], 12345);
const owners = new Set();
for (const [k, id] of state.tileImprovements) {
if (id !== "chip_foundry") continue;
fabs.push({ k, owner: state.tileImprovementOwner.get(k) });
}
this.assertSize(fabs, 2, "the world opens with exactly two chip foundries");
this.assertNotEqual(fabs[0].owner, fabs[1].owner, "the two foundries are in different nations");
for (const fab of fabs) {
owners.add(state.tileImprovementOwner.get(k));
this.assertTrue(
state.roads.has(fab.k) || state.railways.has(fab.k),
state.roads.has(k) || state.railways.has(k),
"a seeded foundry sits on the transport network"
);
}
// The pair is drawn from the world seed, so a replay lays them out again.
const again = smallState(["france", "britain", "poland"], 12345, { startingChipFabs: 2 });
this.assertTrue(owners.size > 0, "the world opens with chip foundries");
// The layout is drawn from the world seed, so a replay lays it out again.
const again = smallState(["france", "britain", "poland"], 12345);
const describe = (s) =>
Array.from(s.tileImprovements.entries())
.filter(([, id]) => id === "chip_foundry")
@@ -629,15 +636,14 @@ export class ResourceModelTest extends TestCase {
}
test_the_chip_foundries_are_powered_on_a_full_map() {
// A full roster: with only two nations the tiny starting islands can leave
// one nation without the land a foundry's grid needs.
// A full roster: the seeded chip foundries draw a lot, so the power pass
// must build the grid to run them on every nation's land.
const state = defaultState(["france", "britain", "germany", "poland"]);
const fabs = [];
for (const [k, id] of state.tileImprovements) {
if (id === "chip_foundry") fabs.push(state.tileImprovementOwner.get(k));
if (id === "chip_foundry") fabs.push(k);
}
this.assertSize(fabs, 2, "a full map opens with two chip foundries");
this.assertNotEqual(fabs[0], fabs[1], "the two foundries are in different nations");
this.assertTrue(fabs.length > 0, "a full map opens with chip foundries");
// Their huge draw is settled like any other converter's, so every grid opens
// with the power to run them.
const components = state._resourceLandComponents();
+4 -3
View File
@@ -6,9 +6,10 @@ export class TextFormatTest extends TestCase {
this.assertEqual(groupDigits(0), "0");
this.assertEqual(groupDigits(7), "7");
this.assertEqual(groupDigits(123), "123");
this.assertEqual(groupDigits(1234), "1 234");
this.assertEqual(groupDigits(1234567), "1 234 567");
this.assertEqual(groupDigits(-1234), "-1 234");
// The separators are non-breaking spaces, so a number never wraps.
this.assertEqual(groupDigits(1234), "1\u00a0234");
this.assertEqual(groupDigits(1234567), "1\u00a0234\u00a0567");
this.assertEqual(groupDigits(-1234), "-1\u00a0234");
}
test_compact() {