diff --git a/client/css/style.css b/client/css/style.css index 911a0fa..fa9fbfb 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -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; diff --git a/client/js/devlog_data.js b/client/js/devlog_data.js index 0db7ecf..55e69c7 100644 --- a/client/js/devlog_data.js +++ b/client/js/devlog_data.js @@ -1,6 +1,12 @@ // Generated by scripts/generate-devlog.js from `git log`; do not edit. // The pre-commit hook refreshes it so the main menu shows the latest commits. export const DEVLOG = [ + { + "hash": "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." } ]; diff --git a/client/js/map_view.js b/client/js/map_view.js index 7165dbd..99ba2db 100644 --- a/client/js/map_view.js +++ b/client/js/map_view.js @@ -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 diff --git a/client/js/map_view/modes.js b/client/js/map_view/modes.js index 1bdd789..0b75403 100644 --- a/client/js/map_view/modes.js +++ b/client/js/map_view/modes.js @@ -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( $("
") .append($("")) - .append($("").text("People between cities")) + .append($("").text("People between cities (flags show origin)")) ); $list.append( $("") @@ -242,6 +283,13 @@ export const modeMethods = { .append($("").text(resource.name)) ); } + if (this._hasIdleEdges(graph)) { + $list.append( + $("") + .append($("")) + .append($("").text("Idle route")) + ); + } $list.append( $("") .append($("")) @@ -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() { diff --git a/shared/game_state/resources.js b/shared/game_state/resources.js index 9849c10..9517a0e 100644 --- a/shared/game_state/resources.js +++ b/shared/game_state/resources.js @@ -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. diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js index 2a310aa..6cf9497 100644 --- a/shared/game_state/serialization.js +++ b/shared/game_state/serialization.js @@ -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 }; diff --git a/tests/map_modes_test.js b/tests/map_modes_test.js index 51caa2c..8f9281a 100644 --- a/tests/map_modes_test.js +++ b/tests/map_modes_test.js @@ -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(); diff --git a/tests/migration_test.js b/tests/migration_test.js index 9a651f6..17679a4 100644 --- a/tests/migration_test.js +++ b/tests/migration_test.js @@ -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() { diff --git a/tests/trade_graph_test.js b/tests/trade_graph_test.js new file mode 100644 index 0000000..b612bd1 --- /dev/null +++ b/tests/trade_graph_test.js @@ -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); + } +}