Better tile building management
This commit is contained in:
+17
-5
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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: [] };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export const motionMethods = {
|
||||
this._applyEntityWrap();
|
||||
this._drawPaths();
|
||||
this._drawStrikeTargets();
|
||||
this._animateDeliveryIcons();
|
||||
this._flushFog();
|
||||
this._glRender();
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user