Compare commits

...
4 Commits
Author SHA1 Message Date
adrien 1324128245 Showed idle trade routes and flagged migrant flows on the economic maps
The delivery graph now ships every route the viewer's own storage nodes could trade over, not just the legs that ran, and keeps exports as well as imports so goods leaving the nation appear. The map draws the idle routes grey beneath the active white legs, with a key entry for them.

The population map replaces its bare flow arrows with the arrows plus a disc textured with the origin nation's flag drifting from city to city, mirroring the merchandise that rides the trade graph.
2026-09-22 21:44:29 +02:00
adrien aa726ff431 Made queued buildings gather their materials and rise ten times faster
City building orders now keep their production-queue slot but open in a materials phase: the order reserves the market value of its steel and high-tech, buys from the nation's own stores and reachable suppliers once a day, and only joins a construction line once the bill is settled, so a city with no supplier simply waits. The queue, the city panel and the quick view show the gathering progress instead of a stalled bar.

Buildings and tile improvements now take a tenth of the old time; unit training is untouched. Any material money left over is returned when the work completes.
2026-09-22 21:43:31 +02:00
adrien c8260b2108 Better tile building management 2026-09-22 19:50:00 +02:00
adrien 7c28c4591e Fixed a typo for Northumbrian towns 2026-09-22 18:13:02 +02:00
26 changed files with 1374 additions and 234 deletions
+43 -6
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;
@@ -376,7 +388,8 @@ input:focus, select:focus { border-color: var(--accent); }
.resource-icon { vertical-align: middle; }
/* The migration graph, shown in the population map: a shaft and an arrowhead
per city pair, the thickness carrying the people who moved last month. */
per city pair, the thickness carrying the people who moved last month, and a
flag-textured disc per route drifting along it like the trade merchandise. */
.migration-arrow { pointer-events: none; }
.migration-arrow-head {
position: absolute;
@@ -384,6 +397,22 @@ input:focus, select:focus { border-color: var(--accent); }
transform-origin: 100% 50%;
clip-path: polygon(0 0, 100% 50%, 0 100%);
}
.migrant-mover {
position: absolute;
width: 22px;
height: 22px;
margin-left: -11px;
margin-top: -11px;
box-sizing: border-box;
border: 2px solid rgba(255, 255, 255, 0.9);
border-radius: 50%;
background-size: cover;
background-position: center;
transform-origin: 50% 50%;
pointer-events: none;
opacity: 0;
box-shadow: 0 0 6px rgba(0, 0, 0, 0.85);
}
/* Outlines the selected or inspected tile. The polygon is built in JS from the
same warped hexagon as the terrain, so it follows the geography. */
@@ -782,10 +811,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 {
@@ -797,6 +826,14 @@ input:focus, select:focus { border-color: var(--accent); }
border: 1px solid rgba(0, 0, 0, 0.6);
flex: none;
}
/* The trade key's idle-route swatch: the same grey as the unused map lines. */
.map-legend-idle {
width: 18px;
height: 4px;
border-radius: 2px;
background: #7b7f85;
flex: none;
}
/* The migration key: a small arrow in the same hue as the map's arrows. */
.map-legend-arrow {
position: relative;
+24 -24
View File
@@ -1,6 +1,30 @@
// 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": "aa726ff",
"date": "2026-09-22",
"subject": "Made queued buildings gather their materials and rise ten times faster",
"body": "City building orders now keep their production-queue slot but open in a materials phase: the order reserves the market value of its steel and high-tech, buys from the nation's own stores and reachable suppliers once a day, and only joins a construction line once the bill is settled, so a city with no supplier simply waits. The queue, the city panel and the quick view show the gathering progress instead of a stalled bar.\n\nBuildings and tile improvements now take a tenth of the old time; unit training is untouched. Any material money left over is returned when the work completes."
},
{
"hash": "c8260b2",
"date": "2026-09-22",
"subject": "Better tile building management",
"body": ""
},
{
"hash": "7c28c45",
"date": "2026-09-22",
"subject": "Fixed a typo for Northumbrian towns",
"body": ""
},
{
"hash": "8f9f72a",
"date": "2026-09-22",
"subject": "Added Bulgaria and Northumbria as playable nations",
"body": "Bulgaria (Sofia, Plovdiv, Varna...) and Northumbria (Newcastle upon Tyne, Washington, Gateshead...) join the roster with their own colours, currencies, city-name pools and 128x128 flag art. France and Britain also lead with their capitals now, Paris and London."
},
{
"hash": "c2f131d",
"date": "2026-09-22",
@@ -36,29 +60,5 @@ export const DEVLOG = [
"date": "2026-09-22",
"subject": "Replaced the GDP tax with national sales and tariff taxes",
"body": ""
},
{
"hash": "91dfedf",
"date": "2026-09-22",
"subject": "Gave regions their own economies and removed the global market",
"body": "- Each nation has a currency and each region a private-sector cash basket, borrowing from its central bank at the bank's rate; the government may always pay, going into central-bank debt when its budget runs red.\n- Resources are bought from reachable stores only: the region itself first, then same-nation regions, then foreign ones, paid for out of private cash or, for government orders, the treasury. Drawn national stores are paid to their owning region.\n- A grid in deficit imports power from a reachable surplus grid instead of a market fallback; downgrading a building no longer refunds anything.\n- The monthly budget lists each resource's economic value, expands to a resource-to-source tree and keeps a cash figure beside it.\n- Regions report what their people went short of over the day; starved research buildings and power-starved works raise a private daily notice.\n- The city panel gains a Resources tab: reserves per resource, their market value and the private sector's daily consumption."
},
{
"hash": "e7ee5db",
"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."
},
{
"hash": "f3e94a3",
"date": "2026-09-21",
"subject": "Added the devlog, fixed the map labels and reworked joining",
"body": "- Political map country names were invisible because the label SVG had a zero-sized viewport, which some browsers never paint; size it to the world and guard it with a test.\n- Added a Devlog button showing the ten latest commits, generated into client/js/devlog_data.js by scripts/generate-devlog.js and refreshed by the pre-commit hook.\n- Worked through FIXME.md: the city button and a unit double-click now open the city, and the stat tooltip escapes the HUD so the unit panel cannot cover it.\n- The server creates its game at startup and clients only join it; the Create game screen and the configure_game path are gone, and testing is a --testing server flag.\n- A page on a loopback host signs in as adrien, claims France and gets the free, instant testing buttons, while the server grants free orders only to a loopback socket whose Origin is also loopback."
}
];
+142 -100
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 1% a day, doubling each 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.
@@ -1080,6 +1094,15 @@ export const panelMethods = {
const building = entry.kind === "building";
const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex];
const name = proto ? proto.name : (building ? "Building" : "Unit");
if (building && entry.phase === "materials") {
const $bar = $("<div class='progress city-production-bar'></div>");
$bar.append($("<div></div>").css("width", `${this._gatherFraction(entry) * 100}%`));
$el.append(
$("<div class='city-production-label'></div>").text(`Gathering materials for ${name}`),
$bar
);
return;
}
const verb = building ? "Building" : "Training";
const total = Math.max(entry.totalHours || 0, 0.0001);
const elapsed = entry.elapsedHours || 0;
@@ -1181,13 +1204,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,90 +1227,86 @@ 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.buildingHours(gdp, improvement.buildCost) / (1 + speed);
const title = standing && !built
? `${improvement.name} — another improvement already stands here`
: `${improvement.name} — ${this._improvementCostText(improvement, prices)} · ` +
`${this._improvementUpkeepText(improvement)}/day · ${hours(buildHours)} to build`;
const $button = this._improvementButton(improvement, {
className: "tile-btn",
title,
disabled: !!standing,
current: built,
prices,
});
$button.on("click", () => this._buildTileImprovement(coords, improvement.id));
military.push($button);
}
}
// 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);
const buildHours = ECONOMY.buildingHours(gdp, producer.buildCost) / (1 + speed);
// Some producers are locked behind a technology (the fusion plant).
const techIndex = producer.requiresTechnology
? TECHNOLOGIES.findIndex((tech) => tech.id === producer.requiresTechnology)
@@ -1298,14 +1319,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 +1332,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 +1399,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) {
@@ -1835,6 +1856,13 @@ export const panelMethods = {
const building = entry.kind === "building";
const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex];
const name = proto ? proto.name : (building ? "building" : "unit");
// A building order spends its first days buying its steel and high-tech, so
// show that gathering rather than a construction clock that has not started.
if (building && entry.phase === "materials") {
this._trainingCityId = this.selectedCityId;
this.cityModal.setGatheringProgress(name, this._gatherFraction(entry));
return;
}
const verb = building ? "Building" : "Training";
if (this._trainingCityId !== this.selectedCityId) {
this._trainingCityId = this.selectedCityId;
@@ -1844,6 +1872,20 @@ export const panelMethods = {
}
},
// How far a building order has got through gathering its material bill, as the
// average of each storable material's share bought. 1 when nothing is needed.
_gatherFraction(entry) {
let sum = 0;
let count = 0;
for (const id of ["steel", "hightech"]) {
const need = (entry.needed && entry.needed[id]) || 0;
if (!(need > 0)) continue;
sum += Math.min(1, ((entry.bought && entry.bought[id]) || 0) / need);
count += 1;
}
return count > 0 ? sum / count : 1;
},
// How many levels of each building are already waiting in the city's shared
// production queue, so the buildings tab prices the next order correctly.
_queuedBuildingLevels(cityId) {
+3 -1
View File
@@ -57,7 +57,9 @@ export class MapView {
if (this.$targets && this.$targets.length) this.$targets.after(this.$resources);
else this.$world.append(this.$resources);
}
this.resourceGraph = { nodes: [], links: [] };
this.resourceGraph = { nodes: [], links: [], edges: [] };
// 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 {
+140 -15
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";
@@ -15,12 +15,34 @@ import { RESOURCES } from "../../../shared/data/resources.js";
export const MAP_MODES = ["terrain", "political", "gdp", "population"];
// The migration arrows: one hue so they stay legible over the population
// gradient, with the month's flow mapped between these thicknesses.
// The migration map: one hue for the route so it stays legible over the
// population gradient, with the month's flow mapped between these thicknesses.
// A disc textured with the origin nation's flag rides each route, mirroring the
// merchandise icons that ride the trade graph.
const MIGRATION_COLOUR = "#ff3ec8";
const MIGRATION_ARROW_MIN = 1.5;
const MIGRATION_ARROW_MAX = 12;
// The delivery graph draws one 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. Routes
// with no goods moving along them are drawn in the idle grey instead.
const DELIVERY_COLOUR = "#ffffff";
const DELIVERY_IDLE_COLOUR = "#7b7f85";
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
@@ -76,6 +98,7 @@ export const modeMethods = {
this.resourceGraph = {
nodes: Array.isArray(graph.nodes) ? graph.nodes : [],
links: Array.isArray(graph.links) ? graph.links : [],
edges: Array.isArray(graph.edges) ? graph.edges : [],
};
this._renderResourceGraph();
},
@@ -88,25 +111,57 @@ export const modeMethods = {
if (!this.$resources || !this.$resources.length) return;
const migration = this.mapMode === "population";
const graph = migration ? this.migrationGraph : this.resourceGraph;
if (!this.economic || graph.links.length === 0) {
const empty = graph.links.length === 0 && (!graph.edges || graph.edges.length === 0);
if (!this.economic || empty) {
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._renderMigrationFlow(graph.links);
} else {
this._renderDeliveryLines(graph);
}
this._renderGraphLegend();
},
// One line per delivery leg and a dot on every storage node.
// Every potential route grey, the routes that actually carried goods white
// over them, a dot on every storage node and a merchandise icon per active
// 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 edge of graph.edges || []) {
const [fromX, fromY, toX, toY] = edge;
fragment.appendChild(
makeSegment(mapToLocal(fromX, fromY), mapToLocal(toX, toY), DELIVERY_IDLE_COLOUR, 2)
);
}
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,23 +172,69 @@ export const modeMethods = {
fragment.appendChild(dot);
}
this.$resources.empty()[0].appendChild(fragment);
this._deliveryMovers = movers;
this._animateDeliveryIcons();
},
// Slides every graph icon along its leg (merchandise on the trade routes,
// flags on the migration ones), 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
// the month so the volume reads at a glance.
_renderMigrationArrows(links) {
// the month so the volume reads at a glance, with a flag disc per route
// drifting from origin to destination like the trade graph's merchandise.
_renderMigrationFlow(links) {
let max = 0;
for (const link of links) if (link[4] > max) max = link[4];
const fragment = document.createDocumentFragment();
const movers = [];
for (const link of links) {
const [fromX, fromY, toX, toY, people] = link;
const [fromX, fromY, toX, toY, people, civ] = link;
const a = mapToLocal(fromX, fromY);
const b = mapToLocal(toX, toY);
const width = MIGRATION_ARROW_MIN +
(MIGRATION_ARROW_MAX - MIGRATION_ARROW_MIN) * (max > 0 ? people / max : 0);
fragment.appendChild(makeArrow(a, b, MIGRATION_COLOUR, width));
const flag = this._migrationFlag(civ);
if (!flag) continue;
const mover = document.createElement("div");
mover.className = "migrant-mover";
mover.style.backgroundImage = `url("assets/flags/${flag}")`;
// The layer is foreshortened on Y; undo the tilt on the disc so the flag
// stays circular while its centre rides the route.
mover.style.transform = `scaleY(${1 / CAMERA_TILT})`;
movers.push({
el: mover,
a,
b,
phase: deliveryPhase(`migrant:${civ}`, fromX, fromY, toX, toY),
});
fragment.appendChild(mover);
}
this.$resources.empty()[0].appendChild(fragment);
this._deliveryMovers = movers;
this._animateDeliveryIcons();
},
// The flag art of the nation an origin city belongs to, or null when the
// catalogue is not in yet. `this.civilisations` is indexed by civ number.
_migrationFlag(civ) {
const entry = this.civilisations && this.civilisations[civ];
return entry && entry.flag ? entry.flag : null;
},
// The key for the economic overlay's graph. The population mode keys the
@@ -144,7 +245,8 @@ export const modeMethods = {
if (!$el || !$el.length) return;
const migration = this.mapMode === "population";
const graph = migration ? this.migrationGraph : this.resourceGraph;
if (!this.economic || graph.links.length === 0) {
const hasEdges = !migration && graph.edges && graph.edges.length > 0;
if (!this.economic || (graph.links.length === 0 && !hasEdges)) {
$el.addClass("hidden");
return;
}
@@ -159,7 +261,7 @@ export const modeMethods = {
$list.append(
$("<div class='map-legend-resource'></div>")
.append($("<span class='map-legend-arrow'></span>"))
.append($("<span></span>").text("People between cities"))
.append($("<span></span>").text("People between cities (flags show origin)"))
);
$list.append(
$("<div class='map-legend-resource'></div>")
@@ -173,10 +275,21 @@ 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))
);
}
if (this._hasIdleEdges(graph)) {
$list.append(
$("<div class='map-legend-resource'></div>")
.append($("<span class='map-legend-idle'></span>"))
.append($("<span></span>").text("Idle route"))
);
}
$list.append(
$("<div class='map-legend-resource'></div>")
.append($("<span class='map-legend-node'></span>"))
@@ -184,6 +297,18 @@ export const modeMethods = {
);
},
// Whether any potential route is carrying nothing: the ones the graph draws
// grey. Keyed to the same directed pair the server ships as an edge.
_hasIdleEdges(graph) {
if (!graph.edges || graph.edges.length === 0) return false;
const active = new Set(
graph.links.map((link) => `${link[0]},${link[1]}->${link[2]},${link[3]}`)
);
return graph.edges.some(
(edge) => !active.has(`${edge[0]},${edge[1]}->${edge[2]},${edge[3]}`)
);
},
// Political shows when the user asked for it, or while terrain mode is
// zoomed far enough out. Economic modes are exclusive with it.
_effectivePolitical() {
+1
View File
@@ -95,6 +95,7 @@ export const motionMethods = {
this._applyEntityWrap();
this._drawPaths();
this._drawStrikeTargets();
this._animateDeliveryIcons();
this._flushFog();
this._glRender();
},
+21 -5
View File
@@ -391,10 +391,15 @@ export class CityModal {
for (const index of group.indices) {
const entry = queue[index];
const elapsed = index === 0 ? entry.elapsedHours || 0 : 0;
cumulative += Math.max(entry.totalHours - elapsed, 0);
// A building still gathering its materials has not started building, so
// it adds no time to the queue's readiness estimate.
const gather = entry.kind === "building" && entry.phase === "materials";
cumulative += gather ? 0 : Math.max(entry.totalHours - elapsed, 0);
if (index === group.indices[0]) firstReady = cumulative;
}
const count = group.indices.length;
const gathering = queue[group.indices[0]].kind === "building" &&
queue[group.indices[0]].phase === "materials";
const name = group.kind === "building"
? this.buildingNames.get(group.protoIndex) || "Building"
: this.protoNames.get(group.protoIndex) || "Unit";
@@ -404,9 +409,11 @@ export class CityModal {
$info.append($("<div class='name'></div>").text(`${groupIndex + 1}. ${label}`));
$info.append(
$("<div class='desc'></div>").text(
count > 1
? `Next in ${hours(firstReady)} · all ready in ${hours(cumulative)}`
: `Ready in ${hours(cumulative)}`
gathering
? "Gathering materials"
: count > 1
? `Next in ${hours(firstReady)} · all ready in ${hours(cumulative)}`
: `Ready in ${hours(cumulative)}`
)
);
const $actions = $("<div class='actions'></div>");
@@ -435,6 +442,15 @@ export class CityModal {
this.$trainProgress.css("width", "100%");
}
// The selected city's head of queue is a building still buying its steel and
// high-tech: show how far that gathering has got, with no hours figure since
// the construction clock has not started.
setGatheringProgress(name, fraction = 0) {
this.$trainLabel.text(`Gathering materials for ${name}…`);
this.$trainProgress.css("width", `${Math.min(100, Math.max(0, fraction) * 100)}%`);
this.$trainStatus.removeClass("hidden");
}
// ------------------------------------------------------------- buildings --
_makeBuildingRow(proto, index) {
@@ -495,7 +511,7 @@ export class CityModal {
const parts = [];
if (level > 0) parts.push(`Upkeep ${currencySymbol()}${groupDigits(Math.round(buildingUpkeep(row.proto, level)))}/h`);
const cost = buildingBuildCost(row.proto, nextLevel);
const duration = ECONOMY.trainingHours(gdp, cost) * this._constructionTimeFactor();
const duration = ECONOMY.buildingHours(gdp, cost) * this._constructionTimeFactor();
parts.push(`Build cost ${currencySymbol()}${groupDigits(this._quotedCost(cost, row.proto, nextLevel))} · ${hours(duration)}`);
const requirement = this._buildRequirement(row.proto);
if (requirement) parts.push(requirement);
-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));
}
+1 -1
View File
@@ -40,7 +40,7 @@ export const CIVILISATIONS = [
primaryColour: "#5ec6e7",
accentColour: "#13233c",
flag: "flag_northumberland.png",
cityNames: ["Newcastle upon Tyne", "Washington", "New York", "Gateshead", "Morpeth", "Ashington", "Blyth", "Newsham", "Blaydon", "Rothbury", "Longbenton", "Snitter", "Alnwick", "Consettm Tynemouth", "Whickham"]
cityNames: ["Newcastle upon Tyne", "Washington", "New York", "Gateshead", "Morpeth", "Ashington", "Blyth", "Newsham", "Blaydon", "Rothbury", "Longbenton", "Snitter", "Alnwick", "Consett", "Tynemouth", "Whickham"]
},
{
id: "slovenia",
+10
View File
@@ -86,6 +86,10 @@ export const ECONOMY = {
capitalTileProductionMultiplier: 2.5,
citySurroundingProductionBonus: 0.3,
gdpPerProductionPoint: 100_000,
// Building and tile-improvement construction runs this much faster than
// training: a building takes a tenth of the hours a unit of the same cost
// would. Unit training is untouched.
buildingTimeMultiplier: 0.1,
// A nation opens the game on 1 January 2000 with a tenth of its starting GDP.
startingBudgetFraction: 0.1,
startingBudget: 1_000_000_000,
@@ -106,6 +110,12 @@ export const ECONOMY = {
return cost / capacity;
},
// The hours a building or tile improvement of `cost` takes to rise, ten times
// quicker than training something of the same cost.
buildingHours(gdp, cost) {
return this.trainingHours(gdp, cost) * this.buildingTimeMultiplier;
},
populationGrowthFactor(hoursPerYear, hours = 1) {
if (this.basePopulationGrowthRate <= 0 || hoursPerYear <= 0 || hours <= 0) return 0;
return Math.pow(1 + this.basePopulationGrowthRate, hours / hoursPerYear) - 1;
+20 -2
View File
@@ -923,12 +923,22 @@ export const economyMethods = {
_tickTraining() {
const completed = [];
const day = Math.floor(this.totalHours / HOURS_PER_DAY);
for (const [cityId, queue] of this.training) {
const city = this.findCity(cityId);
if (!city) {
completed.push(cityId);
continue;
}
// A queued building first gathers its materials, once a day, exactly as a
// tile improvement's construction site does; only then does its
// construction time begin.
for (const entry of queue) {
if (entry.kind !== "building" || entry.phase !== "materials") continue;
if (entry.lastGatherDay === day) continue;
entry.lastGatherDay = day;
this._gatherBuildEntry(city, entry);
}
// A city runs one production line per kind plus whatever its Barracks
// (units) and Shipyards (buildings) add. Each line works one entry at full
// capacity, so a higher-level city produces several things at once; the
@@ -970,6 +980,12 @@ export const economyMethods = {
i += 1;
continue;
}
// A building still gathering its materials is not on a construction line
// yet, so it neither works nor blocks the ones that are.
if (entry.phase === "materials") {
i += 1;
continue;
}
const proto = this._queueEntryProto(entry);
if (!proto) {
queue.splice(i, 1);
@@ -1006,9 +1022,11 @@ export const economyMethods = {
return index >= 0 && index < this.protoUnits.length ? this.protoUnits[index] : null;
},
// Raises one level of a completed building. The cost was paid when the order
// was placed, so this only changes the city and invalidates the GDP cache.
// Raises one level of a completed building. The money cost was paid when the
// order was placed and the materials gathered as it ran, so this only returns
// any unspent material budget, changes the city and invalidates the GDP cache.
_completeBuilding(city, entry) {
this._releaseEntryBudget(city.civ, entry);
const level = this.getCityBuildingLevel(city, entry.protoIndex);
city.buildings[entry.protoIndex] = level + 1;
this._gdpPerCapitaCache.clear();
+28 -10
View File
@@ -7,7 +7,7 @@ import { GOVERNMENTS } from "../data/governments.js";
import { PILLAGE, pillageSteps } from "../data/combat.js";
import { transportImprovement } from "../data/improvements.js";
import { buildingBuildCost } from "../rules.js";
import { TRAINING_QUEUE_LIMIT } from "./constants.js";
import { TRAINING_QUEUE_LIMIT, HOURS_PER_DAY } from "./constants.js";
export const orderMethods = {
// Adds one unit to the city's production queue. The queue is shared with
@@ -73,10 +73,12 @@ export const orderMethods = {
return true;
},
// Queues one more level of a building in the city's production queue. Like a
// unit, the level is paid for up front from the budget and only rises once
// its production time has elapsed. Repeated orders stack levels: the second
// queued level is priced off the level the first will leave behind.
// Queues one more level of a building in the city's production queue. The
// level's money cost is paid up front, but its steel and high-tech are
// gathered over the days that follow -- exactly as a tile improvement does --
// and only then does the construction time begin. Repeated orders stack
// levels: the second queued level is priced off the level the first will
// leave behind.
requestBuild(cityId, protoIndex, free = false) {
const city = this.findCity(cityId);
if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false;
@@ -113,23 +115,39 @@ export const orderMethods = {
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(city.civ));
if (capacity <= 0) return false;
if (!this._spendBudget(city.civ, cost, "construction")) return false;
// The level's material bill is not bought up front any more: like a tile
// improvement, the order reserves a budget and gathers the steel and
// high-tech over the days before construction can begin.
const materials = this.constructionResourceCost(proto, level, cost);
if (!this._payConstructionResources(city.civ, city.coords, materials, "construction", proto.name)) {
this._refundBudget(city.civ, cost, "construction");
return false;
}
const needed = {
steel: Math.max(0, materials.steel || 0),
hightech: Math.max(0, materials.hightech || 0),
};
const reserved = needed.steel * this.getResourcePrice("steel") +
needed.hightech * this.getResourcePrice("hightech");
this._reserveSiteBudget(city.civ, reserved, "construction");
const entry = {
kind: "building",
protoIndex,
level,
capacity,
phase: "materials",
budget: reserved,
prices: this._lockedUnitPrices(),
needed,
bought: { steel: 0, hightech: 0 },
elapsedHours: 0,
totalHours: (cost / capacity) *
totalHours: ECONOMY.buildingHours(this.getPlayerGdp(city.civ), cost) *
this.constructionApprovalMultiplier(city) *
this.constructionSpeedMultiplier(city.civ),
lastGatherDay: -1,
};
if (queue) queue.push(entry);
else this.training.set(cityId, [entry]);
// Make the first purchase at once, so a well-supplied city starts building
// right away instead of waiting for the first day boundary.
entry.lastGatherDay = Math.floor(this.totalHours / HOURS_PER_DAY);
this._gatherBuildEntry(city, entry);
this._emitChanged();
return true;
},
+41 -2
View File
@@ -92,6 +92,9 @@ export const resourceMethods = {
for (const id of RESOURCE_IDS) this.resourcePriceHistory.set(id, [RESOURCE_MARKET_BASE[id]]);
this.resourceLinks = [];
this._resourceShortages = new Map();
// The viewer-keyed delivery graph is rebuilt when the resource day rolls
// over; the cache starts empty after a fresh configure.
this._resourceGraphCache = null;
// The food each region has bought to relay on to its neighbours, so only
// that stock carries the middleman margin when it is resold.
this._relayStock = new Map();
@@ -2133,6 +2136,15 @@ export const resourceMethods = {
// at RESOURCE_TRADE_RADIUS tiles. `routeEnergyPerTonne` is the accumulated
// kWh to move one tonne from the city to that node.
_nearbySuppliers(city, id, nodes, allowSea = true) {
return this._tradeReachable(city, nodes, allowSea, (node) => this._nodeSurplus(node, id) > 0, id);
},
// The storage nodes a city can haul from over the transport network: a
// breadth-first search over land and sea, capped at RESOURCE_TRADE_RADIUS
// tiles, that hands every reachable node to `accept`. `resource` only names
// the commodity charged for the haul; the transport rules themselves are
// commodity-independent, so the delivery graph passes one to price its rides.
_tradeReachable(city, nodes, allowSea, accept, resource = "steel") {
const results = [];
const byKey = new Map(nodes.map((node) => [key(node.coords.x, node.coords.y), node]));
const origin = key(city.coords.x, city.coords.y);
@@ -2146,7 +2158,7 @@ export const resourceMethods = {
const here = byKey.get(key(current.coords.x, current.coords.y));
if (here && key(here.coords.x, here.coords.y) !== origin) {
const hostile = here.civ >= 0 && here.civ !== city.civ && this.isAtWar(city.civ, here.civ);
if (!hostile && this._nodeSurplus(here, id) > 0) {
if (!hostile && accept(here)) {
results.push({
...here,
tiles: current.tiles,
@@ -2177,7 +2189,7 @@ export const resourceMethods = {
const owner = this.civAt(neighbour);
if (owner >= 0 && owner !== city.civ && this.isAtWar(city.civ, owner)) continue;
}
const stepEnergy = deliveryEnergyForResource(id, 1, 1, mode, tile.movementCostMultiplier || 1);
const stepEnergy = deliveryEnergyForResource(resource, 1, 1, mode, tile.movementCostMultiplier || 1);
visited.add(nk);
queue.push({
coords: neighbour,
@@ -2190,6 +2202,33 @@ export const resourceMethods = {
return results;
},
// Every supplier-to-city route among a nation's own storage nodes, whether or
// not a delivery ran along it. The trade graph draws these idle edges grey and
// lays the active legs over them. One BFS per city, so callers cache it.
_resourceCandidateEdges(civ) {
const nodes = this._resourceNodes();
const ownKeys = new Set();
for (const node of nodes) {
if (node.civ === civ) ownKeys.add(key(node.coords.x, node.coords.y));
}
const edges = new Map();
for (const city of this.cities) {
if (city.civ !== civ) continue;
const reachable = this._tradeReachable(
city, nodes, this._cityMarketAccess(city),
(node) => ownKeys.has(key(node.coords.x, node.coords.y))
);
for (const node of reachable) {
const from = key(node.coords.x, node.coords.y);
const to = key(city.coords.x, city.coords.y);
if (!edges.has(`${from}->${to}`)) {
edges.set(`${from}->${to}`, [node.coords.x, node.coords.y, city.coords.x, city.coords.y]);
}
}
}
return Array.from(edges.values());
},
// The middleman margin a region charges when it sells food it bought to relay.
// Only the stock it actually bought for resale carries the margin, and the
// stock is drawn down as it sells.
+43 -11
View File
@@ -1,7 +1,7 @@
// Serialisation: turning the live model into the snapshots the server ships to
// each viewer, with fog-of-war applied per civilisation.
import { parseKey } from "../hex.js";
import { key, parseKey } from "../hex.js";
import { OPINION } from "../data/politics.js";
import { EFFECT_RAIL_SPEED, EFFECT_CONSTRUCTION_SPEED, EFFECT_RESEARCH } from "../data/effects.js";
import { RESOURCE_IDS } from "../data/resources.js";
@@ -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 },
@@ -691,21 +694,49 @@ export const serializationMethods = {
});
},
// The viewer's own storage nodes and delivery legs, as [x, y] nodes and
// [fromX, fromY, toX, toY, resource, amount] links. A spectator sees none.
// The viewer's own storage nodes and delivery legs, as [x, y, isCity] nodes,
// [fromX, fromY, toX, toY, resource, amount] links and [fromX, fromY, toX,
// toY] edges. Links are the deliveries that actually ran over the last day,
// imports and exports alike; edges are every route the viewer's own nodes
// could trade over, so the map can draw the idle ones grey. A spectator sees
// none.
_serializeResourceGraph(viewerCiv) {
if (viewerCiv < 0 || !this.resourceStock) return { nodes: [], links: [] };
const nodes = [];
if (viewerCiv < 0 || !this.resourceStock) return { nodes: [], links: [], edges: [] };
// The reachability behind the edges costs a BFS per city, and the trade
// network only moves on the resource day, so the whole graph is memoised
// against the resource version.
if (!this._resourceGraphCache) this._resourceGraphCache = new Map();
const cached = this._resourceGraphCache.get(viewerCiv);
if (cached && cached.version === this._resourceVersion) return cached.graph;
const graph = this._buildResourceGraph(viewerCiv);
this._resourceGraphCache.set(viewerCiv, { version: this._resourceVersion, graph });
return graph;
},
_buildResourceGraph(viewerCiv) {
const nodes = new Map();
const addNode = (coords, isCity) => {
const k = key(coords.x, coords.y);
if (!nodes.has(k)) nodes.set(k, [coords.x, coords.y, isCity ? 1 : 0]);
};
for (const [k] of this.resourceStock) {
const coords = parseKey(k);
const node = this._resourceNodeAt(coords);
if (!node || node.civ !== viewerCiv) continue;
nodes.push([coords.x, coords.y, node.kind === "city" ? 1 : 0]);
addNode(coords, node.kind === "city");
}
const links = [];
for (const link of this.resourceLinks || []) {
const to = link.to;
if (this.civAt({ x: to[0], y: to[1] }) !== viewerCiv) continue;
const from = { x: link.from[0], y: link.from[1] };
const to = { x: link.to[0], y: link.to[1] };
const supplier = this._resourceNodeAt(from);
const supplierCiv = supplier ? supplier.civ : -1;
const buyerCiv = this.civAt(to);
// Imports into the viewer's cities and exports out of its nodes both
// belong on the graph.
if (supplierCiv !== viewerCiv && buyerCiv !== viewerCiv) continue;
addNode(from, !!(supplier && supplier.kind === "city"));
addNode(to, true);
links.push([
link.from[0],
link.from[1],
@@ -715,7 +746,7 @@ export const serializationMethods = {
Math.round(link.amount * 1000) / 1000,
]);
}
return { nodes, links };
return { nodes: Array.from(nodes.values()), links, edges: this._resourceCandidateEdges(viewerCiv) };
},
// --------------------------------------------------------- politics --
@@ -828,7 +859,8 @@ export const serializationMethods = {
},
// The city-to-city migration over the rolling month, as [fromX, fromY, toX,
// toY, people] links for the population map's arrows. World-wide, like the
// toY, people, fromCiv] links for the population map. The origin nation rides
// along so the client can flag the people on the move. World-wide, like the
// per-tile population the mode paints, and memoised against the flow version
// so an unchanged graph is not re-summed on every broadcast.
_serializeMigrationGraph() {
@@ -842,7 +874,7 @@ export const serializationMethods = {
const from = this._cityById.get(fromId);
const to = this._cityById.get(toId);
if (!from || !to) continue;
links.push([from.coords.x, from.coords.y, to.coords.x, to.coords.y, amount]);
links.push([from.coords.x, from.coords.y, to.coords.x, to.coords.y, amount, from.civ]);
}
links.sort((a, b) => a[4] - b[4]);
const value = { links };
+138 -29
View File
@@ -9,10 +9,13 @@
// 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 -- each
// day, 1% at first and doubling daily, 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 +27,13 @@ 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;
// The first day a stalled site cannot buy, its budgeted prices and the matching
// money rise by 1%; the rise doubles each following day (2%, 4%, 8%, ...), so
// it bids its way up to the market unaided.
const BUDGET_DAILY_RISE = 0.01;
const MATERIAL_IDS = ["steel", "hightech"];
const RESOURCE_IDS = ["steel", "hightech", "energy"];
@@ -121,10 +128,8 @@ export const siteMethods = {
: tileImprovementResourceCost(proto, buildCost);
const needed = emptyResources();
for (const id of RESOURCE_IDS) needed[id] = Math.max(0, materials[id] || 0);
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(civ));
const baseHours = kind === "upgrade"
? (capacity > 0 ? Math.max(1, buildCost / capacity) : 1)
: ECONOMY.trainingHours(this.getPlayerGdp(civ), buildCost);
const gdp = this.getPlayerGdp(civ);
const baseHours = ECONOMY.buildingHours(gdp, buildCost);
const totalHours = Math.max(1, baseHours * this.constructionSpeedMultiplier(civ));
const route = this._siteCityPath(civ, coords);
const site = {
@@ -143,6 +148,8 @@ export const siteMethods = {
cityId: route.cityId,
path: route.path,
stalled: false,
// Consecutive stalled days, driving the doubling budget rise.
budgetRaises: 0,
lastBuyDay: -1,
};
this.constructionSites.set(k, site);
@@ -194,6 +201,7 @@ export const siteMethods = {
if (site.lastBuyDay !== day) {
site.lastBuyDay = day;
this._gatherSiteMaterials(site);
this._raiseSiteBudgetIfStalled(site);
}
} else {
site.elapsedHours += 1;
@@ -207,8 +215,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) {
@@ -223,6 +231,7 @@ export const siteMethods = {
const stillMissing = MATERIAL_IDS.some((id) => site.needed[id] - site.bought[id] > 1e-9);
if (!stillMissing) {
site.stalled = false;
site.budgetRaises = 0;
site.phase = "construction";
site.elapsedHours = 0;
this._emitChanged();
@@ -233,6 +242,28 @@ export const siteMethods = {
site.stalled = stalled;
this._emitChanged();
}
if (!stalled) site.budgetRaises = 0;
},
// A day that bought nothing while a supplier is within reach raises the site's
// budgeted prices and moves the matching money out of the treasury, then tries
// again. The first day's rise is 1% and it doubles every day after (2%, 4%,
// 8%, ...), so a stubborn price is met within a few days. A supplier still
// above the raised cap simply stalls it once more; the count resets as soon as
// it buys. 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;
const raises = site.budgetRaises || 0;
const factor = 1 + BUDGET_DAILY_RISE * 2 ** raises;
for (const id of RESOURCE_IDS) site.prices[id] *= factor;
site.budgetRaises = raises + 1;
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
@@ -303,6 +334,70 @@ export const siteMethods = {
return payer;
},
// ------------------------------------------------ city building orders --
// One day of a queued city building's material phase. Like a construction
// site it buys whatever of its steel and high-tech bill it still lacks, at the
// prices locked in when the order was placed: the nation's own stores first,
// then the reachable suppliers. It only flips to construction once the whole
// bill is gathered; a day that buys nothing leaves it stalled.
_gatherBuildEntry(city, entry) {
const payer = this._entryPayer(city, entry);
const remaining = { steel: 0, hightech: 0 };
for (const id of MATERIAL_IDS) {
remaining[id] = Math.max(0, (entry.needed[id] || 0) - (entry.bought[id] || 0));
}
// The nation's own cities are bought from first, nearest to the building.
const drawn = this._drawFromNearest(city.civ, city.coords, remaining, payer);
for (const id of MATERIAL_IDS) {
if (drawn[id] > 0) entry.bought[id] = (entry.bought[id] || 0) + drawn[id];
}
for (const id of MATERIAL_IDS) {
const missing = (entry.needed[id] || 0) - (entry.bought[id] || 0);
if (!(missing > 1e-9)) continue;
const temp = emptyResources();
this._procureFromNeighbours(city, id, missing, temp, this._resourceNodes(), {
payer,
allowSea: this._cityMarketAccess(city),
});
if (temp[id] > 0) entry.bought[id] = (entry.bought[id] || 0) + temp[id];
}
const stillMissing = MATERIAL_IDS.some(
(id) => (entry.needed[id] || 0) - (entry.bought[id] || 0) > 1e-9
);
if (!stillMissing) entry.phase = "construction";
entry.stalled = stillMissing;
return !stillMissing;
},
// A payer for a queued building: its reserved material budget first, then the
// public treasury, so an overrun is public money.
_entryPayer(city, entry) {
const payer = { total: 0, overflow: 0 };
payer.charge = (cost) => {
if (!(cost > 0)) return;
const fromBudget = Math.min(entry.budget || 0, cost);
entry.budget = (entry.budget || 0) - fromBudget;
const rest = cost - fromBudget;
if (rest > 0) {
this.budgets.set(city.civ, this.getBudget(city.civ) - rest);
this._recordBudgetCash(city.civ, "construction", -rest);
payer.overflow += rest;
}
payer.total += cost;
};
return payer;
},
// Hands a finished order's unspent material budget back to the treasury. The
// reserved money was never paid to the regions, so it is not clawed back.
_releaseEntryBudget(civ, entry) {
if (!(entry.budget > 0)) return;
this.budgets.set(civ, this.getBudget(civ) + entry.budget);
this._recordBudgetCash(civ, "construction", entry.budget);
entry.budget = 0;
},
// -------------------------------------------------- completion --
_completeConstructionSite(site) {
@@ -351,26 +446,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
+207 -11
View File
@@ -37,6 +37,7 @@ function makeSite(state, coords, overrides = {}) {
cityId: null,
path: [],
stalled: false,
budgetRaises: 0,
lastBuyDay: -1,
...overrides,
};
@@ -141,7 +142,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.01,
1e-6,
"the first day's budgeted price rises 1%"
);
this.assertGreater(site.budget, 0, "the matching money moved into the site");
this.assertLess(state.getBudget(0), treasury, "and came from the treasury");
}
test_the_budget_rise_doubles_every_day() {
const state = smallState();
clearMaterials(state);
const city = cityOf(state, 0);
@@ -151,17 +180,85 @@ export class ConstructionSiteTest extends TestCase {
budget: 0,
needed: { steel: 100, hightech: 0, energy: 0 },
});
state.totalHours = 1;
// A supplier far out of reach in price keeps the site stalling as it climbs.
site.prices.steel = state._marketUnitCost("steel") * 0.001;
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");
// The rises compound: 1%, then 2%, then 4%, ...
let prev = site.prices.steel;
for (let day = 0; day < 4 && site.phase === "materials"; day++) {
state._raiseSiteBudgetIfStalled(site);
const expected = prev * (1 + 0.01 * 2 ** day);
this.assertApprox(site.prices.steel, expected, 1e-6, `day ${day + 1} rise`);
prev = site.prices.steel;
}
}
test_a_stalled_budget_climbs_until_a_supplier_is_affordable() {
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 },
});
// 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, "the locked price is too low to buy at");
const locked = site.prices.steel;
state._raiseSiteBudgetIfStalled(site);
this.assertApprox(
site.prices.steel,
locked * 1.01,
1e-6,
"the first day's budgeted price rises 1%"
);
// 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 +275,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 +376,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 +472,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");
}
}
+17
View File
@@ -44,6 +44,23 @@ export class EconomyTest extends TestCase {
this.assertEqual(config({ gdpPerProductionPoint: 0 }).trainingHours(1000, 50), 0);
}
test_building_hours_are_a_tenth_of_training() {
const cfg = config({ gdpPerProductionPoint: 100 });
this.assertApprox(cfg.buildingHours(1000, 50), 0.5);
this.assertApprox(
cfg.buildingHours(1000, 50),
cfg.trainingHours(1000, 50) / 10,
1e-9,
"construction is ten times faster than training the same cost"
);
}
test_building_hours_follow_the_multiplier() {
const cfg = config({ gdpPerProductionPoint: 100, buildingTimeMultiplier: 0.5 });
this.assertApprox(cfg.buildingHours(1000, 50), 2.5);
this.assertEqual(config({ gdpPerProductionPoint: 0 }).buildingHours(1000, 50), 0);
}
test_trade_tax_defaults() {
this.assertApprox(TAXES.defaultSales, 0.2);
this.assertApprox(TAXES.defaultExport, 0.05);
+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("doubling"), `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 {
+46 -1
View File
@@ -3,7 +3,7 @@ import { smallState, cityOf, gdpBuilding, grantBuilding } from "./framework/help
import { key } from "../shared/hex.js";
import { EFFECT_RESEARCH } from "../shared/data.js";
import { tileFoodOutput } from "../shared/resources.js";
import { DAYS_PER_YEAR } from "../shared/game_state/constants.js";
import { DAYS_PER_YEAR, HOURS_PER_DAY } from "../shared/game_state/constants.js";
const BIG_BUDGET = 1.0e15;
@@ -182,6 +182,51 @@ export class GameStateBuildingsTest extends TestCase {
this.assertGreater(state.getPlayerGdp(0), beforeGdp, "GDP bonus applied");
}
test_a_building_order_gathers_its_materials_before_building() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const index = this.addBuilding(state, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 }));
const city = cityOf(state, 0);
// Drain every own store and cut the network, so nothing can be gathered.
for (const own of state.cities) {
if (own.civ !== 0) continue;
const store = state.getCityResourceStock(own);
store.steel = 0;
store.hightech = 0;
}
state.roads.clear();
state.railways.clear();
this.assertTrue(state.requestBuild(city.id, index));
const queue = state.getTraining(city.id);
this.assertEqual(queue[0].phase, "materials", "the order gathers before it builds");
this.assertEqual(queue[0].elapsedHours, 0, "the construction clock has not started");
// Supply the steel; the next day the order can buy and start building.
state.getCityResourceStock(city).steel = 1.0e9;
for (let i = 0; i < HOURS_PER_DAY; i++) state.advanceHour();
this.assertEqual(queue[0].phase, "construction", "the bill is gathered");
this.advanceUntilIdle(state);
this.assertEqual(state.getCityBuildingLevel(city, index), 1);
}
test_a_stalled_building_order_waits_for_materials() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const index = this.addBuilding(state, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 }));
const city = cityOf(state, 0);
// Nothing anywhere to buy: the order can only wait.
for (const own of state.cities) {
const store = state.getCityResourceStock(own);
store.steel = 0;
store.hightech = 0;
}
this.assertTrue(state.requestBuild(city.id, index));
for (let i = 0; i < HOURS_PER_DAY * 3; i++) state.advanceHour();
const entry = state.getTraining(city.id)[0];
this.assertEqual(entry.phase, "materials", "it is still gathering");
this.assertTrue(entry.stalled, "the entry reports being stalled");
this.assertEqual(state.getCityBuildingLevel(city, index), 0, "nothing was built");
}
test_building_and_unit_share_one_queue_and_are_produced_in_order() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
+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);
+109 -6
View File
@@ -166,11 +166,21 @@ export class MapModesTest extends TestCase {
try {
map.applySnapshot(state.snapshot(0));
map.setMapMode("population");
map.setMigrationGraph({ links: [[0, 0, 3, 3, 120], [3, 3, 0, 0, 40]] });
map.setMigrationGraph({ links: [[0, 0, 3, 3, 120, 0], [3, 3, 0, 0, 40, 1]] });
this.assertSize(
env.$("#layer-resources .migration-arrow-head"), 2,
"one arrowhead per migration flow"
);
const movers = env.$("#layer-resources .migrant-mover");
this.assertSize(movers, 2, "a flagged disc per migration flow");
this.assertTrue(
movers.eq(0).css("background-image").includes("flag_france.png"),
"the origin nation's flag textures the disc"
);
this.assertTrue(
movers.eq(1).css("background-image").includes("flag_britain.png"),
"a second flow carries its own flag"
);
this.assertEmpty(
env.$("#layer-resources .resource-node"), "the delivery graph is not drawn"
);
@@ -181,19 +191,24 @@ export class MapModesTest extends TestCase {
const labels = env.$("#map-legend-resources .map-legend-resource")
.map((_, el) => env.$(el).text()).get();
this.assertTrue(labels.some((t) => t.includes("People between cities")), "the flow is keyed");
this.assertTrue(
labels.some((t) => t.includes("flags show origin")),
"the key explains the flag discs"
);
// Hidden off the economic modes, and off the other economic mode.
map.setMapMode("terrain");
this.assertEmpty(env.$("#layer-resources .migration-arrow-head"), "hidden on terrain");
this.assertEmpty(env.$("#layer-resources .migrant-mover"), "flag discs hidden too");
map.setMapMode("gdp");
map.setMigrationGraph({ links: [[0, 0, 3, 3, 120]] });
map.setMigrationGraph({ links: [[0, 0, 3, 3, 120, 0]] });
this.assertEmpty(env.$("#layer-resources .migration-arrow-head"), "not on the GDP map");
} finally {
teardownDom(env);
}
}
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 +228,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 +246,91 @@ 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_idle_routes_are_drawn_grey_beneath_the_active_legs() {
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]],
edges: [[0, 0, 1, 1], [2, 2, 1, 1], [3, 3, 1, 1]],
});
const segments = env.$("#layer-resources .border-seg");
this.assertSize(segments, 4, "one line per edge and per active leg");
let white = 0;
let grey = 0;
segments.each((_index, element) => {
const background = element.style.background || "";
if (/255, 255, 255|#ffffff/i.test(background)) white += 1;
else if (/123, 127, 133|#7b7f85/i.test(background)) grey += 1;
});
this.assertEqual(white, 1, "the active leg is white");
this.assertEqual(grey, 3, "the idle routes are grey");
const labels = env.$("#map-legend-resources .map-legend-resource")
.map((_, el) => env.$(el).text()).get();
this.assertTrue(labels.some((t) => t.includes("Idle route")), "the key names the idle route");
} 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);
+1
View File
@@ -124,6 +124,7 @@ export class MigrationTest extends TestCase {
);
this.assertNotNull(link, "the city-to-city flow was tallied");
this.assertApprox(link[4], moved, 1, "the link carries everyone who moved");
this.assertEqual(link[5], from.civ, "the link names the origin nation for its flag");
}
test_the_migration_graph_rolls_over_a_month() {
+105
View File
@@ -0,0 +1,105 @@
import { TestCase } from "./framework/test_case.js";
import { smallState, cityOf } from "./framework/helpers.js";
import { key } from "../shared/hex.js";
// Places a resource building on a road tile next to `city` and returns its
// coords, so the trade BFS can reach it over the transport network.
function placeReachableMill(state, city, civ = 0) {
for (const neighbour of state.topology.neighbours(city.coords.x, city.coords.y)) {
const k = key(neighbour.x, neighbour.y);
if (!state._isLand(neighbour)) continue;
if (state.cityAt(neighbour) || state.tileImprovements.has(k)) continue;
state.tileImprovements.set(k, "steel_mill");
state.tileImprovementOwner.set(k, civ);
state.tileImprovementHp.set(k, 1000);
state._ensureResourceStock(k);
state.roads.add(k);
return neighbour;
}
return null;
}
export class TradeGraphTest extends TestCase {
// The graph carries every internal route as an edge, whether or not a delivery
// ran along it, so the map can draw the idle ones grey.
test_owned_routes_appear_as_idle_edges() {
const state = smallState();
const city = cityOf(state, 0);
const mill = placeReachableMill(state, city);
this.assertNotNull(mill, "the fixture found a tile for the mill");
const graph = state._serializeResourceGraph(0);
this.assertHas(graph, "edges");
const hasEdge = graph.edges.some(
(edge) =>
edge[0] === mill.x && edge[1] === mill.y &&
edge[2] === city.coords.x && edge[3] === city.coords.y
);
this.assertTrue(hasEdge, "the mill-to-city route is an edge");
this.assertEmpty(graph.links, "no delivery has run, so no link is active");
this.assertTrue(
graph.nodes.some((node) => node[0] === mill.x && node[1] === mill.y),
"the mill is a storage node"
);
}
// A delivery out of the viewer's own city is an export and belongs on the
// graph just as an import does, with the foreign buyer drawn as a node.
test_exports_from_own_nodes_are_kept() {
const state = smallState();
const city = cityOf(state, 0);
const mill = placeReachableMill(state, city);
const foreign = cityOf(state, 1);
this.assertNotNull(foreign, "the fixture has a foreign city");
state.resourceLinks = [{
from: [mill.x, mill.y],
to: [foreign.coords.x, foreign.coords.y],
resource: "steel",
amount: 12.5,
}];
state._resourceGraphCache = null;
const graph = state._serializeResourceGraph(0);
const link = graph.links.find(
(entry) => entry[0] === mill.x && entry[1] === mill.y &&
entry[2] === foreign.coords.x && entry[3] === foreign.coords.y
);
this.assertNotNull(link, "the export is on the graph");
this.assertEqual(link[4], "steel", "it names the commodity");
this.assertTrue(
graph.nodes.some((node) => node[0] === foreign.coords.x && node[1] === foreign.coords.y),
"the foreign buyer is a node"
);
}
// A delivery into the viewer's own city (an import) still shows, as before.
test_imports_into_own_cities_are_kept() {
const state = smallState();
const city = cityOf(state, 0);
const foreign = cityOf(state, 1);
state.resourceLinks = [{
from: [foreign.coords.x, foreign.coords.y],
to: [city.coords.x, city.coords.y],
resource: "food",
amount: 3,
}];
state._resourceGraphCache = null;
const graph = state._serializeResourceGraph(0);
this.assertTrue(
graph.links.some(
(entry) => entry[2] === city.coords.x && entry[3] === city.coords.y && entry[4] === "food"
),
"the import is on the graph"
);
}
// A spectator gets no graph at all.
test_a_spectator_sees_no_graph() {
const state = smallState();
const graph = state._serializeResourceGraph(-1);
this.assertEmpty(graph.nodes);
this.assertEmpty(graph.links);
this.assertEmpty(graph.edges);
}
}