Better tile building management

This commit is contained in:
2026-09-22 19:50:00 +02:00
parent 7c28c4591e
commit c8260b2108
16 changed files with 742 additions and 167 deletions
+17 -5
View File
@@ -357,9 +357,21 @@ input:focus, select:focus { border-color: var(--accent); }
/* A barrage the player is looking at stands out from the rest. */
.target-line.target-selected, .target-ring.target-selected { opacity: 1; }
/* The delivery graph, shown only in the economic map modes: a coloured line
per delivery leg and a dot on every storage node. */
/* The delivery graph, shown only in the economic map modes: a white line per
delivery leg, an animated merchandise icon riding it and a dot on every
storage node. */
#layer-resources { pointer-events: none; }
.resource-mover {
position: absolute;
width: 20px;
height: 20px;
margin-left: -10px;
margin-top: -10px;
box-sizing: border-box;
transform-origin: 50% 50%;
pointer-events: none;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9));
}
.resource-node {
position: absolute;
width: 10px;
@@ -782,10 +794,10 @@ input:focus, select:focus { border-color: var(--accent); }
align-items: center;
gap: 6px;
}
.map-legend-swatch {
.map-legend-icon {
width: 16px;
height: 3px;
border-radius: 2px;
height: 16px;
object-fit: contain;
flex: none;
}
.map-legend-node {
+6 -6
View File
@@ -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": "7c28c45",
"date": "2026-09-22",
"subject": "Fixed a typo for Northumbrian towns",
"body": ""
},
{
"hash": "8f9f72a",
"date": "2026-09-22",
@@ -54,11 +60,5 @@ export const DEVLOG = [
"date": "2026-09-22",
"subject": "Added the resource economy, its works and their icons",
"body": "- Five resources (energy, steel, food, luxury, high-tech) with per-city stores, a world market, delivery and material upkeep.\n- Resource-producing tile works built outside cities; every nation opens with the works its economy needs.\n- Real game-icons.net art replacing the dummy icons for the new resources and works, with the credits updated."
},
{
"hash": "9690c68",
"date": "2026-09-21",
"subject": "Fixed air-strike range and made war-crime popularity a one-off",
"body": "- An aircraft now returns to the nearest friendly airport instead of always its original home, so a plane rebased to a forward airfield can strike targets beside it; the browser mirrors the rule.\n- A pillage's popularity cost is an instant shift that drifts back, not a generation-long grudge. Popularity and approval read the base opinion, while the grudge stays as the diplomatic memory.\n- Doubled aircraft range: the fighter's effective reach is now 25 tiles and the bomber's 50, with missionRange raised to match."
}
];
+111 -99
View File
@@ -969,7 +969,8 @@ export const panelMethods = {
// The construction site on this tile. A site gathering materials shows a
// spinner and a bought/needed bar per material; once everything is bought it
// builds with the same progress bar as a city's production. A stalled site
// warns and offers to raise its budget or cancel the work.
// raises its budget on its own, so the only control is cancelling the work;
// the warning "!" is kept for a site no supplier can reach at all.
_renderTileWorks(coords) {
const $el = $("#tile-works");
if (!$el.length) return;
@@ -988,9 +989,10 @@ export const panelMethods = {
? `Upgrading ${name} to level ${site.targetLevel} — `
: `Building ${name} — `;
if (site.phase === "materials") {
const unreachable = site.unreachable || [];
const $label = $("<div class='city-production-label'></div>").text(gathering);
if (site.stalled) {
$label.prepend($("<span class='site-warning' title='No purchase at the locked price'>!</span> "));
if (unreachable.length) {
$label.prepend($("<span class='site-warning' title='No supplier in reach'>!</span> "));
}
$el.append($label);
for (const id of ["steel", "hightech"]) {
@@ -1007,27 +1009,34 @@ export const panelMethods = {
$bar
);
}
if (!site.stalled) {
$el.append($("<span class='gather-spinner' title='Gathering materials'></span>"));
if (unreachable.length) {
const names = unreachable.map((id) => resourceById(id).name).join(", ");
$el.append(
$("<div class='site-material-label'></div>").text(
`Budget ${currencySymbol()}${compact(site.budget || 0)}`
$("<div class='site-material-label stalled'></div>").text(
`No supplier can reach this site for ${names}. ` +
"It stays stalled until the trade network does."
)
);
} else if (site.stalled) {
// The budget climbs on its own; the player is only told why the work
// is waiting, with no button to press.
$el.append(
$("<div class='site-material-label stalled'></div>").text(
"The market is above the budgeted prices. " +
"The budget rises 10% a day until the materials can be bought."
)
);
} else {
$el.append(
$("<div class='site-material-label stalled'></div>").text(
"The market moved above the locked prices."
)
);
const $raise = $("<button type='button' class='aero'></button>")
.text("Increase budget")
.on("click", () => this._increaseSiteBudget(coords));
const $cancel = $("<button type='button' class='aero'></button>")
.text("Cancel work")
.on("click", () => this._cancelSite(coords));
$el.append($("<div class='site-actions'></div>").append($raise, " ", $cancel));
$el.append($("<span class='gather-spinner' title='Gathering materials'></span>"));
}
$el.append(
$("<div class='site-material-label'></div>").text(
`Budget ${currencySymbol()}${compact(site.budget || 0)}`
)
);
$el.append(
$("<div class='site-actions'></div>").append(this._cancelSiteButton(coords))
);
} else {
const total = Math.max(site.totalHours || 0, 0.0001);
const elapsed = site.elapsedHours || 0;
@@ -1039,6 +1048,11 @@ export const panelMethods = {
$("<div class='city-production-label'></div>").text(`${building}${remaining} h left`),
$bar
);
// Once building has begun the work can still be called off; only the
// materials already bought and the time spent are lost.
$el.append(
$("<div class='site-actions'></div>").append(this._cancelSiteButton(coords))
);
}
}
// A production building upgrades itself: show the work its agent has begun.
@@ -1181,13 +1195,15 @@ export const panelMethods = {
// 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; an unaffordable improvement is greyed out.
// 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.
_renderImprovementActions(coords, buildable, current) {
const $row = $("#tile-improvement-actions");
if (!$row.length) return;
$row.empty();
if (!buildable) return;
const budget = (this.snapshot.viewerStats || {}).budget || 0;
const testing = !!this.testing;
const gdp = (this.snapshot.viewerStats || {}).gdp || 0;
const speed = (this.snapshot.viewerStats || {}).constructionSpeed || 0;
@@ -1202,89 +1218,85 @@ export const panelMethods = {
);
// Military tile works. They cost resources and take production time, so the
// tooltip names the expected hours alongside the upkeep.
// 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.
const military = [];
for (const improvement of TILE_IMPROVEMENTS) {
if (improvement.coastal && !this._tileIsCoastal(coords)) continue;
const built = !!(standing && standing.id === improvement.id);
const affordable = budget >= improvement.buildCost;
const buildHours = ECONOMY.trainingHours(gdp, improvement.buildCost) / (1 + speed);
const title = standing && !built
? `${improvement.name} — another improvement already stands here`
: affordable
? `${improvement.name} — ${this._improvementCostText(improvement, prices)} · ` +
`${this._improvementUpkeepText(improvement)}/day · ${hours(buildHours)} to build`
: `${improvement.name} — needs ${this._improvementCostText(improvement, prices)}`;
const $button = this._improvementButton(improvement, {
className: "tile-btn",
title,
disabled: !!standing || !!pending || !affordable,
current: built,
prices,
});
$button.on("click", () => this._buildTileImprovement(coords, improvement.id));
military.push($button);
}
if (pending) {
military.push(
$("<button type='button' class='aero improvement-btn remove'></button>")
.text("Cancel work")
.on("click", () => this._cancelSite(coords))
);
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.trainingHours(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);
}
}
// 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.
const transport = [];
for (const improvement of TRANSPORT_IMPROVEMENTS) {
const isCurrent = current === improvement.id;
const affordable = budget >= improvement.buildCost;
const title = isCurrent
? `${improvement.name} — already built on this tile`
: affordable
? `${improvement.name} — ${this._improvementCostText(improvement, prices)} · ` +
`${this._improvementUpkeepText(improvement)}/day upkeep`
: `${improvement.name} — needs ${this._improvementCostText(improvement, prices)}`;
const $button = this._improvementButton(improvement, {
className: "transport-btn",
title,
disabled: isCurrent || !!pending || !affordable,
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,
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,
});
$free.on("click", () => this._buildImprovement(coords, improvement.id, true));
transport.push($free);
$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))
);
}
}
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))
);
}
// Economic: resource producers. They live on their own land, take
// production time and feed the delivery graph; only one may stand on a
// tile.
const economic = [];
if (!isResource) {
if (!isResource && !pending) {
for (const producer of RESOURCE_BUILDINGS) {
if (producer.coastal && !this._tileIsCoastal(coords)) continue;
const built = !!(standing && standing.id === producer.id);
const affordable = budget >= producer.buildCost;
const buildHours = ECONOMY.trainingHours(gdp, producer.buildCost) / (1 + speed);
// Some producers are locked behind a technology (the fusion plant).
const techIndex = producer.requiresTechnology
@@ -1298,14 +1310,12 @@ export const panelMethods = {
? `${producer.name} — requires ${TECHNOLOGIES[techIndex].name}`
: standing && !built
? `${producer.name} — another building already stands here`
: affordable
? `${producer.name} — ${out} · ${this._improvementCostText(producer, prices)} · ` +
`${hours(buildHours)} to build`
: `${producer.name} — needs ${this._improvementCostText(producer, prices)}`;
: `${producer.name} — ${out} · ${this._improvementCostText(producer, prices)} · ` +
`${hours(buildHours)} to build`;
const $button = this._improvementButton(producer, {
className: "tile-btn resource-btn",
title,
disabled: !!standing || !!pending || !affordable || locked,
disabled: !!standing || locked,
current: built,
prices,
});
@@ -1313,7 +1323,7 @@ export const panelMethods = {
economic.push($button);
}
}
if (standing) {
if (standing && !pending) {
const name = standingProto ? standingProto.name : standing.id;
const $remove = $("<button type='button' class='aero improvement-btn remove'></button>")
.text(`Remove ${name}`)
@@ -1380,11 +1390,13 @@ export const panelMethods = {
});
},
_increaseSiteBudget(coords) {
this.sendOrder({
type: "increase_site_budget",
coords: [coords.x, coords.y],
});
// The one control a tile under construction still offers, wherever its
// progress is drawn. Cancelling loses the money and materials already sunk
// into the work and raises nothing.
_cancelSiteButton(coords) {
return $("<button type='button' class='aero'></button>")
.text("Cancel work")
.on("click", () => this._cancelSite(coords));
},
_cancelSite(coords) {
+2
View File
@@ -58,6 +58,8 @@ export class MapView {
else this.$world.append(this.$resources);
}
this.resourceGraph = { nodes: [], links: [] };
// The merchandise icons riding the delivery legs, repositioned every frame.
this._deliveryMovers = [];
// City-to-city migration over the last month, drawn instead of the delivery
// graph while the population map is active.
this.migrationGraph = { links: [] };
+4 -4
View File
@@ -110,13 +110,13 @@ export const entityMethods = {
},
// Mirrors the construction sites from the snapshot and draws one marker per
// tile: a spinner while materials are gathered, a red mark when the market
// stalled the work, and a progress bar while it builds.
// tile: a spinner while materials are gathered, a red mark when no supplier
// can reach the work at all, and a progress bar while it builds.
setConstructionSites(sites) {
const flat = [];
for (const list of sites) for (const site of list || []) flat.push(site);
const signature = flat
.map((site) => `${site.coords[0]},${site.coords[1]}:${site.phase}:${site.stalled ? 1 : 0}:${Math.round(site.elapsedHours || 0)}`)
.map((site) => `${site.coords[0]},${site.coords[1]}:${site.phase}:${(site.unreachable || []).length ? 1 : 0}:${Math.round(site.elapsedHours || 0)}`)
.join("|");
if (signature === this._siteSignature) return;
this._siteSignature = signature;
@@ -135,7 +135,7 @@ export const entityMethods = {
const el = document.createElement("div");
el.className = "construction-marker";
if (site.phase === "materials") {
if (site.stalled) {
if ((site.unreachable || []).length) {
el.classList.add("stalled");
el.textContent = "!";
} else {
+72 -7
View File
@@ -5,7 +5,7 @@
// live in ./political.js; this file only decides what is on screen.
import { HEX_W, mapToLocal } from "../../../shared/hex.js";
import { POLITICAL_ZOOM } from "./constants.js";
import { CAMERA_TILT, POLITICAL_ZOOM } from "./constants.js";
import { ECONOMIC_GRADIENT, ECONOMIC_MODES, economicColour, isEconomicMode } from "./economic.js";
import { NEUTRAL_LAND, POLITICAL_WATER } from "./political.js";
import { isWaterTile } from "./textures.js";
@@ -21,6 +21,24 @@ const MIGRATION_COLOUR = "#ff3ec8";
const MIGRATION_ARROW_MIN = 1.5;
const MIGRATION_ARROW_MAX = 12;
// The delivery graph draws one white line per leg, so the goods carried are
// shown by the merchandise icon riding the line rather than by the line's
// colour. Every icon makes the round trip in this many milliseconds.
const DELIVERY_COLOUR = "#ffffff";
const DELIVERY_PERIOD_MS = 2600;
const RESOURCE_META = Object.fromEntries(RESOURCES.map((r) => [r.id, r]));
// A stable offset in 0..1 so every leg starts its run at a different point of
// the cycle, spreading the icons out instead of moving them in lockstep.
function deliveryPhase(resource, fromX, fromY, toX, toY) {
const seed = `${resource}:${fromX},${fromY}->${toX},${toY}`;
let hash = 2166136261;
for (let i = 0; i < seed.length; i++) {
hash = Math.imul(hash ^ seed.charCodeAt(i), 16777619);
}
return ((hash >>> 0) % 997) / 997;
}
export const modeMethods = {
// Selects a mode and repaints. Terrain mode keeps the far-out auto-switch, so
// "political" forces the political map at any zoom while "terrain" only shows
@@ -90,23 +108,47 @@ export const modeMethods = {
const graph = migration ? this.migrationGraph : this.resourceGraph;
if (!this.economic || graph.links.length === 0) {
if (this.$resources[0].firstChild) this.$resources.empty();
this._deliveryMovers = [];
this._renderGraphLegend();
return;
}
if (migration) this._renderMigrationArrows(graph.links);
else this._renderDeliveryLines(graph);
if (migration) {
this._deliveryMovers = [];
this._renderMigrationArrows(graph.links);
} else {
this._renderDeliveryLines(graph);
}
this._renderGraphLegend();
},
// One line per delivery leg and a dot on every storage node.
// One white line per delivery leg, a dot on every storage node and a
// merchandise icon per leg, animated along the line by `_animateDeliveryIcons`
// so the goods moving between regions are obvious at a glance.
_renderDeliveryLines(graph) {
const colours = Object.fromEntries(RESOURCES.map((r) => [r.id, r.colour]));
const fragment = document.createDocumentFragment();
const movers = [];
for (const link of graph.links) {
const [fromX, fromY, toX, toY, resource] = link;
const a = mapToLocal(fromX, fromY);
const b = mapToLocal(toX, toY);
fragment.appendChild(makeSegment(a, b, colours[resource] || "#ffffff", 3));
fragment.appendChild(makeSegment(a, b, DELIVERY_COLOUR, 3));
const meta = RESOURCE_META[resource];
if (!meta || !meta.icon) continue;
const mover = document.createElement("img");
mover.className = "resource-mover";
mover.src = `assets/${meta.icon}`;
mover.alt = meta.name;
mover.draggable = false;
// The layer is foreshortened on Y; undo the tilt on the sprite so the
// merchandise stays upright while its centre rides the line.
mover.style.transform = `scaleY(${1 / CAMERA_TILT})`;
movers.push({
el: mover,
a,
b,
phase: deliveryPhase(resource, fromX, fromY, toX, toY),
});
fragment.appendChild(mover);
}
for (const node of graph.nodes) {
const point = mapToLocal(node[0], node[1]);
@@ -117,6 +159,25 @@ export const modeMethods = {
fragment.appendChild(dot);
}
this.$resources.empty()[0].appendChild(fragment);
this._deliveryMovers = movers;
this._animateDeliveryIcons();
},
// Slides every merchandise icon along its leg, the whole fleet sharing one
// wall-clock cycle so a snapshot rebuild leaves the animation uninterrupted.
_animateDeliveryIcons() {
const movers = this._deliveryMovers;
if (!movers || movers.length === 0) return;
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
const travel = (now % DELIVERY_PERIOD_MS) / DELIVERY_PERIOD_MS;
for (const mover of movers) {
const t = (travel + mover.phase) % 1;
mover.el.style.left = `${mover.a.x + (mover.b.x - mover.a.x) * t}px`;
mover.el.style.top = `${mover.a.y + (mover.b.y - mover.a.y) * t}px`;
// Fade at both ends, so the jump back to the origin is hidden and the
// fleet reads as a continuous flow.
mover.el.style.opacity = Math.min(1, t / 0.1, (1 - t) / 0.1).toFixed(3);
}
},
// One arrow per city pair, its thickness scaled to the people who moved over
@@ -173,7 +234,11 @@ export const modeMethods = {
if (!present.has(resource.id)) continue;
$list.append(
$("<div class='map-legend-resource'></div>")
.append($("<span class='map-legend-swatch'></span>").css("background", resource.colour))
.append(
$("<img class='map-legend-icon' />")
.attr("src", `assets/${resource.icon}`)
.attr("alt", resource.name)
)
.append($("<span></span>").text(resource.name))
);
}
+1
View File
@@ -95,6 +95,7 @@ export const motionMethods = {
this._applyEntityWrap();
this._drawPaths();
this._drawStrikeTargets();
this._animateDeliveryIcons();
this._flushFog();
this._glRender();
},
-6
View File
@@ -196,8 +196,6 @@ export class GameServer extends EventEmitter {
return this._handleBuildTileImprovement(civ, order);
case "remove_tile_improvement":
return this._handleRemoveTileImprovement(civ, order);
case "increase_site_budget":
return this._handleIncreaseSiteBudget(civ, order);
case "cancel_site":
return this._handleCancelSite(civ, order);
case "embark":
@@ -353,10 +351,6 @@ export class GameServer extends EventEmitter {
return this.state.requestRemoveTileImprovement(civ, coordsOf(order.coords));
}
_handleIncreaseSiteBudget(civ, order) {
return this.state.requestIncreaseSiteBudget(civ, coordsOf(order.coords));
}
_handleCancelSite(civ, order) {
return this.state.requestCancelSite(civ, coordsOf(order.coords));
}
+3
View File
@@ -522,6 +522,9 @@ export const serializationMethods = {
phase: site.phase,
stalled: site.stalled,
budget: site.budget,
// Materials no reachable source can supply: the one case the panel
// keeps a warning for, since no budget can unstall the work.
unreachable: site.stalled ? this._siteUnreachableMaterials(site) : [],
prices: { ...site.prices },
needed: { ...site.needed },
bought: { ...site.bought },
+61 -25
View File
@@ -9,10 +9,12 @@
// treasury.
//
// A site stalls when a whole day passes without buying a needed material at the
// locked price (the market moved against it). The owner may then raise the
// site's budget to the current prices, or cancel the work, losing everything
// already spent. Production buildings that finish become private agents and step
// through the same phases again when they upgrade.
// locked price (the market moved against it). Rather than waiting for a manual
// top-up, it raises its budgeted prices -- and the money behind them -- by a
// tenth a day until it can buy; only when no supplier is within reach at all
// does it keep a lasting warning. The owner may still cancel the work, losing
// everything already spent. Production buildings that finish become private
// agents and step through the same phases again when they upgrade.
import { key } from "../hex.js";
import { ECONOMY } from "../data/economy.js";
@@ -24,9 +26,12 @@ import {
import { TILE_IMPROVEMENT_HP, HOURS_PER_DAY } from "./constants.js";
// A supplier whose delivered price has risen more than this much over the
// locked price is not bought from: the site waits for the market or for its
// budget to be raised.
const PRICE_TOLERANCE = 1.01;
// locked price is not bought from: the site waits for the market or raises its
// own budget.
const PRICE_TOLERANCE = 0.01;
// Each day a stalled site cannot buy, its budgeted prices and the matching
// money rise by this factor, so it bids its way up to the market unaided.
const BUDGET_DAILY_GROWTH = 1.1;
const MATERIAL_IDS = ["steel", "hightech"];
const RESOURCE_IDS = ["steel", "hightech", "energy"];
@@ -194,6 +199,7 @@ export const siteMethods = {
if (site.lastBuyDay !== day) {
site.lastBuyDay = day;
this._gatherSiteMaterials(site);
this._raiseSiteBudgetIfStalled(site);
}
} else {
site.elapsedHours += 1;
@@ -207,8 +213,8 @@ export const siteMethods = {
},
// Tries to buy every material the site still needs at its locked prices. A day
// that buys nothing while materials are still missing stalls the work, so the
// owner is told (and may raise the budget or cancel).
// that buys nothing while materials are still missing stalls the work, and the
// budget below starts climbing to meet the market.
_gatherSiteMaterials(site) {
let boughtAnything = false;
for (const id of MATERIAL_IDS) {
@@ -235,6 +241,22 @@ export const siteMethods = {
}
},
// A day that bought nothing while a supplier is within reach raises the site's
// budgeted prices by a tenth and moves the matching money out of the treasury,
// then tries again. A supplier price above the raised cap simply stalls it
// once more and the budget climbs another tenth tomorrow. When no supplier can
// reach the site at any price the rise cannot help, so it is left alone.
_raiseSiteBudgetIfStalled(site) {
if (!site.stalled || site.phase !== "materials") return;
if (this._siteUnreachableMaterials(site).length > 0) return;
for (const id of RESOURCE_IDS) site.prices[id] *= BUDGET_DAILY_GROWTH;
const add = this._siteTopUp(site, site.prices);
this._reserveSiteBudget(site.civ, add);
site.budget += add;
this._gatherSiteMaterials(site);
this._emitChanged();
},
// Buys `amount` of one resource for the site: first from the allied city the
// route hauls to, then from the reachable stores, paying from the site's own
// budget first and the public treasury after. A supplier quoting above the
@@ -351,26 +373,40 @@ export const siteMethods = {
// ------------------------------------------------- owner actions --
// Raises a stalled site's budget to the current material prices and unlocks
// it. The extra money comes from the public treasury.
requestIncreaseSiteBudget(civ, coords) {
const site = this.constructionSiteAt(coords);
if (!site || site.civ !== civ) return false;
const prices = this._lockedUnitPrices();
// The materials a site still needs that no reachable source can provide: the
// hub city's own store is empty and no supplier sits within the trade radius.
// A non-empty result means no budget, however large, can unstall the work, so
// the panel keeps a lasting warning instead.
_siteUnreachableMaterials(site) {
const missing = MATERIAL_IDS.filter(
(id) => site.needed[id] - site.bought[id] > 1e-9
);
if (missing.length === 0) return [];
const city = site.cityId != null ? this._cityById.get(site.cityId) : null;
if (!city) return missing.slice();
const nodes = this._resourceNodes();
const allowSea = this._cityMarketAccess(city);
const store = this.getCityResourceStock(city);
const unreachable = [];
for (const id of missing) {
if ((store[id] || 0) > 0) continue;
if (this._nearbySuppliers(city, id, nodes, allowSea).length > 0) continue;
unreachable.push(id);
}
return unreachable;
},
// The extra a site must be granted to buy its remaining materials at the given
// unit prices (its own budgeted prices by default): the shortfall between what
// those materials cost and the money the site still holds.
_siteTopUp(site, prices = null) {
const p = prices || site.prices;
let wanted = 0;
for (const id of RESOURCE_IDS) {
const missing = Math.max(0, site.needed[id] - site.bought[id]);
wanted += missing * prices[id];
wanted += missing * p[id];
}
const add = Math.max(0, wanted - site.budget);
this._reserveSiteBudget(civ, add);
site.budget += add;
site.prices = prices;
site.stalled = false;
// Try again at once so the panel updates without waiting for the day.
if (site.phase === "materials") this._gatherSiteMaterials(site);
this._emitChanged();
return true;
return Math.max(0, wanted - site.budget);
},
// Cancels a site. Nothing is refunded: the budget already handed to it and
+174 -11
View File
@@ -141,7 +141,35 @@ export class ConstructionSiteTest extends TestCase {
this.assertEqual(site.phase, "materials", "it never reached construction");
}
test_increasing_a_stalled_budget_reopens_the_work() {
test_a_stalled_budget_climbs_on_its_own() {
const state = smallState();
clearMaterials(state);
const city = cityOf(state, 0);
const coords = bareOwnedLand(state);
const site = makeSite(state, coords, {
cityId: city.id,
budget: 0,
needed: { steel: 100, hightech: 0, energy: 0 },
stalled: true,
});
// A token of stock in the hub city keeps the material reachable, so the
// automatic rise applies; it is nowhere near enough to finish the work.
state.getCityResourceStock(city).steel = 1;
const treasury = 1e9;
state.budgets.set(0, treasury);
const locked = site.prices.steel;
state._raiseSiteBudgetIfStalled(site);
this.assertApprox(
site.prices.steel,
locked * 1.1,
1e-6,
"the budgeted price rises a tenth"
);
this.assertGreater(site.budget, 0, "the matching money moved into the site");
this.assertLess(state.getBudget(0), treasury, "and came from the treasury");
}
test_a_stalled_budget_climbs_until_a_supplier_is_affordable() {
const state = smallState();
clearMaterials(state);
const city = cityOf(state, 0);
@@ -151,17 +179,53 @@ export class ConstructionSiteTest extends TestCase {
budget: 0,
needed: { steel: 100, hightech: 0, energy: 0 },
});
state.totalHours = 1;
// A supplier sits within reach, but its delivered price is far above the
// site's locked price, so it takes several days of rises to afford it.
site.prices.steel = state._marketUnitCost("steel") * 0.2;
for (const other of state.cities) state.getCityResourceStock(other).steel = 1e9;
const nodes = state._resourceNodes();
const suppliers = state._nearbySuppliers(city, "steel", nodes);
this.assertGreater(suppliers.length, 0, "a supplier is reachable");
for (const other of state.cities) state.getCityResourceStock(other).steel = 0;
suppliers[0].stock.steel = 1e9;
state.budgets.set(0, 1e15);
state._gatherSiteMaterials(site);
this.assertTrue(site.stalled, "no money and no purchase");
// Refill the store, then raise the budget to the current prices.
state.getCityResourceStock(city).steel = 1000;
this.assertTrue(site.stalled, "the locked price is too low to buy at");
const locked = site.prices.steel;
state._raiseSiteBudgetIfStalled(site);
this.assertApprox(
site.prices.steel,
locked * 1.1,
1e-6,
"the budgeted price rose a tenth"
);
// Keep letting the budget climb; it must eventually reach the supplier.
for (let day = 0; day < 60 && site.phase === "materials"; day++) {
state._raiseSiteBudgetIfStalled(site);
}
this.assertEqual(site.phase, "construction", "the site eventually bought at the market");
this.assertApprox(site.bought.steel, 100, 1e-6, "all the steel was gathered");
}
test_an_unreachable_site_does_not_waste_money_on_a_rise() {
const state = smallState();
clearMaterials(state);
const city = cityOf(state, 0);
const coords = bareOwnedLand(state);
const site = makeSite(state, coords, {
cityId: city.id,
budget: 0,
needed: { steel: 100, hightech: 0, energy: 0 },
stalled: true,
});
const treasury = 1e9;
state.budgets.set(0, treasury);
this.assertTrue(state.requestIncreaseSiteBudget(0, coords));
this.assertFalse(site.stalled, "the work is unlocked");
this.assertApprox(site.bought.steel, 100, 1e-6, "the materials were bought");
this.assertLess(state.getBudget(0), treasury, "the treasury funded the increase");
const locked = site.prices.steel;
state._raiseSiteBudgetIfStalled(site);
this.assertEqual(site.stalled, true, "the work stays stalled");
this.assertApprox(site.budget, 0, 1e-6, "no money was moved");
this.assertApprox(site.prices.steel, locked, 1e-9, "and no price was raised");
this.assertApprox(state.getBudget(0), treasury, 1e-6, "the treasury is untouched");
}
test_cancelling_a_site_refunds_nothing() {
@@ -178,13 +242,28 @@ export class ConstructionSiteTest extends TestCase {
this.assertApprox(state.getBudget(0), treasury, 1e-6, "the budget is not returned");
}
test_only_the_owner_may_increase_or_cancel_a_site() {
test_a_site_can_be_cancelled_once_construction_begins() {
const state = smallState();
const coords = bareOwnedLand(state);
const site = makeSite(state, coords, {
budget: 1e6,
needed: { steel: 10, hightech: 0, energy: 0 },
bought: { steel: 10, hightech: 0, energy: 0 },
phase: "construction",
elapsedHours: 5,
totalHours: 10,
});
this.assertEqual(site.phase, "construction", "the materials are in and building has begun");
this.assertTrue(state.requestCancelSite(0, coords), "the work can still be called off");
this.assertNull(state.constructionSiteAt(coords), "the site is gone");
}
test_only_the_owner_may_cancel_a_site() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const coords = bareOwnedLand(state);
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
this.assertFalse(state.requestCancelSite(1, coords), "a foreign nation cannot cancel it");
this.assertFalse(state.requestIncreaseSiteBudget(1, coords), "nor raise its budget");
this.assertNotNull(state.constructionSiteAt(coords));
}
@@ -264,6 +343,88 @@ export class ConstructionSiteTest extends TestCase {
this.assertLess(state.improvementCashAt(coords), 1e9, "the reserve was spent");
}
test_a_stalled_site_knows_what_its_remaining_materials_cost() {
const state = smallState();
const city = cityOf(state, 0);
const coords = bareOwnedLand(state);
const site = makeSite(state, coords, {
cityId: city.id,
budget: 1000,
needed: { steel: 100, hightech: 0, energy: 0 },
stalled: true,
});
// The shortfall between the remaining materials at the site's own budgeted
// prices and the money it still holds is what a raise must move in.
const expected = 100 * site.prices.steel - 1000;
this.assertApprox(state._siteTopUp(site), expected, 1e-6, "the site's shortfall");
this.assertApprox(
state._siteTopUp(site, { ...site.prices, steel: site.prices.steel * 2 }),
expected + 100 * site.prices.steel,
1e-6,
"a higher price asks for more"
);
}
test_a_site_holding_enough_reports_no_top_up() {
const state = smallState();
const coords = bareOwnedLand(state);
const site = makeSite(state, coords, {
budget: 1e12,
needed: { steel: 100, hightech: 0, energy: 0 },
stalled: true,
});
this.assertEqual(state._siteTopUp(site), 0, "a funded site owes nothing");
}
test_a_site_with_no_reachable_supplier_reports_it() {
const state = smallState();
clearMaterials(state);
const city = cityOf(state, 0);
const coords = bareOwnedLand(state);
const site = makeSite(state, coords, {
cityId: city.id,
budget: 1e9,
needed: { steel: 100, hightech: 40, energy: 0 },
stalled: true,
});
// Nothing within reach holds either material, so no budget can unstall it.
const unreachable = state._siteUnreachableMaterials(site).slice().sort();
this.assertEqual(unreachable.join(","), "hightech,steel", "both materials are unreachable");
const snapshot = state.snapshot(0);
const entry = (snapshot.constructionSites[0] || []).find(
(row) => row.coords[0] === coords.x && row.coords[1] === coords.y
);
this.assertEqual(
entry.unreachable.slice().sort().join(","),
"hightech,steel",
"the panel is told no supplier is in reach"
);
}
test_a_reachable_supplier_clears_the_unreachable_flag() {
const state = smallState();
clearMaterials(state);
const city = cityOf(state, 0);
const coords = bareOwnedLand(state);
const site = makeSite(state, coords, {
cityId: city.id,
budget: 1e9,
needed: { steel: 100, hightech: 0, energy: 0 },
stalled: true,
});
this.assertEqual(
state._siteUnreachableMaterials(site).join(","),
"steel",
"the empty market leaves steel unreachable"
);
state.getCityResourceStock(city).steel = 1000;
this.assertEqual(
state._siteUnreachableMaterials(site).length,
0,
"the hub city's own store counts as reachable"
);
}
test_the_snapshot_ships_the_sites_with_their_phase_and_budget() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
@@ -278,5 +439,7 @@ export class ConstructionSiteTest extends TestCase {
this.assertTrue(entry.phase === "materials" || entry.phase === "construction");
this.assertNotNull(entry.prices.steel, "the locked prices travel too");
this.assertNotNull(entry.cityId, "the haul city travels too");
this.assertEqual(entry.unreachable.length, 0, "a fresh site has no missing supplier");
this.assertEqual("topUp" in entry, false, "no manual top-up is shipped any more");
}
}
+172
View File
@@ -1647,6 +1647,178 @@ export class GameScreenTest extends TestCase {
}
}
async test_a_negative_budget_never_greys_out_an_improvement() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
// The treasury is far in the red: any order is financed by the central
// bank as debt, so the buttons must stay live.
state.budgets.set(0, -5_000_000);
screen.onState(state.snapshot(0));
const coords = state.landCells.find((cell) => {
const k = `${cell.x},${cell.y}`;
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
});
this.assertNotNull(coords, "the player owns a bare land tile");
screen._showTile(coords);
this.assertFalse(
env.$("#tile-improvement-actions .transport-btn").eq(0).prop("disabled"),
"transport stays clickable in debt"
);
const military = env.$("#tile-improvement-actions .improvement-row")
.eq(0).find(".tile-btn").first();
this.assertFalse(military.prop("disabled"), "a military work stays clickable in debt");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_a_tile_under_construction_offers_only_a_cancel() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
state.budgets.set(0, 1.0e15);
const coords = state.landCells.find((cell) => {
const k = `${cell.x},${cell.y}`;
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
});
this.assertNotNull(coords, "the player owns a bare land tile");
this.assertTrue(
state.requestBuildTileImprovement(0, coords, "radar_tower"),
"the work is ordered"
);
screen.onState(state.snapshot(0));
screen._showTile(coords);
// 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");
// 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");
this.assertTrue($cancel.text().includes("Cancel"), `it cancels the work: ${$cancel.text()}`);
$cancel.click();
const order = network.orders[network.orders.length - 1];
this.assertEqual(order.type, "cancel_site");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_a_stalled_site_raises_its_budget_without_a_button() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
state.budgets.set(0, 1.0e15);
const coords = state.landCells.find((cell) => {
const k = `${cell.x},${cell.y}`;
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
});
this.assertNotNull(coords, "the player owns a bare land tile");
this.assertTrue(state.requestBuildImprovement(0, coords, "road"), "the work is ordered");
// Force a stall with materials still to buy and the market up sharply,
// but a supplier within reach so the budget can climb.
const site = state.constructionSiteAt(coords);
site.budget = 1000;
site.phase = "materials";
site.needed.steel = 100;
site.bought.steel = 0;
site.stalled = true;
state.setResourcePrice("steel", state.getResourcePrice("steel") * 10);
const snapshot = state.snapshot(0);
const entry = (snapshot.constructionSites[0] || []).find(
(row) => row.coords[0] === coords.x && row.coords[1] === coords.y
);
entry.unreachable = [];
screen.onState(snapshot);
screen._showTile(coords);
const labels = env.$("#tile-works .site-material-label")
.map((_, el) => env.$(el).text())
.get()
.join("|");
this.assertTrue(labels.includes("10%"), `the automatic rise is explained: ${labels}`);
this.assertFalse(labels.includes("Increase budget"), `no raise is offered: ${labels}`);
this.assertEqual(
env.$("#tile-works .site-warning").length,
0,
"a reachable site keeps no warning sign"
);
const $actions = env.$("#tile-works .site-actions button");
this.assertEqual($actions.length, 1, "only one control is left");
this.assertTrue(
$actions.eq(0).text().includes("Cancel"),
`cancelling stays: ${$actions.eq(0).text()}`
);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_a_stalled_site_with_no_supplier_says_so() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
state.budgets.set(0, 1.0e15);
const coords = state.landCells.find((cell) => {
const k = `${cell.x},${cell.y}`;
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
});
this.assertNotNull(coords, "the player owns a bare land tile");
this.assertTrue(state.requestBuildImprovement(0, coords, "road"), "the work is ordered");
const site = state.constructionSiteAt(coords);
site.budget = 1000;
site.phase = "materials";
site.needed.steel = 100;
site.bought.steel = 0;
site.stalled = true;
const snapshot = state.snapshot(0);
const entry = (snapshot.constructionSites[0] || []).find(
(row) => row.coords[0] === coords.x && row.coords[1] === coords.y
);
// No reachable source holds the steel: no budget can unstall the work, so
// this is the only case that keeps the warning sign.
entry.unreachable = ["steel"];
screen.onState(snapshot);
screen._showTile(coords);
const labels = env.$("#tile-works .site-material-label")
.map((_, el) => env.$(el).text())
.get()
.join("|");
this.assertTrue(labels.includes("No supplier"), `the missing supplier is shown: ${labels}`);
this.assertFalse(labels.includes("Increase budget"), `no raise is offered: ${labels}`);
this.assertEqual(
env.$("#tile-works .site-warning").length,
1,
"the warning sign marks the missing supplier"
);
const $actions = env.$("#tile-works .site-actions button");
this.assertEqual($actions.length, 1, "only the cancel button remains");
this.assertTrue($actions.eq(0).text().includes("Cancel"), "cancelling still works");
$actions.eq(0).click();
this.assertEqual(
network.orders[network.orders.length - 1],
{ type: "cancel_site", coords: [coords.x, coords.y] }
);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_improvement_cost_tracks_market_prices() {
const env = await setupDom();
try {
+12
View File
@@ -47,6 +47,18 @@ export class GameStateTechnologyTest extends TestCase {
this.assertEqual(state.getFocusedTechnology(0), -1, "the focus clears");
}
test_research_is_never_gated_on_the_treasury() {
const state = smallState();
// A treasury deep in the red does not stop a nation focusing or accruing
// research: the shortfall is central-bank debt, not a spending limit.
state.budgets.set(0, -1_000_000);
const index = this.index(state, "scientific_method");
this.assertTrue(state.requestFocusTechnology(0, index), "the focus is accepted in debt");
state._addResearch(0, 5);
this.assertApprox(state.getTechnologyProgress(0), 5, 1e-9, "points still accrue");
this.assertLess(state.getBudget(0), 0, "the treasury stayed in debt");
}
test_switching_focus_keeps_committed_progress() {
const state = smallState();
this.complete(state, "scientific_method");
+13
View File
@@ -157,6 +157,19 @@ export class ImprovementsTest extends TestCase {
this.assertTrue(state.roads.has(key(coords.x, coords.y)));
}
test_a_negative_treasury_never_refuses_a_work() {
const state = smallState();
state.budgets.set(0, -1_000_000);
stockMaterials(state);
const coords = bareOwnedLand(state);
// A treasury already in the red borrows still more from the central bank;
// the order is never refused for want of money.
this.assertTrue(state.requestBuildImprovement(0, coords, "road"), "the work is still ordered");
this.assertLess(state.getBudget(0), -1_000_000, "the shortfall deepened the debt");
completeSites(state);
this.assertTrue(state.roads.has(key(coords.x, coords.y)), "the road was built");
}
test_maintenance_counts_roads_and_railways_the_nation_holds() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
+61 -4
View File
@@ -193,7 +193,7 @@ export class MapModesTest extends TestCase {
}
}
async test_the_delivery_graph_has_a_colour_legend() {
async test_the_delivery_graph_has_an_icon_legend() {
const state = smallState();
const env = await setupDom();
const map = this.buildMap(env);
@@ -213,9 +213,12 @@ export class MapModesTest extends TestCase {
this.assertTrue(labels.some((t) => t.includes("Steel")), "steel is keyed");
this.assertTrue(labels.some((t) => t.includes("Food")), "food is keyed");
this.assertTrue(labels.some((t) => t.includes("Storage node")), "the node marker is keyed");
this.assertSize(
env.$("#map-legend-resources .map-legend-swatch"), 2,
"one swatch per commodity on the map"
const icons = env.$("#map-legend-resources .map-legend-icon");
this.assertSize(icons, 2, "one merchandise icon per commodity on the map");
this.assertTrue(
icons.map((_, el) => el.getAttribute("src")).get()
.some((src) => src.endsWith("icon_steel.svg")),
"the key shows the merchandise art"
);
map.setMapMode("terrain");
map.setResourceGraph({ nodes: [[1, 1, 1]], links: [[0, 0, 1, 1, "steel", 10]] });
@@ -228,6 +231,60 @@ export class MapModesTest extends TestCase {
}
}
async test_the_delivery_graph_uses_white_lines_and_merchandise_icons() {
const state = smallState();
const env = await setupDom();
const map = this.buildMap(env);
try {
map.applySnapshot(state.snapshot(0));
map.setMapMode("gdp");
map.setResourceGraph({
nodes: [[1, 1, 1]],
links: [[0, 0, 1, 1, "steel", 10], [2, 2, 3, 3, "food", 5]],
});
const segments = env.$("#layer-resources .border-seg");
this.assertSize(segments, 2, "one line per delivery leg");
segments.each((_index, element) => {
const background = element.style.background || "";
this.assertTrue(
/255, 255, 255|#ffffff/i.test(background),
`the delivery line is white: ${background}`
);
});
const movers = env.$("#layer-resources .resource-mover");
this.assertSize(movers, 2, "one merchandise icon rides each leg");
const sources = movers.map((_index, element) => element.getAttribute("src")).get();
this.assertTrue(sources.some((src) => src.endsWith("icon_steel.svg")), "steel is carried");
this.assertTrue(sources.some((src) => src.endsWith("icon_food.svg")), "food is carried");
} finally {
teardownDom(env);
}
}
async test_the_merchandise_icons_animate_along_the_line() {
const state = smallState();
const env = await setupDom();
const map = this.buildMap(env);
const originalNow = performance.now;
try {
map.applySnapshot(state.snapshot(0));
map.setMapMode("gdp");
map.setResourceGraph({ nodes: [], links: [[0, 0, 4, 0, "steel", 10]] });
const mover = env.$("#layer-resources .resource-mover");
this.assertSize(mover, 1, "an icon rides the leg");
performance.now = () => 0;
map._animateDeliveryIcons();
const first = mover[0].style.left;
performance.now = () => 650; // a quarter of the 2600 ms cycle
map._animateDeliveryIcons();
const second = mover[0].style.left;
this.assertNotEqual(first, second, "the icon moves over time");
} finally {
performance.now = originalNow;
teardownDom(env);
}
}
async test_fog_rebuilds_are_coalesced_between_frames() {
const env = await setupDom();
const map = this.buildMap(env);
+33
View File
@@ -47,6 +47,39 @@ export class MapViewTest extends TestCase {
}
}
async test_a_site_warns_only_when_no_supplier_is_in_reach() {
const state = smallState();
const { env, map } = await this.build(state);
try {
const coords = [2, 1];
map.setConstructionSites([[{
coords,
phase: "materials",
stalled: true,
unreachable: [],
elapsedHours: 0,
totalHours: 10,
}]]);
let marker = env.$("#layer-entities .construction-marker").eq(0);
this.assertEqual(marker.text(), "", "a reachable stalled site keeps no exclamation");
this.assertEqual(marker.find(".gather-spinner").length, 1, "it keeps working");
map.setConstructionSites([[{
coords,
phase: "materials",
stalled: true,
unreachable: ["steel"],
elapsedHours: 0,
totalHours: 10,
}]]);
marker = env.$("#layer-entities .construction-marker").eq(0);
this.assertEqual(marker.text(), "!", "only an unreachable site warns");
this.assertTrue(marker.hasClass("stalled"), "and the marker is flagged stalled");
} finally {
teardownDom(env);
}
}
async test_dom_terrain_uses_the_tiling_land_textures() {
const state = smallState();
const { env, map } = await this.build(state);