diff --git a/AGENTS.md b/AGENTS.md
index 78a286c..4013936 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/client/css/style.css b/client/css/style.css
index 675205c..998cb23 100644
--- a/client/css/style.css
+++ b/client/css/style.css
@@ -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;
}
@@ -1555,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 {
diff --git a/client/index.html b/client/index.html
index d6ba568..c2e87dc 100644
--- a/client/index.html
+++ b/client/index.html
@@ -151,7 +151,9 @@
-
+
+
+
@@ -264,6 +266,24 @@
+
+
+
+
+ Tile
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/js/devlog_data.js b/client/js/devlog_data.js
index 1afd802..a3625f2 100644
--- a/client/js/devlog_data.js
+++ b/client/js/devlog_data.js
@@ -1,6 +1,12 @@
// 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": "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",
@@ -54,11 +60,5 @@ export const DEVLOG = [
"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": ""
}
];
diff --git a/client/js/game_screen.js b/client/js/game_screen.js
index 60e80a6..3965e32 100644
--- a/client/js/game_screen.js
+++ b/client/js/game_screen.js
@@ -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();
diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js
index e41c016..7b57f20 100644
--- a/client/js/game_screen/panels.js
+++ b/client/js/game_screen/panels.js
@@ -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 = $(``)
- .attr("title", title)
- .prop("disabled", disabled)
- .toggleClass("current", current);
- $button.append(
- $("")
- .attr("src", `assets/${improvement.icon}`)
- .attr("alt", improvement.name)
- );
- if (costLabel !== undefined) {
- $button.append($("").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 = $("");
- const items = [];
- for (const id of ["steel", "energy", "hightech"]) {
- if (!(costs[id] > 0)) continue;
- const proto = resourceById(id);
- items.push(
- $("").append(
- $("")
- .attr("src", `assets/${proto.icon}`)
- .attr("alt", proto.name)
- .attr("title", proto.name),
- $("").text(formatResourceAmount(id, costs[id]))
- )
- );
- }
- items.push(
- $("").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 = $("")
+ .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(
- $("")
- .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 = $("")
- .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 = $("");
$group.append($("").text(group.label));
- const $buttons = $("");
- for (const $button of group.buttons) $buttons.append($button);
- $group.append($buttons);
+ const $cards = $("");
+ 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) {
diff --git a/client/js/modals.js b/client/js/modals.js
index 5240526..47fb422 100644
--- a/client/js/modals.js
+++ b/client/js/modals.js
@@ -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";
diff --git a/client/js/modals/city.js b/client/js/modals/city.js
index 7f60b51..762edb6 100644
--- a/client/js/modals/city.js
+++ b/client/js/modals/city.js
@@ -12,6 +12,13 @@ import { buildingEffectText, signedMoney, quotedBuildCost } from "./format.js";
import { renderBudget } from "./budget.js";
import { bindTabs } from "./tabs.js";
import { ethnicityChart } from "../ui/ethnicity.js";
+import {
+ buildCard,
+ setCardMeta,
+ tooltipSections,
+ CARD_RESOURCES,
+ hideCardTooltip,
+} from "../ui/card.js";
import { currencySymbol, formatMoney } from "../currency.js";
export class CityModal {
@@ -56,6 +63,10 @@ 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
@@ -81,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;
@@ -92,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 = $("");
+ this.$buildingsList.append(
+ $("").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);
@@ -486,45 +504,56 @@ export class CityModal {
// ------------------------------------------------------------- buildings --
- _makeBuildingRow(proto, index) {
- const $card = $("");
- const $icon = proto.icon
- ? $("").attr({ src: `assets/${proto.icon}`, alt: proto.name })
- : null;
- const $info = $("");
- const $name = $("");
- const $desc = $("").text(proto.description);
- const $produce = $("");
- const $consume = $("");
- const $cost = $("");
- $info.append($name, $desc, $produce, $consume, $cost);
- const $actions = $("");
- const $upgrade = $("");
- const $downgrade = $("");
- $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 ? $("") : null;
- if ($free) {
- $free.on("click", () => this.onFreeBuild(this.cityId, index));
- $actions.append($free);
+ if (this.testing) {
+ row.$free = $("")
+ .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, $produce, $consume, $cost, $upgrade, $downgrade, $free };
+ return row;
}
- // The materials a bill spends, in resource units and with the commodity
- // named: "10 000 t steel · 200 u high-tech". An empty bill reads "nothing".
- _resourceBillText(resources, ids = ["steel", "energy", "hightech"]) {
- const parts = [];
- for (const id of ids) {
- const amount = resources[id] || 0;
- if (amount > 0) parts.push(formatResourceAmount(id, amount));
+ // 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 parts.length > 0 ? parts.join(" · ") : "nothing";
+ return items;
}
// Why a building cannot be raised here, or null when it can. Ports need the
@@ -550,45 +579,49 @@ export class CityModal {
// 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}`);
- // What the building makes and wears out once it stands at its next level,
- // so an upgrade quotes the level it is buying. Resources lead; the money
- // is only the settlement the state falls back on.
- const shownLevel = Math.max(nextLevel, 1);
- row.$produce.text(`Produces ${buildingEffectText(row.proto, shownLevel)}`);
- const upkeep = protoUpkeep(row.proto, buildingBuildCost(row.proto, shownLevel - 1));
- row.$consume.text(`Consumes ${this._resourceBillText(upkeep)} /day`);
+ 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();
- const materials = constructionResourceCost(row.proto, nextLevel, cost);
- const requirement = this._buildRequirement(row.proto);
- row.$cost.empty();
- row.$cost.append(
- document.createTextNode(
- `Costs ${this._resourceBillText(materials)} · ${hours(duration)} · `
- )
- );
- row.$cost.append(
- $("").text(
- formatMoney(this._quotedCost(cost, row.proto, nextLevel))
- )
- );
- if (requirement) row.$cost.append(document.createTextNode(` · ${requirement}`));
- row.$upgrade.text("Upgrade").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 --
@@ -606,6 +639,7 @@ export class CityModal {
}
close() {
+ hideCardTooltip();
this.$modal.addClass("hidden");
this.cityId = 0;
}
diff --git a/client/js/modals/tile.js b/client/js/modals/tile.js
new file mode 100644
index 0000000..a191e10
--- /dev/null
+++ b/client/js/modals/tile.js
@@ -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;
+ }
+}
diff --git a/client/js/ui/card.js b/client/js/ui/card.js
new file mode 100644
index 0000000..47d851a
--- /dev/null
+++ b/client/js/ui/card.js
@@ -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 = $("");
+ if (proto) {
+ $span.append($("").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 = $("");
+ for (const section of sections) {
+ const $section = $("");
+ $section.append($("").text(section.label));
+ if (!section.items || section.items.length === 0) {
+ $section.append($("").text("nothing"));
+ } else {
+ for (const item of section.items) {
+ const $line = $("").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($("").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 = $("");
+ if (spec.built) $card.addClass("built");
+ if (spec.locked) $card.addClass("locked");
+
+ const $top = $("");
+ if (spec.icon) {
+ $top.append(
+ $("").attr("src", `assets/${spec.icon}`).attr("alt", spec.name)
+ );
+ }
+ const $id = $("");
+ $id.append($("").text(spec.name));
+ const $status = $("")
+ .text(spec.status || "")
+ .toggleClass("built", !!spec.statusBuilt);
+ $id.append($status);
+ $top.append($id);
+ $card.append($top);
+
+ const action = spec.action || {};
+ const $action = $("").prop("disabled", !!action.disabled);
+ $action.append($("").text(action.label || ""));
+ const $meta = $("");
+ 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 = $("")
+ .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 };
+}
diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js
index 7ee7cc6..a939b17 100644
--- a/tests/game_screen_test.js
+++ b/tests/game_screen_test.js
@@ -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"));
@@ -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("50\u00a0000 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("50\u00a0000 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);
diff --git a/tests/modals_test.js b/tests/modals_test.js
index d78d987..3ce6422 100644
--- a/tests/modals_test.js
+++ b/tests/modals_test.js
@@ -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"));