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,