From 3acd2487df1f5de149c09a422087d40466a413fb Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Fri, 18 Sep 2026 07:11:59 +0200 Subject: [PATCH] Added roads, regional taxation and a reworked budget view Roads are pre-generated between cities with a greedy geometric spanner and flatten unit movement cost. City improvements now only affect the tiles their region controls, with per-region tax collection and a Cities tab. The budget is a grouped, scrollable table with 25%/year tax and a 10%-of-GDP opening treasury. Also merged the tile info into the city panel, kept conflict and budget expansion state across snapshots, added join notifications, filled border corners, drew battle sides as stacks and made tab overflow scroll. --- AGENTS.md | 5 +- client/css/style.css | 65 ++++++- client/index.html | 8 +- client/js/game_screen.js | 3 + client/js/game_screen/panels.js | 88 +++++++++- client/js/map_view.js | 7 + client/js/map_view/entities.js | 38 ++++- client/js/map_view/motion.js | 7 +- client/js/map_view/terrain.js | 68 +++++++- client/js/modals/format.js | 6 +- client/js/modals/nation.js | 263 ++++++++++++++++++++++------- server/game_server.js | 2 + shared/data/buildings.js | 12 +- shared/data/economy.js | 12 +- shared/data/index.js | 1 + shared/data/relations.js | 2 + shared/data/roads.js | 20 +++ shared/game_state.js | 6 + shared/game_state/diplomacy.js | 14 +- shared/game_state/economy.js | 210 +++++++++++++++++------ shared/game_state/movement.js | 7 +- shared/game_state/orders.js | 5 + shared/game_state/serialization.js | 45 +++++ shared/game_state/territory.js | 69 +++++++- shared/game_state/world.js | 21 ++- shared/roads.js | 198 ++++++++++++++++++++++ shared/rules.js | 17 ++ tests/framework/helpers.js | 1 + tests/game_state_buildings_test.js | 16 +- tests/game_state_test.js | 8 +- tests/map_view_test.js | 14 ++ tests/modals_test.js | 86 +++++++--- tests/roads_test.js | 95 +++++++++++ tests/run_tests.js | 2 + 34 files changed, 1236 insertions(+), 185 deletions(-) create mode 100644 shared/data/roads.js create mode 100644 shared/roads.js create mode 100644 tests/roads_test.js diff --git a/AGENTS.md b/AGENTS.md index 8f40206..49c4545 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,10 @@ There is no linter, formatter, or CI. Do not invent commands beyond these. extrapolates each unit along its snapshot path and predicts move orders locally (`map_view.js`), mirroring `GameState.find_path` so a click feels instant. **Any pathfinding or movement-cost change must be mirrored on both - sides** or prediction will fight the server. + sides** or prediction will fight the server. Roads (`shared/roads.js`, + pre-generated into `GameState.roads`) flatten a tile's movement cost on both + sides through `_tileTravelHours` / `_stepHours`; the road set is shipped in + the snapshot and drawn in the chunked `layer-roads`. - `shared/` holds the reusable algorithms so they stay testable without a server: `hex.js` (topology/geometry), `map_generator.js`, `hex_pathfinder.js`, `terrain_stats.js`, `rules.js`, `economy` figures in `game_state.js`, diff --git a/client/css/style.css b/client/css/style.css index 2fcd66b..14b8ec9 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -198,6 +198,24 @@ input:focus, select:focus { border-color: var(--accent); } pointer-events: none; } +/* Rounds off the junction where two border edges meet, so the outline has no + notches at the corners. */ +.border-corner { + position: absolute; + border-radius: 50%; + pointer-events: none; +} + +/* A single pre-generated road link, running from tile centre to tile centre. */ +.road-seg { + position: absolute; + background: #8a5a2b; + border-radius: 2px; + box-shadow: 0 0 1px rgba(0, 0, 0, 0.6); + transform-origin: 0 50%; + pointer-events: none; +} + .tile-selection { position: absolute; width: var(--hex-w); @@ -597,7 +615,12 @@ input:focus, select:focus { border-color: var(--accent); } } .tab.active { background: rgba(110, 175, 255, 0.22); color: var(--text); } .tab-page { display: none; } -.tab-page.active { display: block; } +/* The active tab fills the rest of the card and lets its body scroll: without + min-height: 0 the flex item would grow past the card and be clipped by the + card's overflow: hidden, hiding e.g. long diplomacy tables. */ +.tab-page.active { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; } +.tab-page.active > .modal-sub { flex: 0 0 auto; } +.tab-page.active > .modal-body { flex: 1 1 auto; min-height: 0; } .row-card { display: flex; @@ -664,6 +687,46 @@ input:focus, select:focus { border-color: var(--accent); } .diplomacy-table td.status { white-space: nowrap; } .diplomacy-table td.status .aero { margin-left: 8px; padding: 4px 10px; } +/* Budget table: income green, every cost red, alternating rows and the net + total pinned to the last row. */ +.budget-table { width: 100%; border-collapse: collapse; } +.budget-table th, +.budget-table td { + padding: 7px 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + text-align: left; +} +.budget-table th { color: var(--muted); font-weight: 600; } +.budget-table td.numeric, +.budget-table th.numeric { text-align: right; } +.budget-table tbody tr:nth-child(even) { background: rgba(255, 255, 255, 0.045); } +.budget-table tbody tr:hover { background: rgba(110, 175, 255, 0.12); } +.budget-table .budget-value, +.budget-table td.numeric { text-align: right; font-variant-numeric: tabular-nums; } +.budget-table tr.income .budget-value, +.budget-table tr.income-detail .budget-value, +.budget-table td.income { color: #8fe3a8; } +.budget-table tr.budget-group .budget-value, +.budget-table tr.expense .budget-value, +.budget-table tr.expense-detail .budget-value { color: #ff9a9a; } +.budget-table tr.budget-section td { + padding-top: 14px; + color: var(--muted); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.budget-table tr.budget-group .budget-name.clickable { cursor: pointer; } +.budget-table tr.budget-group .budget-name.clickable:hover { color: var(--accent); } +.budget-caret { display: inline-block; width: 12px; color: var(--muted); } +.budget-note { margin-top: 2px; color: var(--muted); font-size: 11px; } +.budget-table tfoot .budget-total td { + padding-top: 10px; + border-top: 2px solid rgba(255, 255, 255, 0.25); + font-weight: 700; +} +.budget-table tfoot .budget-total .budget-value { color: #fff; } + .training-status { padding: 0 18px 16px; } .training-queue { padding-top: 0; } .training-queue .queue-empty { padding: 4px 0; } diff --git a/client/index.html b/client/index.html index efb4ea2..ca59b9e 100644 --- a/client/index.html +++ b/client/index.html @@ -95,6 +95,7 @@
+
@@ -134,8 +135,6 @@
+
+ + +
diff --git a/client/js/game_screen.js b/client/js/game_screen.js index 11a66a3..b05350c 100644 --- a/client/js/game_screen.js +++ b/client/js/game_screen.js @@ -30,6 +30,7 @@ export class GameScreen { this._tileGdpPenalties = new Map(); this._tileBattleGdpDeficits = new Map(); this._territoryOwner = new Map(); + this._tileRegion = new Map(); this._battles = new Set(); this._researched = new Set(); this._wars = new Set(); @@ -47,6 +48,7 @@ export class GameScreen { { overview: $("#layer-overview"), terrain: $("#layer-terrain"), + roads: $("#layer-roads"), borders: $("#layer-borders"), fog: $("#layer-fog"), paths: $("#layer-paths"), @@ -121,6 +123,7 @@ export class GameScreen { (state.tileBattleGdpDeficits || []).map((e) => [`${e[0]},${e[1]}`, e[2]]) ); this._territoryOwner = new Map(state.territory.map((e) => [`${e[0]},${e[1]}`, e[2]])); + this._tileRegion = new Map((state.regions || []).map((e) => [`${e[0]},${e[1]}`, e[2]])); this._battles = new Set((state.battles || []).map((e) => `${e[0]},${e[1]}`)); this._cityBuildings = new Map(); for (const city of state.cities) { diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js index 976d7be..312d8be 100644 --- a/client/js/game_screen/panels.js +++ b/client/js/game_screen/panels.js @@ -13,7 +13,10 @@ import { TECHNOLOGIES, NEWS_WAR, ECONOMY, + EFFECT_GDP, + effectValue, pillageSteps, + ROADS, } from "../../../shared/data.js"; import { defenseFactors, unitDefense, gdpPerCapitaFactors, gdpPerCapitaFromFactors } from "../../../shared/rules.js"; import { dateString } from "../../../shared/game_clock.js"; @@ -342,21 +345,39 @@ export const panelMethods = { } }, + // A city panel shows the tile it stands on (owner, terrain, population, GDP + // per capita, improvements) plus the city's name and its actions; the tile + // fields are the same ones the plain tile panel renders, so nothing is + // duplicated between the two. _showCity(city) { if (!city) { $("#city-info").addClass("hidden"); + $("#tile-info").addClass("hidden"); this._refreshBottomVisibility(); return; } + this._renderTileInfo({ x: city.coords[0], y: city.coords[1] }); $("#city-name").text(city.name); - $("#city-population").text(`Population: ${groupDigits(city.population)}`); - $("#city-improvements").text(`Improvements: ${fmtImprovements(city.improvements)}`); - $("#tile-info, #unit-info").addClass("hidden"); - $("#city-info").removeClass("hidden"); + $("#unit-info").addClass("hidden"); + $("#tile-info, #city-info").removeClass("hidden"); this._refreshBottomVisibility(); }, _showTile(coords) { + // Inspecting a tile drops any city or unit selection, otherwise the next + // snapshot would immediately switch the panel back to the old selection. + this.selectedCityId = 0; + this.selectedUnitIds = []; + this.map.setSelectedUnits([]); + this._hideStackMenu(); + this._renderTileInfo(coords); + $("#city-info, #unit-info").addClass("hidden"); + $("#tile-info").removeClass("hidden"); + this._refreshBottomVisibility(); + }, + + // Fills the shared tile fields without touching which section is visible. + _renderTileInfo(coords) { const k = `${coords.x},${coords.y}`; const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1; const civ = this.civilisations[owner]; @@ -369,21 +390,31 @@ export const panelMethods = { $("#tile-terrain").text(`Terrain: ${tile ? tile.terrainType : "Unknown"}`); $("#tile-population").text(`Population: ${groupDigits(this._population.get(k) || 0)}`); this._renderTileGdp(owner >= 0 ? this._tileGdpBreakdown(coords, owner, city, tile) : null); - $("#tile-improvements").text(`Improvements: ${fmtImprovements(city ? city.improvements : [])}`); - $("#city-info, #unit-info").addClass("hidden"); - $("#tile-info").removeClass("hidden"); - this._refreshBottomVisibility(); + const roads = this.map.roads && this.map.roads.has(k) ? [ROADS.improvementName] : []; + const improvements = city + ? Array.from(new Set([...(city.improvements || []), ...roads])) + : roads; + $("#tile-improvements").text(`Improvements: ${fmtImprovements(improvements)}`); }, // The base GDP per capita of a claimable tile plus every factor that shapes // it, mirroring the server's getTileGdpPerCapita through the shared helper. + // The region that develops the tile supplies the building bonus; government + // and research supply the nation-wide one. _tileGdpBreakdown(coords, owner, city, tile) { const k = `${coords.x},${coords.y}`; + const regionCity = + city || (this.snapshot.cities || []).find((c) => c.id === this._tileRegion.get(k)); + const regionalModifier = regionCity ? this._cityGdpModifier(regionCity) : 0; + const nationalModifier = this._nationalGdpModifier(owner); const factors = gdpPerCapitaFactors(tile, { cityKind: city ? (city.isCapital ? "capital" : "city") : null, nextToCity: !city && this._isNextToCity(coords, owner), pillagePenalty: this._tileGdpPenalties.has(k) ? this._tileGdpPenalties.get(k) : 1, battleDeficit: this._tileBattleGdpDeficits.get(k) || 0, + regionalMultiplier: 1 + regionalModifier, + regionalLabel: regionCity ? regionCity.name : null, + nationalMultiplier: 1 + nationalModifier, }); return { base: ECONOMY.baseGdpPerCapita, @@ -392,6 +423,39 @@ export const panelMethods = { }; }, + // The GDP bonus a single city's own buildings give. + _cityGdpModifier(city) { + let total = 0; + for (const indexStr of Object.keys(city.buildings || {})) { + const proto = BUILDINGS[Number(indexStr)]; + if (!proto) continue; + const level = city.buildings[indexStr]; + for (const e of proto.effects) { + if (e.stat === EFFECT_GDP) total += effectValue(e, level); + } + } + return total; + }, + + // The GDP bonus a nation gets from its government and researched technology. + _nationalGdpModifier(civ) { + let total = 0; + const government = GOVERNMENTS[(this.snapshot.government || [])[civ]]; + if (government) { + for (const e of government.effects) { + if (e.stat === EFFECT_GDP) total += effectValue(e, 1); + } + } + for (const index of (this.snapshot.researched || [])[civ] || []) { + const tech = TECHNOLOGIES[index]; + if (!tech) continue; + for (const e of tech.effects) { + if (e.stat === EFFECT_GDP) total += effectValue(e, 1); + } + } + return total; + }, + _isNextToCity(coords, owner) { for (const neighbour of this.map.topology.neighbours(coords.x, coords.y)) { const found = (this.snapshot.cities || []).some( @@ -594,6 +658,12 @@ export const panelMethods = { }, _finances(stats) { - return stats.breakdown || null; + if (!stats || !stats.breakdown) return null; + // The region figures are global, so the Cities tab can show every nation's; + // the panel filters them to the viewer. + return { + ...stats.breakdown, + cities: (this.snapshot && this.snapshot.cityStats) || [], + }; }, }; diff --git a/client/js/map_view.js b/client/js/map_view.js index 3d33b1d..60a37b5 100644 --- a/client/js/map_view.js +++ b/client/js/map_view.js @@ -21,6 +21,9 @@ export class MapView { this.$viewport = $viewport; this.$world = $world; this.$terrain = layers.terrain; + this.$roads = layers.roads && layers.roads.length + ? layers.roads + : $("#layer-roads"); this.$borders = layers.borders; this.$fog = layers.fog; this.$paths = layers.paths; @@ -51,6 +54,8 @@ export class MapView { this.explored = new Set(); this.visible = new Set(); this.territory = new Map(); + // Pre-generated road tiles, keyed "x,y". + this.roads = new Set(); this.civilisations = []; this.localCiv = -1; this.protoUnits = []; @@ -73,6 +78,7 @@ export class MapView { this._maxStepLength = 1.0; this._territorySignature = ""; + this._roadsSignature = null; this._exploredCount = -1; this._visibleSignature = null; @@ -97,6 +103,7 @@ export class MapView { this._chunkDirty = true; this._hexPool = []; this._borderPool = []; + this._roadPool = []; this._wrapperPool = []; this.lod = false; diff --git a/client/js/map_view/entities.js b/client/js/map_view/entities.js index 40b04f1..1d91bc6 100644 --- a/client/js/map_view/entities.js +++ b/client/js/map_view/entities.js @@ -3,7 +3,7 @@ import { key, mapToLocal, parseKey } from "../../../shared/hex.js"; import { statusById } from "../../../shared/data.js"; -import { BATTLE_SIDE_OFFSET, BATTLE_STACK_STEP } from "./constants.js"; +import { BATTLE_SIDE_OFFSET } from "./constants.js"; import { hashEntries, pathProgress, nearestCorners, inwardOffset } from "./utils.js"; export const entityMethods = { @@ -14,6 +14,7 @@ export const entityMethods = { applySnapshot(state) { this.ensureTerrain(state.seed); + this.setRoads(state.roads || []); this.localCiv = state.viewer; this._applyTerritory(state.territory); this._applyKnowledge(state.explored, state.visible); @@ -116,6 +117,23 @@ export const entityMethods = { : `rotate(${angle}rad) translateY(${inward}px)`; el.style.borderRadius = `${width / 2}px`; fragment.appendChild(el); + + // A dot at each end of the (inward-nudged) segment fills the gaps that + // the rounded, offset edges would otherwise leave at hex corners where + // two borders meet. + const nx = length > 0 ? -dy / length : 0; + const ny = length > 0 ? dx / length : 0; + const corner = width * 1.5; + for (const point of [a, b]) { + const dot = this._acquireHex(this._borderPool); + dot.className = "border-corner"; + dot.style.left = `${point.x + nx * inward - corner / 2}px`; + dot.style.top = `${point.y + ny * inward - corner / 2}px`; + dot.style.width = `${corner}px`; + dot.style.height = `${corner}px`; + dot.style.background = civ.primaryColour; + fragment.appendChild(dot); + } } } rec.borderEl.appendChild(fragment); @@ -320,15 +338,17 @@ export const entityMethods = { members.forEach((id, index) => { const motion = this._unitMotion.get(id); if (!motion) return; - motion.offset = { - x: sign * BATTLE_SIDE_OFFSET, - y: (index - (members.length - 1) / 2) * BATTLE_STACK_STEP, - }; + // A whole side is drawn as one icon with a count badge; the rest stay + // hidden but are kept for hit-testing, so a 100-unit battle is two + // markers instead of a column taller than the screen. + motion.offset = { x: sign * BATTLE_SIDE_OFFSET, y: 0 }; const view = this._unitViews.get(id); - if (view) { - view.classList.toggle("in-battle", true); - view.dataset.battleSide = side; - } + if (!view) return; + view.classList.toggle("in-battle", true); + view.dataset.battleSide = side; + const leader = index === 0; + this._setStackCount(view, leader ? members.length : 0); + view.style.visibility = leader ? "" : "hidden"; }); } }, diff --git a/client/js/map_view/motion.js b/client/js/map_view/motion.js index 6a92e0c..7c32d75 100644 --- a/client/js/map_view/motion.js +++ b/client/js/map_view/motion.js @@ -3,6 +3,7 @@ import { mapToLocal, key } from "../../../shared/hex.js"; import { HexPathfinder } from "../../../shared/hex_pathfinder.js"; +import { ROADS } from "../../../shared/data/roads.js"; import { CONTROLLED_SPEED_MULTIPLIER } from "../../../shared/game_state/constants.js"; import { RESYNC_HOURS, PREDICTION_GRACE_HOURS } from "./constants.js"; import { hoursAlong, samePath, pathProgress, makeSegment } from "./utils.js"; @@ -411,6 +412,10 @@ export const motionMethods = { const k = key(coords.x, coords.y); let effective = Math.max(speed || proto.speed, 0.0001); if (this.territory.get(k) === civ) effective *= CONTROLLED_SPEED_MULTIPLIER; - return this.terrainStats.movementCostMultiplier(coords) / effective; + // Mirrors GameState._tileTravelHours: a road levels the terrain. + const multiplier = this.roads.has(k) + ? ROADS.movementCostMultiplier + : this.terrainStats.movementCostMultiplier(coords); + return multiplier / effective; }, }; diff --git a/client/js/map_view/terrain.js b/client/js/map_view/terrain.js index b4a334c..2ee24ca 100644 --- a/client/js/map_view/terrain.js +++ b/client/js/map_view/terrain.js @@ -21,6 +21,11 @@ export const terrainMethods = { ensureTerrain(seed) { if (seed === this.seed) return; this.seed = seed; + // A new world brings a new road network; drop the old one (and the cache + // signature) before the chunks are rebuilt, so setRoads always installs the + // new tiles. + this.roads = new Set(); + this._roadsSignature = null; this.topology = new MapTopology(MAP_CONFIG.mapSize, MAP_CONFIG.topology === "cylindrical"); const generator = new MapGenerator(MAP_CONFIG, this.topology); generator.generate(seed); @@ -38,9 +43,11 @@ export const terrainMethods = { this._chunkCacheLimit = CHUNK_CACHE_LIMIT; this._destroyAllChunks(); this.$terrain.empty(); + this.$roads.empty(); this.$borders.empty(); this.$fog.empty(); this.$terrain.addClass("detail-layer"); + this.$roads.addClass("detail-layer"); this.$borders.addClass("detail-layer"); this.$fog.addClass("detail-layer"); @@ -134,10 +141,12 @@ export const terrainMethods = { const terrain = this._acquireWrapper(); const fog = this._acquireWrapper(); const borders = this._acquireWrapper(); + const roads = this._acquireWrapper(); this.$terrain[0].appendChild(terrain); this.$fog[0].appendChild(fog); + this.$roads[0].appendChild(roads); this.$borders[0].appendChild(borders); - return { terrain, fog, borders }; + return { terrain, fog, borders, roads }; }, _createChunk(cx, cy) { @@ -149,10 +158,12 @@ export const terrainMethods = { this._chunkCache.delete(chunkKey); this.$terrain[0].appendChild(cached.terrainEl); this.$fog[0].appendChild(cached.fogEl); + this.$roads[0].appendChild(cached.roadsEl); this.$borders[0].appendChild(cached.borderEl); this._chunks.set(chunkKey, cached); if (cached.fogDirty) this._buildFog(cached); if (cached.borderDirty) this._buildBorders(cached); + if (cached.roadsDirty) this._buildRoads(cached); this._setChunkPosition(cached, this._wrapShift(cached.x)); return cached; } @@ -173,16 +184,20 @@ export const terrainMethods = { tileKeys: [], fogDirty: false, borderDirty: false, + roadsDirty: false, terrainEl: null, fogEl: null, borderEl: null, + roadsEl: null, }; const wrappers = this._newChunk(); rec.terrainEl = wrappers.terrain; rec.fogEl = wrappers.fog; rec.borderEl = wrappers.borders; + rec.roadsEl = wrappers.roads; this._chunks.set(chunkKey, rec); this._buildTerrain(rec); + this._buildRoads(rec); this._buildBorders(rec); this._buildFog(rec); this._setChunkPosition(rec, this._wrapShift(rec.x)); @@ -195,6 +210,7 @@ export const terrainMethods = { rec.terrainEl.style.transform = transform; rec.fogEl.style.transform = transform; rec.borderEl.style.transform = transform; + rec.roadsEl.style.transform = transform; }, // Parks a built chunk off-screen so panning back over it is a reattach rather @@ -203,6 +219,7 @@ export const terrainMethods = { _detachChunk(chunkKey, rec) { rec.terrainEl.parentNode && rec.terrainEl.parentNode.removeChild(rec.terrainEl); rec.fogEl.parentNode && rec.fogEl.parentNode.removeChild(rec.fogEl); + rec.roadsEl.parentNode && rec.roadsEl.parentNode.removeChild(rec.roadsEl); rec.borderEl.parentNode && rec.borderEl.parentNode.removeChild(rec.borderEl); this._chunks.delete(chunkKey); this._chunkCache.set(chunkKey, rec); @@ -216,9 +233,11 @@ export const terrainMethods = { _destroyChunk(rec) { this._recycleLayer(rec.terrainEl, this._hexPool); this._recycleLayer(rec.fogEl, this._hexPool); + this._recycleLayer(rec.roadsEl, this._roadPool); this._recycleLayer(rec.borderEl, this._borderPool); this._releaseWrapper(rec.terrainEl); this._releaseWrapper(rec.fogEl); + this._releaseWrapper(rec.roadsEl); this._releaseWrapper(rec.borderEl); }, @@ -349,6 +368,53 @@ export const terrainMethods = { if (this._chunkCache) for (const rec of this._chunkCache.values()) rec.fogDirty = true; }, + // Installs the road network once. Roads never change once generated, so a + // length check is enough to skip rebuilding the on-screen chunks. + setRoads(entries) { + const list = entries || []; + const signature = String(list.length); + if (this._roadsSignature === signature) return; + this._roadsSignature = signature; + this.roads = new Set(list.map((entry) => key(entry[0], entry[1]))); + for (const rec of this._chunks.values()) this._buildRoads(rec); + if (this._chunkCache) { + for (const rec of this._chunkCache.values()) rec.roadsDirty = true; + } + }, + + // Draws a brown line from the centre of every road tile to the centre of each + // of its road neighbours. The work is paid per visible chunk, so panning over + // a huge network only ever builds the few hundred tiles on screen. + _buildRoads(rec) { + rec.roadsDirty = false; + this._recycleLayer(rec.roadsEl, this._roadPool); + if (!this.roads || this.roads.size === 0) return; + const width = 5; + const fragment = document.createDocumentFragment(); + for (const k of rec.tileKeys) { + if (!this.roads.has(k)) continue; + const coords = parseKey(k); + const center = mapToLocal(coords.x, coords.y); + const localCenter = { x: center.x - rec.x, y: center.y - rec.y }; + for (const neighbour of this.topology.neighbours(coords.x, coords.y)) { + if (!this.roads.has(key(neighbour.x, neighbour.y))) continue; + const delta = this.topology.pixelDelta(coords, neighbour); + const length = Math.hypot(delta.x, delta.y); + if (length <= 0) continue; + const angle = Math.atan2(delta.y, delta.x); + const el = this._acquireHex(this._roadPool); + el.className = "road-seg"; + el.style.left = `${localCenter.x}px`; + el.style.top = `${localCenter.y - width / 2}px`; + el.style.width = `${length}px`; + el.style.height = `${width}px`; + el.style.transform = `rotate(${angle}rad)`; + fragment.appendChild(el); + } + } + rec.roadsEl.appendChild(fragment); + }, + // ------------------------------------------------------------ pooling -- _acquireWrapper() { diff --git a/client/js/modals/format.js b/client/js/modals/format.js index 113e2b8..ca6ff2f 100644 --- a/client/js/modals/format.js +++ b/client/js/modals/format.js @@ -6,9 +6,9 @@ export function formatEffect(effect, level) { const value = level <= 0 ? 0 : effect.baseValue * Math.pow(level, effect.exponent); switch (effect.stat) { case "gdp": - return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% GDP`; + return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% GDP/capita`; case "budget_income": - return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% budget income`; + return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% tax income`; case "research": return `${value >= 0 ? "+" : ""}${value.toFixed(2)} research/h`; case "culture": @@ -45,6 +45,8 @@ export function describeNews(entry, civs) { return `${people(entry.actor)} declared war on ${name(entry.target)}`; case "capture": return `${people(entry.to)} captured ${entry.city} from ${name(entry.from)}`; + case "join": + return `${entry.player} took control of ${name(entry.civ)}`; default: return entry.type; } diff --git a/client/js/modals/nation.js b/client/js/modals/nation.js index fbe438f..582780b 100644 --- a/client/js/modals/nation.js +++ b/client/js/modals/nation.js @@ -18,6 +18,8 @@ export class NationModal { this.$budgetList = $("#budget-list"); this.$diplomacySub = $("#diplomacy-sub"); this.$diplomacyList = $("#diplomacy-list"); + this.$citiesSub = $("#cities-sub"); + this.$citiesList = $("#cities-list"); this.$conflictsSub = $("#conflicts-sub"); this.$conflictsList = $("#conflicts-list"); this.governments = []; @@ -29,12 +31,12 @@ export class NationModal { this.diplomacy = null; this.diplomacySort = { key: "name", dir: 1 }; this.conflicts = []; - this.budgetRows = { - income: this._makeBudgetRow("Tax income", "State revenue drawn from the nation's GDP."), - building: this._makeBudgetRow("Building upkeep", "Upkeep of every building across your cities."), - units: this._makeBudgetRow("Military upkeep", "Upkeep of all your units."), - net: this._makeBudgetRow("Net balance", "Projected change to the treasury each hour."), - }; + // Conflict cards the player expanded, keyed "a:b". Snapshot refreshes + // rebuild the list, so the open/closed state has to live outside the DOM. + this.expandedConflicts = new Set(); + // Building types whose per-city budget breakdown is expanded. + this.expandedBudgetGroups = new Set(); + this._lastBreakdown = {}; this.onGovernment = () => {}; this.onTechnology = () => {}; this.onDeclareWar = () => {}; @@ -68,33 +70,191 @@ export class NationModal { this.$modal.removeClass("hidden"); } - _makeBudgetRow(name, description) { - const $card = $("
"); - const $info = $("
"); - const $name = $("
").text(name); - const $desc = $("
").text(description); - const $details = $("
"); - const $value = $("
"); - $info.append($name, $desc, $details); - $card.append($info, $value); - this.$budgetList.append($card); - return { $value, $details }; - } + // The budget is a two-column table: every row is an item and its hourly + // amount, income in green and every cost in red, with the projected total on + // the last row. Building types are rows that expand to their per-city cost. + _renderBudget(breakdown) { + if (!this.$budgetList || !this.$budgetList.length) return; + this._lastBreakdown = breakdown || {}; + const b = this._lastBreakdown; + const income = b.income || { rate: 0, base: 0, modifier: 0, total: 0, sources: [], regions: [] }; + const incomeSources = income.sources || []; + const building = b.buildingUpkeep || { total: 0, groups: [], sources: [] }; + const buildingGroups = building.groups || []; + const units = b.unitUpkeep || { total: 0, groups: [], sources: [] }; + const unitSources = units.sources || []; + const net = b.net || 0; - _renderBudgetRow(row, value, lines) { - row.$value - .text(signedMoney(value)) - .toggleClass("positive", value >= 0) - .toggleClass("negative", value < 0); - row.$details.empty(); - for (const line of lines) { - row.$details.append($("
").text(line)); + this.$budgetList.empty(); + const $body = $(""); + + $body.append(this._budgetSectionRow("Income")); + $body.append( + this._budgetMoneyRow( + "Tax income", + income.total, + "income", + `Collecting ${(income.rate * 100).toFixed(3)}% of GDP per hour — ${( + income.rate * + 365 * + 24 * + 100 + ).toFixed(0)}% a year` + ) + ); + if (incomeSources.length === 0) { + $body.append(this._budgetDetailRow("No bonuses", 0, "income-detail")); } + for (const source of incomeSources) { + const where = source.cityName ? ` in ${source.cityName}` : ""; + $body.append( + this._budgetDetailRow( + `${source.label}${where} (${SOURCE_LABELS[source.kind] || source.kind}) ${percent(source.effect)}`, + source.amount, + "income-detail" + ) + ); + } + + $body.append(this._budgetSectionRow("Expenses")); + if (buildingGroups.length === 0) { + $body.append(this._budgetDetailRow("No buildings", 0, "expense-detail")); + } + for (const group of buildingGroups) { + const key = `building:${group.index}`; + const expanded = this.expandedBudgetGroups.has(key); + $body.append(this._budgetGroupRow(group.label, -group.amount, key, expanded)); + if (expanded) { + for (const city of group.cities) { + $body.append( + this._budgetDetailRow( + `${group.label} in ${city.cityName} — level ${city.level}`, + -city.amount, + "expense-detail" + ) + ); + } + } + } + if (unitSources.length === 0) { + $body.append(this._budgetDetailRow("No units", 0, "expense-detail")); + } + for (const source of unitSources) { + $body.append( + this._budgetDetailRow(`${source.label} ×${source.count}`, -source.amount, "expense-detail") + ); + } + + const $table = $("
"); + $table.append($body); + $table.append($("").append(this._budgetTotalRow("TOTAL", net))); + this.$budgetList.append($table); } - _sourceLines(sources, format, empty) { - if (!sources || sources.length === 0) return [empty]; - return sources.map((source) => format(source)); + _budgetSectionRow(label) { + return $("").append( + $("").text(label) + ); + } + + _budgetMoneyRow(label, value, cls, note) { + const $tr = $("").addClass(cls); + const $name = $("").text(label); + if (note) $name.append($("
").text(note)); + $tr.append($name, $("").text(signedMoney(value))); + return $tr; + } + + _budgetDetailRow(label, value, cls) { + const $tr = $("").addClass(cls); + $tr.append($("").text(label)); + $tr.append( + $("").text(value === 0 ? "—" : signedMoney(value)) + ); + return $tr; + } + + _budgetGroupRow(label, value, key, expanded) { + const $tr = $(""); + const $name = $(""); + $name.append($("").text(expanded ? "▾" : "▸")); + $name.append(document.createTextNode(` ${label}`)); + $name.on("click", () => { + if (this.expandedBudgetGroups.has(key)) this.expandedBudgetGroups.delete(key); + else this.expandedBudgetGroups.add(key); + this._renderBudget(this._lastBreakdown); + }); + $tr.append($name, $("").text(signedMoney(value))); + return $tr; + } + + _budgetTotalRow(label, value) { + const $tr = $(""); + $tr.append($("").text(label)); + $tr.append($("").text(signedMoney(value))); + return $tr; + } + + // The nation's regions: each city's population, GDP and the tax it collects. + _renderCities(cities) { + if (!this.$citiesList || !this.$citiesList.length) return; + this.$citiesList.empty(); + const localCiv = this.diplomacy ? this.diplomacy.localCiv : -1; + const rows = (cities || []).filter((city) => city.civ === localCiv); + if (this.$citiesSub && this.$citiesSub.length) { + this.$citiesSub.text( + rows.length === 0 + ? "No cities." + : `${rows.length} region${rows.length === 1 ? "" : "s"} collecting taxes independently.` + ); + } + if (rows.length === 0) { + this.$citiesList.append($("
").text("No cities.")); + return; + } + rows.sort((a, b) => b.income - a.income); + const columns = [ + { key: "name", label: "City" }, + { key: "population", label: "Population", numeric: true }, + { key: "gdp", label: "GDP", numeric: true }, + { key: "income", label: "Tax / h", numeric: true }, + ]; + const $table = $("
"); + const $head = $(""); + const $headRow = $(""); + for (const column of columns) { + const $th = $("").text(column.label); + if (column.numeric) $th.addClass("numeric"); + $headRow.append($th); + } + $head.append($headRow); + const $body = $(""); + let totalPopulation = 0; + let totalGdp = 0; + let totalIncome = 0; + for (const city of rows) { + totalPopulation += city.population; + totalGdp += city.gdp; + totalIncome += city.income; + const $tr = $(""); + const $name = $("").text(city.isCapital ? `★ ${city.name}` : city.name); + $tr.append($name); + $tr.append($("").text(compact(city.population))); + $tr.append($("").text(`€${compact(city.gdp)}`)); + $tr.append($("").text(`+€${compact(city.income)}`)); + $body.append($tr); + } + $table.append($head, $body); + $table.append( + $("").append( + $("") + .append($("").text("TOTAL")) + .append($("").text(compact(totalPopulation))) + .append($("").text(`€${compact(totalGdp)}`)) + .append($("").text(`+€${compact(totalIncome)}`)) + ) + ); + this.$citiesList.append($table); } _makeRow($list, proto, index, kind) { @@ -128,45 +288,14 @@ export class NationModal { this.$technologySub.text(`Research points: ${groupDigits(Math.round(research))}`); const breakdown = finances || {}; - const income = breakdown.income || { rate: 0, base: 0, modifier: 0, total: 0, sources: [] }; - const building = breakdown.buildingUpkeep || { total: 0, sources: [] }; - const units = breakdown.unitUpkeep || { total: 0, sources: [] }; const net = breakdown.net || 0; this.$budgetSub.text( `Treasury: €${groupDigits(Math.round(budget))} · Net: ${signedMoney(net)}` ); - this._renderBudgetRow(this.budgetRows.income, income.total, [ - `Tax rate ${(income.rate * 100).toFixed(2)}% of GDP — base €${groupDigits(Math.round(income.base))}/h`, - ...this._sourceLines( - income.sources, - (s) => `${s.label} (${SOURCE_LABELS[s.kind] || s.kind}) ${percent(s.effect)} → ${signedMoney(s.amount)}`, - "No bonuses" - ), - ]); - this._renderBudgetRow( - this.budgetRows.building, - -building.total, - this._sourceLines( - building.sources, - (s) => `${s.label} — €${groupDigits(Math.round(s.amount))}/h`, - "No buildings" - ) - ); - this._renderBudgetRow( - this.budgetRows.units, - -units.total, - this._sourceLines( - units.sources, - (s) => `${s.label} ×${s.count} — €${groupDigits(Math.round(s.amount))}/h`, - "No units" - ) - ); - this._renderBudgetRow(this.budgetRows.net, net, [ - `Income ${signedMoney(income.total)}`, - `Expenses −€${groupDigits(Math.round(building.total + units.total))}/h`, - ]); + this._renderBudget(breakdown); + this._renderCities(breakdown.cities); for (const row of this.governmentRows) { const proto = row.proto; @@ -364,7 +493,9 @@ export class NationModal { const $desc = $("
").text(`Ongoing since ${conflict.started}`); $info.append($name, $desc); - const $detail = $(""); + const key = `${conflict.a}:${conflict.b}`; + const $detail = $("
"); + if (!this.expandedConflicts.has(key)) $detail.addClass("hidden"); $detail.append( $("
").text(`Conflict started ${conflict.started}`) ); @@ -378,7 +509,11 @@ export class NationModal { } $card.append($info, $detail); - $card.on("click", () => $detail.toggleClass("hidden")); + $card.on("click", () => { + if (this.expandedConflicts.has(key)) this.expandedConflicts.delete(key); + else this.expandedConflicts.add(key); + $detail.toggleClass("hidden"); + }); this.$conflictsList.append($card); } diff --git a/server/game_server.js b/server/game_server.js index afc36b5..8fb0bad 100644 --- a/server/game_server.js +++ b/server/game_server.js @@ -248,6 +248,7 @@ export class GameServer extends EventEmitter { const name = this.peerNames.get(peerId); if (name) this.identityCiv.set(name, choice); this.network.send(peerId, { t: "civ_assigned", civ: choice }); + this.state.addControlNews(choice, name); this._broadcastState(); return true; } @@ -261,6 +262,7 @@ export class GameServer extends EventEmitter { if (Array.from(this.peerCiv.values()).includes(choice)) return false; this.peerCiv.set(peerId, choice); this.network.send(peerId, { t: "civ_assigned", civ: choice }); + this.state.addControlNews(choice, playerName); return true; } diff --git a/shared/data/buildings.js b/shared/data/buildings.js index 884a7bc..b754ce3 100644 --- a/shared/data/buildings.js +++ b/shared/data/buildings.js @@ -8,7 +8,7 @@ export const BUILDINGS = [ { id: "market", name: "Market", - description: "A hub of local trade. Raises national GDP, with diminishing returns.", + description: "A hub of local trade. Raises the GDP per capita of the city's region, with diminishing returns.", baseCost: 5_000_000, costAlpha: 1.6, refundFraction: 0.5, @@ -17,7 +17,7 @@ export const BUILDINGS = [ { id: "bank", name: "Bank", - description: "Channels private wealth into the state coffers. Raises budget income.", + description: "Channels private wealth into the state coffers. Raises the tax collected from the city's region.", baseCost: 10_000_000, costAlpha: 1.7, refundFraction: 0.5, @@ -26,7 +26,7 @@ export const BUILDINGS = [ { id: "research_lab", name: "Research lab", - description: "Applies science to industry and government. Produces research points.", + description: "Applies science to industry and government. Produces research points for the whole nation.", baseCost: 100_000_000, costAlpha: 1.65, refundFraction: 0.5, @@ -35,7 +35,7 @@ export const BUILDINGS = [ { id: "university", name: "University", - description: "Higher education produces research and a little culture.", + description: "Higher education produces research and a little culture for the whole nation.", baseCost: 100_000_000, costAlpha: 1.7, refundFraction: 0.5, @@ -44,7 +44,7 @@ export const BUILDINGS = [ { id: "museum", name: "Museum", - description: "Preserves national heritage, strengthening culture against foreign ideas.", + description: "Preserves the nation's heritage, raising its culture output across the country.", baseCost: 50_000_000, costAlpha: 1.6, refundFraction: 0.5, @@ -53,7 +53,7 @@ export const BUILDINGS = [ { id: "cinema_studio", name: "Cinema studio", - description: "Mass media spreads the nation's culture at home and abroad.", + description: "Mass media spreads the nation's culture, raising its culture output across the country.", baseCost: 50_000_000, costAlpha: 1.75, refundFraction: 0.5, diff --git a/shared/data/economy.js b/shared/data/economy.js index 5033ea1..9c95bd4 100644 --- a/shared/data/economy.js +++ b/shared/data/economy.js @@ -2,6 +2,12 @@ // both use to derive production, training time, budget income and population // growth. +import { HOURS_PER_YEAR } from "../game_clock.js"; + +// Nations collect this share of their GDP as tax over a whole year; the hourly +// rate below is what the per-tick economy actually uses. +export const ANNUAL_TAX_RATE = 0.25; + export const ECONOMY = { baseGdpPerCapita: 10_000, basePopulation: 100_000, @@ -9,7 +15,11 @@ export const ECONOMY = { capitalTileGdpMultiplier: 2.5, citySurroundingGdpBonus: 0.3, gdpPerProductionPoint: 100_000, - budgetGdpRate: 0.001, + // 25% of GDP per year, spread evenly over the hours of the year. + taxRatePerYear: ANNUAL_TAX_RATE, + budgetGdpRate: ANNUAL_TAX_RATE / HOURS_PER_YEAR, + // A nation opens the game on 1 January 2000 with a tenth of its starting GDP. + startingBudgetFraction: 0.1, startingBudget: 1_000_000_000, basePopulationGrowthRate: 0.005, diff --git a/shared/data/index.js b/shared/data/index.js index 86c8e68..ce36789 100644 --- a/shared/data/index.js +++ b/shared/data/index.js @@ -14,3 +14,4 @@ export * from "./combat.js"; export * from "./economy.js"; export * from "./map_config.js"; export * from "./terrain.js"; +export * from "./roads.js"; diff --git a/shared/data/relations.js b/shared/data/relations.js index 0d4619a..45b0a51 100644 --- a/shared/data/relations.js +++ b/shared/data/relations.js @@ -9,3 +9,5 @@ export const RELATION_WAR = "war"; // GameState with their in-game hour so every client can render them. export const NEWS_WAR = "war"; export const NEWS_CAPTURE = "capture"; +// A player took control of a nation. Recorded by the server, not the model. +export const NEWS_JOIN = "join"; diff --git a/shared/data/roads.js b/shared/data/roads.js new file mode 100644 index 0000000..2ca51b2 --- /dev/null +++ b/shared/data/roads.js @@ -0,0 +1,20 @@ +// Road network tuning. Roads are tile improvements laid down once during world +// generation: they connect the cities with a sparse, near-shortest network and +// make movement cost the same on every terrain they cross. + +export const ROADS = { + // Travel multiplier while a unit stands on a road tile. It is flat, unlike + // the terrain multipliers, so a road levels out hills and forests. + movementCostMultiplier: 1.0, + // A road tile's generation cost is costBase raised to the terrain's own + // movement multiplier, so hills and mountains are exponentially dearer to + // run a road through than plains. + costBase: 2.0, + // Cities already have a road, so they cost nothing to include in a route. + cityCost: 0, + // Stretch factor of the greedy geometric spanner that picks which city pairs + // are actually connected. + spannerStretch: 2, + // The label a road contributes to a tile's improvement list. + improvementName: "Road", +}; diff --git a/shared/game_state.js b/shared/game_state.js index edb4869..d437b72 100644 --- a/shared/game_state.js +++ b/shared/game_state.js @@ -92,6 +92,12 @@ export class GameState { this.cities = []; this.units = []; this.territory = new Map(); + // Which city's region each land tile belongs to: the nearest city, used to + // attribute population, GDP and tax to the city that develops the land. + this.tileRegion = new Map(); + this._regionByCity = new Map(); + // Tiles carrying a road (pre-generated city network). + this.roads = new Set(); this.tilePopulation = new Map(); // Per-tile GDP-per-capita multiplier, lowered by pillaging (default 1). this.tileGdpPenalty = new Map(); diff --git a/shared/game_state/diplomacy.js b/shared/game_state/diplomacy.js index a0f547b..57c328d 100644 --- a/shared/game_state/diplomacy.js +++ b/shared/game_state/diplomacy.js @@ -1,7 +1,7 @@ // Diplomacy: war declarations, the war record with its casualty tallies, and // the news feed. -import { RELATION_PEACE, RELATION_WAR, NEWS_WAR } from "../data/relations.js"; +import { RELATION_PEACE, RELATION_WAR, NEWS_WAR, NEWS_JOIN } from "../data/relations.js"; import { dateString } from "../game_clock.js"; import { MAX_NEWS } from "./constants.js"; import { warKey } from "./helpers.js"; @@ -70,6 +70,18 @@ export const diplomacyMethods = { return this.news; }, + // Announces that a player has taken control of a civilisation. Driven by the + // server when a joining peer claims a nation, so every client sees it through + // the next snapshot. + addControlNews(civ, playerName) { + if (!this._validCiv(civ)) return null; + const player = String(playerName || "").trim(); + if (player.length === 0) return null; + const record = this._addNews({ type: NEWS_JOIN, civ, player }); + this._emitChanged(); + return record; + }, + _addNews(entry) { const record = { id: this._nextNewsId, diff --git a/shared/game_state/economy.js b/shared/game_state/economy.js index e6ec456..1a07d46 100644 --- a/shared/game_state/economy.js +++ b/shared/game_state/economy.js @@ -43,7 +43,9 @@ export const economyMethods = { }; }, - // The economy exactly as the tiles describe it, before balancing. + // The economy exactly as the tiles describe it, before balancing. Every + // tile's per-capita figure already carries its region's and its nation's + // modifiers, so the sum needs no further adjustment. _basePlayerAggregates(civ) { let population = 0; let baseGdp = 0; @@ -53,9 +55,7 @@ export const economyMethods = { population += pop; baseGdp += pop * this.getTileGdpPerCapita(coords); } - const modifiers = this.getCivModifiers(civ); - const gdp = baseGdp * (1 + (modifiers[EFFECT_GDP] || 0)); - return { population, gdp }; + return { population, gdp: baseGdp }; }, getTileGdpPerCapita(coords) { @@ -64,12 +64,18 @@ export const economyMethods = { const owner = this.civAt(coords); if (owner < 0) return 0; const city = this.cityAt(coords); + const region = city || this.regionCityAt(coords); + const regional = region ? this.getCityBuildingModifiers(region) : null; + const national = this.getNationalModifiers(owner); const factors = gdpPerCapitaFactors(this.tiles[k], { cityKind: city ? (city.isCapital ? "capital" : "city") : null, nextToCity: !city && this._isNextToCity(coords, owner), pillagePenalty: this.tileGdpPenalty.get(k) !== undefined ? this.tileGdpPenalty.get(k) : 1, // Fighting scars the tile too, but that loss recovers over time. battleDeficit: this.tileBattleGdpDeficit.get(k) || 0, + regionalMultiplier: 1 + ((regional && regional[EFFECT_GDP]) || 0), + regionalLabel: region ? region.name : null, + nationalMultiplier: 1 + (national[EFFECT_GDP] || 0), }); const perCapita = gdpPerCapitaFromFactors(ECONOMY.baseGdpPerCapita, factors); this._gdpPerCapitaCache.set(k, perCapita); @@ -113,85 +119,172 @@ export const economyMethods = { return city.buildings[protoIndex] || 0; }, - // Income and upkeep with the source behind every figure, so the UI can show - // which buildings, government or technologies shape the budget. Amounts are - // per in-game hour, matching the rest of the economy figures. - budgetBreakdown(civ) { - const gdp = this.getPlayerGdp(civ); + // Population, GDP and the tax one city's region yields per hour. The tax is + // collected by the region alone: a city's income modifiers never apply to + // another city's land, only government and research reach every region. + getCityEconomy(city) { + let population = 0; + let gdp = 0; + for (const coords of this.regionTiles(city)) { + const k = key(coords.x, coords.y); + const pop = this.tilePopulation.get(k) || 0; + population += pop; + gdp += pop * this.getTileGdpPerCapita(coords); + } + // The nation-wide balancing factors map generation assigned keep every + // civilisation's start equal; they scale the region figures too, so the + // regions of a nation always add up to its headline population and GDP. + const populationScale = this._populationBalance.get(city.civ) || 1; + const perCapitaScale = this._perCapitaBalance.get(city.civ) || 1; + population *= populationScale; + gdp *= populationScale * perCapitaScale; const rate = ECONOMY.budgetGdpRate; const base = gdp * rate; - const incomeSources = this._incomeSources(civ); - const modifier = incomeSources.reduce((sum, source) => sum + source.effect, 0); - const income = base * (1 + modifier); - const buildingSources = this._buildingUpkeepSources(civ); - const buildingUpkeep = buildingSources.reduce((sum, source) => sum + source.amount, 0); + const cityMods = this.getCityBuildingModifiers(city); + const national = this.getNationalModifiers(city.civ); + const modifier = + (cityMods[EFFECT_BUDGET_INCOME] || 0) + (national[EFFECT_BUDGET_INCOME] || 0); + return { population, gdp, base, modifier, income: base * (1 + modifier) }; + }, + + // The tax the whole nation collects in one hour, summed over its regions. + getCivHourlyIncome(civ) { + let total = 0; + for (const city of this.cities) { + if (city.civ !== civ) continue; + total += this.getCityEconomy(city).income; + } + return total; + }, + + // Income and upkeep with the source behind every figure, so the UI can show + // which buildings, government or technologies shape the budget. Amounts are + // per in-game hour, matching the rest of the economy figures. Income is + // aggregated from the regions; buildings are grouped by type with a per-city + // breakdown the budget table can expand. + budgetBreakdown(civ) { + const rate = ECONOMY.budgetGdpRate; + const regions = []; + for (const city of this.cities) { + if (city.civ !== civ) continue; + const economy = this.getCityEconomy(city); + const buildingModifier = this.getCityBuildingModifiers(city)[EFFECT_BUDGET_INCOME] || 0; + regions.push({ + cityId: city.id, + cityName: city.name, + isCapital: city.isCapital, + population: economy.population, + gdp: economy.gdp, + base: economy.base, + buildingModifier, + modifier: economy.modifier, + income: economy.income, + }); + } + const base = regions.reduce((sum, region) => sum + region.base, 0); + const income = regions.reduce((sum, region) => sum + region.income, 0); + const modifier = base > 0 ? income / base - 1 : 0; + const incomeSources = this._incomeSources(civ, regions, base); + const groups = this._buildingUpkeepGroups(civ); + const buildingSources = this._sortSources( + groups.map((group) => ({ label: group.label, kind: "building", amount: group.amount })) + ); + const buildingUpkeep = groups.reduce((sum, group) => sum + group.amount, 0); const unitSources = this._unitUpkeepSources(civ); const unitUpkeep = unitSources.reduce((sum, source) => sum + source.amount, 0); return { - income: { - rate, - base, - modifier, - total: income, - sources: incomeSources.map((source) => ({ ...source, amount: base * source.effect })), - }, - buildingUpkeep: { total: buildingUpkeep, sources: buildingSources }, - unitUpkeep: { total: unitUpkeep, sources: unitSources }, + income: { rate, base, modifier, total: income, sources: incomeSources, regions }, + buildingUpkeep: { total: buildingUpkeep, sources: buildingSources, groups }, + unitUpkeep: { total: unitUpkeep, sources: unitSources, groups: unitSources }, net: income - buildingUpkeep - unitUpkeep, }; }, // Every building, government and researched technology that shifts budget - // income, combined per source and expressed as a fraction of the base take. - _incomeSources(civ) { + // income. A building's bonus is measured against the region that hosts it, + // while government and research are measured against the whole nation. + _incomeSources(civ, regions, base) { const sources = new Map(); - const add = (label, kind, effect) => { + const add = (key, label, kind, effect, amount, region) => { if (effect === 0) return; - const k = `${kind}:${label}`; - const entry = sources.get(k) || { label, kind, effect: 0 }; + const entry = sources.get(key) || { + label, + kind, + effect: 0, + amount: 0, + cityId: region ? region.cityId : null, + cityName: region ? region.cityName : null, + }; entry.effect += effect; - sources.set(k, entry); + entry.amount += amount; + sources.set(key, entry); }; - for (const city of this.cities) { - if (city.civ !== civ) continue; + for (const region of regions) { + const city = this._cityById.get(region.cityId); + if (!city) continue; for (const indexStr of Object.keys(city.buildings)) { const proto = this.protoBuildings[Number(indexStr)]; const level = city.buildings[indexStr]; for (const e of proto.effects) { - if (e.stat === EFFECT_BUDGET_INCOME) add(proto.name, "building", effectAt(e, level)); + if (e.stat !== EFFECT_BUDGET_INCOME) continue; + const effect = effectAt(e, level); + add(`building:${proto.name}:${city.id}`, proto.name, "building", effect, region.base * effect, region); } } } const government = this.governments[this.getGovernment(civ)]; if (government) { for (const e of government.effects) { - if (e.stat === EFFECT_BUDGET_INCOME) add(government.name, "government", effectAt(e, 1)); + if (e.stat !== EFFECT_BUDGET_INCOME) continue; + const effect = effectAt(e, 1); + add(`government:${government.name}`, government.name, "government", effect, base * effect); } } const researched = this.researched.get(civ) || new Set(); for (const index of researched) { const tech = this.technologies[index]; for (const e of tech.effects) { - if (e.stat === EFFECT_BUDGET_INCOME) add(tech.name, "technology", effectAt(e, 1)); + if (e.stat !== EFFECT_BUDGET_INCOME) continue; + const effect = effectAt(e, 1); + add(`technology:${tech.name}`, tech.name, "technology", effect, base * effect); } } return this._sortSources(Array.from(sources.values())); }, - _buildingUpkeepSources(civ) { - const sources = new Map(); + // Upkeep of every building the nation keeps, grouped by building type and + // carrying the per-city breakdown the budget table expands. + _buildingUpkeepGroups(civ) { + const byType = new Map(); for (const city of this.cities) { if (city.civ !== civ) continue; for (const indexStr of Object.keys(city.buildings)) { const index = Number(indexStr); - const amount = buildingUpkeep(this.protoBuildings[index], city.buildings[indexStr]); + const level = city.buildings[indexStr]; + const amount = buildingUpkeep(this.protoBuildings[index], level); if (amount === 0) continue; - const entry = sources.get(index) || { label: this.protoBuildings[index].name, kind: "building", amount: 0 }; - entry.amount += amount; - sources.set(index, entry); + const group = byType.get(index) || { + index, + label: this.protoBuildings[index].name, + amount: 0, + cities: [], + }; + group.amount += amount; + group.cities.push({ cityId: city.id, cityName: city.name, level, amount }); + byType.set(index, group); } } - return this._sortSources(Array.from(sources.values())); + const groups = Array.from(byType.values()); + for (const group of groups) group.cities.sort((a, b) => b.amount - a.amount); + return groups.sort((a, b) => b.amount - a.amount); + }, + + _buildingUpkeepSources(civ) { + return this._buildingUpkeepGroups(civ).map((group) => ({ + label: group.label, + kind: "building", + amount: group.amount, + })); }, _unitUpkeepSources(civ) { @@ -221,12 +314,20 @@ export const economyMethods = { const totals = {}; for (const city of this.cities) { if (city.civ !== civ) continue; - for (const indexStr of Object.keys(city.buildings)) { - const proto = this.protoBuildings[Number(indexStr)]; - const level = city.buildings[indexStr]; - for (const e of proto.effects) { - totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, level); - } + mergeModifiers(totals, this.getCityBuildingModifiers(city)); + } + return totals; + }, + + // The modifiers one city's own buildings give. Improvements only shape the + // region their city controls, so a tile's per-capita draws on this. + getCityBuildingModifiers(city) { + const totals = {}; + for (const indexStr of Object.keys(city.buildings)) { + const proto = this.protoBuildings[Number(indexStr)]; + const level = city.buildings[indexStr]; + for (const e of proto.effects) { + totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, level); } } return totals; @@ -237,6 +338,15 @@ export const economyMethods = { return resource ? governmentEffects(resource) : {}; }, + // Government and research reach every region of the nation; only city + // buildings are regional. + getNationalModifiers(civ) { + const totals = {}; + mergeModifiers(totals, this.getCivGovernmentModifiers(civ)); + mergeModifiers(totals, this.getCivTechnologyModifiers(civ)); + return totals; + }, + getCivModifiers(civ) { const totals = this.getCivBuildingModifiers(civ); mergeModifiers(totals, this.getCivGovernmentModifiers(civ)); @@ -295,7 +405,7 @@ export const economyMethods = { }, getBudget(civ) { - return this.budgets.has(civ) ? this.budgets.get(civ) : ECONOMY.startingBudget; + return this.budgets.has(civ) ? this.budgets.get(civ) : 0; }, getTraining(cityId) { @@ -315,9 +425,7 @@ export const economyMethods = { _tickEconomy() { const hours = HOURS_PER_DAY; for (const civ of Array.from(this.budgets.keys())) { - const gdp = this.getPlayerGdp(civ); - const modifiers = this.getCivModifiers(civ); - const income = ECONOMY.budgetIncome(gdp, hours) * (1 + (modifiers[EFFECT_BUDGET_INCOME] || 0)); + const income = this.getCivHourlyIncome(civ) * hours; const upkeep = this.getPlayerUpkeep(civ) * hours; this.budgets.set(civ, this.getBudget(civ) + income - upkeep); } diff --git a/shared/game_state/movement.js b/shared/game_state/movement.js index 29a07ba..31432da 100644 --- a/shared/game_state/movement.js +++ b/shared/game_state/movement.js @@ -2,6 +2,7 @@ // tick, and the travel-cost model the browser mirrors for prediction. import { key } from "../hex.js"; +import { ROADS } from "../data/roads.js"; import { MIN_STEP_COST, DEFAULT_UNIT_SPEED, @@ -267,7 +268,11 @@ export const movementMethods = { }, _tileTravelHours(unit, coords) { - return this.terrainStats.movementCostMultiplier(coords) / this._effectiveSpeedAt(unit, coords); + // A road levels the terrain: every road tile costs the same to cross. + const multiplier = this.roads.has(key(coords.x, coords.y)) + ? ROADS.movementCostMultiplier + : this.terrainStats.movementCostMultiplier(coords); + return multiplier / this._effectiveSpeedAt(unit, coords); }, _fastestTileTravelHours(unit) { diff --git a/shared/game_state/orders.js b/shared/game_state/orders.js index 55e46fd..964530c 100644 --- a/shared/game_state/orders.js +++ b/shared/game_state/orders.js @@ -60,6 +60,8 @@ export const orderMethods = { if (cost > 0 && this.getBudget(civ) < cost) return false; if (cost > 0) this.budgets.set(civ, this.getBudget(civ) - cost); city.buildings[protoIndex] = level + 1; + // The building changes its region's GDP per capita, so the cache is stale. + this._gdpPerCapitaCache.clear(); this._emitChanged(); return true; }, @@ -75,6 +77,7 @@ export const orderMethods = { if (refund > 0) this.budgets.set(civ, this.getBudget(civ) + refund); if (level <= 1) delete city.buildings[protoIndex]; else city.buildings[protoIndex] = level - 1; + this._gdpPerCapitaCache.clear(); this._emitChanged(); return true; }, @@ -88,6 +91,7 @@ export const orderMethods = { if (cost > 0 && this.getBudget(civ) < cost) return false; if (cost > 0) this.budgets.set(civ, this.getBudget(civ) - cost); this.government.set(civ, index); + this._gdpPerCapitaCache.clear(); this._emitChanged(); return true; }, @@ -98,6 +102,7 @@ export const orderMethods = { if (this.getResearch(civ) < technology.cost) return false; this.research.set(civ, this.getResearch(civ) - technology.cost); this.researched.get(civ).add(index); + this._gdpPerCapitaCache.clear(); this._emitChanged(); return true; }, diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js index 20eb277..52804a9 100644 --- a/shared/game_state/serialization.js +++ b/shared/game_state/serialization.js @@ -21,12 +21,18 @@ export const serializationMethods = { cities: this._serializeCities(), units: this._serializeUnits(), territory: this._serializedTerritory, + // The pre-generated road tiles, as [x, y]. + roads: this._serializeRoads(), + // Each land tile's region: [x, y, cityId], so the browser attributes a + // tile's GDP modifiers to the city that develops it. + regions: this._serializeRegions(), population: this._serializePopulation(), // Sparse per-tile marks of war, so the browser can explain a tile's // GDP per capita without shipping the whole breakdown for every tile. tileGdpPenalties: this._serializeTileValues(this.tileGdpPenalty), tileBattleGdpDeficits: this._serializeTileValues(this.tileBattleGdpDeficit), civStats: this._serializeCivStats(), + cityStats: this._serializeCityStats(), budgets: this._serializeCivValues(this.budgets), research: this._serializeCivValues(this.research), culture: this._serializeCivValues(this.culture), @@ -120,6 +126,26 @@ export const serializationMethods = { }); }, + // A sparse [x, y] list of every road tile. + _serializeRoads() { + const result = []; + for (const k of this.roads) { + const coords = parseKey(k); + result.push([coords.x, coords.y]); + } + return result; + }, + + // A sparse [x, y, cityId] list of every tile's region. + _serializeRegions() { + const result = []; + for (const [k, cityId] of this.tileRegion) { + const coords = parseKey(k); + result.push([coords.x, coords.y, cityId]); + } + return result; + }, + _serializePopulation() { const result = []; for (const [k, value] of this.tilePopulation) { @@ -145,6 +171,25 @@ export const serializationMethods = { return this.civilisations.map((_, index) => this._civHeadline(index)); }, + // One entry per city with its region's population, GDP and tax, so the city + // government panel can show what each region collects. + _serializeCityStats() { + return this.cities.map((city) => { + const economy = this.getCityEconomy(city); + return { + id: city.id, + civ: city.civ, + name: city.name, + isCapital: city.isCapital, + population: Math.round(economy.population), + gdp: economy.gdp, + base: economy.base, + modifier: economy.modifier, + income: economy.income, + }; + }); + }, + // Population, GDP and GDP per capita for one civilisation: the headline // figures the diplomacy panel and the viewer's own stats both show. _civHeadline(civ) { diff --git a/shared/game_state/territory.js b/shared/game_state/territory.js index 8397f16..c1a27fa 100644 --- a/shared/game_state/territory.js +++ b/shared/game_state/territory.js @@ -56,17 +56,60 @@ export const territoryMethods = { } frontier = next; } - this._territoryByCiv = new Map(); - for (const [k, owner] of this.territory) { - if (!this._territoryByCiv.has(owner)) this._territoryByCiv.set(owner, []); - this._territoryByCiv.get(owner).push(parseKey(k)); - } - this._serializedTerritory = serializeCells(this.territory); + this._rebuildRegionIndex(); + this._indexTerritory(); }, - // Rebuilds the owner index, the serialised territory and the GDP-per-capita - // cache after `this.territory` changes outside a full recompute. - _reindexTerritory() { + // Subdivides each nation's territory into the regions of its own cities: a + // multi-source BFS grown from every city, but never crossing into another + // nation's land. Ties between equally close cities are settled by city order, + // so the result is deterministic and identical on both sides of the wire. + _rebuildRegionIndex() { + this.tileRegion = new Map(); + this._regionByCity = new Map(); + const cityByCell = new Map(); + let frontier = []; + for (const city of this.cities) { + const k = key(city.coords.x, city.coords.y); + cityByCell.set(k, city); + this.tileRegion.set(k, city.id); + frontier.push(city.coords); + } + while (frontier.length > 0) { + const next = []; + for (const coords of frontier) { + const city = cityByCell.get(key(coords.x, coords.y)); + for (const neighbour of this._neighbours(coords)) { + const nk = key(neighbour.x, neighbour.y); + if (cityByCell.has(nk) || !this._isLand(neighbour)) continue; + if (this.territory.get(nk) !== city.civ) continue; + cityByCell.set(nk, city); + this.tileRegion.set(nk, city.id); + next.push(neighbour); + } + } + frontier = next; + } + for (const [k, cityId] of this.tileRegion) { + if (!this._regionByCity.has(cityId)) this._regionByCity.set(cityId, []); + this._regionByCity.get(cityId).push(parseKey(k)); + } + }, + + // The city whose region the tile belongs to, or null on unclaimed land. + regionCityAt(coords) { + const id = this.tileRegion.get(key(coords.x, coords.y)); + return id !== undefined ? this._cityById.get(id) || null : null; + }, + + // The land tiles a city's region covers that are still owned by the city's + // civilisation. A freshly captured city only taxes the land that came with it. + regionTiles(city) { + const tiles = this._regionByCity.get(city.id) || []; + return tiles.filter((coords) => this.civAt(coords) === city.civ); + }, + + _indexTerritory() { this._territoryByCiv = new Map(); for (const [k, owner] of this.territory) { if (!this._territoryByCiv.has(owner)) this._territoryByCiv.set(owner, []); @@ -76,6 +119,14 @@ export const territoryMethods = { this._gdpPerCapitaCache = new Map(); }, + // Rebuilds the owner index, the regions, the serialised territory and the + // GDP-per-capita cache after `this.territory` changes outside a full + // recompute. + _reindexTerritory() { + this._rebuildRegionIndex(); + this._indexTerritory(); + }, + // Transfers `city` to `newCiv` and, with it, every tile of the old owner's // territory that is strictly closer (through land) to the captured city than // to any of their remaining cities. Ties stay with the old owner. diff --git a/shared/game_state/world.js b/shared/game_state/world.js index ef1bc59..dd42e4e 100644 --- a/shared/game_state/world.js +++ b/shared/game_state/world.js @@ -3,6 +3,8 @@ // terrain predicates the rest of the model leans on. import { ECONOMY } from "../data/economy.js"; +import { ROADS } from "../data/roads.js"; +import { buildRoadNetwork } from "../roads.js"; import { Random } from "../rng.js"; import { key, parseKey } from "../hex.js"; @@ -11,13 +13,16 @@ export const worldMethods = { const capitals = this._placeCapitals(this.civilisations.length); for (const site of this._placeCities(capitals)) this._spawnCity(site); this._rebuildCityIndex(); + this._generateRoads(); for (let i = 0; i < Math.min(this.civilisations.length, capitals.length); i++) { this._spawnUnit(capitals[i], i, this.protoUnits[0]); } this._recomputeTerritory(); this._generatePopulation(); - this._initBudgets(); this._balanceStartingEconomy(); + // Budgets are founded once the balanced economy is known, so every nation + // opens on 1 January 2000 with a tenth of its own starting GDP. + this._initBudgets(); }, _placeCapitals(count) { @@ -91,6 +96,18 @@ export const worldMethods = { return candidate; }, + // Lays the pre-generated road network. Cities are founded with a road, so + // they are free nodes while the network is drawn; the chosen links are the + // greedy spanner over every possible city-to-city route. + _generateRoads() { + for (const city of this.cities) { + if (!city.improvements.includes(ROADS.improvementName)) { + city.improvements.push(ROADS.improvementName); + } + } + this.roads = buildRoadNetwork(this.cities, this.topology, this.tiles); + }, + _spawnUnit(center, civIndex, proto) { let index = this.protoUnits.indexOf(proto); if (index < 0) { @@ -134,7 +151,7 @@ export const worldMethods = { _initBudgets() { for (let i = 0; i < this.civilisations.length; i++) { - this.budgets.set(i, ECONOMY.startingBudget); + this.budgets.set(i, this.getPlayerGdp(i) * ECONOMY.startingBudgetFraction); } }, diff --git a/shared/roads.js b/shared/roads.js new file mode 100644 index 0000000..1dc9208 --- /dev/null +++ b/shared/roads.js @@ -0,0 +1,198 @@ +// Pre-generated road network. The network is built once, after the cities are +// placed but before any player controls them, and never changes afterwards. +// +// The steps mirror the brief: +// 1. weigh every possible city-to-city route with the same hex pathfinding +// units use, on a cost that grows exponentially with the terrain; +// 2. order those candidate links by cost and keep a greedy geometric spanner +// of stretch `ROADS.spannerStretch`, so the road network stays sparse +// while no two cities are more than that factor worse off than direct; +// 3. return the set of tiles the chosen routes cross. + +import { key, parseKey } from "./hex.js"; +import { ROADS } from "./data/roads.js"; + +// Cost of running a road through a tile. Non-land tiles are impassable, and a +// city tile is free because cities are founded with a road already in place. +export function tileRoadCost(tile, isCity = false) { + if (isCity) return ROADS.cityCost; + if (!tile || tile.terrainClass !== "Land") return Infinity; + return Math.pow(ROADS.costBase, tile.movementCostMultiplier || 1); +} + +class MinHeap { + constructor() { + this.keys = []; + this.values = []; + } + + isEmpty() { + return this.keys.length === 0; + } + + push(priority, value) { + this.keys.push(priority); + this.values.push(value); + let index = this.keys.length - 1; + while (index > 0) { + const parent = (index - 1) >> 1; + if (this.keys[parent] <= this.keys[index]) break; + this._swap(parent, index); + index = parent; + } + } + + pop() { + const value = this.values[0]; + const last = this.keys.length - 1; + this.keys[0] = this.keys[last]; + this.values[0] = this.values[last]; + this.keys.pop(); + this.values.pop(); + let index = 0; + while (true) { + let smallest = index; + const left = (index << 1) + 1; + const right = left + 1; + if (left < this.keys.length && this.keys[left] < this.keys[smallest]) smallest = left; + if (right < this.keys.length && this.keys[right] < this.keys[smallest]) smallest = right; + if (smallest === index) break; + this._swap(index, smallest); + index = smallest; + } + return value; + } + + _swap(a, b) { + const k = this.keys[a]; + this.keys[a] = this.keys[b]; + this.keys[b] = k; + const v = this.values[a]; + this.values[a] = this.values[b]; + this.values[b] = v; + } +} + +// Dijkstra over the land tiles from `source`, costing each step through +// `stepCost(coords, tile)`. Returns the distance to every reachable tile and +// the parent needed to rebuild the route. +export function shortestPaths(source, topology, tiles, stepCost) { + const sourceKey = key(source.x, source.y); + const distance = new Map([[sourceKey, 0]]); + const parent = new Map(); + const settled = new Set(); + const heap = new MinHeap(); + heap.push(0, source); + while (!heap.isEmpty()) { + const current = heap.pop(); + const currentKey = key(current.x, current.y); + if (settled.has(currentKey)) continue; + settled.add(currentKey); + for (const neighbour of topology.neighbours(current.x, current.y)) { + const nk = key(neighbour.x, neighbour.y); + const tile = tiles[nk]; + if (!tile || tile.terrainClass !== "Land") continue; + const cost = stepCost(neighbour, tile); + if (!Number.isFinite(cost)) continue; + const candidate = distance.get(currentKey) + cost; + if (candidate < (distance.has(nk) ? distance.get(nk) : Infinity)) { + distance.set(nk, candidate); + parent.set(nk, current); + heap.push(candidate, neighbour); + } + } + } + return { distance, parent }; +} + +function reconstruct(parent, sourceKey, goalKey) { + const path = []; + let cursor = parseKey(goalKey); + while (cursor && key(cursor.x, cursor.y) !== sourceKey) { + path.push(cursor); + cursor = parent.get(key(cursor.x, cursor.y)); + if (path.length > 100000) return []; + } + path.push(parseKey(sourceKey)); + return path.reverse(); +} + +// Distance between two city indices in a weighted adjacency map, or Infinity +// when no spanner path connects them yet. +function spannerDistance(adjacency, from, to) { + if (from === to) return 0; + const distance = new Map([[from, 0]]); + const settled = new Set(); + const heap = new MinHeap(); + heap.push(0, from); + while (!heap.isEmpty()) { + const current = heap.pop(); + if (settled.has(current)) continue; + settled.add(current); + if (current === to) return distance.get(current); + for (const [next, weight] of adjacency.get(current) || []) { + const candidate = distance.get(current) + weight; + if (candidate < (distance.has(next) ? distance.get(next) : Infinity)) { + distance.set(next, candidate); + heap.push(candidate, next); + } + } + } + return Infinity; +} + +// Builds the road tile set connecting `cities`. `cities` is a list of objects +// with a `coords` field; the returned Set holds canonical "x,y" keys, cities +// included. +export function buildRoadNetwork(cities, topology, tiles, options = {}) { + const stretch = options.spannerStretch || ROADS.spannerStretch; + const roads = new Set(); + if (!cities || cities.length === 0) return roads; + const cityKeys = cities.map((city) => key(city.coords.x, city.coords.y)); + const isCity = new Set(cityKeys); + const stepCost = (coords, tile) => tileRoadCost(tile, isCity.has(key(coords.x, coords.y))); + for (const k of cityKeys) roads.add(k); + if (cities.length < 2) return roads; + + // Every possible link, with the cheapest route and its cost. + const searches = cities.map((city) => shortestPaths(city.coords, topology, tiles, stepCost)); + const edges = []; + for (let a = 0; a < cities.length; a++) { + for (let b = a + 1; b < cities.length; b++) { + if (!searches[a].distance.has(cityKeys[b])) continue; + edges.push({ + a, + b, + cost: searches[a].distance.get(cityKeys[b]), + path: reconstruct(searches[a].parent, cityKeys[a], cityKeys[b]), + }); + } + } + edges.sort((a, b) => a.cost - b.cost || a.a - b.a || a.b - b.b); + for (const edge of greedySpanner(edges, stretch)) { + for (const coords of edge.path) roads.add(key(coords.x, coords.y)); + } + return roads; +} + +// Greedy geometric spanner: walk the candidate links cheapest first and keep a +// link only when the links kept so far do not already connect its ends within +// `stretch` times its cost. Exported so the spanner property can be tested on a +// synthetic edge list without a hex map. +export function greedySpanner(edges, stretch = ROADS.spannerStretch) { + const adjacency = new Map(); + const chosen = []; + const addEdge = (a, b, cost) => { + if (!adjacency.has(a)) adjacency.set(a, []); + if (!adjacency.has(b)) adjacency.set(b, []); + adjacency.get(a).push([b, cost]); + adjacency.get(b).push([a, cost]); + }; + for (const edge of edges) { + if (spannerDistance(adjacency, edge.a, edge.b) > stretch * edge.cost) { + addEdge(edge.a, edge.b, edge.cost); + chosen.push(edge); + } + } + return chosen; +} diff --git a/shared/rules.js b/shared/rules.js index 56444d4..6c33a6c 100644 --- a/shared/rules.js +++ b/shared/rules.js @@ -100,6 +100,9 @@ export function gdpPerCapitaFactors(tile, { nextToCity = false, pillagePenalty = 1, battleDeficit = 0, + regionalMultiplier = 1, + regionalLabel = null, + nationalMultiplier = 1, } = {}) { const factors = []; const terrainMultiplier = tile ? tile.gdpMultiplier || 0 : 0; @@ -114,6 +117,20 @@ export function gdpPerCapitaFactors(tile, { if (pillagePenalty !== 1) { factors.push({ label: "Pillaging", multiplier: pillagePenalty }); } + // City improvements only shape the region their city controls, so a tile's + // GDP per capita carries its region's multiplier plus the nation-wide one + // from government and research. Both are factors on the per-capita figure, + // never an adjustment to the final GDP. They are applied before the absolute + // war damage below, so a scar stays proportional to the tile's productivity. + if (regionalMultiplier !== 1) { + factors.push({ + label: regionalLabel ? `${regionalLabel} improvements` : "Regional improvements", + multiplier: regionalMultiplier, + }); + } + if (nationalMultiplier !== 1) { + factors.push({ label: "National modifiers", multiplier: nationalMultiplier }); + } if (battleDeficit > 0) { factors.push({ label: "Battle damage", amount: -battleDeficit }); } diff --git a/tests/framework/helpers.js b/tests/framework/helpers.js index 41cfb4f..17468ee 100644 --- a/tests/framework/helpers.js +++ b/tests/framework/helpers.js @@ -55,6 +55,7 @@ export function mapLayers($) { return { overview: $("#layer-overview"), terrain: $("#layer-terrain"), + roads: $("#layer-roads"), borders: $("#layer-borders"), fog: $("#layer-fog"), paths: $("#layer-paths"), diff --git a/tests/game_state_buildings_test.js b/tests/game_state_buildings_test.js index a796075..63decc6 100644 --- a/tests/game_state_buildings_test.js +++ b/tests/game_state_buildings_test.js @@ -120,14 +120,22 @@ export class GameStateBuildingsTest extends TestCase { state, gdpBuilding({ baseCost: 0, effects: [effect] }) ); - this.assertTrue(state.requestBuild(cityOf(state, 0).id, index)); + const city = cityOf(state, 0); + this.assertTrue(state.requestBuild(city.id, index)); const breakdown = state.viewerStats(0).breakdown; - const source = breakdown.income.sources.find((s) => s.label === "Test market"); + const source = breakdown.income.sources.find( + (s) => s.label === "Test market" && s.cityId === city.id + ); this.assertTrue(source, "the building appears as an income source"); this.assertEqual(source.kind, "building"); this.assertApprox(source.effect, 0.05); - this.assertApprox(breakdown.income.modifier, state.getCivModifiers(0)[EFFECT_BUDGET_INCOME] || 0); - this.assertApprox(source.amount, breakdown.income.base * 0.05); + // A building's bonus is measured against its own region, not the nation. + const region = breakdown.income.regions.find((r) => r.cityId === city.id); + this.assertApprox(source.amount, region.base * 0.05); + this.assertApprox( + breakdown.income.modifier, + (breakdown.income.total - breakdown.income.base) / breakdown.income.base + ); this.assertApprox(breakdown.income.total, breakdown.income.base * (1 + breakdown.income.modifier)); } diff --git a/tests/game_state_test.js b/tests/game_state_test.js index 425e1ba..b6af03f 100644 --- a/tests/game_state_test.js +++ b/tests/game_state_test.js @@ -1,5 +1,6 @@ import { TestCase } from "./framework/test_case.js"; import { TRAINING_QUEUE_LIMIT } from "../shared/game_state.js"; +import { ECONOMY } from "../shared/data.js"; import { smallState, adjacentLand, @@ -102,7 +103,12 @@ export class GameStateTest extends TestCase { this.assertGreater(state.getPlayerPopulation(0), 0); this.assertGreater(state.getPlayerGdp(0), 0); this.assertGreater(state.getPlayerUpkeep(0), 0); - this.assertEqual(state.getBudget(0), 0); + this.assertApprox( + state.getBudget(0), + state.getPlayerGdp(0) * ECONOMY.startingBudgetFraction, + 1e-6, + "a nation opens with a tenth of its starting GDP" + ); } test_civilisations_start_with_equal_cities_population_and_gdp_per_capita() { diff --git a/tests/map_view_test.js b/tests/map_view_test.js index 0e24cac..9c15a7f 100644 --- a/tests/map_view_test.js +++ b/tests/map_view_test.js @@ -38,6 +38,20 @@ export class MapViewTest extends TestCase { } } + async test_renders_the_pre_generated_road_network() { + const state = defaultState(); + const { env, map, snapshot } = await this.build(state); + try { + this.assertSize(map.roads, snapshot.roads.length, "the snapshot's road tiles are mirrored"); + const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); + const capitalKey = `${capital.coords.x},${capital.coords.y}`; + this.assertTrue(map.roads.has(capitalKey), "a city carries a road"); + this.assertGreater(env.$("#layer-roads .road-seg").length, 0, "roads are drawn in the chunks"); + } finally { + teardownDom(env); + } + } + async test_unit_views_show_their_owners_flag() { const state = defaultState(); const { env } = await this.build(state); diff --git a/tests/modals_test.js b/tests/modals_test.js index 4e32a92..cd2c213 100644 --- a/tests/modals_test.js +++ b/tests/modals_test.js @@ -199,11 +199,22 @@ export class NationModalTest extends TestCase { modifier: 0.15, total: 1035, sources: [ - { label: "Bank", kind: "building", effect: 0.05, amount: 45 }, + { label: "Bank", kind: "building", effect: 0.05, amount: 45, cityId: 1, cityName: "Paris" }, { label: "Republic", kind: "government", effect: 0.1, amount: 90 }, ], }, - buildingUpkeep: { total: 400, sources: [{ label: "Bank", kind: "building", amount: 400 }] }, + buildingUpkeep: { + total: 400, + sources: [{ label: "Bank", kind: "building", amount: 400 }], + groups: [ + { + index: 1, + label: "Bank", + amount: 400, + cities: [{ cityId: 1, cityName: "Paris", level: 2, amount: 400 }], + }, + ], + }, unitUpkeep: { total: 150, sources: [{ label: "Modern infantry", kind: "unit", amount: 150, count: 3 }], @@ -213,30 +224,67 @@ export class NationModalTest extends TestCase { modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, finances); env.$("#modal-nation .tab[data-tab=budget]").click(); this.assertTrue(env.$("#tab-budget").hasClass("active")); + this.assertSize(env.$("#budget-list table.budget-table"), 1); - const rows = env.$("#budget-list .row-card"); - this.assertSize(rows, 4); - this.assertTrue(rows.eq(0).find(".value").text().includes("+€1 035/h")); - this.assertTrue(rows.eq(0).find(".budget-detail").text().includes("Tax rate 0.10% of GDP")); - this.assertTrue(rows.eq(0).find(".budget-detail").text().includes("Bank (building) +5.0%")); - this.assertTrue(rows.eq(0).find(".budget-detail").text().includes("Republic (government) +10.0%")); - this.assertTrue(rows.eq(1).find(".value").text().includes("−€400/h")); - this.assertTrue(rows.eq(1).find(".budget-detail").text().includes("Bank — €400/h")); - this.assertTrue(rows.eq(2).find(".value").text().includes("−€150/h")); - this.assertTrue(rows.eq(2).find(".budget-detail").text().includes("Modern infantry ×3")); - this.assertTrue(rows.eq(3).find(".value").text().includes("+€485/h")); - this.assertTrue(rows.eq(3).find(".value").hasClass("positive")); + const text = env.$("#budget-list").text(); + this.assertTrue(text.includes("+€1 035/h"), "tax income is green"); + this.assertTrue(text.includes("Bank in Paris (building) +5.0%"), "a building bonus is listed"); + this.assertTrue(text.includes("Republic (government) +10.0%"), "a government bonus is listed"); + this.assertTrue(text.includes("−€400/h"), "the building group shows its total cost"); + this.assertTrue(text.includes("Modern infantry ×3"), "unit upkeep is listed"); + this.assertTrue(text.includes("TOTAL"), "a total row is present"); + this.assertTrue(text.includes("+€485/h"), "the total carries the net balance"); this.assertTrue(env.$("#budget-sub").text().includes("Treasury")); + // Expanding the building type reveals the per-city cost. + env.$("#budget-list tr.budget-group .budget-name").click(); + this.assertTrue( + env.$("#budget-list").text().includes("Bank in Paris — level 2"), + "expanding a building type shows its cities" + ); + modal.refresh(0, new Set(), 1.0e9, 1.0e15, 1.0e6, { income: { rate: 0.001, base: 100, modifier: 0, total: 100, sources: [] }, - buildingUpkeep: { total: 400, sources: [] }, - unitUpkeep: { total: 150, sources: [] }, + buildingUpkeep: { total: 400, sources: [], groups: [] }, + unitUpkeep: { total: 150, sources: [], groups: [] }, net: -450, }); - this.assertTrue(rows.eq(3).find(".value").text().includes("−€450/h")); - this.assertTrue(rows.eq(3).find(".value").hasClass("negative")); - this.assertTrue(rows.eq(0).find(".budget-detail").text().includes("No bonuses")); + const refreshed = env.$("#budget-list").text(); + this.assertTrue(refreshed.includes("−€450/h"), "the total updates"); + this.assertTrue(refreshed.includes("No bonuses"), "empty income sources are named"); + } finally { + teardownDom(env); + } + } + + async test_cities_tab_lists_regions_with_their_tax() { + const env = await setupDom(); + try { + const modal = new NationModal(); + const finances = { + income: { rate: 0.001, base: 0, modifier: 0, total: 0, sources: [], regions: [] }, + buildingUpkeep: { total: 0, sources: [], groups: [] }, + unitUpkeep: { total: 0, sources: [], groups: [] }, + net: 0, + cities: [ + { id: 1, civ: 0, name: "Paris", isCapital: true, population: 1.0e6, gdp: 1.0e10, income: 300 }, + { id: 2, civ: 0, name: "Lille", isCapital: false, population: 5.0e5, gdp: 4.0e9, income: 120 }, + { id: 3, civ: 1, name: "London", isCapital: true, population: 9.0e5, gdp: 8.0e9, income: 240 }, + ], + }; + modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, finances, { + civs: [{ name: "France" }, { name: "Britain" }], + localCiv: 0, + relations: [], + stats: [], + }); + env.$("#modal-nation .tab[data-tab=cities]").click(); + this.assertTrue(env.$("#tab-cities").hasClass("active")); + const text = env.$("#cities-list").text(); + this.assertTrue(text.includes("Paris"), "own regions are listed"); + this.assertTrue(text.includes("Lille"), "every own region is listed"); + this.assertFalse(text.includes("London"), "foreign regions are hidden"); + this.assertTrue(text.includes("TOTAL"), "the region totals are summed"); } finally { teardownDom(env); } diff --git a/tests/roads_test.js b/tests/roads_test.js new file mode 100644 index 0000000..bebb208 --- /dev/null +++ b/tests/roads_test.js @@ -0,0 +1,95 @@ +import { TestCase } from "./framework/test_case.js"; +import { smallState } from "./framework/helpers.js"; +import { buildRoadNetwork, greedySpanner, tileRoadCost } from "../shared/roads.js"; +import { ROADS, TERRAIN_TILES } from "../shared/data.js"; +import { key, parseKey } from "../shared/hex.js"; + +export class RoadsTest extends TestCase { + test_road_cost_grows_exponentially_with_terrain() { + const plains = TERRAIN_TILES.find((t) => t.terrainType === "Land"); + const hills = TERRAIN_TILES.find((t) => t.terrainType === "Hills"); + const mountain = TERRAIN_TILES.find((t) => t.terrainType === "Mountain"); + const sea = TERRAIN_TILES.find((t) => t.terrainType === "Sea"); + this.assertApprox(tileRoadCost(plains), Math.pow(ROADS.costBase, 1)); + this.assertApprox(tileRoadCost(hills), Math.pow(ROADS.costBase, 1.8)); + this.assertApprox(tileRoadCost(mountain), Math.pow(ROADS.costBase, 5)); + this.assertGreater(tileRoadCost(mountain), tileRoadCost(hills) * 8, "mountains dwarf hills"); + this.assertGreater(tileRoadCost(hills), tileRoadCost(plains), "hills cost more than plains"); + this.assertFalse(Number.isFinite(tileRoadCost(sea)), "the sea cannot carry a road"); + this.assertEqual(tileRoadCost(sea, true), 0, "a city tile is free"); + } + + test_cities_start_with_a_road_and_are_connected_by_the_network() { + const state = smallState(["france", "britain", "poland"], 12345, { citiesPerCiv: 3 }); + const cityKeys = state.cities.map((city) => key(city.coords.x, city.coords.y)); + for (const city of state.cities) { + this.assertTrue( + city.improvements.includes(ROADS.improvementName), + "a city is founded with a road" + ); + } + for (const cityKey of cityKeys) this.assertTrue(state.roads.has(cityKey)); + this.assertGreater(state.roads.size, cityKeys.length, "roads also cross the land"); + + // Following roads from the first city must reach every other city. + const seen = new Set([cityKeys[0]]); + const queue = [parseKey(cityKeys[0])]; + while (queue.length > 0) { + const coords = queue.pop(); + for (const neighbour of state.topology.neighbours(coords.x, coords.y)) { + const nk = key(neighbour.x, neighbour.y); + if (!state.roads.has(nk) || seen.has(nk)) continue; + seen.add(nk); + queue.push(neighbour); + } + } + for (const cityKey of cityKeys) { + this.assertTrue(seen.has(cityKey), `city ${cityKey} is reachable by road`); + } + } + + test_roads_level_the_movement_cost_across_terrain() { + const state = smallState(); + const hill = state.landCells.find( + (coords) => state.tiles[key(coords.x, coords.y)].terrainType === "Hills" + ); + this.assertNotNull(hill, "the map has a hill"); + const unit = state._spawnUnit(hill, 0, state.protoUnits[0]); + const before = state._tileTravelHours(unit, hill); + state.roads.add(key(hill.x, hill.y)); + const after = state._tileTravelHours(unit, hill); + this.assertLess(after, before, "a road makes the hill as cheap as plains"); + this.assertApprox( + after, + ROADS.movementCostMultiplier / state._effectiveSpeedAt(unit, hill), + 1e-9 + ); + } + + test_greedy_spanner_drops_links_the_others_already_cover() { + // Four collinear points. The direct long links are covered by the chain of + // unit links, so only the three short ones survive. + const edges = []; + for (let a = 0; a < 4; a++) { + for (let b = a + 1; b < 4; b++) { + edges.push({ a, b, cost: b - a, path: [] }); + } + } + edges.sort((x, y) => x.cost - y.cost); + const chosen = greedySpanner(edges, 2); + this.assertSize(chosen, 3, "only the short links are kept"); + for (const edge of chosen) this.assertEqual(edge.cost, 1); + } + + test_the_network_is_sparse() { + const state = smallState(["france", "britain", "poland", "slovenia"], 12345, { citiesPerCiv: 4 }); + const network = buildRoadNetwork(state.cities, state.topology, state.tiles); + const cityKeys = state.cities.map((city) => key(city.coords.x, city.coords.y)); + const allPairs = (state.cities.length * (state.cities.length - 1)) / 2; + this.assertGreater(allPairs, 6, "several candidate links exist"); + this.assertGreater(network.size, cityKeys.length, "roads cross the land"); + // 16 cities would need 120 links if every pair were connected; the spanner + // stays far below that even counting the tiles between cities. + this.assertLess(network.size, allPairs * 20, "the network stays sparse"); + } +} diff --git a/tests/run_tests.js b/tests/run_tests.js index 2a34167..ac92d8c 100644 --- a/tests/run_tests.js +++ b/tests/run_tests.js @@ -10,6 +10,7 @@ import { DataTest } from "./data_test.js"; import { MapGeneratorTest } from "./map_generator_test.js"; import { HexPathfinderTest } from "./hex_pathfinder_test.js"; import { TerrainStatsTest } from "./terrain_stats_test.js"; +import { RoadsTest } from "./roads_test.js"; import { EconomyTest } from "./economy_test.js"; import { TextFormatTest } from "./text_format_test.js"; import { GameClockTest } from "./game_clock_test.js"; @@ -51,6 +52,7 @@ const SUITES = [ MapGeneratorTest, HexPathfinderTest, TerrainStatsTest, + RoadsTest, EconomyTest, TextFormatTest, GameClockTest,