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.
This commit is contained in:
2026-09-22 21:44:29 +02:00
parent aa726ff431
commit 1324128245
9 changed files with 344 additions and 39 deletions
+26 -1
View File
@@ -388,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;
@@ -396,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. */
@@ -809,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;
+6 -6
View File
@@ -1,6 +1,12 @@
// Generated by scripts/generate-devlog.js from `git log`; do not edit.
// The pre-commit hook refreshes it so the main menu shows the latest commits.
export const DEVLOG = [
{
"hash": "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",
@@ -54,11 +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."
}
];
+1 -1
View File
@@ -57,7 +57,7 @@ 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
+76 -16
View File
@@ -15,16 +15,20 @@ 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 white line per leg, so the goods carried are
// 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.
// 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]));
@@ -94,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();
},
@@ -106,7 +111,8 @@ 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();
@@ -114,19 +120,26 @@ export const modeMethods = {
}
if (migration) {
this._deliveryMovers = [];
this._renderMigrationArrows(graph.links);
this._renderMigrationFlow(graph.links);
} else {
this._renderDeliveryLines(graph);
}
this._renderGraphLegend();
},
// One white line per delivery leg, a dot on every storage node and a
// merchandise icon per leg, animated along the line by `_animateDeliveryIcons`
// so the goods moving between regions are obvious at a glance.
// 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 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);
@@ -163,8 +176,9 @@ export const modeMethods = {
this._animateDeliveryIcons();
},
// Slides every merchandise icon along its leg, the whole fleet sharing one
// wall-clock cycle so a snapshot rebuild leaves the animation uninterrupted.
// 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;
@@ -181,20 +195,46 @@ export const modeMethods = {
},
// 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
@@ -205,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;
}
@@ -220,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>")
@@ -242,6 +283,13 @@ export const modeMethods = {
.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>"))
@@ -249,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() {
+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.
+40 -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";
@@ -694,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],
@@ -718,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 --
@@ -831,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() {
@@ -845,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 };
+48 -2
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,12 +191,17 @@ 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);
@@ -261,6 +276,37 @@ export class MapModesTest extends TestCase {
}
}
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();
+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);
}
}