From 4e2eae330667df33d8b92834b3ba3c07e862fb4b Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Thu, 17 Sep 2026 22:01:30 +0200 Subject: [PATCH] Better map generation --- AGENTS.md | 14 +- client/css/style.css | 174 +++- client/index.html | 23 +- client/js/app.js | 7 +- client/js/game_screen.js | 477 ++-------- client/js/map_view.js | 1250 +------------------------ client/js/modals.js | 530 +---------- server/game_server.js | 78 +- shared/data.js | 457 +-------- shared/game_state.js | 1757 +++-------------------------------- shared/hex_pathfinder.js | 38 +- shared/map_generator.js | 427 +++++++-- shared/terrain_stats.js | 8 +- shared/text_format.js | 5 +- tests/app_test.js | 14 + tests/data_test.js | 16 + tests/game_screen_test.js | 225 ++++- tests/game_server_test.js | 59 +- tests/game_state_test.js | 91 +- tests/map_generator_test.js | 130 ++- tests/map_view_test.js | 222 ++++- tests/modals_test.js | 104 ++- tests/run_tests.js | 23 + 23 files changed, 1766 insertions(+), 4363 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 035d753..8f40206 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,11 +59,17 @@ There is no linter, formatter, or CI. Do not invent commands beyond these. server: `hex.js` (topology/geometry), `map_generator.js`, `hex_pathfinder.js`, `terrain_stats.js`, `rules.js`, `economy` figures in `game_state.js`, `game_clock.js`, `rng.js`, `noise.js`, `text_format.js`, `login_manager.js`. - Data-driven content (civilisations, units, buildings, governments, - technologies) lives in `shared/data.js`. + Data-driven content lives in `shared/data/` (one small module per topic: + civilisations, units, buildings, governments, technologies, terrain, ...). + `shared/data.js` is a barrel re-exporting the whole catalogue, so keep + importing from `shared/data.js` and edit the file under `shared/data/`. + `shared/game_state.js` is likewise a thin class composed from the mixins in + `shared/game_state/` (world, territory, economy, movement, combat, status, + diplomacy, orders, visibility, serialization). - `client/js/` splits the UI: `app.js` (screens and flow), `net.js` (transport - wrapper), `game_screen.js` (HUD and panels), `map_view.js` (canvas/DOM map), - `modals.js` (training, buildings, government/technology modals). + wrapper), `game_screen.js` + `game_screen/` (HUD and panels), `map_view.js` + + `map_view/` (DOM map), `modals.js` + `modals/` (training, buildings, + nation/technology, confirm, news). - Tests live in `tests/` as `*_test.js` files extending the `TestCase` base in `tests/framework/`; `tests/run_tests.js` discovers and runs them. Add a new `tests/_test.js` for each new pure behaviour. diff --git a/client/css/style.css b/client/css/style.css index 9db8283..fa58c05 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -155,6 +155,9 @@ input:focus, select:focus { border-color: var(--accent); } avoids holding a huge composited layer in memory. */ #map-viewport.dragging #map-world { will-change: transform; } .layer { position: absolute; left: 0; top: 0; } +/* A materialised block of the detailed map. Positioned at its world origin and + moved with `transform`; its tiles are positioned relative to it. */ +.chunk { position: absolute; left: 0; top: 0; } /* Zoomed-out level of detail: the detailed hex/fog/border layers are hidden and a single simplified canvas is shown instead. */ @@ -167,16 +170,25 @@ input:focus, select:focus { border-color: var(--accent); } position: absolute; width: var(--hex-w); height: var(--hex-h); - clip-path: polygon(100% 50%, 75% 0, 25% 0, 0 50%, 25% 100%, 75% 100%); + /* Terrain and fog tiles are drawn from pre-masked art (transparent corners), + so no per-tile clip-path is needed. Clipping thousands of hexes forced a + repaint of the whole map on every pan frame. */ + background-repeat: no-repeat; } .hex.terrain { background-image: url("../assets/terrain.png"); - background-repeat: no-repeat; background-size: 441px 56px; } -.hex.fog { pointer-events: none; } -.fog-unexplored { background: #04040a; } -.fog-explored { background: rgba(4, 6, 18, 0.62); } +.hex.fog { + pointer-events: none; + background-size: var(--hex-w) var(--hex-h); +} +.fog-unexplored { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='63' height='56'%3E%3Cpolygon points='63,28 47.25,0 15.75,0 0,28 15.75,56 47.25,56' fill='%2304040a'/%3E%3C/svg%3E"); +} +.fog-explored { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='63' height='56'%3E%3Cpolygon points='63,28 47.25,0 15.75,0 0,28 15.75,56 47.25,56' fill='%23040612' fill-opacity='0.62'/%3E%3C/svg%3E"); +} .border-seg { position: absolute; @@ -241,16 +253,18 @@ input:focus, select:focus { border-color: var(--accent); } justify-content: center; } .unit .body img { width: 78%; height: 78%; image-rendering: auto; } -/* Owner's flag pinned to the top-left quarter of the unit. */ +/* Owner's flag centred on the unit's top-left corner, so only a sliver of it + overlaps the unit and the unit icon stays readable. */ .unit .unit-flag { position: absolute; top: 0; left: 0; width: 50%; height: 50%; + transform: translate(-50%, -50%); object-fit: cover; border: 1px solid rgba(0, 0, 0, 0.65); - border-radius: 6px 0 4px 0; + border-radius: 3px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); pointer-events: none; z-index: 3; @@ -269,6 +283,39 @@ input:focus, select:focus { border-color: var(--accent); } overflow: hidden; } .unit .health .fill { height: 100%; background: #46d36a; } +/* Stack size, sitting on the lower-right corner of the unit icon. */ +.unit .unit-count { + position: absolute; + right: 0; + bottom: 0; + min-width: 14px; + height: 14px; + padding: 0 3px; + box-sizing: border-box; + border-radius: 7px; + background: #000000; + color: #ffffff; + font-size: 10px; + font-weight: 700; + line-height: 14px; + text-align: center; + pointer-events: none; + z-index: 4; +} +/* On a battlefield both stacks shrink to half size and face each other across + the battle icon. */ +.unit.in-battle { transform: scale(0.5); } +.battle-marker { + position: absolute; + width: 22px; + height: 22px; + margin-left: -11px; + margin-top: -11px; + z-index: 4; + pointer-events: none; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9)); +} +.battle-marker img { width: 100%; height: 100%; } .city { --city-size: 26px; @@ -383,8 +430,10 @@ input:focus, select:focus { border-color: var(--accent); } .info-section.hidden { display: none !important; } .info-title { font-size: 17px; font-weight: 600; margin-bottom: 4px; } #bottom-panel .row { justify-content: flex-start; margin-top: 10px; } +.health-row { display: flex; align-items: center; gap: 8px; } .health-bar { width: 220px; height: 12px; background: rgba(30, 0, 0, 0.8); border-radius: 6px; overflow: hidden; margin-top: 6px; } #unit-health-fill { height: 100%; background: linear-gradient(180deg, #6fe08a, #2fa04d); } +#unit-health-label { margin-top: 6px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; } .unit-stats { display: flex; gap: 16px; margin-top: 6px; } .unit-stat { @@ -395,8 +444,9 @@ input:focus, select:focus { border-color: var(--accent); } } .unit-stat:hover { color: var(--text); } -/* Chat-like feed of short-lived news in the lower-left corner. */ -.game-feed { +/* Chat-like feed of short-lived news in the lower-left corner, with the News + button anchored beneath it. */ +.game-corner { position: absolute; left: 14px; bottom: 14px; @@ -404,10 +454,22 @@ input:focus, select:focus { border-color: var(--accent); } max-width: calc(50vw - 30px); display: flex; flex-direction: column; - gap: 4px; + align-items: flex-start; + gap: 8px; pointer-events: none; z-index: 40; } +.game-feed { + width: 100%; + display: flex; + flex-direction: column; + gap: 4px; +} +.game-corner .news-button { + pointer-events: auto; + padding: 6px 14px; + font-size: 13px; +} .feed-line { align-self: flex-start; max-width: 100%; @@ -451,6 +513,37 @@ input:focus, select:focus { border-color: var(--accent); } box-shadow: 0 6px 18px rgba(0, 0, 0, 0.6); } +/* Right-click menu for the units sharing a tile. */ +.stack-menu { + position: absolute; + z-index: 70; + min-width: 200px; + padding: 4px; + border-radius: 8px; + background: var(--panel-bg); + border: 1px solid var(--panel-border); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55); + display: flex; + flex-direction: column; + gap: 2px; +} +.stack-item { + text-align: left; + padding: 7px 10px; + border: none; + border-radius: 5px; + background: transparent; + color: var(--text); + font-size: 13px; + cursor: pointer; +} +.stack-item:hover { background: rgba(110, 175, 255, 0.2); } +.stack-item.stack-all { + margin-top: 2px; + border-top: 1px solid rgba(255, 255, 255, 0.12); + color: #ffcf7a; +} + /* --------------------------------------------------------------- modals -- */ .modal { @@ -533,7 +626,68 @@ input:focus, select:focus { border-color: var(--accent); } .row-card .budget-detail { margin-top: 4px; } .row-card .budget-line { font-size: 12px; color: var(--muted); margin-top: 2px; } +.row-card.conflict-card { display: block; cursor: pointer; } +.row-card.conflict-card:hover { background: rgba(255, 255, 255, 0.09); } +.conflict-detail { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} +.conflict-detail .conflict-line { font-size: 12px; color: var(--muted); } +.conflict-detail .conflict-side { font-size: 13px; margin-top: 4px; } + +.diplomacy-table { width: 100%; border-collapse: collapse; } +.diplomacy-table th, +.diplomacy-table td { + padding: 8px 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + text-align: left; +} +.diplomacy-table th { + cursor: pointer; + user-select: none; + color: var(--muted); + font-weight: 600; + white-space: nowrap; +} +.diplomacy-table th:hover { color: var(--text); } +.diplomacy-table th.sorted-asc, +.diplomacy-table th.sorted-desc { color: var(--text); } +.diplomacy-table th.numeric, +.diplomacy-table td.numeric { text-align: right; } +.diplomacy-table .sort-arrow { font-size: 10px; margin-left: 4px; } +.diplomacy-table tbody tr:hover { background: rgba(255, 255, 255, 0.05); } +.diplomacy-table tbody tr.local-nation { background: rgba(120, 170, 255, 0.16); } +.diplomacy-table tbody tr.local-nation:hover { background: rgba(120, 170, 255, 0.24); } +.diplomacy-table tbody tr.local-nation td.name { font-weight: 600; } +.diplomacy-table td.status { white-space: nowrap; } +.diplomacy-table td.status .aero { margin-left: 8px; padding: 4px 10px; } + .training-status { padding: 0 18px 16px; } +.training-queue { padding-top: 0; } +.training-queue .queue-empty { padding: 4px 0; } +.training-queue .queue-row { padding: 8px 12px; } +.training-queue .queue-row .actions .aero { min-width: 110px; } + +/* The training modal keeps a fixed, slightly larger size. The unit buttons sit + in a fixed, non-scrolling box; the queue is the flexible part and scrolls, so + adding units never stretches the card or pushes the buttons out of reach. */ +#modal-train .modal-card { + width: 680px; + height: min(80vh, 740px); +} +#modal-train #train-list { + flex: 0 0 auto; + width: 100%; + height: 120px; + max-height: none; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 8px; +} +#modal-train .training-status { flex: 0 0 auto; } +#modal-train .training-queue { flex: 1 1 auto; min-height: 0; } .progress { height: 14px; margin-top: 8px; background: rgba(0, 0, 0, 0.5); border-radius: 7px; overflow: hidden; } #train-progress, #buildings-progress { height: 100%; width: 0; background: linear-gradient(180deg, #7fc4ff, #2f7fd0); } diff --git a/client/index.html b/client/index.html index 384f69e..649389b 100644 --- a/client/index.html +++ b/client/index.html @@ -108,7 +108,6 @@ -
—
@@ -147,14 +146,26 @@ Attack — Defense —
-
+
+
+ +
+
+ +
+ + + -
+
+
+ +
+ @@ -220,6 +232,7 @@ +
@@ -234,6 +247,10 @@
+
+ + +
diff --git a/client/js/app.js b/client/js/app.js index 64ccb72..07c90a5 100644 --- a/client/js/app.js +++ b/client/js/app.js @@ -41,7 +41,10 @@ $("#menu-join").on("click", () => { // ---------------------------------------------------------- new game ------ const $civSelect = $("#new-civs"); -for (const civ of CIVILISATIONS) { +// The picker reads alphabetically; the catalogue order is left alone so it can +// still drive the default roster. +const menuCivs = CIVILISATIONS.slice().sort((a, b) => a.name.localeCompare(b.name)); +for (const civ of menuCivs) { const $item = civOption(civ); $item.on("click", () => { selectedCivId = civ.id; @@ -50,7 +53,7 @@ for (const civ of CIVILISATIONS) { }); $civSelect.append($item); } -$civSelect.children().first().addClass("selected"); +$civSelect.children().filter(`[data-civ-id="${selectedCivId}"]`).addClass("selected"); $("#new-count").attr("max", CIVILISATIONS.length); // A fresh game starts with every civilisation in the world. $("#new-count").val(CIVILISATIONS.length); diff --git a/client/js/game_screen.js b/client/js/game_screen.js index d369222..1233c59 100644 --- a/client/js/game_screen.js +++ b/client/js/game_screen.js @@ -2,22 +2,16 @@ // to the snapshots the server broadcasts, and turns player input into orders. // It owns no simulation: movement between snapshots is dead reckoning with the // same cost model as the server. +// +// The HUD, selection and modal behaviour lives in ./game_screen/panels.js and is +// composed onto the prototype below; this file keeps the screen lifecycle, the +// snapshot plumbing and the order flow. import { MapView } from "./map_view.js"; -import { TrainModal, BuildingsModal, NationModal, ConfirmModal, NewsModal, describeNews } from "./modals.js"; +import { TrainModal, BuildingsModal, NationModal, ConfirmModal, NewsModal } from "./modals.js"; import { GameFeed } from "./feed.js"; -import { compact, groupDigits, improvements as fmtImprovements } from "../../shared/text_format.js"; -import { - CIVILISATIONS, - PROTO_UNITS, - TRAINABLE_UNIT_IDS, - BUILDINGS, - GOVERNMENTS, - TECHNOLOGIES, - NEWS_WAR, -} from "../../shared/data.js"; -import { defenseFactors, unitDefense } from "../../shared/rules.js"; -import { dateString } from "../../shared/game_clock.js"; +import { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.js"; +import { panelMethods } from "./game_screen/panels.js"; export class GameScreen { constructor(network, config) { @@ -28,14 +22,16 @@ export class GameScreen { this.localCiv = 0; this.civilisations = []; this.protoUnits = PROTO_UNITS; - this.selectedUnitId = 0; + this.selectedUnitIds = []; this.selectedCityId = 0; this._trainingCityId = 0; this._cityBuildings = new Map(); this._population = new Map(); this._territoryOwner = new Map(); + this._battles = new Set(); this._researched = new Set(); this._wars = new Set(); + this._conflicts = []; this._news = []; this._seenNews = null; this._entered = false; @@ -62,6 +58,7 @@ export class GameScreen { this.confirmModal = new ConfirmModal(); this.newsModal = new NewsModal(); this.feed = new GameFeed($("#game-feed")); + this.$stackMenu = $("#stack-menu"); this.$statTooltip = $("#stat-tooltip"); this._hoveredStat = null; @@ -79,9 +76,12 @@ export class GameScreen { leave() { this._entered = false; + this.map.cancelPathfinding(); this.$screen.removeClass("active"); this.feed.clear(); this._seenNews = null; + this._hideStackMenu(); + $(document).off("mousedown.stackmenu"); } // ------------------------------------------------------------- orders --- @@ -113,6 +113,7 @@ export class GameScreen { ); this._population = new Map(state.population.map((e) => [`${e[0]},${e[1]}`, e[2]])); this._territoryOwner = new Map(state.territory.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) { this._cityBuildings.set(city.id, { ...city.buildings }); @@ -127,12 +128,14 @@ export class GameScreen { const b = entry[1]; this._wars.add(a < b ? `${a}:${b}` : `${b}:${a}`); } + this._conflicts = state.conflicts || []; this._news = state.news || []; this._updateFeed(this._news); this.map.setCatalogue(this.civilisations, this.protoUnits); this.map.applySnapshot(state); - this.map.setSelectedUnit(this.selectedUnitId); + this.selectedUnitIds = this.selectedUnitIds.filter((id) => this._unit(id)); + this.map.setSelectedUnits(this.selectedUnitIds); this._updateTopPanel(state); this._updateBottomPanel(); this._updateTrainModal(); @@ -141,407 +144,105 @@ export class GameScreen { this._updateNewsModal(); } - // ---------------------------------------------------------------- HUD ---- - - _bindHud() { - $("#city-train").on("click", () => this._openTrainModal()); - $("#city-build").on("click", () => this._openBuildingsModal()); - $("#civ-flag").on("click", () => this._openNationModal()); - $("#news-button").on("click", () => this._openNewsModal()); - - this.trainModal.onTrain = (cityId, protoIndex) => - this.sendOrder({ type: "train", city: cityId, proto: protoIndex }); - this.buildingsModal.onBuild = (cityId, index) => - this.sendOrder({ type: "build", city: cityId, building: index }); - this.buildingsModal.onDemolish = (cityId, index) => - this.sendOrder({ type: "demolish", city: cityId, building: index }); - this.nationModal.onGovernment = (index) => - this.sendOrder({ type: "government", government: index }); - this.nationModal.onTechnology = (index) => - this.sendOrder({ type: "technology", technology: index }); - this.nationModal.onDeclareWar = (target) => - this.sendOrder({ type: "declare_war", target }); - - $("#unit-attack") - .on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget))) - .on("mouseleave", () => this._hideStatTooltip()); - $("#unit-defense") - .on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget))) - .on("mouseleave", () => this._hideStatTooltip()); - - $(window).on("keydown.game", (event) => this._onKey(event)); - } - - // A small hover tooltip anchored above a stat, fed by its data-tooltip text. - _showStatTooltip($stat) { - const text = $stat.attr("data-tooltip"); - if (!text) return; - this._hoveredStat = $stat; - this.$statTooltip.text(text).removeClass("hidden"); - const offset = $stat.offset() || { left: 0, top: 0 }; - const width = this.$statTooltip.outerWidth() || 0; - const height = this.$statTooltip.outerHeight() || 0; - this.$statTooltip.css({ - left: `${Math.max(8, offset.left + $stat.outerWidth() / 2 - width / 2)}px`, - top: `${Math.max(8, offset.top - height - 8)}px`, - }); - } - - _hideStatTooltip() { - this._hoveredStat = null; - this.$statTooltip.addClass("hidden"); - } - _bindMap() { this.map.onUnitSelected = (id) => { if (!id) { - this.selectedUnitId = 0; - this._showUnit(null); + this._selectUnits([]); return; } - const unit = (this.snapshot.units || []).find((u) => u.id === id); + const unit = this._unit(id); if (!unit || unit.civ !== this.localCiv) { - this.map.setSelectedUnit(0); - this.selectedUnitId = 0; + this._selectUnits([]); return; } - this.selectedUnitId = id; - this.selectedCityId = 0; - this.map.setSelectedUnit(id); - this._showUnit(unit); + // Clicking any member of a stack selects the whole stack, so the next + // order is given to every unit sharing the tile. + const stack = this.map.stackIds(unit.id).filter((stackId) => { + const data = this._unit(stackId); + return data && data.civ === this.localCiv; + }); + this._selectUnits(stack.length > 0 ? stack : [unit.id]); }; this.map.onCitySelected = (id) => { const city = (this.snapshot.cities || []).find((c) => c.id === id); if (!city || city.civ !== this.localCiv) return; this.selectedCityId = id; - this.selectedUnitId = 0; - this.map.setSelectedUnit(0); + this.selectedUnitIds = []; + this.map.setSelectedUnits([]); + this._hideStackMenu(); this._showCity(city); }; this.map.onTileRequested = (coords) => this._showTile(coords); - this.map.onMoveOrdered = (unitId, coords) => { - const unit = (this.snapshot.units || []).find((u) => u.id === unitId); - if (!unit || unit.civ !== this.localCiv) return; - const targets = this._unalliedTerritoryOnRoute(unit, coords); - if (targets.length > 0) { - const names = targets.map((index) => this._civName(index)).join(", "); - const demonyms = targets.map((index) => this._civDemonym(index)).join(", "); - this.confirmModal.show( - "Declare war?", - `Marching into ${demonyms} territory will declare war on ${names}. Continue?`, - () => this._sendMove(unit, coords) - ); - return; - } - this._sendMove(unit, coords); + this.map.onStackMenu = (coords, ids, x, y) => this._openStackMenu(coords, ids, x, y); + this.map.onMoveOrdered = (unitIds, coords) => { + const units = unitIds.map((id) => this._unit(id)).filter((u) => u && u.civ === this.localCiv); + if (units.length === 0) return; + return this._orderMove(units, coords); + }; + this.map.onAttackOrdered = (unitIds, coords) => { + const units = unitIds.map((id) => this._unit(id)).filter((u) => u && u.civ === this.localCiv); + if (units.length === 0) return; + return this._orderMove(units, coords, "attack"); + }; + this.map.onScheduleOrdered = (unitIds, coords) => { + const units = unitIds.map((id) => this._unit(id)).filter((u) => u && u.civ === this.localCiv); + if (units.length === 0) return; + return this._orderMove(units, coords, "schedule"); }; } - _sendMove(unit, coords) { - this.sendOrder({ type: "move", unit: unit.id, coords: [coords.x, coords.y] }); - this.map.predictMove(unit, coords); - } - - // The civilisations, other than our own, whose territory the predicted route - // would enter while we are at peace with them. Only military units can start - // a war this way; civilians wander in without one. - _unalliedTerritoryOnRoute(unit, coords) { - if (!this._isMilitaryUnit(unit)) return []; - const route = this.map.planMove(unit, coords); - if (!route || route.length < 2) return []; - const targets = []; - for (const point of route) { - const owner = this.map.territory.get(`${point.x},${point.y}`); - if (owner === undefined || owner < 0 || owner === unit.civ) continue; - if (this._isAtWar(unit.civ, owner)) continue; - if (!targets.includes(owner)) targets.push(owner); - } - return targets; - } - - _isMilitaryUnit(unit) { - const proto = this.protoUnits[unit.proto]; - return !!(proto && proto.military); - } - - _isAtWar(a, b) { - if (a < 0 || b < 0 || a === b) return false; - return this._wars.has(a < b ? `${a}:${b}` : `${b}:${a}`); - } - - _civName(index) { - const civ = this.civilisations[index]; - return civ ? civ.name : `Nation ${index + 1}`; - } - - _civDemonym(index) { - const civ = this.civilisations[index]; - return civ && civ.demonym ? civ.demonym : this._civName(index); - } - - _updateTopPanel(state) { - const civ = this.civilisations[this.localCiv]; - if (civ) { - $("#civ-name").text(civ.name); - const flag = civ.flag || (CIVILISATIONS.find((c) => c.id === civ.id) || {}).flag; - if (flag) $("#civ-flag-img").attr("src", `assets/flags/${flag}`); - } - $("#player-name").text(this.config.playerName || (this.network.players[this.network.localPlayerId] || {}).name || ""); - $("#admin-label").toggle(this.network.isAdmin); - $("#stat-date").text(dateString(state.totalHours)); - const stats = state.viewerStats || {}; - $("#stat-pop").text(compact(stats.population || 0)); - $("#stat-gdp").text(`€${compact(stats.gdp || 0)}`); - $("#stat-gdppc").text(`€${compact(stats.gdpPerCapita || 0)}`); - $("#stat-budget").text(`€${compact(stats.budget || 0)}`); - } - - _updateBottomPanel() { - if (this.selectedUnitId) { - const unit = this._unit(this.selectedUnitId); - if (unit) this._showUnit(unit); - } else if (this.selectedCityId) { - const city = this._city(this.selectedCityId); - if (city) this._showCity(city); - } - } - - _showUnit(unit) { - if (!unit) { - $("#unit-info").addClass("hidden"); - this._hideStatTooltip(); - this._refreshBottomVisibility(); + // Issues a move for the group, prompting first when the route would enter + // foreign territory at peace. `type` is "move", "attack" or "schedule". + async _orderMove(units, coords, type = "move") { + const { targets, speed, routes } = await this._groupMovePlan(units, coords); + if (targets.length > 0) { + const names = targets.map((index) => this._civName(index)).join(", "); + const demonyms = targets.map((index) => this._civDemonym(index)).join(", "); + this.confirmModal.show( + "Declare war?", + `Marching into ${demonyms} territory will declare war on ${names}. Continue?`, + () => this._sendMove(units, coords, speed, type, routes) + ); return; } - const proto = this.protoUnits[unit.proto]; - $("#unit-name").text(proto ? proto.name : "Unit"); - this._showUnitStats(unit, proto); - const ratio = unit.maxHp > 0 ? (unit.hp / unit.maxHp) * 100 : 0; - $("#unit-health-fill").css("width", `${Math.max(0, Math.min(100, ratio))}%`); - $("#tile-info, #city-info").addClass("hidden"); - $("#unit-info").removeClass("hidden"); - this._refreshBottomVisibility(); + return this._sendMove(units, coords, speed, type, routes); } - // Attack and defense of the unit where it stands, with the hovering tooltips - // listing each factor that shaped the number. - _showUnitStats(unit, proto) { - const coords = { x: unit.coords[0], y: unit.coords[1] }; - const tile = this.map.getTile(coords); - const hasCity = ((this.snapshot && this.snapshot.cities) || []).some( - (city) => city.coords[0] === coords.x && city.coords[1] === coords.y - ); - const baseAttack = proto && typeof proto.attack === "number" ? proto.attack : 0; - const baseDefense = proto && typeof proto.defense === "number" ? proto.defense : 0; - const baseRange = proto && typeof proto.range === "number" ? proto.range : 1; - - const attackParts = [`Base ${Math.round(baseAttack)}`]; - if (baseRange > 1) attackParts.push(`Range ${baseRange}`); - $("#unit-attack") - .text(`Attack ${Math.round(baseAttack)}`) - .attr("data-tooltip", attackParts.join(", ")); - - const factors = defenseFactors(tile, hasCity); - const defense = unitDefense(baseDefense, tile, hasCity); - const parts = [`Base ${Math.round(baseDefense)}`]; - for (const factor of factors) { - parts.push(`${factor.label} +${Math.round(baseDefense * factor.bonus)}`); + _sendMove(units, coords, speed = null, type = "move", routes = null) { + const ids = units.map((unit) => unit.id); + const order = { type, coords: [coords.x, coords.y] }; + if (ids.length === 1) order.unit = ids[0]; + else order.units = ids; + if (type === "schedule") { + order.goals = [[coords.x, coords.y]]; + order.append = true; } - $("#unit-defense") - .text(`Defense ${Math.round(defense)}`) - .attr("data-tooltip", parts.join(", ")); - // Keep an open tooltip in step with the unit it describes across snapshots. - if (this._hoveredStat && !this.$statTooltip.hasClass("hidden")) { - const text = this._hoveredStat.attr("data-tooltip"); - if (text) this.$statTooltip.text(text); - } else { - this._hideStatTooltip(); + this.sendOrder(order); + const pending = []; + for (const unit of units) { + // An append joins the queue behind the leg already under way, so only an + // idle stack gets a fresh prediction. + const motion = this.map._unitMotion.get(unit.id); + if (type === "schedule" && motion && motion.predicting) continue; + // Reuse the route the war check already found instead of searching again. + const route = routes && routes.get(unit.id); + if (route) this.map.predictMove(unit, coords, speed, route); + else pending.push(unit); } - } - - _showCity(city) { - if (!city) { - $("#city-info").addClass("hidden"); - this._refreshBottomVisibility(); - return; - } - $("#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"); - this._refreshBottomVisibility(); - } - - _showTile(coords) { - const k = `${coords.x},${coords.y}`; - const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1; - const civ = this.civilisations[owner]; - const tile = this.map.getTile(coords); - const city = (this.snapshot.cities || []).find( - (c) => c.coords[0] === coords.x && c.coords[1] === coords.y - ); - $("#tile-coords").text(`Tile (${coords.x}, ${coords.y})`); - $("#tile-owner").text(`Owner: ${civ ? civ.name : "Unclaimed"}`); - $("#tile-terrain").text(`Terrain: ${tile ? tile.terrainType : "Unknown"}`); - $("#tile-population").text(`Population: ${groupDigits(this._population.get(k) || 0)}`); - $("#tile-improvements").text(`Improvements: ${fmtImprovements(city ? city.improvements : [])}`); - $("#city-info, #unit-info").addClass("hidden"); - $("#tile-info").removeClass("hidden"); - this._refreshBottomVisibility(); - } - - _refreshBottomVisibility() { - const visible = - !$("#tile-info").hasClass("hidden") || - !$("#city-info").hasClass("hidden") || - !$("#unit-info").hasClass("hidden"); - $("#bottom-panel").toggleClass("hidden", !visible); - } - - _unit(id) { - return (this.snapshot.units || []).find((u) => u.id === id) || null; - } - - _city(id) { - return (this.snapshot.cities || []).find((c) => c.id === id) || null; - } - - // -------------------------------------------------------------- modals --- - - _openTrainModal() { - const city = this._city(this.selectedCityId); - if (!city) return; - const trainable = TRAINABLE_UNIT_IDS.map((id) => { - const index = this.protoUnits.findIndex((p) => p.id === id); - return index >= 0 ? { ...this.protoUnits[index], serverIndex: index } : null; - }).filter(Boolean); - this.buildingsModal.close(); - this.trainModal.show( - { id: city.id, name: city.name }, - (this.snapshot.viewerStats || {}).gdp || 0, - trainable - ); - this._updateTrainModal(); - } - - _updateTrainModal() { - if (!this.snapshot || !this.selectedCityId) return; - if (!this.trainModal.isShowing(this.selectedCityId)) { - this._trainingCityId = 0; - return; - } - const entry = (this.snapshot.training || {})[this.selectedCityId]; - if (!entry) { - if (this._trainingCityId === this.selectedCityId) { - this.trainModal.endTraining(); - this._trainingCityId = 0; - } - return; - } - const proto = this.protoUnits[entry.protoIndex]; - const name = proto ? proto.name : "unit"; - if (this._trainingCityId !== this.selectedCityId) { - this._trainingCityId = this.selectedCityId; - this.trainModal.beginTraining(name, entry.totalHours, entry.elapsedHours); - } else { - this.trainModal.setTrainingProgress(name, entry.elapsedHours, entry.totalHours); - } - } - - _openBuildingsModal() { - const city = this._city(this.selectedCityId); - if (!city) return; - this.trainModal.close(); - this.buildingsModal.show( - { id: city.id, name: city.name }, - (this.snapshot.viewerStats || {}).gdp || 0, - (this.snapshot.viewerStats || {}).budget || 0, - BUILDINGS, - this._cityBuildings.get(city.id) || {} + // A fresh search cancels any other, so units still without a planned route + // wait their turn rather than fighting for the single search slot. + return pending.reduce( + (chain, unit) => chain.then(() => this.map.predictMove(unit, coords, speed)), + Promise.resolve() ); } - _updateBuildingsModal() { - if (!this.snapshot || !this.buildingsModal.isShowing(this.selectedCityId)) return; - const stats = this.snapshot.viewerStats || {}; - this.buildingsModal.refresh( - stats.gdp || 0, - stats.budget || 0, - this._cityBuildings.get(this.selectedCityId) || {} - ); - } + // ------------------------------------------------------- pillage ------- - _openNationModal() { - const stats = this.snapshot.viewerStats || {}; - this.nationModal.show( - GOVERNMENTS, - stats.government || 0, - TECHNOLOGIES, - this._researched, - stats.gdp || 0, - stats.budget || 0, - stats.research || 0, - this._finances(stats), - this._diplomacyInfo() - ); - } - - _updateNationModal() { - if (!this.snapshot || !this.nationModal.isShowing()) return; - const stats = this.snapshot.viewerStats || {}; - this.nationModal.refresh( - stats.government || 0, - this._researched, - stats.gdp || 0, - stats.budget || 0, - stats.research || 0, - this._finances(stats), - this._diplomacyInfo() - ); - } - - _diplomacyInfo() { - return { - civs: this.civilisations, - localCiv: this.localCiv, - relations: (this.snapshot && this.snapshot.diplomacy) || [], - }; - } - - _openNewsModal() { - this.newsModal.show(this._news, this.civilisations); - } - - _updateNewsModal() { - if (!this.snapshot || !this.newsModal.isShowing()) return; - this.newsModal.refresh(this._news, this.civilisations); - } - - // Pops newly arrived news into the corner feed. The first snapshot only - // records what is already known, so history never floods the screen. - _updateFeed(news) { - if (this._seenNews === null) { - this._seenNews = new Set(news.map((entry) => entry.id)); - return; - } - for (const entry of news) { - if (this._seenNews.has(entry.id)) continue; - this._seenNews.add(entry.id); - this._pushNews(entry); - } - } - - _pushNews(entry) { - if (entry.type === NEWS_WAR && entry.target === this.localCiv) { - this.feed.push(`${this._civName(entry.actor)} has declared war on you!`, { alert: true }); - return; - } - this.feed.push(describeNews(entry, this.civilisations)); - } - - _finances(stats) { - return stats.breakdown || null; + _pillageSelected() { + const units = this._selectedUnits(); + if (units.length === 0) return; + this.sendOrder({ type: "pillage", units: units.map((unit) => unit.id) }); } // --------------------------------------------------------------- input --- @@ -594,3 +295,5 @@ export class GameScreen { requestAnimationFrame((t) => this._frame(t)); } } + +Object.assign(GameScreen.prototype, panelMethods); diff --git a/client/js/map_view.js b/client/js/map_view.js index f576b4b..3d33b1d 100644 --- a/client/js/map_view.js +++ b/client/js/map_view.js @@ -3,36 +3,18 @@ // seed and mirrors units, cities, territory and fog from each snapshot, then // turns pointer input into selection and move orders. All rendering is done // with ordinary DOM elements, as required. +// +// The behaviour is split across the modules in ./map_view/ and composed onto the +// prototype below, so each concern (terrain, entities, motion, input) stays in +// its own file. -import { MapTopology, mapToLocal, key, COL_STEP, HEX_W, HEX_H } from "../../shared/hex.js"; -import { MapGenerator } from "../../shared/map_generator.js"; -import { TerrainStats } from "../../shared/terrain_stats.js"; -import { HexPathfinder } from "../../shared/hex_pathfinder.js"; -import { MAP_CONFIG, protoUnitById, statusById } from "../../shared/data.js"; +import { terrainMethods } from "./map_view/terrain.js"; +import { entityMethods } from "./map_view/entities.js"; +import { motionMethods } from "./map_view/motion.js"; +import { inputMethods } from "./map_view/input.js"; -const RESYNC_HOURS = 0.75; -const PREDICTION_GRACE_HOURS = 2.0; -const DRAG_THRESHOLD = 5; -const ZOOM_MIN = 0.2; -const ZOOM_MAX = 2.0; - -// The zoomed-out overview groups this many tiles per side into one block, so a -// full world is a few hundred flat elements instead of tens of thousands of -// textured ones. -const OVERVIEW_BLOCK = 4; - -// Flat colours used by the simplified overview, keyed by terrain type. The -// full-resolution view keeps the textured hexes; this is only the LOD stand-in. -const OVERVIEW_COLOURS = { - Forest: "#2f6b3a", - Hills: "#7a6a44", - Mountain: "#7d7469", - Ice: "#dcecf5", - Land: "#4f8a4f", - Sea: "#1b3f6b", -}; -const OVERVIEW_SEA = "#1b3f6b"; -const OVERVIEW_FOG = "#04040a"; +// Re-exported for tests and callers that use the border geometry directly. +export { inwardOffset } from "./map_view/utils.js"; export class MapView { constructor($viewport, $world, layers) { @@ -72,10 +54,12 @@ export class MapView { this.civilisations = []; this.localCiv = -1; this.protoUnits = []; + // Battlefield tiles, keyed "x,y" and mapped to { defender, attacker }. + this._battles = new Map(); this.camera = { x: 0, y: 0, zoom: 1.0 }; this._centered = false; - this._selectedUnitId = 0; + this._selectedUnitIds = new Set(); this._selectedTile = null; this._unitViews = new Map(); @@ -83,1201 +67,55 @@ export class MapView { this._unitData = new Map(); this._cityViews = new Map(); this._cityCiv = new Map(); - this._predictNeighbourCache = new Map(); + this._battleViews = new Map(); + // In-flight pathfinding job, cancelled when a newer request supersedes it. + this._pathToken = null; this._maxStepLength = 1.0; this._territorySignature = ""; - this._fogSignature = ""; + this._exploredCount = -1; + this._visibleSignature = null; // Cylindrical wrap state. `_period` is the horizontal pixel period (0 on a - // flat map); every static element remembers its unwrapped position and is - // shifted by a whole period so it stays next to the camera. + // flat map); every entity is shifted to the copy nearest the camera. this._period = 0; this._worldWidth = 0; this._worldHeight = 0; this._worldMinX = 0; this._worldMinY = 0; - this._terrainWrap = []; - this._borderWrap = []; - this._fogWrap = []; - this._wrapCameraX = null; + // Chunked detailed layers: only the chunks near the viewport exist as DOM. + // The tile wrappers are pooled so panning recycles nodes instead of + // allocating a fresh one for every tile that scrolls in. + this._chunkSize = 0; + this._chunkCols = 0; + this._chunkRows = 0; + this._chunks = new Map(); + this._chunkCache = new Map(); + this._chunkCacheLimit = 0; + this._chunkOriginCache = new Map(); + this._chunkView = { x: null, y: null, zoom: null }; + this._chunkDirty = true; + this._hexPool = []; + this._borderPool = []; + this._wrapperPool = []; this.lod = false; this.onUnitSelected = () => {}; this.onCitySelected = () => {}; this.onTileRequested = () => {}; this.onMoveOrdered = () => {}; + this.onAttackOrdered = () => {}; + this.onScheduleOrdered = () => {}; + this.onStackMenu = () => {}; this._setupCameraInput(); } - - // ------------------------------------------------------------- terrain -- - - ensureTerrain(seed) { - if (seed === this.seed) return; - this.seed = seed; - this.topology = new MapTopology(MAP_CONFIG.mapSize, MAP_CONFIG.topology === "cylindrical"); - const generator = new MapGenerator(MAP_CONFIG, this.topology); - generator.generate(seed); - this.tiles = generator.tiles; - this.terrainStats = new TerrainStats(this.tiles); - this._maxStepLength = this._computeMaxStepLength(); - this._period = this.topology.periodPixels(); - this._computeWorldBounds(); - - this._terrainWrap = []; - const fragment = document.createDocumentFragment(); - for (const k in this.tiles) { - const coords = parseKey(k); - const tile = this.tiles[k]; - const local = mapToLocal(coords.x, coords.y); - const div = document.createElement("div"); - div.className = "hex terrain detail-layer"; - div.style.left = `${local.x - HEX_W / 2}px`; - div.style.top = `${local.y - HEX_H / 2}px`; - div.style.backgroundPosition = `${-tile.col * HEX_W}px 0`; - fragment.appendChild(div); - this._terrainWrap.push({ - el: div, - centerX: local.x, - shift: null, - baseTransform: "", - }); - } - this.$terrain.empty()[0].appendChild(fragment); - this.$terrain.addClass("detail-layer"); - - this._territorySignature = ""; - this._fogSignature = ""; - this._borderWrap = []; - this._fogWrap = []; - this._wrapCameraX = null; - this.$borders.empty(); - this.$fog.empty(); - this.$entities.empty(); - this.$labels.empty(); - this._unitViews.clear(); - this._unitMotion.clear(); - this._unitData.clear(); - this._cityViews.clear(); - this._cityCiv.clear(); - this._centered = false; - this._drawOverview(true); - this._applyWrap(true); - } - - // Pixel bounds of the whole world, used to lay out the overview and clamp the - // camera when the simplified map is on screen. - _computeWorldBounds() { - let minX = Infinity; - let maxX = -Infinity; - let minY = Infinity; - let maxY = -Infinity; - for (const k in this.tiles) { - const coords = parseKey(k); - const local = mapToLocal(coords.x, coords.y); - minX = Math.min(minX, local.x - HEX_W / 2); - maxX = Math.max(maxX, local.x + HEX_W / 2); - minY = Math.min(minY, local.y - HEX_H / 2); - maxY = Math.max(maxY, local.y + HEX_H / 2); - } - if (!isFinite(minX)) { - minX = 0; - maxX = 0; - minY = 0; - maxY = 0; - } - this._worldMinX = minX; - this._worldMinY = minY; - this._worldWidth = maxX - minX; - this._worldHeight = maxY - minY; - } - - getTile(coords) { - return this.tiles[key(coords.x, coords.y)] || null; - } - - // ------------------------------------------------------------ snapshot -- - - setCatalogue(civilisations, protoUnits) { - this.civilisations = civilisations; - this.protoUnits = protoUnits; - } - - applySnapshot(state) { - this.ensureTerrain(state.seed); - this.localCiv = state.viewer; - this._applyTerritory(state.territory); - this._applyKnowledge(state.explored, state.visible); - this._syncCities(state.cities); - this._syncUnits(state.units); - if (!this._centered) this._centerOnCapital(state.cities); - this.animate(0); - } - - _applyTerritory(entries) { - const signature = hashEntries(entries); - if (signature === this._territorySignature) return; - this._territorySignature = signature; - - this.territory = new Map(); - for (const entry of entries) { - this.territory.set(key(entry[0], entry[1]), entry[2]); - } - - this._renderBorders(); - this._drawOverview(); - this._applyWrap(true); - } - - // Draws a thick tinted segment along every edge shared with a different owner - // (or the open sea). Each segment is - // nudged towards its owner's tile by half its width so two neighbours' borders - // sit side by side instead of painting over each other on the shared edge. - _renderBorders() { - const fragment = document.createDocumentFragment(); - this._borderWrap = []; - for (const [k, owner] of this.territory) { - const civ = this.civilisations[owner]; - if (!civ) continue; - const coords = parseKey(k); - const center = mapToLocal(coords.x, coords.y); - for (const neighbour of this.topology.neighbours(coords.x, coords.y)) { - const nk = key(neighbour.x, neighbour.y); - if (this.territory.has(nk) && this.territory.get(nk) === owner) continue; - const delta = this.topology.pixelDelta(coords, neighbour); - const target = { x: center.x + delta.x, y: center.y + delta.y }; - const [a, b] = nearestCorners(center, target); - const width = 6; - const inward = inwardOffset(a, b, center, width / 2); - const el = makeSegment(a, b, civ.primaryColour, width, inward); - fragment.appendChild(el); - this._borderWrap.push({ - el, - centerX: center.x, - shift: null, - baseTransform: el.style.transform, - }); - } - } - this.$borders.empty()[0].appendChild(fragment); - this.$borders.addClass("detail-layer"); - } - - _applyKnowledge(exploredEntries, visibleEntries) { - const explored = new Set(exploredEntries.map((e) => key(e[0], e[1]))); - const visible = new Set(visibleEntries.map((e) => key(e[0], e[1]))); - const signature = `e${hashEntries(exploredEntries)}v${hashEntries(visibleEntries)}`; - this.explored = explored; - this.visible = visible; - if (signature === this._fogSignature) return; - this._fogSignature = signature; - - this._fogWrap = []; - const fragment = document.createDocumentFragment(); - for (const k in this.tiles) { - if (visible.has(k)) continue; - const coords = parseKey(k); - const local = mapToLocal(coords.x, coords.y); - const div = document.createElement("div"); - div.className = "hex fog detail-layer " + (explored.has(k) ? "fog-explored" : "fog-unexplored"); - div.style.left = `${local.x - HEX_W / 2}px`; - div.style.top = `${local.y - HEX_H / 2}px`; - fragment.appendChild(div); - this._fogWrap.push({ - el: div, - centerX: local.x, - shift: null, - baseTransform: "", - }); - } - this.$fog.empty()[0].appendChild(fragment); - this.$fog.addClass("detail-layer"); - this._drawOverview(); - this._applyWrap(true); - } - - _syncCities(cities) { - const seen = new Set(); - this._cityCiv = new Map(); - for (const data of cities) { - const id = data.id; - seen.add(id); - let view = this._cityViews.get(id); - if (!view) { - view = this._createCityView(data); - this._cityViews.set(id, view); - } - const coords = { x: data.coords[0], y: data.coords[1] }; - this._cityCiv.set(key(coords.x, coords.y), data.civ); - const local = mapToLocal(coords.x, coords.y); - view._localX = local.x; - view._localY = local.y; - const wrappedX = local.x + this._wrapShift(local.x); - view.style.left = `${wrappedX}px`; - view.style.top = `${local.y}px`; - const owned = data.civ === this.localCiv; - const label = view._label; - label.textContent = data.name; - label.classList.toggle("capital", !!data.isCapital); - label.style.left = `${wrappedX}px`; - label.style.top = `${local.y}px`; - const marker = view.querySelector(".marker"); - marker.style.background = this.civilisations[data.civ] - ? this.civilisations[data.civ].primaryColour - : "#444"; - marker.style.cursor = owned ? "pointer" : "default"; - view.dataset.owned = owned ? "1" : "0"; - this._syncStatusIcons(view.querySelector(".city-statuses"), data.statuses || []); - // Foreign cities are only shown when their tile has been explored. - const k = key(coords.x, coords.y); - const shown = owned || this.explored.has(k); - view.style.display = shown ? "" : "none"; - label.style.display = shown ? "" : "none"; - } - for (const [id, view] of Array.from(this._cityViews)) { - if (seen.has(id)) continue; - view.remove(); - if (view._label) view._label.remove(); - this._cityViews.delete(id); - } - } - - _createCityView(data) { - const view = document.createElement("div"); - view.className = "entity city"; - const marker = document.createElement("div"); - marker.className = "marker"; - marker.style.backgroundImage = "url(assets/icon_modern_city.svg)"; - const statuses = document.createElement("div"); - statuses.className = "city-statuses"; - view.appendChild(marker); - view.appendChild(statuses); - marker.addEventListener("click", (event) => { - event.stopPropagation(); - if (view.dataset.owned === "1") this.onCitySelected(data.id); - }); - this.$entities[0].appendChild(view); - const label = document.createElement("div"); - label.className = "city-label"; - this.$labels[0].appendChild(label); - view._label = label; - return view; - } - - _syncUnits(units) { - const seen = new Set(); - // Store every unit's authoritative data first, so motion prediction can - // see where all of them are before any single one is advanced. - for (const data of units) { - seen.add(data.id); - this._unitData.set(data.id, data); - } - for (const data of units) { - const id = data.id; - let view = this._unitViews.get(id); - if (!view) { - view = this._createUnitView(data); - this._unitViews.set(id, view); - } - const proto = this.protoUnits[data.proto]; - const civ = this.civilisations[data.civ]; - view.querySelector(".body").style.background = civ ? civ.primaryColour : "#666"; - view.querySelector(".body img").src = proto ? `assets/${proto.icon}` : "assets/person.png"; - const flag = view.querySelector(".unit-flag"); - if (flag) { - if (civ && civ.flag) { - flag.src = `assets/flags/${civ.flag}`; - flag.style.display = ""; - } else { - flag.style.display = "none"; - } - } - view.querySelector(".health .fill").style.width = - `${Math.max(0, Math.min(100, (data.hp / data.maxHp) * 100))}%`; - view.classList.toggle("selected", id === this._selectedUnitId); - this._syncUnitStatuses(view, data); - this._updateUnitMotion(data); - // Foreign units are only shown inside currently visible tiles. - const k = key(data.coords[0], data.coords[1]); - view.style.display = data.civ === this.localCiv || this.visible.has(k) ? "" : "none"; - } - for (const [id, view] of Array.from(this._unitViews)) { - if (seen.has(id)) continue; - view.remove(); - this._unitViews.delete(id); - this._unitMotion.delete(id); - this._unitData.delete(id); - } - } - - _createUnitView(data) { - const view = document.createElement("div"); - view.className = "entity unit"; - const body = document.createElement("div"); - body.className = "body"; - body.style.background = this.civilisations[data.civ] - ? this.civilisations[data.civ].primaryColour - : "#666"; - const img = document.createElement("img"); - img.alt = ""; - body.appendChild(img); - const health = document.createElement("div"); - health.className = "health"; - const fill = document.createElement("div"); - fill.className = "fill"; - health.appendChild(fill); - const statuses = document.createElement("div"); - statuses.className = "unit-statuses"; - const flag = document.createElement("img"); - flag.className = "unit-flag"; - flag.alt = ""; - view.appendChild(flag); - view.appendChild(body); - view.appendChild(health); - view.appendChild(statuses); - view.addEventListener("mousedown", (event) => { - event.stopPropagation(); - if (event.button === 0) this.onUnitSelected(data.id); - }); - this.$entities[0].appendChild(view); - return view; - } - - // Draws one small icon per active status over the unit. The layout is driven - // by the snapshot's status ids and the shared catalogue, so a new status - // needs only a catalogue entry and an icon file. - _syncUnitStatuses(view, data) { - this._syncStatusIcons(view.querySelector(".unit-statuses"), data.statuses || []); - } - - _syncStatusIcons(container, ids) { - if (!container) return; - const signature = ids.join(","); - if (container._statusSignature === signature) return; - container._statusSignature = signature; - container.textContent = ""; - for (const id of ids) { - const def = statusById(id); - if (!def) continue; - const icon = document.createElement("img"); - icon.className = "status-icon"; - icon.src = `assets/${def.icon}`; - icon.alt = def.label; - icon.title = def.description || def.label; - container.appendChild(icon); - } - } - - // ------------------------------------------------------------- motion --- - - // A living unit standing on the path blocks dead reckoning: the animation - // must stop before it. For an enemy military unit that is the fight itself, - // so the mover halts within its own firing range instead — an attacker is not - // really moving until the tile it wants is free. - _truncateAtOccupant(path, segmentHours, data) { - const proto = this.protoUnits[data.proto]; - const range = proto && typeof proto.range === "number" ? Math.max(1, proto.range) : 1; - for (let i = 1; i < path.length; i++) { - const occupant = this._unitAtCoords(path[i], data.id); - if (!occupant) continue; - if (proto && proto.military && this._isMilitaryData(occupant) && occupant.civ !== data.civ) { - let stop = i - 1; - for (let j = 0; j <= i; j++) { - if (this.topology.tileDistance(path[j], path[i]) <= range) { - stop = j; - break; - } - } - return { path: path.slice(0, stop + 1), segmentHours: segmentHours.slice(0, stop) }; - } - return { - path: path.slice(0, i), - segmentHours: segmentHours.slice(0, Math.max(0, i - 1)), - }; - } - return { path, segmentHours }; - } - - _updateUnitMotion(data) { - const id = data.id; - let path = (data.path || []).map((p) => ({ x: p[0], y: p[1] })); - let segmentHours = data.segmentHours || []; - const clipped = this._truncateAtOccupant(path, segmentHours, data); - path = clipped.path; - segmentHours = clipped.segmentHours; - const existing = this._unitMotion.get(id); - if (path.length < 2 || segmentHours.length === 0) { - if (existing && existing.predicting && existing.predictAge < PREDICTION_GRACE_HOURS) { - return; - } - this._unitMotion.set(id, { - path: [], - segmentHours: [], - pathHours: 0, - coords: { x: data.coords[0], y: data.coords[1] }, - predicting: false, - predictAge: 0, - }); - return; - } - const authoritative = hoursAlong(data, segmentHours); - let pathHours = authoritative; - if (existing && samePath(existing.path, path)) { - pathHours = Math.abs(authoritative - existing.pathHours) > RESYNC_HOURS - ? authoritative - : existing.pathHours; - } - this._unitMotion.set(id, { - path, - segmentHours, - pathHours, - coords: { x: data.coords[0], y: data.coords[1] }, - predicting: false, - predictAge: 0, - }); - } - - animate(dtHours) { - for (const [, motion] of this._unitMotion) { - if (motion.path.length < 2) continue; - motion.pathHours += dtHours; - if (motion.predicting) motion.predictAge += dtHours; - } - this._applyWrap(false); - this._applyEntityWrap(); - this._drawPaths(); - } - - // Repositions every unit and city at the wrapped copy nearest the camera. - _applyEntityWrap() { - for (const [id, motion] of this._unitMotion) { - const view = this._unitViews.get(id); - if (!view) continue; - const position = motion.path.length >= 2 - ? this._positionAlongPath(motion) - : mapToLocal(motion.coords.x, motion.coords.y); - const wrapped = this._wrappedPosition(position); - view.style.left = `${wrapped.x}px`; - view.style.top = `${wrapped.y}px`; - } - for (const view of this._cityViews.values()) { - if (view._localX === undefined) continue; - const left = view._localX + this._wrapShift(view._localX); - view.style.left = `${left}px`; - view.style.top = `${view._localY}px`; - if (view._label) { - view._label.style.left = `${left}px`; - view._label.style.top = `${view._localY}px`; - } - } - } - - _positionAlongPath(motion) { - const path = motion.path; - const segmentHours = motion.segmentHours; - let remaining = motion.pathHours; - let index = 0; - while (index < segmentHours.length) { - const step = segmentHours[index]; - if (step > 0 && remaining < step) break; - remaining -= step; - index += 1; - } - if (index >= segmentHours.length) { - index = segmentHours.length - 1; - if (index < 0) { - const local = mapToLocal(path[path.length - 1].x, path[path.length - 1].y); - return local; - } - remaining = segmentHours[index]; - } - const from = path[index]; - const to = path[index + 1] || from; - const a = mapToLocal(from.x, from.y); - const delta = this.topology.pixelDelta(from, to); - const step = segmentHours[index] || 1; - const t = Math.max(0, Math.min(1, remaining / step)); - return { x: a.x + delta.x * t, y: a.y + delta.y * t }; - } - - _drawPaths() { - const fragment = document.createDocumentFragment(); - for (const [id, motion] of this._unitMotion) { - if (id !== this._selectedUnitId) continue; - if (motion.path.length < 2) continue; - const index = pathProgress(motion).index; - const base = mapToLocal(motion.path[index].x, motion.path[index].y); - const current = this._positionAlongPath(motion); - let previous = base; - for (let i = index + 1; i < motion.path.length; i++) { - const from = motion.path[i - 1]; - const to = motion.path[i]; - const delta = this.topology.pixelDelta(from, to); - const next = { x: previous.x + delta.x, y: previous.y + delta.y }; - const start = i === index + 1 ? current : previous; - const a = this._wrappedPosition(start); - const b = this._wrappedPosition(next); - fragment.appendChild(makeSegment(a, b, "#ffffff", 4)); - previous = next; - } - } - this.$paths.empty()[0].appendChild(fragment); - } - - // -------------------------------------------------------------- wrap ------ - - // Pixel offset (a whole number of map periods) that moves an element sitting - // at `centerX` to the copy closest to the camera. Choosing the half-open band - // [camera - P/2, camera + P/2) keeps neighbouring tiles contiguous, so the - // world tiles seamlessly in both directions. - _wrapShift(centerX) { - if (!this._period || this.lod) return 0; - const period = this._period; - return Math.ceil((this.camera.x - period / 2 - centerX) / period) * period; - } - - _wrappedPosition(point) { - return { x: point.x + this._wrapShift(point.x), y: point.y }; - } - - // Shifts the static layers (terrain, borders, fog) so they cover - // the visible period. Only elements whose period index changed are touched. - _applyWrap(force = false) { - if (force) this._wrapCameraX = null; - // The detailed layers are hidden on the overview, so their wrapped offsets - // do not need refreshing until the player zooms back in. - if (this.lod) return; - if (this._wrapCameraX === this.camera.x) return; - this._wrapCameraX = this.camera.x; - for (const list of [this._terrainWrap, this._borderWrap, this._fogWrap]) { - for (const item of list) { - const shift = this._wrapShift(item.centerX); - if (shift === item.shift) continue; - item.shift = shift; - // Moving with `transform` keeps this off the layout path, so dragging - // never forces the browser to lay the map out again. - if (item.baseTransform) { - item.el.style.transform = shift - ? `translateX(${shift}px) ${item.baseTransform}` - : item.baseTransform; - } else { - item.el.style.transform = shift ? `translateX(${shift}px)` : ""; - } - } - } - } - - // -------------------------------------------------------------- LOD ------ - - // Builds the simplified map as a coarse grid: one flat block per - // OVERVIEW_BLOCK x OVERVIEW_BLOCK tiles. At low zoom these few hundred - // elements replace the thousands of textured hex, fog and border elements, so - // seeing the entire map stays cheap. - _drawOverview(force = false) { - if (!this.$overview || !this.$overview.length || !this.topology) return; - // While the detailed view is on screen the overview is hidden; keep the - // redraw for the moment the player zooms out rather than paying for it on - // every hourly snapshot. - if (!force && !this.lod) return; - - const step = OVERVIEW_BLOCK; - const startX = this.topology.originX; - const startY = this.topology.originY; - const endX = startX + this.topology.width; - const endY = startY + this.topology.height; - const fragment = document.createDocumentFragment(); - for (let by = startY; by < endY; by += step) { - for (let bx = startX; bx < endX; bx += step) { - const block = this._overviewBlock(bx, by, step); - if (block) fragment.appendChild(block); - } - } - this.$overview.empty()[0].appendChild(fragment); - } - - _overviewBlock(bx, by, step) { - const endX = Math.min(bx + step, this.topology.originX + this.topology.width); - const endY = Math.min(by + step, this.topology.originY + this.topology.height); - let minX = Infinity; - let maxX = -Infinity; - let minY = Infinity; - let maxY = -Infinity; - let representative = null; - let land = null; - let owner = -1; - let visible = false; - let explored = false; - for (let y = by; y < endY; y++) { - for (let x = bx; x < endX; x++) { - const k = key(x, y); - const tile = this.tiles[k]; - if (!tile) continue; - if (!representative) representative = tile; - if (tile.terrainClass !== "Sea" && !land) land = tile; - const local = mapToLocal(x, y); - minX = Math.min(minX, local.x - HEX_W / 2); - maxX = Math.max(maxX, local.x + HEX_W / 2); - minY = Math.min(minY, local.y - HEX_H / 2); - maxY = Math.max(maxY, local.y + HEX_H / 2); - const tileOwner = this.territory.get(k); - if (tileOwner !== undefined && tileOwner >= 0 && this.civilisations[tileOwner]) { - owner = tileOwner; - } - if (this.visible.has(k)) visible = true; - if (this.explored.has(k)) explored = true; - } - } - if (!representative) return null; - - let colour = overviewColour(land || representative); - if (owner >= 0) colour = blendColours(colour, this.civilisations[owner].primaryColour, 0.45); - if (!visible) colour = blendColours(colour, OVERVIEW_FOG, explored ? 0.62 : 1); - - const div = document.createElement("div"); - div.className = "overview-block"; - div.style.left = `${minX}px`; - div.style.top = `${minY}px`; - div.style.width = `${maxX - minX}px`; - div.style.height = `${maxY - minY}px`; - div.style.background = colour; - return div; - } - - // ------------------------------------------------------------- camera --- - - centerOn(coords) { - const local = mapToLocal(coords.x, coords.y); - this.camera.x = local.x; - this.camera.y = local.y; - this._applyCamera(); - } - - _centerOnCapital(cities) { - for (const city of cities) { - if (city.civ === this.localCiv && city.isCapital) { - this.centerOn({ x: city.coords[0], y: city.coords[1] }); - this._centered = true; - return; - } - } - } - - // `applyWrap` is false while dragging: the per-element wrapped copies are - // refreshed once per animation frame in `animate`, so a high pointer rate - // cannot multiply the work. Panning and zooming from elsewhere apply them - // immediately so the view is always consistent when read. - _applyCamera(applyWrap = true) { - const width = this.$viewport.width(); - const height = this.$viewport.height(); - this._updateLod(width, height); - if (this.lod) { - // With the whole world on screen there is nothing to scroll to; keep the - // camera over the map so the overview never drifts into the void. - this.camera.x = clamp(this.camera.x, this._worldMinX, this._worldMinX + this._worldWidth); - this.camera.y = clamp(this.camera.y, this._worldMinY, this._worldMinY + this._worldHeight); - } - const { x, y, zoom } = this.camera; - const tx = width / 2 - x * zoom; - const ty = height / 2 - y * zoom; - this.$world.css("transform", `translate(${tx}px, ${ty}px) scale(${zoom})`); - if (applyWrap) this._applyWrap(false); - } - - // Switches between the textured hex view and the simplified overview. The - // detailed view only makes sense while the visible world stays within one - // horizontal period (so wrapping is seamless); past that -- when the player - // is zoomed out to see the whole map -- the coarse overview takes over. - _updateLod(width, height) { - let detailedWidth = this._worldWidth; - if (this._period > 0) detailedWidth = Math.min(detailedWidth, this._period) - HEX_W; - const detailed = detailedWidth > 0 && - width / this.camera.zoom <= detailedWidth && - height / this.camera.zoom <= this._worldHeight + HEX_H; - const lod = !detailed; - if (lod === this.lod) return; - this.lod = lod; - this.$world.toggleClass("lod", lod); - this._wrapCameraX = null; - if (lod) this._drawOverview(true); - } - - _setupCameraInput() { - let dragging = false; - let dragged = false; - let pressX = 0; - let pressY = 0; - let lastX = 0; - let lastY = 0; - let pressButton = -1; - - this.$viewport.on("mousedown", (event) => { - if (event.button !== 0 && event.button !== 2) return; - dragging = true; - dragged = false; - pressButton = event.button; - pressX = event.clientX; - pressY = event.clientY; - lastX = event.clientX; - lastY = event.clientY; - if (event.button === 0) this.$viewport.addClass("dragging"); - }); - - $(window).on("mousemove", (event) => { - if (!dragging) return; - if (Math.abs(event.clientX - pressX) > DRAG_THRESHOLD || - Math.abs(event.clientY - pressY) > DRAG_THRESHOLD) { - dragged = true; - } - if (dragged) { - this.camera.x -= (event.clientX - lastX) / this.camera.zoom; - this.camera.y -= (event.clientY - lastY) / this.camera.zoom; - this._applyCamera(false); - } - lastX = event.clientX; - lastY = event.clientY; - }); - - $(window).on("mouseup", (event) => { - if (!dragging) return; - dragging = false; - this.$viewport.removeClass("dragging"); - if (dragged) return; - const coords = this.tileAtScreen(event.clientX, event.clientY); - if (!coords) return; - if (pressButton === 0) this._onLeftClick(coords); - else if (pressButton === 2) this._onRightClick(coords); - }); - - this.$viewport.on("contextmenu", (event) => event.preventDefault()); - this.$viewport.on("wheel", (event) => { - event.preventDefault(); - const factor = event.originalEvent.deltaY < 0 ? 1.1 : 1 / 1.1; - this.camera.zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, this.camera.zoom * factor)); - this._applyCamera(); - }); - } - - panBy(dx, dy) { - this.camera.x += dx; - this.camera.y += dy; - this._applyCamera(); - } - - _onLeftClick(coords) { - const unitId = this.unitAt(coords); - if (unitId && this._selectedUnitId !== unitId) { - this.onUnitSelected(unitId); - return; - } - if (unitId) return; - const city = this.cityAt(coords); - if (city) { - this.onCitySelected(city.id); - return; - } - this.clearSelection(); - } - - _onRightClick(coords) { - if (this._selectedUnitId) this.onMoveOrdered(this._selectedUnitId, coords); - else this.onTileRequested(coords); - } - - setSelectedUnit(id) { - this._selectedUnitId = id; - for (const [unitId, view] of this._unitViews) { - view.classList.toggle("selected", unitId === id); - } - } - - clearSelection() { - this.setSelectedUnit(0); - this.onUnitSelected(0); - } - - unitAt(coords) { - for (const [id, motion] of this._unitMotion) { - const view = this._unitViews.get(id); - if (view && view.style.display === "none") continue; - const position = motion.path.length >= 2 - ? this._positionAlongPath(motion) - : mapToLocal(motion.coords.x, motion.coords.y); - const local = this._clickLocal(coords); - if (Math.hypot(position.x - local.x, position.y - local.y) < HEX_W * 0.5) return id; - } - return 0; - } - - cityAt(coords) { - for (const [id, view] of this._cityViews) { - if (view.style.display === "none") continue; - const local = this._clickLocal(coords); - const cx = parseFloat(view.style.left); - const cy = parseFloat(view.style.top); - if (Math.hypot(cx - local.x, cy - local.y) < HEX_W * 0.5) return { id }; - } - return null; - } - - // World position of a clicked tile at the wrapped copy nearest the camera, so - // hit-testing works across the seam. - _clickLocal(coords) { - return this._wrappedPosition(mapToLocal(coords.x, coords.y)); - } - - tileAtScreen(clientX, clientY) { - const rect = this.$viewport[0].getBoundingClientRect(); - const screenX = clientX - rect.left; - const screenY = clientY - rect.top; - const width = this.$viewport.width(); - const height = this.$viewport.height(); - const worldX = (screenX - (width / 2 - this.camera.x * this.camera.zoom)) / this.camera.zoom; - const worldY = (screenY - (height / 2 - this.camera.y * this.camera.zoom)) / this.camera.zoom; - return this._worldToHex(worldX, worldY); - } - - _worldToHex(worldX, worldY) { - const column = Math.round(worldX / COL_STEP); - let best = null; - let bestDistance = Infinity; - for (let dx = -1; dx <= 1; dx++) { - const x = column + dx; - const baseY = Math.round(worldY / HEX_H - 0.5 * parity(x)); - for (let dy = -1; dy <= 1; dy++) { - const y = baseY + dy; - const local = mapToLocal(x, y); - const distance = Math.hypot(local.x - worldX, local.y - worldY); - if (distance < bestDistance) { - bestDistance = distance; - best = { x, y }; - } - } - } - return best ? this.topology.wrapCoords(best.x, best.y) : null; - } - - // -------------------------------------------------------- move preview -- - - // Mirrors GameState.find_path closely enough that a click feels instant; the - // next snapshot confirms or corrects the route. `planMove` computes the route - // without touching the view, so callers can inspect it (for example to warn - // before entering foreign territory) before committing to the prediction. - planMove(unitData, goal) { - const plan = this._planMove(unitData, goal); - return plan ? plan.route : null; - } - - predictMove(unitData, goal) { - const plan = this._planMove(unitData, goal); - if (!plan) return null; - this._unitMotion.set(unitData.id, { - path: plan.route, - segmentHours: plan.segmentHours, - pathHours: 0, - coords: plan.start, - predicting: true, - predictAge: 0, - }); - this.animate(0); - return plan.route; - } - - _planMove(unitData, goal) { - const proto = this.protoUnits[unitData.proto]; - if (!proto || !proto.moveable || !this.topology) return null; - const civ = unitData.civ; - const start = this._unitCoords(unitData.id) || { x: unitData.coords[0], y: unitData.coords[1] }; - if (start.x === goal.x && start.y === goal.y) return null; - const enterCache = new Map(); - const costCache = new Map(); - const heuristicCache = new Map(); - this._predictNeighbourCache.clear(); - const path = new HexPathfinder().findPath( - start, - goal, - (coords) => { - const k = key(coords.x, coords.y); - if (!this._predictNeighbourCache.has(k)) { - this._predictNeighbourCache.set(k, this.topology.neighbours(coords.x, coords.y)); - } - return this._predictNeighbourCache.get(k); - }, - (coords, isGoal) => { - const k = key(coords.x, coords.y) + (isGoal ? ":goal" : ""); - if (!enterCache.has(k)) { - enterCache.set(k, this._canEnter(civ, proto, coords, unitData.id, isGoal)); - } - return enterCache.get(k); - }, - (_from, to) => { - const k = key(to.x, to.y); - if (!costCache.has(k)) costCache.set(k, this._stepHours(proto, civ, to)); - return costCache.get(k); - }, - (from, to) => { - const k = key(from.x, from.y); - if (!heuristicCache.has(k)) { - const delta = this.topology.pixelDelta(from, to); - const fastest = this.terrainStats.minMovementCostMultiplier() / - (Math.max(proto.speed, 0.0001) * 1.5); - heuristicCache.set(k, (Math.hypot(delta.x, delta.y) / this._maxStepLength) * fastest); - } - return heuristicCache.get(k); - } - ); - if (path.length < 2) return null; - // The server holds a unit as soon as its target is in range and never - // steps onto a living enemy's tile. Mirror that so a ranged unit stops at - // its firing distance and a melee one stops beside its opponent. - const range = Math.max(1, proto.range || 1); - const goalOccupant = this._unitAtCoords(goal, unitData.id); - const goalIsEnemy = !!goalOccupant && goalOccupant.civ !== civ && - proto.military === true && this._isMilitaryData(goalOccupant); - let route; - if (goalIsEnemy) { - if (this.topology.tileDistance(start, goal) <= range) return null; - const approach = path.slice(0, path.length - 1); - let stop = approach.length - 1; - for (let i = 1; i < approach.length; i++) { - if (this.topology.tileDistance(approach[i], goal) <= range) { - stop = i; - break; - } - } - route = approach.slice(0, stop + 1); - } else { - const block = this._firstOccupiedStep(path, unitData.id); - route = block >= 1 ? path.slice(0, block) : path; - } - if (route.length < 2) return null; - const segmentHours = []; - for (let i = 1; i < route.length; i++) { - segmentHours.push(this._stepHours(proto, civ, route[i])); - } - return { route, segmentHours, start }; - } - - _isMilitaryData(data) { - const proto = this.protoUnits[data.proto]; - return !!(proto && proto.military); - } - - _firstOccupiedStep(path, moverId) { - for (let i = 1; i < path.length; i++) { - if (this._unitAtCoords(path[i], moverId)) return i; - } - return -1; - } - - _unitAtCoords(coords, ignoreId = 0) { - for (const [id, data] of this._unitData) { - if (id === ignoreId) continue; - const motion = this._unitMotion.get(id); - const tile = motion && motion.path.length >= 2 - ? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)] - : { x: data.coords[0], y: data.coords[1] }; - if (tile.x === coords.x && tile.y === coords.y) return data; - } - return null; - } - - _unitCoords(id) { - const motion = this._unitMotion.get(id); - if (!motion) return null; - if (motion.path.length >= 2) { - const index = pathProgress(motion).index; - return motion.path[Math.min(index, motion.path.length - 1)]; - } - return motion.coords; - } - - // Mirrors GameState._canUnitEnter: a hostile unit or city is only a valid - // destination (`isGoal`), never a tile to pass through. - _canEnter(civ, proto, coords, moverId = 0, isGoal = false) { - const k = key(coords.x, coords.y); - if (!this.explored.has(k)) return false; - const tile = this.tiles[k]; - if (!tile) return false; - if (!proto.traversableTerrains.includes(tile.terrainClass)) return false; - const occupant = this._unitAtCoords(coords, moverId); - if (occupant) { - if (occupant.civ === civ) return false; - if (!isGoal) return false; - const occProto = this.protoUnits[occupant.proto]; - if (proto.military !== true || !occProto || occProto.military !== true) return false; - } - const cityCiv = this._cityCiv.get(k); - if (cityCiv !== undefined && cityCiv !== civ) { - if (!isGoal) return false; - if (proto.military !== true) return false; - } - return true; - } - - _stepHours(proto, civ, coords) { - const k = key(coords.x, coords.y); - let speed = Math.max(proto.speed, 0.0001); - if (this.territory.get(k) === civ) speed *= 1.5; - return this.terrainStats.movementCostMultiplier(coords) / speed; - } - - _computeMaxStepLength() { - let longest = 0; - for (const neighbour of this.topology.neighbours(0, 0)) { - const delta = this.topology.pixelDelta({ x: 0, y: 0 }, neighbour); - longest = Math.max(longest, Math.hypot(delta.x, delta.y)); - } - return longest > 0 ? longest : 1.0; - } } -// ------------------------------------------------------------------ utils -- - -function parity(x) { - return x & 1; -} - -function parseKey(k) { - const i = k.indexOf(","); - return { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) }; -} - -// Order-independent FNV-style hash of an [x, y, ...] entry array, used to skip -// rebuilding a layer when the snapshot did not actually change it. -function hashEntries(entries) { - let hash = 2166136261; - for (const entry of entries) { - hash = Math.imul(hash ^ (entry[0] + 4096), 16777619); - hash = Math.imul(hash ^ (entry[1] + 4096), 16777619); - hash = Math.imul(hash ^ (entry.length > 2 ? entry[2] + 8 : 0), 16777619); - } - return `${entries.length}:${(hash >>> 0).toString(36)}`; -} - -function hoursAlong(data, segmentHours) { - const index = Math.min(data.pathIndex || 0, segmentHours.length); - let total = 0; - for (let i = 0; i < index; i++) total += segmentHours[i]; - return total + (data.progressHours || 0); -} - -function samePath(a, b) { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i].x !== b[i].x || a[i].y !== b[i].y) return false; - } - return true; -} - -function pathProgress(motion) { - let remaining = motion.pathHours; - let index = 0; - while (index < motion.segmentHours.length) { - const step = motion.segmentHours[index]; - if (step <= 0) { - index += 1; - continue; - } - if (remaining < step) break; - remaining -= step; - index += 1; - } - return { index, remaining }; -} - -function nearestCorners(center, target) { - const half = { x: HEX_W / 2, y: HEX_H / 2 }; - const corners = [ - { x: half.x, y: 0 }, - { x: half.x * 0.5, y: -half.y }, - { x: -half.x * 0.5, y: -half.y }, - { x: -half.x, y: 0 }, - { x: -half.x * 0.5, y: half.y }, - { x: half.x * 0.5, y: half.y }, - ]; - let first = 0; - let second = 1; - let firstDistance = Infinity; - let secondDistance = Infinity; - for (let i = 0; i < corners.length; i++) { - const point = { x: center.x + corners[i].x, y: center.y + corners[i].y }; - const distance = (point.x - target.x) ** 2 + (point.y - target.y) ** 2; - if (distance < firstDistance) { - secondDistance = firstDistance; - second = first; - firstDistance = distance; - first = i; - } else if (distance < secondDistance) { - secondDistance = distance; - second = i; - } - } - return [ - { x: center.x + corners[first].x, y: center.y + corners[first].y }, - { x: center.x + corners[second].x, y: center.y + corners[second].y }, - ]; -} - -// Signed distance to nudge an edge segment along its normal so it moves towards -// `center` (the owning tile). Used to keep neighbours' borders side by side -// instead of overlapping on the shared edge. -export function inwardOffset(a, b, center, distance) { - const dx = b.x - a.x; - const dy = b.y - a.y; - const length = Math.hypot(dx, dy) || 1; - const nx = -dy / length; - const ny = dx / length; - const midX = (a.x + b.x) / 2; - const midY = (a.y + b.y) / 2; - const dot = (center.x - midX) * nx + (center.y - midY) * ny; - return dot >= 0 ? distance : -distance; -} - -function makeSegment(a, b, colour, width, offset = 0) { - const div = document.createElement("div"); - div.className = "border-seg"; - const dx = b.x - a.x; - const dy = b.y - a.y; - const length = Math.hypot(dx, dy); - const angle = Math.atan2(dy, dx); - div.style.left = `${a.x}px`; - div.style.top = `${a.y - width / 2}px`; - div.style.width = `${length}px`; - div.style.height = `${width}px`; - div.style.background = colour; - div.style.transform = offset === 0 - ? `rotate(${angle}rad)` - : `rotate(${angle}rad) translateY(${offset}px)`; - div.style.borderRadius = `${width / 2}px`; - return div; -} - -function overviewColour(tile) { - if (tile.terrainType && OVERVIEW_COLOURS[tile.terrainType]) { - return OVERVIEW_COLOURS[tile.terrainType]; - } - return tile.terrainClass === "Sea" ? OVERVIEW_SEA : OVERVIEW_COLOURS.Land; -} - -// Mixes two #rrggbb colours, `t` being how much of `b` to use. -function blendColours(a, b, t) { - const ca = parseColour(a); - const cb = parseColour(b); - const mix = (i) => Math.round(ca[i] + (cb[i] - ca[i]) * t); - return `rgb(${mix(0)}, ${mix(1)}, ${mix(2)})`; -} - -function parseColour(value) { - const hex = value.charAt(0) === "#" ? value.slice(1) : value; - return [ - parseInt(hex.slice(0, 2), 16), - parseInt(hex.slice(2, 4), 16), - parseInt(hex.slice(4, 6), 16), - ]; -} - -function clamp(value, min, max) { - if (min > max) return (min + max) / 2; - return Math.max(min, Math.min(max, value)); -} +Object.assign( + MapView.prototype, + terrainMethods, + entityMethods, + motionMethods, + inputMethods +); diff --git a/client/js/modals.js b/client/js/modals.js index c6fa8c1..7906748 100644 --- a/client/js/modals.js +++ b/client/js/modals.js @@ -1,518 +1,14 @@ -// The three modal panels of the game HUD: city training, city buildings, and -// the nation panel (government + technology + budget). They only display state -// supplied by the game screen and forward the player's choices as orders. +// The modal panels of the game HUD: city training, city buildings, the nation +// panel (government + technology + budget + diplomacy) and the generic confirm +// and news dialogs. They only display state supplied by the game screen and +// forward the player's choices as orders. +// +// Each panel lives in its own module in ./modals/; this file is the stable +// import path. -import { groupDigits, hours } from "../../shared/text_format.js"; -import { ECONOMY } from "../../shared/data.js"; -import { - buildingBuildCost, - buildingRefund, - buildingUpkeep, - governmentChangeCost, - prerequisitesMet, -} from "../../shared/rules.js"; - -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`; - case "budget_income": - return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% budget income`; - case "research": - return `${value >= 0 ? "+" : ""}${value.toFixed(2)} research/h`; - case "culture": - return `${value >= 0 ? "+" : ""}${value.toFixed(2)} culture/h`; - default: - return `${effect.stat} ${value.toFixed(2)}`; - } -} - -function effectsText(effects, level) { - const parts = effects.map((e) => formatEffect(e, level)); - return parts.length > 0 ? parts.join(" · ") : "No effect"; -} - -const SOURCE_LABELS = { building: "building", government: "government", technology: "research" }; - -function signedMoney(value) { - return `${value < 0 ? "−" : "+"}€${groupDigits(Math.round(Math.abs(value)))}/h`; -} - -function percent(value) { - return `${value < 0 ? "−" : "+"}${(Math.abs(value) * 100).toFixed(1)}%`; -} - -export class TrainModal { - constructor() { - this.$modal = $("#modal-train"); - this.$title = $("#train-title"); - this.$list = $("#train-list"); - this.$status = $("#train-status"); - this.$label = $("#train-label"); - this.$progress = $("#train-progress"); - this.cityId = 0; - this.gdp = 0; - this.onTrain = () => {}; - this.$modal.find(".modal-close").on("click", () => this.close()); - } - - isShowing(cityId) { - return !this.$modal.hasClass("hidden") && this.cityId === cityId; - } - - show(city, gdp, trainableUnits) { - this.cityId = city.id; - this.gdp = gdp; - this.$title.text(`${city.name} — Train`); - this.$list.empty(); - this.$status.addClass("hidden"); - for (let index = 0; index < trainableUnits.length; index++) { - const proto = trainableUnits[index]; - if (!proto.cost && proto.cost !== 0) continue; - const button = $(""); - const duration = ECONOMY.trainingHours(gdp, proto.cost); - button.text(`${proto.name} — Cost ${groupDigits(proto.cost)} — ${hours(duration)}`); - const serverIndex = proto.serverIndex !== undefined ? proto.serverIndex : index; - button.on("click", () => this.onTrain(city.id, serverIndex)); - this.$list.append(button); - } - this.$modal.removeClass("hidden"); - } - - beginTraining(name, totalHours, elapsedHours = 0) { - this.$label.text(`Training ${name}…`); - this.$progress.css("width", `${Math.min(100, (elapsedHours / Math.max(totalHours, 0.0001)) * 100)}%`); - this.$status.removeClass("hidden"); - this.$list.find("button").prop("disabled", true); - } - - setTrainingProgress(name, elapsedHours, totalHours) { - const remaining = Math.max(0, Math.ceil(totalHours - elapsedHours)); - this.$label.text(`Training ${name}… (${remaining} h left)`); - this.$progress.css("width", `${Math.min(100, (elapsedHours / Math.max(totalHours, 0.0001)) * 100)}%`); - } - - endTraining() { - this.$label.text("Training complete"); - this.$progress.css("width", "100%"); - this.$list.find("button").prop("disabled", false); - } - - close() { - this.$modal.addClass("hidden"); - this.cityId = 0; - } -} - -export class BuildingsModal { - constructor() { - this.$modal = $("#modal-buildings"); - this.$title = $("#buildings-title"); - this.$budget = $("#buildings-budget"); - this.$list = $("#buildings-list"); - this.cityId = 0; - this.cityName = ""; - this.buildings = []; - this.rows = []; - this.onBuild = () => {}; - this.onDemolish = () => {}; - this.$modal.find(".modal-close").on("click", () => this.close()); - } - - isShowing(cityId) { - return !this.$modal.hasClass("hidden") && this.cityId === cityId; - } - - show(city, gdp, budget, buildings, levels) { - this.cityId = city.id; - this.cityName = city.name; - this.buildings = buildings; - this.$title.text(`${city.name} — Buildings`); - this.$list.empty(); - this.rows = buildings.map((proto, index) => this._makeRow(proto, index)); - this.refresh(gdp, budget, levels); - this.$modal.removeClass("hidden"); - } - - _makeRow(proto, index) { - const $card = $("
"); - const $info = $("
"); - const $name = $("
"); - const $desc = $("
").text(proto.description); - const $effects = $("
"); - const $cost = $("
"); - $info.append($name, $desc, $effects, $cost); - const $actions = $("
"); - const $upgrade = $(""); - const $downgrade = $(""); - $upgrade.on("click", () => this.onBuild(this.cityId, index)); - $downgrade.on("click", () => this.onDemolish(this.cityId, index)); - $actions.append($upgrade, $downgrade); - $card.append($info, $actions); - this.$list.append($card); - return { proto, index, $name, $effects, $cost, $upgrade, $downgrade }; - } - - refresh(gdp, budget, levels) { - this.$budget.text(`Budget: €${groupDigits(Math.round(budget))}`); - for (const row of this.rows) { - const level = levels[row.index] || 0; - row.$name.text(`${row.proto.name} — Level ${level} / ${row.proto.maxLevel}`); - row.$effects.text(effectsText(row.proto.effects, level > 0 ? level : 1)); - const parts = []; - if (level > 0) parts.push(`Upkeep €${groupDigits(Math.round(buildingUpkeep(row.proto, gdp, level)))}/h`); - if (level < row.proto.maxLevel) { - parts.push(`Build cost €${groupDigits(Math.round(buildingBuildCost(row.proto, gdp, level)))}`); - } - row.$cost.text(parts.join(" · ")); - if (level < row.proto.maxLevel) { - const cost = buildingBuildCost(row.proto, gdp, level); - row.$upgrade.text(`Upgrade €${groupDigits(Math.round(cost))}`).prop("disabled", budget < cost); - } else { - row.$upgrade.text("Max level").prop("disabled", true); - } - if (level > 0) { - row.$downgrade - .text(`Downgrade +€${groupDigits(Math.round(buildingRefund(row.proto, gdp, level)))}`) - .prop("disabled", false); - } else { - row.$downgrade.text("Not built").prop("disabled", true); - } - } - } - - close() { - this.$modal.addClass("hidden"); - this.cityId = 0; - } -} - -export class NationModal { - constructor() { - this.$modal = $("#modal-nation"); - this.$governmentSub = $("#government-sub"); - this.$governmentList = $("#government-list"); - this.$technologySub = $("#technology-sub"); - this.$technologyList = $("#technology-list"); - this.$budgetSub = $("#budget-sub"); - this.$budgetList = $("#budget-list"); - this.$diplomacySub = $("#diplomacy-sub"); - this.$diplomacyList = $("#diplomacy-list"); - this.governments = []; - this.technologies = []; - this.currentGovernment = 0; - this.researched = new Set(); - this.governmentRows = []; - this.technologyRows = []; - this.diplomacy = null; - 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."), - }; - this.onGovernment = () => {}; - this.onTechnology = () => {}; - this.onDeclareWar = () => {}; - this.$modal.find(".modal-close").on("click", () => this.close()); - this.$modal.find(".tab").on("click", (event) => { - const tab = $(event.currentTarget).data("tab"); - this.$modal.find(".tab").removeClass("active"); - $(event.currentTarget).addClass("active"); - this.$modal.find(".tab-page").removeClass("active"); - $(`#tab-${tab}`).addClass("active"); - }); - } - - isShowing() { - return !this.$modal.hasClass("hidden"); - } - - show(governments, currentGovernment, technologies, researched, gdp, budget, research, finances, diplomacy = null) { - this.governments = governments; - this.technologies = technologies; - this.diplomacy = diplomacy; - this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government")); - this.technologyRows = technologies.map((proto, index) => this._makeRow(this.$technologyList, proto, index, "technology")); - this.refresh(currentGovernment, researched, gdp, budget, research, finances, diplomacy); - 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 }; - } - - _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)); - } - } - - _sourceLines(sources, format, empty) { - if (!sources || sources.length === 0) return [empty]; - return sources.map((source) => format(source)); - } - - _makeRow($list, proto, index, kind) { - const $card = $("
"); - const $info = $("
"); - const $name = $("
"); - const $desc = $("
").text(proto.description || ""); - const $effects = $("
"); - const $cost = $("
"); - $info.append($name, $desc, $effects, $cost); - const $actions = $("
"); - const $action = $(""); - $action.on("click", () => { - if (kind === "government") this.onGovernment(index); - else this.onTechnology(index); - }); - $actions.append($action); - $card.append($info, $actions); - $list.append($card); - return { proto, index, kind, $name, $effects, $cost, $action }; - } - - refresh(currentGovernment, researched, gdp, budget, research, finances = {}, diplomacy = null) { - this.currentGovernment = currentGovernment; - this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number)); - if (diplomacy !== null) this.diplomacy = diplomacy; - this.$governmentSub.text( - `Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: €${groupDigits(Math.round(budget))}` - ); - 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`, - ]); - - for (const row of this.governmentRows) { - const proto = row.proto; - row.$name.text(proto.name); - row.$effects.text(effectsText(proto.effects, 1)); - if (row.index === currentGovernment) { - row.$cost.text("Currently in power"); - row.$action.text("Current government").prop("disabled", true); - } else { - const cost = governmentChangeCost(proto, gdp); - row.$cost.text(`Adoption cost €${groupDigits(Math.round(cost))}`); - row.$action.text(`Adopt €${groupDigits(Math.round(cost))}`).prop("disabled", budget < cost); - } - } - - for (const row of this.technologyRows) { - const proto = row.proto; - row.$name.text(proto.name); - row.$effects.text(effectsText(proto.effects, 1)); - if (this.researched.has(row.index)) { - row.$cost.text("Researched"); - row.$action.text("Researched").prop("disabled", true); - } else if (!prerequisitesMet(proto, this.researched, this.technologies)) { - const names = proto.prerequisites - .map((id) => { - const found = this.technologies.find((t) => t.id === id); - return found ? found.name : id; - }) - .join(", "); - row.$cost.text(`Requires: ${names}`); - row.$action.text("Locked").prop("disabled", true); - } else { - row.$cost.text(`Cost ${groupDigits(Math.round(proto.cost))} research points`); - row.$action.text(`Research ${groupDigits(Math.round(proto.cost))}`).prop("disabled", research < proto.cost); - } - } - - this._renderDiplomacy(); - } - - // Lists every other nation with its relation to the player and a button to - // declare war. `diplomacy` is { civs, localCiv, relations }, where relations - // is the snapshot's [a, b, status] matrix. - _renderDiplomacy() { - if (!this.$diplomacyList || !this.$diplomacyList.length) return; - this.$diplomacyList.empty(); - const info = this.diplomacy; - if (!info) return; - const civs = info.civs || []; - const localCiv = info.localCiv; - const wars = new Set(); - for (const entry of info.relations || []) { - if (entry[2] !== "war") continue; - if (entry[0] === localCiv) wars.add(entry[1]); - else if (entry[1] === localCiv) wars.add(entry[0]); - } - if (this.$diplomacySub && this.$diplomacySub.length) { - const atWar = wars.size; - this.$diplomacySub.text( - atWar === 0 - ? "Your nation is at peace with every other nation." - : `Your nation is at war with ${atWar} nation${atWar === 1 ? "" : "s"}.` - ); - } - for (let index = 0; index < civs.length; index++) { - if (index === localCiv) continue; - const $card = $("
"); - const $info = $("
"); - const $name = $("
").text(civs[index].name); - const atWar = wars.has(index); - const $status = $("
").text(atWar ? "At war" : "At peace"); - $info.append($name, $status); - const $actions = $("
"); - const $action = $(""); - if (atWar) { - $action.text("At war").prop("disabled", true); - } else { - $action.text("Declare war"); - $action.on("click", () => this.onDeclareWar(index)); - } - $actions.append($action); - $card.append($info, $actions); - this.$diplomacyList.append($card); - } - } - - close() { - this.$modal.addClass("hidden"); - } -} - -// A generic yes/no prompt. `show` takes the callbacks it should run when the -// player confirms; cancelling (or closing) runs nothing. -export class ConfirmModal { - constructor() { - this.$modal = $("#modal-confirm"); - this.$title = $("#confirm-title"); - this.$message = $("#confirm-message"); - this._onConfirm = null; - this.$modal.find(".modal-close").on("click", () => this.close()); - this.$modal.find("#confirm-cancel").on("click", () => this.close()); - this.$modal.find("#confirm-ok").on("click", () => { - const callback = this._onConfirm; - this.close(); - if (callback) callback(); - }); - } - - isShowing() { - return !this.$modal.hasClass("hidden"); - } - - show(title, message, onConfirm) { - this.$title.text(title); - this.$message.text(message); - this._onConfirm = onConfirm || null; - this.$modal.removeClass("hidden"); - } - - close() { - this.$modal.addClass("hidden"); - this._onConfirm = null; - } -} - -// The news feed: a reverse-chronological list of newsworthy events. Civ indices -// are resolved to names through the catalogue the game screen passes in. -export class NewsModal { - constructor() { - this.$modal = $("#modal-news"); - this.$list = $("#news-list"); - this.$modal.find(".modal-close").on("click", () => this.close()); - } - - isShowing() { - return !this.$modal.hasClass("hidden"); - } - - show(news, civs) { - this.refresh(news, civs); - this.$modal.removeClass("hidden"); - } - - refresh(news, civs = []) { - if (!this.$list || !this.$list.length) return; - this.$list.empty(); - const entries = (news || []).slice().reverse(); - if (entries.length === 0) { - this.$list.append($("
").text("No news yet.")); - return; - } - for (const entry of entries) { - const $item = $("
"); - $item.append($("
").text(describeNews(entry, civs))); - $item.append($("
").text(entry.date || "")); - this.$list.append($item); - } - } - - close() { - this.$modal.addClass("hidden"); - } -} - -export function describeNews(entry, civs) { - const name = (index) => (civs[index] ? civs[index].name : `Nation ${index + 1}`); - const people = (index) => { - const civ = civs[index]; - return civ && civ.demonym ? `The ${civ.demonym}` : name(index); - }; - switch (entry.type) { - case "war": - return `${people(entry.actor)} declared war on ${name(entry.target)}`; - case "capture": - return `${people(entry.to)} captured ${entry.city} from ${name(entry.from)}`; - default: - return entry.type; - } -} +export { TrainModal } from "./modals/train.js"; +export { BuildingsModal } from "./modals/buildings.js"; +export { NationModal } from "./modals/nation.js"; +export { ConfirmModal } from "./modals/confirm.js"; +export { NewsModal } from "./modals/news.js"; +export { describeNews } from "./modals/format.js"; diff --git a/server/game_server.js b/server/game_server.js index b909423..17dbf49 100644 --- a/server/game_server.js +++ b/server/game_server.js @@ -9,11 +9,6 @@ import { LoginManager } from "../shared/login_manager.js"; import { CIVILISATIONS } from "../shared/data.js"; import { dateString } from "../shared/game_clock.js"; -const DEFAULT_CIV_IDS = [ - "france", "britain", "slovenia", "poland", "switzerland", - "ticino", "galicia", "germany", "iran", -]; - export class GameServer extends EventEmitter { constructor(network) { super(); @@ -87,13 +82,8 @@ export class GameServer extends EventEmitter { const civ = CIVILISATIONS.find((c) => c.id === id); if (civ) civs.push(civ); } - if (civs.length === 0) { - for (const id of DEFAULT_CIV_IDS) { - const civ = CIVILISATIONS.find((c) => c.id === id); - if (civ) civs.push(civ); - } - } - return civs; + // An empty list means the project's full roster. + return civs.length === 0 ? CIVILISATIONS.slice() : civs; } handleOrder(peerId, order) { @@ -104,8 +94,16 @@ export class GameServer extends EventEmitter { switch (type) { case "move": return this._handleMove(civ, order); + case "attack": + return this._handleAttack(civ, order); + case "schedule": + return this._handleSchedule(civ, order); + case "pillage": + return this._handlePillage(civ, order); case "train": return this._handleTrain(civ, order); + case "cancel_train": + return this._handleCancelTrain(civ, order); case "build": return this._handleBuild(civ, order); case "demolish": @@ -122,9 +120,53 @@ export class GameServer extends EventEmitter { } _handleMove(civ, order) { - const unit = this.state.findUnit(Number(order.unit)); - if (!unit || unit.civ !== civ) return false; - return this.state.requestMove(unit.id, coordsOf(order.coords)); + const ids = Array.isArray(order.units) ? order.units : [order.unit]; + const units = []; + for (const id of ids) { + const unit = this.state.findUnit(Number(id)); + if (!unit || unit.civ !== civ) return false; + units.push(unit.id); + } + if (units.length === 0) return false; + return this.state.requestGroupMove(units, coordsOf(order.coords)); + } + + _handleAttack(civ, order) { + const units = this._ownUnitIds(civ, order); + if (units.length === 0) return false; + return this.state.requestAttack(units, coordsOf(order.coords)); + } + + _handleSchedule(civ, order) { + const units = this._ownUnitIds(civ, order); + if (units.length === 0) return false; + const goals = (Array.isArray(order.goals) ? order.goals : []) + .filter((goal) => Array.isArray(goal) && goal.length >= 2) + .map(coordsOf); + return this.state.requestScheduledMove(units, goals, !!order.append); + } + + _ownUnitIds(civ, order) { + const ids = Array.isArray(order.units) ? order.units : [order.unit]; + const units = []; + for (const id of ids) { + const unit = this.state.findUnit(Number(id)); + if (!unit || unit.civ !== civ) continue; + units.push(unit.id); + } + return units; + } + + _handlePillage(civ, order) { + const ids = Array.isArray(order.units) ? order.units : [order.unit]; + const units = []; + for (const id of ids) { + const unit = this.state.findUnit(Number(id)); + if (!unit || unit.civ !== civ) return false; + units.push(unit.id); + } + if (units.length === 0) return false; + return this.state.requestPillage(units); } _handleTrain(civ, order) { @@ -133,6 +175,12 @@ export class GameServer extends EventEmitter { return this.state.requestTrain(city.id, Number(order.proto)); } + _handleCancelTrain(civ, order) { + const city = this.state.findCity(Number(order.city)); + if (!city || city.civ !== civ) return false; + return this.state.cancelTrain(city.id, Number(order.index)); + } + _handleBuild(civ, order) { const city = this.state.findCity(Number(order.city)); if (!city || city.civ !== civ) return false; diff --git a/shared/data.js b/shared/data.js index 652e01b..c0267e9 100644 --- a/shared/data.js +++ b/shared/data.js @@ -1,456 +1,9 @@ // Data-driven content for civilisations, units, buildings, governments and // technologies. The server and the browser both read these plain objects, so // the catalogue text, costs and effects can never disagree between the two. +// +// The catalogue itself now lives in ./data/, split by concern, so each slice is +// small enough to edit comfortably. This file stays as the stable import path: +// keep importing `shared/data.js` and reach for ./data/.js to edit. -export const EFFECT_GDP = "gdp"; -export const EFFECT_RESEARCH = "research"; -export const EFFECT_CULTURE = "culture"; -export const EFFECT_BUDGET_INCOME = "budget_income"; - -// Diplomatic relations between two civilisations. Only peace and war exist for -// now, but the catalogue is data-driven so further statuses (alliances, -// truces, ...) can be slotted in without touching the simulation. -export const RELATION_PEACE = "peace"; -export const RELATION_WAR = "war"; -export const RELATIONS = [RELATION_PEACE, RELATION_WAR]; - -// Kinds of news item shown in the feed. Newsworthy events are recorded in -// GameState with their in-game hour so every client can render them. -export const NEWS_WAR = "war"; -export const NEWS_CAPTURE = "capture"; - -function effect(stat, baseValue, exponent = 1.0) { - return { stat, baseValue, exponent }; -} - -export function effectValue(e, level) { - if (level <= 0) return 0; - return e.baseValue * Math.pow(level, e.exponent); -} - -export const CIVILISATIONS = [ - { - id: "france", - name: "France", - demonym: "French", - primaryColour: "#0d0d5c", - accentColour: "#8c8c82", - flag: "flag_france.png", - cityNames: ["Lyon", "Marseille", "Toulouse", "Nice", "Nantes", "Montpellier", "Strasbourg", "Bordeaux", "Lille"], - }, - { - id: "britain", - name: "Britain", - demonym: "British", - primaryColour: "#a81818", - accentColour: "#0722ff", - flag: "flag_britain.png", - cityNames: ["Birmingham", "Manchester", "Glasgow", "Liverpool", "Leeds", "Sheffield", "Edinburgh", "Bristol", "Cardiff"], - }, - { - id: "slovenia", - name: "Slovenia", - demonym: "Slovenian", - primaryColour: "#005da4", - accentColour: "#ed1c24", - flag: "flag_slovenia.png", - cityNames: ["Ljubljana", "Maribor", "Celje", "Kranj", "Koper", "Velenje", "Novo Mesto", "Ptuj", "Kamnik", "Jesenice"], - }, - { - id: "poland", - name: "Poland", - demonym: "Polish", - primaryColour: "#a6000b", - accentColour: "#ffffff", - flag: "flag_poland.png", - cityNames: ["Warsaw", "Kraków", "Łódź", "Wrocław", "Poznań", "Gdańsk", "Szczecin", "Bydgoszcz", "Lublin", "Katowice"], - }, - { - id: "switzerland", - name: "Switzerland", - demonym: "Swiss", - primaryColour: "#da291c", - accentColour: "#ffffff", - flag: "flag_switzerland.png", - cityNames: ["Bern", "Zürich", "Geneva", "Basel", "Lausanne", "Lucerne", "Winterthur", "St. Gallen", "Lugano", "Biel"], - }, - { - id: "ticino", - name: "Ticino", - demonym: "Ticinese", - primaryColour: "#4646e0", - accentColour: "#a6000b", - flag: "flag_ticino.png", - cityNames: ["Bellinzona", "Lugano", "Locarno", "Mendrisio", "Chiasso", "Airolo", "Biasca", "Ascona", "Faido", "Giubiasco"], - }, - { - id: "galicia", - name: "Galicia", - demonym: "Galician", - primaryColour: "#006dad", - accentColour: "#ffffff", - flag: "flag_galicia.png", - cityNames: ["Santiago de Compostela", "Vigo", "A Coruña", "Ourense", "Lugo", "Pontevedra", "Ferrol", "Vilagarcía de Arousa", "Ribeira", "Redondela"], - }, - { - id: "germany", - name: "Germany", - demonym: "German", - primaryColour: "#141414", - accentColour: "#ffce00", - flag: "flag_germany.png", - cityNames: ["Berlin", "Hamburg", "Munich", "Cologne", "Frankfurt", "Stuttgart", "Düsseldorf", "Leipzig", "Dortmund", "Essen"], - }, - { - id: "iran", - name: "Iran", - demonym: "Iranian", - primaryColour: "#18a645", - accentColour: "#f01c21", - flag: "flag_iran.png", - cityNames: ["Tehran", "Mashhad", "Isfahan", "Karaj", "Shiraz", "Tabriz", "Qom", "Ahvaz", "Kermanshah", "Urmia"], - }, -]; - -export const PROTO_UNITS = [ - { - id: "modern_infantry", - name: "Modern infantry", - maxHp: 500, - cost: 100_000_000, - upkeep: 5000, - moveable: true, - speed: 0.5, - // Only military units fight, capture cities and may enter an enemy - // military unit's tile. `attack` is the damage dealt per combat round and - // `defense` is the damage absorbed per round; a defender's defense is - // doubled (the 100% defensive bonus). `range` is how many tiles away the - // unit can strike (1 is melee). - military: true, - attack: 100, - defense: 40, - range: 1, - traversableTerrains: ["Land"], - icon: "icon_modern_infantry.svg", - }, - { - id: "worker", - name: "Worker", - maxHp: 100, - cost: 0, - upkeep: 0, - moveable: true, - speed: 0.5, - military: false, - attack: 0, - defense: 0, - range: 1, - traversableTerrains: ["Land"], - icon: "icon_settler.svg", - }, - { - id: "artillery", - name: "Artillery", - maxHp: 400, - cost: 300_000_000, - upkeep: 12_000, - moveable: true, - speed: 0.25, - military: true, - attack: 250, - // Barely armoured: nearly every hit gets through. - defense: 5, - // Outranges infantry, so melee units cannot strike back from two tiles. - range: 2, - traversableTerrains: ["Land"], - icon: "icon_artillery.svg", - }, -]; - -// Only units in this list can be trained from the city screen, matching -// GameState.TRAINABLE_UNITS. -export const TRAINABLE_UNIT_IDS = ["modern_infantry", "artillery"]; - -// Status effects a unit or city can carry. They are data-driven so new ones -// can be added without touching the simulation or the renderer: the server -// derives the active ids, applies their fields, and ships the ids in -// snapshots; the browser draws one icon per id. A status may define -// `hpLossPerDay` (a fraction of maximum hit points taken each day), a -// `cityHpLossPerDay` override for when the unit stands in a city, and an -// `upkeepMultiplier` applied to the unit's upkeep while it is active. -export const STATUS_ENCIRCLED = "encircled"; -export const STATUS_BESIEGED = "besieged"; -export const STATUS_BATTLE = "battle"; - -export const STATUSES = [ - { - id: STATUS_ENCIRCLED, - label: "Encircled", - icon: "encirclement.svg", - description: "Cut off from every allied city while a hostile unit is adjacent. Loses 10% of its maximum hit points each day, or 2% inside a city.", - hpLossPerDay: 0.10, - cityHpLossPerDay: 0.02, - }, - { - id: STATUS_BESIEGED, - label: "Besieged", - icon: "encirclement.svg", - description: "Cut off from every other allied city while a hostile unit is adjacent.", - }, - { - id: STATUS_BATTLE, - label: "In battle", - icon: "icon_battle.svg", - description: "Fighting an enemy unit in range. Upkeep is tripled while the battle rages.", - upkeepMultiplier: 3, - }, -]; - -export function statusById(id) { - return STATUSES.find((status) => status.id === id) || null; -} - -export const BUILDINGS = [ - { - id: "market", - name: "Market", - description: "A hub of local trade. Raises national GDP, with diminishing returns.", - maxLevel: 10, - baseCost: 0, - costGdpFraction: 0.01, - costAlpha: 1.6, - baseUpkeep: 0, - upkeepGdpFraction: 0.00003, - upkeepAlpha: 1.5, - refundFraction: 0.5, - effects: [effect(EFFECT_GDP, 0.04, 0.75)], - }, - { - id: "bank", - name: "Bank", - description: "Channels private wealth into the state coffers. Raises budget income.", - maxLevel: 10, - baseCost: 0, - costGdpFraction: 0.02, - costAlpha: 1.7, - baseUpkeep: 0, - upkeepGdpFraction: 0.00004, - upkeepAlpha: 1.6, - refundFraction: 0.5, - effects: [effect(EFFECT_BUDGET_INCOME, 0.05, 0.7)], - }, - { - id: "research_lab", - name: "Research lab", - description: "Applies science to industry and government. Produces research points.", - maxLevel: 10, - baseCost: 0, - costGdpFraction: 0.015, - costAlpha: 1.65, - baseUpkeep: 0, - upkeepGdpFraction: 0.00005, - upkeepAlpha: 1.55, - refundFraction: 0.5, - effects: [effect(EFFECT_RESEARCH, 1.0, 0.9)], - }, - { - id: "university", - name: "University", - description: "Higher education produces research and a little culture.", - maxLevel: 10, - baseCost: 0, - costGdpFraction: 0.025, - costAlpha: 1.7, - baseUpkeep: 0, - upkeepGdpFraction: 0.00008, - upkeepAlpha: 1.6, - refundFraction: 0.5, - effects: [effect(EFFECT_RESEARCH, 2.0, 0.8), effect(EFFECT_CULTURE, 0.5, 0.7)], - }, - { - id: "museum", - name: "Museum", - description: "Preserves national heritage, strengthening culture against foreign ideas.", - maxLevel: 10, - baseCost: 0, - costGdpFraction: 0.02, - costAlpha: 1.6, - baseUpkeep: 0, - upkeepGdpFraction: 0.00005, - upkeepAlpha: 1.55, - refundFraction: 0.5, - effects: [effect(EFFECT_CULTURE, 1.5, 0.85)], - }, - { - id: "cinema_studio", - name: "Cinema studio", - description: "Mass media spreads the nation's culture at home and abroad.", - maxLevel: 10, - baseCost: 0, - costGdpFraction: 0.03, - costAlpha: 1.75, - baseUpkeep: 0, - upkeepGdpFraction: 0.0001, - upkeepAlpha: 1.65, - refundFraction: 0.5, - effects: [effect(EFFECT_CULTURE, 3.0, 0.7)], - }, -]; - -export const GOVERNMENTS = [ - { - id: "republic", - name: "Republic", - description: "Rule by elected representatives. A balanced government that spreads its support across the economy, the treasury and the sciences.", - baseCost: 0, - costGdpFraction: 0.02, - effects: [effect(EFFECT_GDP, 0.05, 1.0), effect(EFFECT_BUDGET_INCOME, 0.1, 1.0), effect(EFFECT_RESEARCH, 1.0, 1.0)], - }, - { - id: "monarchy", - name: "Monarchy", - description: "Rule by a hereditary sovereign. The crown favours commerce and tradition, lifting the economy and the nation's cultural output.", - baseCost: 0, - costGdpFraction: 0.03, - effects: [effect(EFFECT_GDP, 0.1, 1.0), effect(EFFECT_CULTURE, 1.0, 1.0)], - }, - { - id: "technocracy", - name: "Technocracy", - description: "Rule by technical experts. A single-minded drive for progress pours resources into research while economic growth takes second place.", - baseCost: 0, - costGdpFraction: 0.04, - effects: [effect(EFFECT_RESEARCH, 3.0, 1.0), effect(EFFECT_GDP, 0.03, 1.0)], - }, -]; - -export const TECHNOLOGIES = [ - { - id: "scientific_method", - name: "Scientific Method", - description: "A disciplined approach to experiment and evidence. Lays the groundwork for every later technology.", - cost: 20, - prerequisites: [], - effects: [effect(EFFECT_RESEARCH, 1.0, 1.0), effect(EFFECT_GDP, 0.02, 1.0)], - }, - { - id: "industrial_automation", - name: "Industrial Automation", - description: "Robotics and control systems take over the factory floor, lifting national output.", - cost: 60, - prerequisites: ["scientific_method"], - effects: [effect(EFFECT_GDP, 0.08, 1.0)], - }, - { - id: "renewable_energy", - name: "Renewable Energy", - description: "Clean generation cuts waste and strengthens the public finances.", - cost: 80, - prerequisites: ["scientific_method"], - effects: [effect(EFFECT_GDP, 0.03, 1.0), effect(EFFECT_BUDGET_INCOME, 0.05, 1.0)], - }, - { - id: "quantum_computing", - name: "Quantum Computing", - description: "Machines that reason in superposition. A step change in the pace of discovery.", - cost: 120, - prerequisites: ["scientific_method"], - effects: [effect(EFFECT_RESEARCH, 4.0, 1.0)], - }, - { - id: "space_program", - name: "Space Program", - description: "Reaching orbit inspires the nation and spins off technology across the economy.", - cost: 300, - prerequisites: ["industrial_automation", "quantum_computing"], - effects: [effect(EFFECT_CULTURE, 3.0, 1.0), effect(EFFECT_GDP, 0.05, 1.0)], - }, -]; - -export const ECONOMY = { - baseGdpPerCapita: 10_000, - basePopulation: 100_000, - cityTileGdpMultiplier: 2.0, - capitalTileGdpMultiplier: 2.5, - citySurroundingGdpBonus: 0.3, - gdpPerProductionPoint: 100_000, - budgetGdpRate: 0.001, - startingBudget: 0, - basePopulationGrowthRate: 0.005, - - productionCapacity(gdp) { - if (this.gdpPerProductionPoint <= 0) return 0; - return gdp / this.gdpPerProductionPoint; - }, - - trainingHours(gdp, cost) { - const capacity = this.productionCapacity(gdp); - if (capacity <= 0) return 0; - return cost / capacity; - }, - - budgetIncome(gdp, hours = 1) { - return gdp * this.budgetGdpRate * hours; - }, - - populationGrowthFactor(hoursPerYear, hours = 1) { - if (this.basePopulationGrowthRate <= 0 || hoursPerYear <= 0 || hours <= 0) return 0; - return Math.pow(1 + this.basePopulationGrowthRate, hours / hoursPerYear) - 1; - }, -}; - -export const MAP_CONFIG = { - mapSize: { x: 64, y: 40 }, - topology: "cylindrical", - terrainSeed: 0, - seaLevel: -0.2, - falloffEnabled: true, - falloffStart: 0.55, - falloffStrength: 0.6, - iceLatitude: 0.9, - noise: { - frequency: 0.06, - octaves: 4, - lacunarity: 2.0, - gain: 0.5, - }, - capitalMargin: 2, - minCapitalDistance: 8, - minCapitalLandmass: 40, - citiesPerCiv: 10, - citySpacing: 8, -}; - -// The terrain TileSet, flattened. `col` is the atlas column in terrain.png -// (one row, 7 tiles of 63x56); the class decides land/sea/ice and the -// minElevation bands place land tiles. `defenseBonus` is the fraction added to -// a unit's defense while it stands on the tile. -export const TERRAIN_TILES = [ - { col: 0, terrainType: "Forest", terrainClass: "Land", populationMultiplier: 0.3, gdpMultiplier: 0.5, movementCostMultiplier: 1.5, defenseBonus: 0.33, minElevation: 0.06 }, - { col: 1, terrainType: "Hills", terrainClass: "Land", populationMultiplier: 0.7, gdpMultiplier: 0.8, movementCostMultiplier: 1.8, defenseBonus: 0.33, minElevation: 0.2 }, - { col: 2, terrainType: "Ice", terrainClass: "Ice", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 4.0, defenseBonus: 0, minElevation: 0 }, - { col: 3, terrainType: "Land", terrainClass: "Land", populationMultiplier: 1.0, gdpMultiplier: 1.0, movementCostMultiplier: 1.0, defenseBonus: 0, minElevation: -0.08 }, - { col: 4, terrainType: "Land", terrainClass: "Land", populationMultiplier: 0.05, gdpMultiplier: 0.2, movementCostMultiplier: 3.0, defenseBonus: 0, minElevation: -0.2 }, - { col: 5, terrainType: "Sea", terrainClass: "Sea", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 0, defenseBonus: 0, minElevation: 0 }, - { col: 6, terrainType: "Mountain", terrainClass: "Land", populationMultiplier: 0.1, gdpMultiplier: 0.8, movementCostMultiplier: 5.0, defenseBonus: 1.0, minElevation: 0.34 }, -]; - -// A city standing on a tile adds this fraction on top of the terrain's own -// defensive bonus. -export const CITY_DEFENSE_BONUS = 0.5; - -export function protoUnitById(id) { - return PROTO_UNITS.find((u) => u.id === id) || null; -} - -export function buildingById(id) { - return BUILDINGS.find((b) => b.id === id) || null; -} - -export function governmentById(id) { - return GOVERNMENTS.find((g) => g.id === id) || null; -} - -export function technologyById(id) { - return TECHNOLOGIES.find((t) => t.id === id) || null; -} +export * from "./data/index.js"; diff --git a/shared/game_state.js b/shared/game_state.js index ee9c369..677048f 100644 --- a/shared/game_state.js +++ b/shared/game_state.js @@ -1,52 +1,63 @@ -// Authoritative game model, ported from scripts/server/game_state.gd. It owns -// the generated world, cities, units, territory, population, the economy and -// training queues and is completely free of any rendering or transport -// concerns, so the Node server can own it and both sides can share the data -// catalogue. Clients never run this class against a live game; they render -// snapshots of it and send orders back. +// Authoritative game model. It owns the generated world, cities, units, +// territory, population, the economy, combat and diplomacy, and is completely +// free of any rendering or transport concerns, so the Node server can own it +// and both sides can share the data catalogue. Clients never run this class +// against a live game; they render snapshots of it and send orders back. +// +// The behaviour is split across the modules in ./game_state/ and composed onto +// the prototype below. Everything still lives on the one GameState object, so +// methods can freely call each other through `this`; the split is purely about +// keeping each concern in a file small enough to read. import { MapTopology, key } from "./hex.js"; import { MapGenerator } from "./map_generator.js"; import { TerrainStats } from "./terrain_stats.js"; import { HexPathfinder } from "./hex_pathfinder.js"; -import { Random, randomSeed } from "./rng.js"; +import { randomSeed } from "./rng.js"; import { - ECONOMY, - MAP_CONFIG, PROTO_UNITS, BUILDINGS, GOVERNMENTS, TECHNOLOGIES, - EFFECT_GDP, - EFFECT_BUDGET_INCOME, - EFFECT_RESEARCH, - EFFECT_CULTURE, - STATUS_ENCIRCLED, - STATUS_BESIEGED, - STATUS_BATTLE, - RELATION_PEACE, - RELATION_WAR, - NEWS_WAR, - NEWS_CAPTURE, - statusById, + MAP_CONFIG, } from "./data.js"; -import { buildingBuildCost, buildingRefund, buildingUpkeep, prerequisitesMet, defenseMultiplier } from "./rules.js"; -import { dateString } from "./game_clock.js"; +import { + MAX_NEWS, + UNIT_VISION, + TERRITORY_VISION, + HOURS_PER_DAY, + DAYS_PER_YEAR, + HOURS_PER_YEAR, + CONTROLLED_SPEED_MULTIPLIER, + DEFAULT_UNIT_SPEED, + MIN_STEP_COST, + TRAINING_QUEUE_LIMIT, + DEFENDER_DEFENSE_MULTIPLIER, +} from "./game_state/constants.js"; +import { worldMethods } from "./game_state/world.js"; +import { territoryMethods } from "./game_state/territory.js"; +import { economyMethods } from "./game_state/economy.js"; +import { movementMethods } from "./game_state/movement.js"; +import { combatMethods } from "./game_state/combat.js"; +import { statusMethods } from "./game_state/statuses.js"; +import { diplomacyMethods } from "./game_state/diplomacy.js"; +import { orderMethods } from "./game_state/orders.js"; +import { visibilityMethods } from "./game_state/visibility.js"; +import { serializationMethods } from "./game_state/serialization.js"; -// The news feed keeps the most recent entries and drops the oldest ones, so a -// long-running game does not grow the snapshot without bound. -export const MAX_NEWS = 100; - -export const UNIT_VISION = 2; -export const TERRITORY_VISION = 1; -export const HOURS_PER_DAY = 24; -export const DAYS_PER_YEAR = 365; -export const HOURS_PER_YEAR = HOURS_PER_DAY * DAYS_PER_YEAR; -export const CONTROLLED_SPEED_MULTIPLIER = 1.5; -export const DEFAULT_UNIT_SPEED = 0.5; -export const MIN_STEP_COST = 0.0001; -// A defending unit's defense is doubled: a 100% defensive bonus. -export const DEFENDER_DEFENSE_MULTIPLIER = 2.0; +export { + MAX_NEWS, + UNIT_VISION, + TERRITORY_VISION, + HOURS_PER_DAY, + DAYS_PER_YEAR, + HOURS_PER_YEAR, + CONTROLLED_SPEED_MULTIPLIER, + DEFAULT_UNIT_SPEED, + MIN_STEP_COST, + TRAINING_QUEUE_LIMIT, + DEFENDER_DEFENSE_MULTIPLIER, +}; export class GameState { constructor() { @@ -67,6 +78,12 @@ export class GameState { this.units = []; this.territory = new Map(); this.tilePopulation = new Map(); + // Per-tile GDP-per-capita multiplier, lowered by pillaging (default 1). + this.tileGdpPenalty = new Map(); + // Absolute GDP-per-capita lost to fighting on a tile, and the hour its + // recovery may begin. A tile leaves both maps once it has recovered. + this.tileBattleGdpDeficit = new Map(); + this.tileBattleRecoveryStart = new Map(); this.budgets = new Map(); this.training = new Map(); this.research = new Map(); @@ -78,6 +95,9 @@ export class GameState { // Diplomatic relations, stored as a set of canonical "a:b" keys of pairs // at war. Every other pair is at peace by default. this.wars = new Set(); + // One record per war, keyed like `wars`: when it started and the military + // and civilian casualties each side has suffered so far. + this.conflicts = new Map(); this.news = []; this.totalHours = 0; @@ -91,12 +111,17 @@ export class GameState { this._nextCityId = 1; this._nextNewsId = 1; this._cityNamesUsed = new Map(); + this._usedCityNames = new Set(); this._unitById = new Map(); this._cityById = new Map(); this._cityByCoords = new Map(); this._cityAdjacent = new Map(); this._territoryByCiv = new Map(); this._gdpPerCapitaCache = new Map(); + // Per-civ factors that map generation picked so every nation starts with + // the same population and GDP per capita (see _balanceStartingEconomy). + this._populationBalance = new Map(); + this._perCapitaBalance = new Map(); this._serializedTerritory = []; this._serializedExplored = new Map(); this._neighbourCache = new Map(); @@ -136,6 +161,9 @@ export class GameState { this.units = []; this.territory = new Map(); this.tilePopulation = new Map(); + this.tileGdpPenalty = new Map(); + this.tileBattleGdpDeficit = new Map(); + this.tileBattleRecoveryStart = new Map(); this.budgets = new Map(); this.training = new Map(); this.research = new Map(); @@ -143,6 +171,7 @@ export class GameState { this.government = new Map(); this.researched = new Map(); this.wars = new Set(); + this.conflicts = new Map(); this.news = []; this._nextNewsId = 1; this._nextUnitId = 1; @@ -150,10 +179,13 @@ export class GameState { this._unitById = new Map(); this._cityById = new Map(); this._cityNamesUsed = new Map(); + this._usedCityNames = new Set(); this._cityByCoords = new Map(); this._cityAdjacent = new Map(); this._territoryByCiv = new Map(); this._gdpPerCapitaCache = new Map(); + this._populationBalance = new Map(); + this._perCapitaBalance = new Map(); this._serializedTerritory = []; this._serializedExplored = new Map(); this._neighbourCache = new Map(); @@ -176,602 +208,7 @@ export class GameState { this._emitChanged(); } - requestTrain(cityId, protoIndex) { - const city = this.findCity(cityId); - if (!city || this.training.has(cityId)) return false; - if (protoIndex < 0 || protoIndex >= this.protoUnits.length) return false; - const proto = this.protoUnits[protoIndex]; - const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(city.civ)); - if (capacity <= 0 || proto.cost <= 0) { - this._spawnUnit(city.coords, city.civ, proto); - this._visibilityDirty = true; - this._emitChanged(); - return true; - } - this.training.set(cityId, { - protoIndex, - capacity, - elapsedHours: 0, - totalHours: proto.cost / capacity, - }); - this._emitChanged(); - return true; - } - - requestBuild(cityId, protoIndex) { - const city = this.findCity(cityId); - if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false; - const proto = this.protoBuildings[protoIndex]; - const civ = city.civ; - const level = this.getCityBuildingLevel(city, protoIndex); - if (level >= proto.maxLevel) return false; - const gdp = this.getPlayerGdp(civ); - const cost = buildingBuildCost(proto, gdp, level); - 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; - this._emitChanged(); - return true; - } - - requestDemolish(cityId, protoIndex) { - const city = this.findCity(cityId); - if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false; - const proto = this.protoBuildings[protoIndex]; - const civ = city.civ; - const level = this.getCityBuildingLevel(city, protoIndex); - if (level <= 0) return false; - const refund = buildingRefund(proto, this.getPlayerGdp(civ), level); - 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._emitChanged(); - return true; - } - - requestSetGovernment(civ, index) { - if (civ < 0 || civ >= this.civilisations.length) return false; - if (index < 0 || index >= this.governments.length) return false; - if (this.getGovernment(civ) === index) return false; - const cost = GOVERNMENTS[index].baseCost + - GOVERNMENTS[index].costGdpFraction * Math.max(this.getPlayerGdp(civ), 0); - 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._emitChanged(); - return true; - } - - requestResearchTechnology(civ, index) { - if (!this.canResearchTechnology(civ, index)) return false; - const technology = this.technologies[index]; - if (this.getResearch(civ) < technology.cost) return false; - this.research.set(civ, this.getResearch(civ) - technology.cost); - this.researched.get(civ).add(index); - this._emitChanged(); - return true; - } - - // ---------------------------------------------------------- diplomacy -- - - isAtWar(a, b) { - if (a < 0 || b < 0 || a === b) return false; - return this.wars.has(warKey(a, b)); - } - - getRelation(a, b) { - return this.isAtWar(a, b) ? RELATION_WAR : RELATION_PEACE; - } - - // Declares war from `civ` on `target`. Fails for unknown or identical - // civilisations and when the two are already at war. The declaration is - // newsworthy and reaches every player through the next snapshot. - requestDeclareWar(civ, target) { - if (!this._validCiv(civ) || !this._validCiv(target) || civ === target) return false; - if (!this._declareWar(civ, target)) return false; - this._emitChanged(); - return true; - } - - _validCiv(civ) { - return Number.isInteger(civ) && civ >= 0 && civ < this.civilisations.length; - } - - // Records the war and its news item without emitting; the caller decides when - // to publish. Returns false when the pair is already at war. - _declareWar(actor, target) { - if (actor === target) return false; - const k = warKey(actor, target); - if (this.wars.has(k)) return false; - this.wars.add(k); - this._addNews({ type: NEWS_WAR, actor, target }); - return true; - } - - getNews() { - return this.news; - } - - _addNews(entry) { - const record = { - id: this._nextNewsId, - totalHours: this.totalHours, - date: dateString(this.totalHours), - ...entry, - }; - this._nextNewsId += 1; - this.news.push(record); - while (this.news.length > MAX_NEWS) this.news.shift(); - return record; - } - - requestMove(unitId, goal) { - const unit = this.findUnit(unitId); - if (!unit) return false; - const proto = this.unitProto(unit); - if (!proto || !proto.moveable) return false; - // Ordering a move onto an enemy military unit is an attack: remember the - // target so the unit stops as soon as it is within range instead of walking - // all the way into it. - const target = this.unitAt(goal); - const attacking = !!target && target !== unit && target.civ !== unit.civ && - this._isMilitary(unit) && this._isMilitary(target); - unit.attackTarget = attacking ? target.id : null; - const path = this.findPath(unit, unit.coords, goal); - if (path.length < 2) { - unit.attackTarget = null; - return false; - } - // Marching a military unit into, or attacking, a country we are at peace - // with declares war on it. The browser confirms the move first, but the - // server enforces the rule so the two can never disagree. - if (this._isMilitary(unit)) { - if (attacking) this._declareWar(unit.civ, target.civ); - for (const coords of path) { - const owner = this.civAt(coords); - if (owner >= 0 && owner !== unit.civ) this._declareWar(unit.civ, owner); - } - } - unit.path = path; - unit.pathIndex = 0; - unit.progressHours = 0; - this._emitChanged(); - return true; - } - - advanceHour() { - this.advanceMovement(1.0); - this.tickHour(); - } - - advanceMovement(hours) { - if (hours <= 0) return; - // Units in contact are locked in place until the daily combat round - // resolves, so neither side can slip away mid-fight. - const engaged = this._engagedUnits(); - for (const unit of this.units) { - if (!unit.path || unit.path.length === 0) continue; - if (engaged.has(unit)) continue; - let hoursLeft = hours; - while (hoursLeft > 0 && unit.pathIndex < unit.path.length - 1) { - // Stop as soon as the target is in range: a ranged unit holds its - // distance rather than closing to melee. - if (this._combatTarget(unit)) break; - const next = unit.path[unit.pathIndex + 1]; - const occupant = this.unitAt(next); - if (occupant && occupant !== unit) { - // A hostile military unit in the way is the fight we are marching - // into: hold position until it dies rather than treating the tile as - // an obstacle. Anyone else is an obstacle; route around it, and when - // the destination has become unreachable, abandon the move instead of - // stalling on the spot forever. - if (this._isBlockingEnemy(unit, occupant)) break; - if (this._repathAround(unit)) { - const ahead = this.unitAt(unit.path[unit.pathIndex + 1]); - if (ahead && ahead !== unit) break; - continue; - } - this._stopUnit(unit); - break; - } - const cost = Math.max(this._tileTravelHours(unit, next), MIN_STEP_COST); - const needed = cost - unit.progressHours; - if (hoursLeft >= needed) { - hoursLeft -= needed; - unit.pathIndex += 1; - unit.progressHours = 0; - unit.coords = next; - this._visibilityDirty = true; - this._onUnitArrived(unit); - } else { - unit.progressHours += hoursLeft; - hoursLeft = 0; - } - } - if (unit.path && unit.pathIndex >= unit.path.length - 1) { - this._stopUnit(unit); - } - } - } - - // A hostile military unit standing on the next tile is an opponent to fight, - // not an obstacle to route around. - _isBlockingEnemy(unit, occupant) { - return this._isMilitary(unit) && this._isMilitary(occupant) && occupant.civ !== unit.civ; - } - - // Recomputes a route from where the unit stands to the destination it was - // already heading for. Returns false when no route exists any more. - _repathAround(unit) { - if (!unit.path || unit.path.length < 2) return false; - const goal = unit.path[unit.path.length - 1]; - const path = this.findPath(unit, unit.coords, goal); - if (path.length < 2) return false; - unit.path = path; - unit.pathIndex = 0; - unit.progressHours = 0; - return true; - } - - // Abandons every movement for the unit: no route, no attack, no partial step. - _stopUnit(unit) { - unit.path = []; - unit.pathIndex = 0; - unit.progressHours = 0; - unit.attackTarget = null; - } - - _engagedUnits() { - const engaged = new Set(); - for (const { attacker, defender } of this._combatPairs()) { - engaged.add(attacker); - // A defender that cannot strike back (an out-ranged melee unit) is free - // to keep moving; one that is in reach is locked in the exchange. - if (this._canReach(defender, attacker)) engaged.add(defender); - } - return engaged; - } - - _canReach(unit, target) { - return this.topology.tileDistance(unit.coords, target.coords) <= this._unitRange(unit); - } - - // Unique attacker/defender pairs, so two units that ordered moves into each - // other fight one round rather than two. The lower id is the attacker. - _combatPairs() { - const pairs = new Map(); - for (const unit of this.units) { - const defender = this._combatTarget(unit); - if (!defender) continue; - const pairKey = unit.id < defender.id - ? `${unit.id}:${defender.id}` - : `${defender.id}:${unit.id}`; - if (!pairs.has(pairKey)) pairs.set(pairKey, { attacker: unit, defender }); - } - return Array.from(pairs.values()); - } - - // The enemy military unit the given unit is attacking, or null. A unit - // attacks the target it was ordered onto, falling back to whatever occupies - // the next step of its path; either way the target must be within range. - _combatTarget(unit) { - if (!this._isMilitary(unit)) return null; - let target = unit.attackTarget ? this.findUnit(unit.attackTarget) : null; - if (!target) { - const next = this._nextStepTile(unit); - if (!next) return null; - target = this.unitAt(next); - } - if (!target || target === unit) return null; - if (target.civ === unit.civ || !this._isMilitary(target)) return null; - if (this.topology.tileDistance(unit.coords, target.coords) > this._unitRange(unit)) return null; - return target; - } - - _unitRange(unit) { - const proto = this.unitProto(unit); - return proto && typeof proto.range === "number" ? proto.range : 1; - } - - // Recomputes the status of every unit and city. The supply search only runs - // for an occupant that has a hostile unit adjacent, and only once a day (and - // at setup), so it never touches the snapshot path. - _refreshStatuses() { - const components = new Map(); - for (const unit of this.units) { - if (!this._hasAdjacentHostile(unit.coords, unit.civ)) { - unit.statuses = []; - continue; - } - unit.statuses = this._unitStatuses(unit, this._componentsFor(components, unit.civ)); - } - for (const city of this.cities) { - if (!this._hasAdjacentHostile(city.coords, city.civ)) { - city.statuses = []; - continue; - } - city.statuses = this._cityStatuses(city, this._componentsFor(components, city.civ)); - } - } - - // The trigger: encirclement is only evaluated when a hostile unit is next to - // the tile. - _hasAdjacentHostile(coords, civ) { - for (const neighbour of this._neighbours(coords)) { - const occupant = this.unitAt(neighbour); - if (occupant && occupant.civ !== civ) return true; - } - return false; - } - - _componentsFor(cache, civ) { - if (!cache.has(civ)) cache.set(civ, this._landComponents(civ)); - return cache.get(civ); - } - - _isNaval(unit) { - const proto = this.unitProto(unit); - return !!(proto && proto.traversableTerrains.includes("Sea")); - } - - // Land connected components for one civ, with enemy-occupied and enemy-held - // city tiles acting as walls. Each component remembers how many of the civ's - // cities it contains, which is all the supply check needs. - _landComponents(civ) { - const byTile = new Map(); - const cityCountByComp = new Map(); - const totalCities = this.cities.filter((city) => city.civ === civ).length; - let component = 0; - for (const start of this.landCells) { - const startKey = key(start.x, start.y); - if (byTile.has(startKey) || !this._landPassable(start, civ)) continue; - const id = component++; - byTile.set(startKey, id); - let cities = 0; - let frontier = [start]; - while (frontier.length > 0) { - const next = []; - for (const coords of frontier) { - const city = this.cityAt(coords); - if (city && city.civ === civ) cities += 1; - for (const neighbour of this._neighbours(coords)) { - const nk = key(neighbour.x, neighbour.y); - if (byTile.has(nk) || !this._landPassable(neighbour, civ)) continue; - byTile.set(nk, id); - next.push(neighbour); - } - } - frontier = next; - } - cityCountByComp.set(id, cities); - } - return { byTile, cityCountByComp, totalCities }; - } - - _landPassable(coords, civ) { - const tile = this.tiles[key(coords.x, coords.y)]; - if (!tile || tile.terrainClass !== "Land") return false; - const occupant = this.unitAt(coords); - if (occupant && occupant.civ !== civ) return false; - const city = this.cityAt(coords); - if (city && city.civ !== civ) return false; - return true; - } - - // A city is besieged when it no longer shares a land component with any - // other city of its civ. A lone city is never besieged. - _isCityBesieged(city, info) { - if (info.totalCities < 2) return false; - const component = info.byTile.get(key(city.coords.x, city.coords.y)); - const count = component === undefined ? 0 : (info.cityCountByComp.get(component) || 0); - return count <= 1; - } - - _unitStatuses(unit, info) { - const city = this.cityAt(unit.coords); - const inOwnCity = city && city.civ === unit.civ; - let encircled; - if (inOwnCity) { - encircled = this._isCityBesieged(city, info); - } else { - const component = info.byTile.get(key(unit.coords.x, unit.coords.y)); - const count = component === undefined ? 0 : (info.cityCountByComp.get(component) || 0); - encircled = count === 0; - } - const excludeCity = inOwnCity ? city.id : 0; - if (encircled && this._hasMaritimeRoute(unit.coords, unit.civ, excludeCity)) { - encircled = false; - } - return encircled ? [STATUS_ENCIRCLED] : []; - } - - _cityStatuses(city, info) { - const besieged = this._isCityBesieged(city, info) && - !this._hasMaritimeRoute(city.coords, city.civ, city.id); - return besieged ? [STATUS_BESIEGED] : []; - } - - // The maritime exception: with an allied naval unit on an adjacent sea tile, - // a lane is traced across water to a different coastal allied city. Enemy - // units on the water block it. - _hasMaritimeRoute(origin, civ, excludeCityId) { - const visited = new Set(); - let frontier = []; - for (const neighbour of this._neighbours(origin)) { - const tile = this.tiles[key(neighbour.x, neighbour.y)]; - if (!tile || tile.terrainClass !== "Sea") continue; - const occupant = this.unitAt(neighbour); - if (!occupant || occupant.civ !== civ || !this._isNaval(occupant)) continue; - const nk = key(neighbour.x, neighbour.y); - if (visited.has(nk)) continue; - visited.add(nk); - frontier.push(neighbour); - } - while (frontier.length > 0) { - const next = []; - for (const coords of frontier) { - for (const neighbour of this._neighbours(coords)) { - const city = this.cityAt(neighbour); - if (city && city.civ === civ && city.id !== excludeCityId) return true; - } - for (const neighbour of this._neighbours(coords)) { - const tile = this.tiles[key(neighbour.x, neighbour.y)]; - if (!tile || tile.terrainClass !== "Sea") continue; - const nk = key(neighbour.x, neighbour.y); - if (visited.has(nk)) continue; - const occupant = this.unitAt(neighbour); - if (occupant && occupant.civ !== civ) continue; - visited.add(nk); - next.push(neighbour); - } - } - frontier = next; - } - return false; - } - - _statusHpLossPerDay(def, inCity) { - if (inCity && typeof def.cityHpLossPerDay === "number") return def.cityHpLossPerDay; - return typeof def.hpLossPerDay === "number" ? def.hpLossPerDay : 0; - } - - // The ids of every unit currently trading blows, on either side of the - // exchange. Out-ranged defenders cannot strike back but are still in battle. - _battleUnitIds() { - const ids = new Set(); - for (const { attacker, defender } of this._combatPairs()) { - ids.add(attacker.id); - ids.add(defender.id); - } - return ids; - } - - // The statuses shown for a unit this instant: the supply statuses stored on - // the unit plus "in battle", which is derived from the live combat state so - // it appears and clears with the fighting rather than only on the daily tick. - _effectiveStatuses(unit, inBattle) { - const statuses = unit.statuses || []; - if (!inBattle || statuses.includes(STATUS_BATTLE)) return statuses; - return [...statuses, STATUS_BATTLE]; - } - - // The combined upkeep multiplier of the given statuses. - _statusUpkeepMultiplier(statuses) { - let multiplier = 1; - for (const id of statuses) { - const def = statusById(id); - if (def && typeof def.upkeepMultiplier === "number") multiplier *= def.upkeepMultiplier; - } - return multiplier; - } - - // Applies every active status once per day. The only effect so far is - // attrition, but the loop is generic so new daily fields slot in here. - _applyDailyStatusEffects() { - const battle = this._battleUnitIds(); - const damage = new Map(); - for (const unit of this.units) { - const statuses = this._effectiveStatuses(unit, battle.has(unit.id)); - if (statuses.length === 0) continue; - const inCity = !!this.cityAt(unit.coords); - let fraction = 0; - for (const id of statuses) { - const def = statusById(id); - if (def) fraction += this._statusHpLossPerDay(def, inCity); - } - if (fraction > 0) damage.set(unit, unit.maxHp * fraction); - } - if (damage.size === 0) return; - const destroyed = []; - for (const [unit, amount] of damage) { - unit.hp -= amount; - if (unit.hp <= 0) { - unit.hp = 0; - destroyed.push(unit); - } - } - for (const unit of destroyed) this._destroyUnit(unit); - if (destroyed.length > 0) this._visibilityDirty = true; - this._emitChanged(); - } - - _nextStepTile(unit) { - const path = unit.path; - if (!path || path.length < 2) return null; - if (unit.pathIndex + 1 >= path.length) return null; - return path[unit.pathIndex + 1]; - } - - // One combat round: at most one per day, resolved in `tickHour`. Both sides - // trade blows and damage lands simultaneously, so a mutual kill destroys both - // units. - _resolveCombatRound() { - const damage = new Map(); - for (const { attacker, defender } of this._combatPairs()) { - const distance = this.topology.tileDistance(attacker.coords, defender.coords); - const toDefender = Math.max( - 1, - this._unitAttack(attacker) - this._effectiveDefense(defender, true) - ); - damage.set(defender, (damage.get(defender) || 0) + toDefender); - // Only return fire when the attacker is inside the defender's own range, - // so an out-ranged melee unit cannot reach an artillery piece. - if (distance <= this._unitRange(defender)) { - const toAttacker = Math.max( - 1, - this._unitAttack(defender) - this._effectiveDefense(attacker, false) - ); - damage.set(attacker, (damage.get(attacker) || 0) + toAttacker); - } - } - if (damage.size === 0) return; - const destroyed = []; - for (const [unit, amount] of damage) { - unit.hp -= amount; - if (unit.hp <= 0) { - unit.hp = 0; - destroyed.push(unit); - } - } - for (const unit of destroyed) this._destroyUnit(unit); - if (destroyed.length > 0) { - this._visibilityDirty = true; - this._emitChanged(); - } - } - - _destroyUnit(unit) { - const index = this.units.indexOf(unit); - if (index >= 0) this.units.splice(index, 1); - this._unitById.delete(unit.id); - } - - // Called when a unit finishes stepping onto a tile. A military unit entering - // an enemy city captures it. - _onUnitArrived(unit) { - const city = this._cityByCoords.get(key(unit.coords.x, unit.coords.y)); - if (city && city.civ !== unit.civ && this._isMilitary(unit)) { - this._captureCity(city, unit.civ); - } - } - - tickHour() { - this.totalHours += 1; - this._tickTraining(); - if (this._visibilityDirty) { - this._refreshVisibility(); - this._visibilityDirty = false; - } - if (this.totalHours % HOURS_PER_DAY === 0) { - this._tickPopulation(); - this._tickEconomy(); - this._refreshStatuses(); - this._applyDailyStatusEffects(); - this._resolveCombatRound(); - } - this._tickBuildings(); - this._emitChanged(); - } + // ------------------------------------------------------------- lookups -- findUnit(unitId) { return this._unitById.get(unitId) || null; @@ -791,1006 +228,68 @@ export class GameState { return this._cityByCoords.get(key(coords.x, coords.y)) || null; } + // Every unit standing on `coords`. Friendly units may stack, so a tile can + // hold more than one. + unitsAt(coords) { + const result = []; + for (const unit of this.units) { + if (unit.coords.x === coords.x && unit.coords.y === coords.y) result.push(unit); + } + return result; + } + unitAt(coords) { + return this.unitsAt(coords)[0] || null; + } + + // The hostile units standing on `coords` (when seen by `civ`). + enemyUnitsAt(coords, civ) { + return this.unitsAt(coords).filter((unit) => unit.civ !== civ); + } + + advanceHour() { + this.advanceMovement(1.0); + this.tickHour(); + } + + tickHour() { + this.totalHours += 1; + this._tickTraining(); + this._tickGarrisonHealing(); + if (this._visibilityDirty) { + this._refreshVisibility(); + this._visibilityDirty = false; + } + if (this.totalHours % HOURS_PER_DAY === 0) { + this._tickPopulation(); + this._tickBattleTiles(); + this._tickGdpRecovery(); + this._tickEconomy(); + this._refreshStatuses(); + this._applyDailyStatusEffects(); + this._resolveCombatRound(); + } + this._tickBuildings(); + // A unit no longer sharing a tile with an enemy is not an invader any more. for (const unit of this.units) { - if (unit.coords.x === coords.x && unit.coords.y === coords.y) return unit; + if (unit.invader && !this._hasHostileOn(unit.coords, unit.civ)) unit.invader = false; } - return null; - } - - civAt(coords) { - return this.territory.has(key(coords.x, coords.y)) ? this.territory.get(key(coords.x, coords.y)) : -1; - } - - terrainTypeAt(coords) { - const tile = this.tiles[key(coords.x, coords.y)]; - return tile ? tile.terrainType : "Unknown"; - } - - getTilePopulation(coords) { - return Math.round(this.tilePopulation.get(key(coords.x, coords.y)) || 0); - } - - getTileInfo(coords) { - const city = this.cityAt(coords); - return { - coords, - owner: this.civAt(coords), - terrainType: this.terrainTypeAt(coords), - population: this.getTilePopulation(coords), - improvements: city ? city.improvements : [], - }; - } - - getPlayerPopulation(civ) { - return Math.round(this._playerAggregates(civ).population); - } - - _playerAggregates(civ) { - let population = 0; - let baseGdp = 0; - const cells = this._territoryByCiv.get(civ) || []; - for (const coords of cells) { - const pop = this.tilePopulation.get(key(coords.x, coords.y)) || 0; - population += pop; - baseGdp += pop * this.getTileGdpPerCapita(coords); - } - const modifiers = this.getCivModifiers(civ); - const gdp = baseGdp * (1 + (modifiers[EFFECT_GDP] || 0)); - return { population, gdp }; - } - - getTileGdpPerCapita(coords) { - const k = key(coords.x, coords.y); - if (this._gdpPerCapitaCache.has(k)) return this._gdpPerCapitaCache.get(k); - const owner = this.civAt(coords); - if (owner < 0) return 0; - let perCapita = ECONOMY.baseGdpPerCapita * this.terrainStats.gdpMultiplier(coords); - const city = this.cityAt(coords); - if (city) { - perCapita *= city.isCapital - ? ECONOMY.capitalTileGdpMultiplier - : ECONOMY.cityTileGdpMultiplier; - } else if (this._isNextToCity(coords, owner)) { - perCapita *= 1 + ECONOMY.citySurroundingGdpBonus; - } - this._gdpPerCapitaCache.set(k, perCapita); - return perCapita; - } - - getTileGdp(coords) { - return (this.tilePopulation.get(key(coords.x, coords.y)) || 0) * this.getTileGdpPerCapita(coords); - } - - getPlayerGdp(civ) { - return this._playerAggregates(civ).gdp; - } - - getPlayerUpkeep(civ, gdp = -1) { - let total = this.getBuildingUpkeep(civ, gdp); - const battle = this._battleUnitIds(); - for (const unit of this.units) { - if (unit.civ !== civ) continue; - const proto = this.unitProto(unit); - if (!proto) continue; - const statuses = this._effectiveStatuses(unit, battle.has(unit.id)); - total += proto.upkeep * this._statusUpkeepMultiplier(statuses); - } - return total; - } - - getBuildingUpkeep(civ, gdp = -1) { - let total = 0; - if (gdp < 0) gdp = this.getPlayerGdp(civ); - for (const city of this.cities) { - if (city.civ !== civ) continue; - for (const indexStr of Object.keys(city.buildings)) { - const index = Number(indexStr); - total += buildingUpkeep(this.protoBuildings[index], gdp, city.buildings[indexStr]); - } - } - return total; - } - - getCityBuildingLevel(city, protoIndex) { - 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); - 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, gdp); - const buildingUpkeep = buildingSources.reduce((sum, source) => sum + source.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 }, - 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) { - const sources = new Map(); - const add = (label, kind, effect) => { - if (effect === 0) return; - const k = `${kind}:${label}`; - const entry = sources.get(k) || { label, kind, effect: 0 }; - entry.effect += effect; - sources.set(k, entry); - }; - 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) { - if (e.stat === EFFECT_BUDGET_INCOME) add(proto.name, "building", effectAt(e, level)); - } - } - } - 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)); - } - } - 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)); - } - } - return this._sortSources(Array.from(sources.values())); - } - - _buildingUpkeepSources(civ, gdp) { - const sources = 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], gdp, city.buildings[indexStr]); - 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); - } - } - return this._sortSources(Array.from(sources.values())); - } - - _unitUpkeepSources(civ) { - const sources = new Map(); - const battle = this._battleUnitIds(); - for (const unit of this.units) { - if (unit.civ !== civ) continue; - const proto = this.unitProto(unit); - if (!proto || !proto.upkeep) continue; - const statuses = this._effectiveStatuses(unit, battle.has(unit.id)); - const entry = sources.get(unit.proto) || { label: proto.name, kind: "unit", amount: 0, count: 0 }; - entry.amount += proto.upkeep * this._statusUpkeepMultiplier(statuses); - entry.count += 1; - sources.set(unit.proto, entry); - } - return this._sortSources(Array.from(sources.values())); - } - - _sortSources(sources) { - const weight = (source) => (source.amount !== undefined ? source.amount : source.effect); - return sources.sort((a, b) => weight(b) - weight(a)); - } - - getCivBuildingModifiers(civ) { - 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); - } - } - } - return totals; - } - - getCivGovernmentModifiers(civ) { - const totals = {}; - const resource = this.governments[this.getGovernment(civ)]; - if (!resource) return totals; - for (const e of resource.effects) { - totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, 1); - } - return totals; - } - - getCivModifiers(civ) { - const totals = this.getCivBuildingModifiers(civ); - mergeModifiers(totals, this.getCivGovernmentModifiers(civ)); - mergeModifiers(totals, this.getCivTechnologyModifiers(civ)); - return totals; - } - - getResearch(civ) { - return this.research.get(civ) || 0; - } - - getCulture(civ) { - return this.culture.get(civ) || 0; - } - - getGovernment(civ) { - return this.government.get(civ) || 0; - } - - getGovernmentResource(civ) { - const index = this.getGovernment(civ); - if (index < 0 || index >= this.governments.length) return null; - return this.governments[index]; - } - - isResearched(civ, index) { - const set = this.researched.get(civ); - return set ? set.has(index) : false; - } - - getResearchedIndices(civ) { - const set = this.researched.get(civ) || new Set(); - return Array.from(set); - } - - canResearchTechnology(civ, index) { - if (civ < 0 || civ >= this.civilisations.length) return false; - if (index < 0 || index >= this.technologies.length) return false; - if (this.isResearched(civ, index)) return false; - return prerequisitesMet( - this.technologies[index], - this.researched.get(civ) || new Set(), - this.technologies - ); - } - - getCivTechnologyModifiers(civ) { - const totals = {}; - const set = this.researched.get(civ) || new Set(); - for (const index of set) { - for (const e of this.technologies[index].effects) { - totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, 1); - } - } - return totals; - } - - getBudget(civ) { - return this.budgets.has(civ) ? this.budgets.get(civ) : ECONOMY.startingBudget; - } - - getTraining(cityId) { - return this.training.get(cityId) || null; - } - - isExplored(civ, coords) { - const set = this.explored.get(civ); - return set ? set.has(key(coords.x, coords.y)) : false; - } - - isVisible(civ, coords) { - const set = this.visible.get(civ); - return set ? set.has(key(coords.x, coords.y)) : false; - } - - canEnter(civ, proto, coords) { - if (!proto || !this.isExplored(civ, coords)) return false; - const tile = this.tiles[key(coords.x, coords.y)]; - if (!tile) return false; - return proto.traversableTerrains.includes(tile.terrainClass); - } - - // Whether `unit` may occupy `coords`: passable terrain, no friendly unit in - // the way and no enemy unit there unless both are military (which turns the - // step into a fight). Enemy cities are only open to military units, since - // walking one in captures it. - // - // A hostile unit or city is only a valid destination (`isGoal`), never a tile - // to pass through: pathfinding routes around enemies instead of through them. - _canUnitEnter(unit, coords, isGoal = false) { - const proto = this.unitProto(unit); - if (!this.canEnter(unit.civ, proto, coords)) return false; - const occupant = this.unitAt(coords); - if (occupant && occupant !== unit) { - if (occupant.civ === unit.civ) return false; - if (!isGoal) return false; - if (!this._isMilitary(unit) || !this._isMilitary(occupant)) return false; - } - const city = this._cityByCoords.get(key(coords.x, coords.y)); - if (city && city.civ !== unit.civ) { - if (!isGoal) return false; - if (!this._isMilitary(unit)) return false; - } - return true; - } - - isMilitary(unit) { - return this._isMilitary(unit); - } - - _isMilitary(unit) { - const proto = this.unitProto(unit); - return !!(proto && proto.military); - } - - _unitAttack(unit) { - const proto = this.unitProto(unit); - return proto && typeof proto.attack === "number" ? proto.attack : 0; - } - - _unitDefense(unit) { - const proto = this.unitProto(unit); - return proto && typeof proto.defense === "number" ? proto.defense : 0; - } - - // Fraction of extra defense granted by the tile a unit occupies (terrain - // plus any city), as a multiplier. - _defenseMultiplierAt(coords) { - const tile = this.tiles[key(coords.x, coords.y)]; - return defenseMultiplier(tile, !!this.cityAt(coords)); - } - - // A unit's base defense, boosted by its tile, and doubled when it is the - // defending side of the exchange. - _effectiveDefense(unit, isDefender) { - let defense = this._unitDefense(unit) * this._defenseMultiplierAt(unit.coords); - if (isDefender) defense *= DEFENDER_DEFENSE_MULTIPLIER; - return defense; - } - - findPath(unit, start, goal) { - const proto = this.unitProto(unit); - const civ = unit.civ; - const enterCache = new Map(); - const costCache = new Map(); - const heuristicCache = new Map(); - return this._pathfinder.findPath( - start, - goal, - (coords) => this._neighbours(coords), - (coords, isGoal) => { - const k = key(coords.x, coords.y) + (isGoal ? ":goal" : ""); - if (!enterCache.has(k)) enterCache.set(k, this._canUnitEnter(unit, coords, isGoal)); - return enterCache.get(k); - }, - (_from, to) => { - const k = key(to.x, to.y); - if (!costCache.has(k)) costCache.set(k, this._tileTravelHours(unit, to)); - return costCache.get(k); - }, - (from, to) => { - const k = key(from.x, from.y); - if (!heuristicCache.has(k)) heuristicCache.set(k, this._heuristic(unit, from, to)); - return heuristicCache.get(k); - } - ); - } - - _placeSettlements() { - const capitals = this._placeCapitals(this.civilisations.length); - for (const site of this._placeCities(capitals)) this._spawnCity(site); - this._rebuildCityIndex(); - 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(); - } - - _placeCapitals(count) { - const rng = new Random(this.seed); - return this._generator.pickCapitals(count, rng); - } - - _placeCities(capitals) { - return this._generator.pickCities(capitals, this.mapConfig.citiesPerCiv, this.mapConfig.citySpacing); - } - - _spawnCity(site) { - const civIndex = site.civIndex; - if (civIndex < 0 || civIndex >= this.civilisations.length) return; - const civ = this.civilisations[civIndex]; - const coords = site.coords; - const isCapital = site.isCapital; - const description = this._cityDescriptionFor(civ, isCapital, coords); - const city = { - id: this._nextCityId, - civ: civIndex, - coords, - name: description.name, - isCapital, - population: description.population, - improvements: description.improvements.slice(), - buildings: {}, - statuses: [], - }; - this.cities.push(city); - this._cityById.set(this._nextCityId, city); - this._nextCityId += 1; - } - - _cityDescriptionFor(civ, _isCapital, center) { - return this._generatedCityDescription(civ, center); - } - - _rebuildCityIndex() { - this._cityByCoords = new Map(); - this._cityAdjacent = new Map(); - for (const city of this.cities) { - this._cityByCoords.set(key(city.coords.x, city.coords.y), city); - const owner = city.civ; - if (!this._cityAdjacent.has(owner)) this._cityAdjacent.set(owner, new Set()); - const adjacent = this._cityAdjacent.get(owner); - for (const neighbour of this._neighbours(city.coords)) { - adjacent.add(key(neighbour.x, neighbour.y)); - } - } - } - - _generatedCityDescription(civ, center) { - const index = this.civilisations.indexOf(civ); - const used = this._cityNamesUsed.get(index) || 0; - this._cityNamesUsed.set(index, used + 1); - let name = `${civ.name} ${used + 1}`; - if (used < civ.cityNames.length) name = civ.cityNames[used]; - const population = 500000 + - (Math.abs((center.x * 92837111) ^ (center.y * 689287499)) % 3500000); - return { name, population, improvements: [] }; - } - - _spawnUnit(center, civIndex, proto) { - let index = this.protoUnits.indexOf(proto); - if (index < 0) { - this.protoUnits.push(proto); - index = this.protoUnits.length - 1; - } - const unit = { - id: this._nextUnitId, - civ: civIndex, - proto: index, - coords: center, - hp: proto.maxHp, - maxHp: proto.maxHp, - path: [], - pathIndex: 0, - progressHours: 0, - attackTarget: null, - statuses: [], - }; - this.units.push(unit); - this._unitById.set(this._nextUnitId, unit); - this._nextUnitId += 1; - return unit; - } - - _recomputeTerritory() { - this.territory = new Map(); - this._gdpPerCapitaCache = new Map(); - const ownerByCell = new Map(); - let frontier = []; - for (const city of this.cities) { - const k = key(city.coords.x, city.coords.y); - ownerByCell.set(k, city.civ); - this.territory.set(k, city.civ); - frontier.push(city.coords); - } - while (frontier.length > 0) { - const next = []; - for (const coords of frontier) { - const owner = ownerByCell.get(key(coords.x, coords.y)); - for (const neighbour of this._neighbours(coords)) { - const nk = key(neighbour.x, neighbour.y); - if (ownerByCell.has(nk) || !this._isLand(neighbour)) continue; - ownerByCell.set(nk, owner); - this.territory.set(nk, owner); - next.push(neighbour); - } - } - 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); - } - - // Rebuilds the owner index, the serialised territory and the GDP-per-capita - // cache after `this.territory` changes outside a full recompute. - _reindexTerritory() { - 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._gdpPerCapitaCache = new Map(); - } - - // 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. - _captureCity(city, newCiv) { - const oldCiv = city.civ; - if (oldCiv === newCiv) return false; - const region = (this._territoryByCiv.get(oldCiv) || []).slice(); - const claim = this._nearestCityOwner(city, oldCiv, newCiv); - city.civ = newCiv; - for (const coords of region) { - const k = key(coords.x, coords.y); - if (claim.get(k) === newCiv) this.territory.set(k, newCiv); - } - // The captured city's own tile always changes hands, ties or not. - this.territory.set(key(city.coords.x, city.coords.y), newCiv); - this.training.delete(city.id); - this._reindexTerritory(); - this._rebuildCityIndex(); - this._visibilityDirty = true; - this._addNews({ type: NEWS_CAPTURE, city: city.name, from: oldCiv, to: newCiv }); this._emitChanged(); - return true; - } - - // Multi-source BFS over land from the captured city and every remaining city - // of the old owner. Each tile records the civ of the nearest city; a tile - // reached at the same distance from both sides is credited to the old owner. - _nearestCityOwner(captured, oldCiv, newCiv) { - const claim = new Map(); - const visited = new Set(); - let frontier = []; - const sources = [{ coords: captured.coords, civ: newCiv }]; - for (const city of this.cities) { - if (city !== captured && city.civ === oldCiv) { - sources.push({ coords: city.coords, civ: oldCiv }); - } - } - for (const source of sources) { - const k = key(source.coords.x, source.coords.y); - if (visited.has(k)) continue; - visited.add(k); - claim.set(k, source.civ); - frontier.push(source.coords); - } - while (frontier.length > 0) { - const nextFrontier = []; - const nextClaim = new Map(); - for (const coords of frontier) { - const civ = claim.get(key(coords.x, coords.y)); - for (const neighbour of this._neighbours(coords)) { - const nk = key(neighbour.x, neighbour.y); - if (visited.has(nk) || !this._isLand(neighbour)) continue; - if (nextClaim.has(nk)) { - if (nextClaim.get(nk) === newCiv && civ === oldCiv) nextClaim.set(nk, oldCiv); - continue; - } - nextClaim.set(nk, civ); - nextFrontier.push(neighbour); - } - } - for (const [k, civ] of nextClaim) { - claim.set(k, civ); - visited.add(k); - } - frontier = nextFrontier; - } - return claim; - } - - _generatePopulation() { - this.tilePopulation = new Map(); - for (const k in this.tiles) { - if (this._isLand(parseKey(k))) this.tilePopulation.set(k, this._basePopulation(parseKey(k))); - } - for (const city of this.cities) { - this.tilePopulation.set(key(city.coords.x, city.coords.y), city.population); - } - } - - _basePopulation(coords) { - return Math.round(ECONOMY.basePopulation * this.terrainStats.populationMultiplier(coords)); - } - - _initBudgets() { - for (let i = 0; i < this.civilisations.length; i++) { - this.budgets.set(i, ECONOMY.startingBudget); - } - } - - _isLand(coords) { - const tile = this.tiles[key(coords.x, coords.y)]; - return !!tile && tile.terrainClass === "Land"; - } - - _isNextToCity(coords, owner) { - const adjacent = this._cityAdjacent.get(owner); - return adjacent ? adjacent.has(key(coords.x, coords.y)) : false; - } - - _tickPopulation() { - const factor = ECONOMY.populationGrowthFactor(HOURS_PER_YEAR, HOURS_PER_DAY); - if (factor === 0) return; - for (const k of Array.from(this.tilePopulation.keys())) { - this.tilePopulation.set(k, this.tilePopulation.get(k) * (1 + factor)); - } - } - - _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 upkeep = this.getPlayerUpkeep(civ, gdp) * hours; - this.budgets.set(civ, this.getBudget(civ) + income - upkeep); - } - } - - _tickBuildings() { - for (let civ = 0; civ < this.civilisations.length; civ++) { - const modifiers = this.getCivModifiers(civ); - const scienceRate = modifiers[EFFECT_RESEARCH] || 0; - const cultureRate = modifiers[EFFECT_CULTURE] || 0; - if (scienceRate === 0 && cultureRate === 0) continue; - this.research.set(civ, this.getResearch(civ) + scienceRate); - this.culture.set(civ, this.getCulture(civ) + cultureRate); - } - } - - _tickTraining() { - const completed = []; - for (const [cityId, entry] of this.training) { - entry.elapsedHours += 1; - const produced = entry.elapsedHours * entry.capacity; - const proto = this.protoUnits[entry.protoIndex]; - if (produced >= proto.cost) { - const city = this.findCity(cityId); - if (city) { - this._spawnUnit(city.coords, city.civ, proto); - this._visibilityDirty = true; - } - completed.push(cityId); - } - } - for (const cityId of completed) this.training.delete(cityId); - } - - _refreshVisibility() { - this.visible = new Map(); - for (let i = 0; i < this.civilisations.length; i++) this.visible.set(i, new Set()); - for (const [civ, cells] of this._territoryByCiv) { - for (const coords of cells) this._reveal(civ, coords, TERRITORY_VISION); - } - for (const unit of this.units) { - this._reveal(unit.civ, unit.coords, UNIT_VISION); - } - } - - _revealAll() { - for (let i = 0; i < this.civilisations.length; i++) { - this.explored.set(i, new Set(Object.keys(this.tiles))); - } - } - - _reveal(civ, center, radius) { - if (!this.explored.has(civ)) this.explored.set(civ, new Set()); - if (!this.visible.has(civ)) this.visible.set(civ, new Set()); - const exploredCells = this.explored.get(civ); - const visibleCells = this.visible.get(civ); - let discovered = false; - for (const coords of this._cellsInRadius(center, radius)) { - const k = key(coords.x, coords.y); - if (!exploredCells.has(k)) { - exploredCells.add(k); - discovered = true; - } - visibleCells.add(k); - } - if (discovered) this._serializedExplored.delete(civ); - } - - _cachedExplored(civ) { - if (!this._serializedExplored.has(civ)) { - this._serializedExplored.set(civ, serializeSet(this.explored.get(civ) || new Set())); - } - return this._serializedExplored.get(civ); - } - - _cellsInRadius(center, radius) { - const result = [center]; - const visited = new Set([key(center.x, center.y)]); - let frontier = [center]; - for (let step = 0; step < radius; step++) { - const next = []; - for (const coords of frontier) { - for (const neighbour of this._neighbours(coords)) { - const k = key(neighbour.x, neighbour.y); - if (visited.has(k)) continue; - visited.add(k); - next.push(neighbour); - result.push(neighbour); - } - } - frontier = next; - } - return result; - } - - _neighbours(coords) { - const k = key(coords.x, coords.y); - if (this._neighbourCache.has(k)) return this._neighbourCache.get(k); - const result = this.topology.neighbours(coords.x, coords.y); - this._neighbourCache.set(k, result); - return result; - } - - _isControlledBy(unit, coords) { - return this.civAt(coords) === unit.civ; - } - - _unitSpeed(unit) { - const proto = this.unitProto(unit); - return Math.max(proto ? proto.speed : DEFAULT_UNIT_SPEED, 0.0001); - } - - _effectiveSpeedAt(unit, coords) { - let speed = this._unitSpeed(unit); - if (this._isControlledBy(unit, coords)) speed *= CONTROLLED_SPEED_MULTIPLIER; - return speed; - } - - _tileTravelHours(unit, coords) { - return this.terrainStats.movementCostMultiplier(coords) / this._effectiveSpeedAt(unit, coords); - } - - _fastestTileTravelHours(unit) { - return this._minMovementCost / (this._unitSpeed(unit) * CONTROLLED_SPEED_MULTIPLIER); - } - - _heuristic(unit, from, to) { - const delta = this.topology.pixelDelta(from, to); - const tiles = Math.hypot(delta.x, delta.y) / this._maxStepLength; - return tiles * this._fastestTileTravelHours(unit); - } - - _computeMaxStepLength() { - let longest = 0; - for (const neighbour of this._neighbours({ x: 0, y: 0 })) { - const delta = this.topology.pixelDelta({ x: 0, y: 0 }, neighbour); - longest = Math.max(longest, Math.hypot(delta.x, delta.y)); - } - return longest > 0 ? longest : 1.0; - } - - // ----------------------------------------------------------------------- - // Serialisation - // ----------------------------------------------------------------------- - - snapshot(viewerCiv = -1) { - return this.viewerSnapshot(this.serializeShared(), viewerCiv); - } - - serializeShared() { - return { - seed: this.seed, - totalHours: this.totalHours, - civs: this.civilisations.map((c) => ({ id: c.id, name: c.name })), - protos: this.protoUnits.map((p) => ({ id: p.id, name: p.name })), - buildings: this.protoBuildings.map((b) => ({ id: b.id, name: b.name })), - governments: this.governments.map((g) => ({ id: g.id, name: g.name })), - technologies: this.technologies.map((t) => ({ id: t.id, name: t.name })), - cities: this._serializeCities(), - units: this._serializeUnits(), - territory: this._serializedTerritory, - population: this._serializePopulation(), - budgets: this._serializeCivFloats(this.budgets), - research: this._serializeCivFloats(this.research), - culture: this._serializeCivFloats(this.culture), - government: this._serializeCivInts(this.government), - researched: this._serializeResearched(), - diplomacy: this._serializeDiplomacy(), - news: this._serializeNews(), - training: Object.fromEntries( - Array.from(this.training.entries()).map(([cityId, entry]) => [ - cityId, - { ...entry }, - ]) - ), - }; - } - - viewerSnapshot(shared, viewerCiv, stats = null) { - const result = { ...shared }; - result.viewer = viewerCiv; - result.viewerStats = stats || this.viewerStats(viewerCiv); - result.explored = this._cachedExplored(viewerCiv); - result.visible = serializeSet(this.visible.get(viewerCiv) || new Set()); - return result; - } - - viewerStats(viewerCiv) { - if (viewerCiv < 0) { - return { - population: 0, gdp: 0, gdpPerCapita: 0, budget: 0, - upkeep: 0, research: 0, culture: 0, government: 0, - breakdown: null, - }; - } - const aggregates = this._playerAggregates(viewerCiv); - const population = Math.round(aggregates.population); - const gdp = aggregates.gdp; - return { - population, - gdp, - gdpPerCapita: population > 0 ? gdp / population : 0, - budget: this.getBudget(viewerCiv), - upkeep: this.getPlayerUpkeep(viewerCiv, gdp), - research: this.getResearch(viewerCiv), - culture: this.getCulture(viewerCiv), - government: this.getGovernment(viewerCiv), - breakdown: this.budgetBreakdown(viewerCiv), - }; - } - - _serializeCities() { - return this.cities.map((city) => ({ - id: city.id, - civ: city.civ, - coords: [city.coords.x, city.coords.y], - name: city.name, - isCapital: city.isCapital, - population: city.population, - improvements: city.improvements, - buildings: { ...city.buildings }, - statuses: city.statuses || [], - })); - } - - _serializeUnits() { - const battle = this._battleUnitIds(); - return this.units.map((unit) => { - const rawPath = unit.path || []; - const pathIndex = unit.pathIndex; - const path = rawPath.map((coords) => [coords.x, coords.y]); - const segmentHours = []; - for (let i = 1; i < rawPath.length; i++) { - segmentHours.push(this._tileTravelHours(unit, rawPath[i])); - } - let stepHours = 0; - if (pathIndex + 1 < rawPath.length) { - stepHours = this._tileTravelHours(unit, rawPath[pathIndex + 1]); - } - return { - id: unit.id, - civ: unit.civ, - proto: unit.proto, - coords: [unit.coords.x, unit.coords.y], - hp: unit.hp, - maxHp: unit.maxHp, - statuses: this._effectiveStatuses(unit, battle.has(unit.id)), - path, - pathIndex, - progressHours: unit.progressHours, - stepHours, - segmentHours, - }; - }); - } - - _serializePopulation() { - const result = []; - for (const [k, value] of this.tilePopulation) { - const coords = parseKey(k); - result.push([coords.x, coords.y, Math.round(value)]); - } - return result; - } - - _serializeCivFloats(values) { - const result = []; - for (let i = 0; i < this.civilisations.length; i++) { - result.push(values.has(i) ? values.get(i) : 0); - } - return result; - } - - _serializeCivInts(values) { - const result = []; - for (let i = 0; i < this.civilisations.length; i++) { - result.push(values.has(i) ? values.get(i) : 0); - } - return result; - } - - _serializeResearched() { - const result = []; - for (let i = 0; i < this.civilisations.length; i++) { - const set = this.researched.get(i) || new Set(); - const indices = Array.from(set).sort((a, b) => a - b); - result.push(indices); - } - return result; - } - - // Every unordered pair with its current status. With a handful of - // civilisations the whole matrix is tiny, and it lets the client show the - // status of every nation without a second lookup. - _serializeDiplomacy() { - const result = []; - for (let a = 0; a < this.civilisations.length; a++) { - for (let b = a + 1; b < this.civilisations.length; b++) { - result.push([a, b, this.getRelation(a, b)]); - } - } - return result; - } - - _serializeNews() { - return this.news.map((entry) => ({ ...entry })); } } -// Canonical key for an unordered pair of civilisations. -function warKey(a, b) { - return a < b ? `${a}:${b}` : `${b}:${a}`; -} - -function effectAt(e, level) { - if (level <= 0) return 0; - return e.baseValue * Math.pow(level, e.exponent); -} - -function mergeModifiers(totals, additions) { - for (const stat of Object.keys(additions)) { - totals[stat] = (totals[stat] || 0) + additions[stat]; - } -} - -function serializeCells(map) { - const result = []; - for (const [k, value] of map) { - const coords = parseKey(k); - result.push([coords.x, coords.y, value]); - } - return result; -} - -function serializeSet(set) { - const result = []; - for (const k of set) { - const coords = parseKey(k); - result.push([coords.x, coords.y, true]); - } - return result; -} - -function parseKey(k) { - const i = k.indexOf(","); - return { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) }; -} +// Compose the behaviour mixins onto the prototype. Each module owns one concern +// and may call methods from any other through `this`. +Object.assign( + GameState.prototype, + worldMethods, + territoryMethods, + economyMethods, + movementMethods, + combatMethods, + statusMethods, + diplomacyMethods, + orderMethods, + visibilityMethods, + serializationMethods +); diff --git a/shared/hex_pathfinder.js b/shared/hex_pathfinder.js index 9eb9139..c09d557 100644 --- a/shared/hex_pathfinder.js +++ b/shared/hex_pathfinder.js @@ -69,6 +69,16 @@ class MinHeap { export class HexPathfinder { findPath(start, goal, neighbours, canEnter, stepCost, heuristic) { + const search = this.findPathIter(start, goal, neighbours, canEnter, stepCost, heuristic); + let step = search.next(); + while (!step.done) step = search.next(); + return step.value; + } + + // The same search exposed as an iterator, suspending after every expanded + // tile. A caller on the main thread can drive it a few tiles at a time and + // stay responsive instead of freezing on one long search. + *findPathIter(start, goal, neighbours, canEnter, stepCost, heuristic) { const startKey = key(start.x, start.y); const goalKey = key(goal.x, goal.y); if (startKey === goalKey || !canEnter(goal, true)) return []; @@ -81,21 +91,23 @@ export class HexPathfinder { while (!open.isEmpty()) { const current = open.pop(); const currentKey = key(current.x, current.y); - if (closed.has(currentKey)) continue; - if (currentKey === goalKey) return reconstruct(cameFrom, current); - closed.add(currentKey); - for (const neighbour of neighbours(current)) { - const neighbourKey = key(neighbour.x, neighbour.y); - if (closed.has(neighbourKey)) continue; - if (!canEnter(neighbour, neighbourKey === goalKey)) continue; - const tentative = (gScore.has(currentKey) ? gScore.get(currentKey) : Infinity) + - stepCost(current, neighbour); - if (tentative < (gScore.has(neighbourKey) ? gScore.get(neighbourKey) : Infinity)) { - cameFrom.set(neighbourKey, current); - gScore.set(neighbourKey, tentative); - open.push(tentative + heuristic(neighbour, goal), neighbour); + if (!closed.has(currentKey)) { + if (currentKey === goalKey) return reconstruct(cameFrom, current); + closed.add(currentKey); + for (const neighbour of neighbours(current)) { + const neighbourKey = key(neighbour.x, neighbour.y); + if (closed.has(neighbourKey)) continue; + if (!canEnter(neighbour, neighbourKey === goalKey)) continue; + const tentative = (gScore.has(currentKey) ? gScore.get(currentKey) : Infinity) + + stepCost(current, neighbour); + if (tentative < (gScore.has(neighbourKey) ? gScore.get(neighbourKey) : Infinity)) { + cameFrom.set(neighbourKey, current); + gScore.set(neighbourKey, tentative); + open.push(tentative + heuristic(neighbour, goal), neighbour); + } } } + yield; } return []; } diff --git a/shared/map_generator.js b/shared/map_generator.js index 2ddb532..d1c8fca 100644 --- a/shared/map_generator.js +++ b/shared/map_generator.js @@ -5,11 +5,15 @@ import { SimplexNoise } from "./noise.js"; import { Random } from "./rng.js"; -import { key } from "./hex.js"; +import { key, parseKey } from "./hex.js"; import { TERRAIN_TILES } from "./data.js"; const TAU = Math.PI * 2; +// Hard floor on the spacing between any two cities; cities are spread from a +// neutral layout and then handed to the players in clusters. +const CITY_MIN_DISTANCE = 5; + function smoothstep(edge0, edge1, x) { const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0))); return t * t * (3 - 2 * t); @@ -23,35 +27,52 @@ export class MapGenerator { this.tiles = {}; this._landmassSize = new Map(); this._palette = buildPalette(); + if (!this._palette.plains || !this._palette.sea) { + // A catalogue without land or sea cannot tile the map; fail with a clear + // message instead of a null dereference deep in generate(). + throw new Error("TERRAIN_TILES must define at least one Land and one Sea tile"); + } } + // Three passes: a smooth continent field decides land and sea, a finer field + // roughens the coastline, and clusters of terrain are then scattered over the + // land independently of the continent shape. generate(seed) { const noise = new SimplexNoise(seed); const halfX = Math.floor(this.config.mapSize.x / 2); const halfY = Math.floor(this.config.mapSize.y / 2); const wrapped = this.topology.isCylindrical(); const radius = this.config.mapSize.x / TAU; + this.seed = seed; this.landCells = []; this.tiles = {}; for (let row = 0; row < this.config.mapSize.y; row++) { for (let column = 0; column < this.config.mapSize.x; column++) { const coords = { x: column - halfX, y: row - halfY }; - let value = this._sampleNoise(noise, column, coords, wrapped, radius); + let value = this._sampleNoise(noise, column, coords, wrapped, radius, this.config.noise); value = this._applyFalloff(coords, value, wrapped); - const tile = this._chooseTile(coords, value); + let elevation = value; + if (this.config.coastNoise) { + const coast = this._sampleNoise(noise, column, coords, wrapped, radius, this.config.coastNoise); + elevation += (this.config.coastRoughness || 0) * coast; + } + const tile = this._chooseTile(coords, elevation); this.tiles[key(coords.x, coords.y)] = tile; if (tile.terrainClass === "Land") this.landCells.push(coords); } } + this._removeSmallFeatures(); + if (this.config.connectOceans !== false) this._connectOceans(); + this._placeTerrainClusters(); this._computeLandmasses(); } - _sampleNoise(noise, column, coords, wrapped, radius) { + _sampleNoise(noise, column, coords, wrapped, radius, config) { if (!wrapped) { - return noise.fbm(coords.x, coords.y, undefined, this.config.noise); + return noise.fbm(coords.x, coords.y, undefined, config); } const angle = (TAU * column) / this.config.mapSize.x; - return noise.fbm(Math.cos(angle) * radius, Math.sin(angle) * radius, coords.y, this.config.noise); + return noise.fbm(Math.cos(angle) * radius, Math.sin(angle) * radius, coords.y, config); } _applyFalloff(coords, value, wrapped) { @@ -72,22 +93,77 @@ export class MapGenerator { return Math.abs(coords.y) / half >= this.config.iceLatitude; } - _chooseTile(coords, value) { + _chooseTile(coords, elevation) { const palette = this._palette; if (palette.ice && this._isPolar(coords)) return palette.ice; - if (value >= this.config.seaLevel) { - return this._landTileFor(value); - } - return palette.sea; + if (elevation < this.config.seaLevel) return palette.sea; + // Land starts as plains; the terrain clusters are painted over it later. + return palette.plains; } - _landTileFor(elevation) { - const land = this._palette.land; - if (land.length === 0) return null; - for (let i = land.length - 1; i >= 0; i--) { - if (land[i].minElevation <= elevation) return land[i]; + // Third pass: scatter clusters of each biome over the land. Clusters are + // seeded from random land tiles and grown one tile at a time into an + // irregular blob, in priority order (earlier entries win overlaps) and + // clipped to the land, so terrain types have nothing to do with the + // continent shape. + _placeTerrainClusters() { + const biomes = this.config.terrainBiomes; + if (!biomes || biomes.length === 0 || this.landCells.length === 0) return; + const rng = new Random(this.seed ^ 0x5bd1e995); + for (const biome of biomes) { + const tile = this._palette.byType[biome.terrainType]; + if (!tile) continue; + const size = Math.max(1, Math.round(biome.size || 1)); + const count = Math.max( + 1, + Math.round((biome.coverage * this.landCells.length) / size) + ); + for (let i = 0; i < count; i++) { + const center = this.landCells[rng.range(0, this.landCells.length - 1)]; + // Vary the size so the patches do not all look the same. + const grown = Math.max(1, Math.round(size * (0.5 + rng.float()))); + this._growCluster(center, grown, tile, rng); + } } - return land[0]; + } + + // Grows one irregular blob of `size` tiles from `center`. At every step it + // paints a random tile on the blob's edge, so the outline wanders instead of + // closing into a hexagon, while the interior fills in rather than leaving + // holes. It stops at the coast and at tiles already claimed by a + // higher-priority biome. + _growCluster(center, size, tile, rng) { + if (!this._isPaintable(center)) return; + const frontier = []; + const queued = new Set(); + const startKey = key(center.x, center.y); + queued.add(startKey); + this.tiles[startKey] = tile; + this._queueClusterNeighbours(center, frontier, queued); + let painted = 1; + while (painted < size && frontier.length > 0) { + const index = rng.range(0, frontier.length - 1); + const coords = frontier[index]; + frontier[index] = frontier[frontier.length - 1]; + frontier.pop(); + if (!this._isPaintable(coords)) continue; + this.tiles[key(coords.x, coords.y)] = tile; + painted += 1; + this._queueClusterNeighbours(coords, frontier, queued); + } + } + + _queueClusterNeighbours(coords, frontier, queued) { + for (const neighbour of this.topology.neighbours(coords.x, coords.y)) { + const nk = key(neighbour.x, neighbour.y); + if (queued.has(nk) || !this._isPaintable(neighbour)) continue; + queued.add(nk); + frontier.push(neighbour); + } + } + + _isPaintable(coords) { + return this._isLand(coords) && this.tiles[key(coords.x, coords.y)] === this._palette.plains; } pickCapitals(count, rng) { @@ -111,48 +187,150 @@ export class MapGenerator { } pickCities(capitals, citiesPerCiv, minSpacing) { - const sites = []; - if (capitals.length === 0) return sites; - const occupied = []; - for (let i = 0; i < capitals.length; i++) { - sites.push({ coords: capitals[i], civIndex: i, isCapital: true }); - occupied.push(capitals[i]); + const capitalsOnly = () => + capitals.map((coords, civIndex) => ({ coords, civIndex, isCapital: true })); + if (capitals.length === 0) return []; + if (citiesPerCiv <= 1) return capitalsOnly(); + // Lay out neutral cities first, evenly spread and never closer than the + // minimum distance, then cluster them onto the players so each civ ends up + // with a compact block of cities instead of scattered exclaves. + const minDistance = Math.max(minSpacing || 0, CITY_MIN_DISTANCE); + // Work one landmass at a time: each coast is given as many neutral sites as + // its capitals need, so the cluster pass can hand every civ the same count. + const groups = this._capitalGroups(capitals); + let perCiv = citiesPerCiv; + for (const group of groups) { + group.layout = this._neutralCitySites( + capitals, + group.civs, + group.candidates, + group.civs.length * citiesPerCiv, + minDistance + ); + perCiv = Math.min(perCiv, Math.floor(group.layout.length / group.civs.length)); } - if (citiesPerCiv <= 1) return sites; - const candidateLists = capitals.map((capital) => this._cityCandidates(capital)); - const counts = capitals.map(() => 1); - let placedAny = true; - while (placedAny) { - placedAny = false; - for (let i = 0; i < capitals.length; i++) { - if (counts[i] >= citiesPerCiv) continue; - const candidates = candidateLists[i]; - while (candidates.length > 0) { - const coords = candidates.shift(); - if (!this._isFarEnough(coords, occupied, minSpacing)) continue; - sites.push({ coords, civIndex: i, isCapital: false }); - occupied.push(coords); - counts[i] += 1; - placedAny = true; - break; + if (perCiv <= 1) return capitalsOnly(); + // The assignment pass emits the capitals too, so it is the only source of + // cities here. + const cities = []; + for (const group of groups) { + this._assignCitySites(capitals, group, perCiv, cities); + } + return cities; + } + + // Groups the capitals by the connected landmass they stand on, with that + // landmass's tiles as the pool cities may be drawn from, so no city ends up + // stranded on an island no capital can reach. + _capitalGroups(capitals) { + const components = this._components("Land"); + const indexByKey = new Map(); + for (let i = 0; i < components.length; i++) { + for (const coords of components[i]) indexByKey.set(key(coords.x, coords.y), i); + } + const groups = components.map((candidates) => ({ civs: [], candidates, layout: [] })); + for (let civ = 0; civ < capitals.length; civ++) { + const group = indexByKey.get(key(capitals[civ].x, capitals[civ].y)); + if (group !== undefined) groups[group].civs.push(civ); + } + return groups.filter((group) => group.civs.length > 0); + } + + // Neutral layout: the capitals plus the land tile furthest from every city so + // far, until there is one site per slot. Farthest-point sampling keeps them + // evenly spread and at least `minDistance` apart. + _neutralCitySites(capitals, civs, candidates, count, minDistance) { + const sites = civs.map((civ) => ({ coords: capitals[civ], civIndex: civ, isCapital: true })); + if (sites.length >= count) return sites; + const centers = civs.map((civ) => capitals[civ]); + const distance = candidates.map((coords) => this._nearestDistance(coords, centers)); + while (sites.length < count) { + let best = -1; + let bestDistance = -1; + for (let i = 0; i < candidates.length; i++) { + if (distance[i] < minDistance) continue; + if (distance[i] > bestDistance) { + bestDistance = distance[i]; + best = i; } } + if (best < 0) break; + const coords = candidates[best]; + sites.push({ coords, civIndex: -1, isCapital: false }); + for (let i = 0; i < candidates.length; i++) { + const d = this.topology.tileDistance(candidates[i], coords); + if (d < distance[i]) distance[i] = d; + } } return sites; } - _cityCandidates(capital) { - const candidates = this.landCells.filter((c) => c.x !== capital.x || c.y !== capital.y); - candidates.sort((a, b) => { - const da = this.topology.tileDistance(capital, a); - const db = this.topology.tileDistance(capital, b); - if (Math.abs(da - db) < 1e-9) { - if (a.x === b.x) return a.y - b.y; - return a.x - b.x; + // Hands a landmass's neutral layout to its players: each capital keeps its + // site, and every other site goes to the nearest capital by land that still + // has room. Working by distance keeps each civ's cities together; a site is + // dropped rather than sent far away if every nearby civ is full. + _assignCitySites(capitals, group, perCiv, cities) { + const layout = group.layout; + const civs = group.civs; + const distances = civs.map((civ) => this._landDistances(capitals[civ])); + const capacity = civs.map(() => perCiv - 1); + const owner = layout.map((site) => (site.isCapital ? site.civIndex : -1)); + const pairs = []; + for (let site = 0; site < layout.length; site++) { + if (layout[site].isCapital) continue; + const k = key(layout[site].coords.x, layout[site].coords.y); + for (let index = 0; index < civs.length; index++) { + const distance = distances[index].get(k); + if (distance === undefined) continue; + pairs.push({ site, index, distance }); } - return da - db; - }); - return candidates; + } + pairs.sort((a, b) => a.distance - b.distance || a.site - b.site || a.index - b.index); + for (const pair of pairs) { + if (owner[pair.site] !== -1 || capacity[pair.index] <= 0) continue; + owner[pair.site] = civs[pair.index]; + capacity[pair.index] -= 1; + } + for (let site = 0; site < layout.length; site++) { + if (owner[site] < 0) continue; + cities.push({ + coords: layout[site].coords, + civIndex: owner[site], + isCapital: layout[site].isCapital, + }); + } + } + + // Land-path distance from one capital to every tile it can reach, so the + // assignment measures closeness along the coast rather than as the crow flies + // across a bay. + _landDistances(capital) { + const distances = new Map(); + distances.set(key(capital.x, capital.y), 0); + let frontier = [capital]; + while (frontier.length > 0) { + const next = []; + for (const coords of frontier) { + const distance = distances.get(key(coords.x, coords.y)); + for (const neighbour of this.topology.neighbours(coords.x, coords.y)) { + const nk = key(neighbour.x, neighbour.y); + if (distances.has(nk) || !this._isLand(neighbour)) continue; + distances.set(nk, distance + 1); + next.push(neighbour); + } + } + frontier = next; + } + return distances; + } + + _nearestDistance(coords, centers) { + let nearest = Infinity; + for (const center of centers) { + const distance = this.topology.tileDistance(coords, center); + if (distance < nearest) nearest = distance; + } + return nearest; } _capitalCandidates(ignoreMargin) { @@ -187,6 +365,137 @@ export class MapGenerator { return true; } + // Joins every separate sea into one connected ocean, so no stretch of water + // is cut off from the rest. The largest sea is the trunk; a 0-1 search spreads + // out from it where crossing open water is free and crossing land costs the + // one tile that would have to be dug. Each sea is then linked by carving the + // cheapest channel to water already reachable, which keeps the new canals + // short. The result is deterministic, so the browser rebuilds the same coast. + _connectOceans() { + const components = this._seaComponents(); + if (components.length <= 1) return; + let mainIndex = 0; + for (let i = 1; i < components.length; i++) { + if (components[i].length > components[mainIndex].length) mainIndex = i; + } + const dist = new Map(); + const parent = new Map(); + const buckets = []; + const place = (k, d, from) => { + if (dist.has(k) && dist.get(k) <= d) return; + dist.set(k, d); + parent.set(k, from); + (buckets[d] || (buckets[d] = [])).push(k); + }; + for (const coords of components[mainIndex]) place(key(coords.x, coords.y), 0, undefined); + for (let d = 0; d < buckets.length; d++) { + const bucket = buckets[d]; + if (!bucket) continue; + while (bucket.length > 0) { + const k = bucket.pop(); + if (dist.get(k) !== d) continue; + const coords = parseKey(k); + for (const neighbour of this.topology.neighbours(coords.x, coords.y)) { + const nk = key(neighbour.x, neighbour.y); + const tile = this.tiles[nk]; + if (!tile) continue; + place(nk, d + (tile.terrainClass === "Sea" ? 0 : 1), k); + } + } + } + // Dig each sea's cheapest route back to the ocean. The routes may share + // tiles, which is harmless. + for (let i = 0; i < components.length; i++) { + if (i === mainIndex) continue; + let endpoint = null; + let best = Infinity; + for (const coords of components[i]) { + const k = key(coords.x, coords.y); + const d = dist.get(k); + if (d !== undefined && d < best) { + best = d; + endpoint = k; + } + } + let k = endpoint; + while (k !== undefined) { + if (this.tiles[k].terrainClass !== "Sea") this.tiles[k] = this._palette.sea; + k = parent.get(k); + } + } + this._rebuildLandCells(); + } + + // Every sea tile grouped into its connected body, in scan order. + _seaComponents() { + return this._components("Sea"); + } + + // Every tile of the given terrain class grouped into connected bodies, in + // scan order. Both the ocean search and the small-feature cleanup use it. + _components(terrainClass) { + const seen = new Set(); + const components = []; + for (const k in this.tiles) { + if (seen.has(k) || this.tiles[k].terrainClass !== terrainClass) continue; + const component = []; + let frontier = [parseKey(k)]; + seen.add(k); + while (frontier.length > 0) { + const next = []; + for (const coords of frontier) { + component.push(coords); + for (const neighbour of this.topology.neighbours(coords.x, coords.y)) { + const nk = key(neighbour.x, neighbour.y); + if (seen.has(nk)) continue; + const tile = this.tiles[nk]; + if (!tile || tile.terrainClass !== terrainClass) continue; + seen.add(nk); + next.push(neighbour); + } + } + frontier = next; + } + components.push(component); + } + return components; + } + + // Smooths away the speckle: islands below the configured fraction of the map + // become sea, and seas below it become land. Small maps round the thresholds + // to one tile, which leaves them untouched. + _removeSmallFeatures() { + const area = this.config.mapSize.x * this.config.mapSize.y; + const minIsland = Math.max(1, Math.round(area * (this.config.minIslandFraction || 0))); + const minLake = Math.max(1, Math.round(area * (this.config.minLakeFraction || 0))); + if (minIsland <= 1 && minLake <= 1) return; + let changed = false; + if (minIsland > 1) { + for (const component of this._components("Land")) { + if (component.length >= minIsland) continue; + for (const coords of component) this.tiles[key(coords.x, coords.y)] = this._palette.sea; + changed = true; + } + } + if (minLake > 1) { + const fill = this._palette.plains; + for (const component of this._components("Sea")) { + if (component.length >= minLake) continue; + for (const coords of component) this.tiles[key(coords.x, coords.y)] = fill; + changed = true; + } + } + if (changed) this._rebuildLandCells(); + } + + // Recomputes the land cells after carving bridges through land. + _rebuildLandCells() { + this.landCells = []; + for (const k in this.tiles) { + if (this.tiles[k].terrainClass === "Land") this.landCells.push(parseKey(k)); + } + } + _computeLandmasses() { this._landmassSize = new Map(); const landSet = new Set(this.landCells.map((c) => key(c.x, c.y))); @@ -212,12 +521,22 @@ export class MapGenerator { } } } + _isLand(coords) { + const tile = this.tiles[key(coords.x, coords.y)]; + return !!tile && tile.terrainClass === "Land"; + } } function buildPalette() { - const land = TERRAIN_TILES.filter((t) => t.terrainClass === "Land").slice(); - land.sort((a, b) => a.minElevation - b.minElevation); + const landTiles = TERRAIN_TILES.filter((t) => t.terrainClass === "Land"); + const byType = {}; + for (const tile of landTiles) { + // The first entry of a type is the canonical one (there is more than one + // plain "Land" sprite only through the atlas, not the model). + if (!byType[tile.terrainType]) byType[tile.terrainType] = tile; + } + const plains = byType.Land || landTiles[0] || null; const sea = TERRAIN_TILES.find((t) => t.terrainClass === "Sea") || null; const ice = TERRAIN_TILES.find((t) => t.terrainClass === "Ice") || null; - return { land, sea, ice }; + return { plains, byType, sea, ice }; } diff --git a/shared/terrain_stats.js b/shared/terrain_stats.js index 2cae74f..61ec139 100644 --- a/shared/terrain_stats.js +++ b/shared/terrain_stats.js @@ -7,6 +7,7 @@ export const DEFAULT_MOVEMENT_COST_MULTIPLIER = 1.0; export class TerrainStats { constructor(tiles) { this._tiles = tiles; + this._minMovementCost = undefined; } _value(coords, field) { @@ -28,8 +29,10 @@ export class TerrainStats { } // Cheapest authored movement cost across every tile in use, so the A* - // heuristic never overestimates a path. + // heuristic never overestimates a path. The terrain is fixed once generated, + // so the scan is done once and cached. minMovementCostMultiplier() { + if (this._minMovementCost !== undefined) return this._minMovementCost; let cheapest = Infinity; for (const k in this._tiles) { const value = this.movementCostMultiplier({ @@ -38,6 +41,7 @@ export class TerrainStats { }); if (value < cheapest) cheapest = value; } - return cheapest < Infinity ? cheapest : DEFAULT_MOVEMENT_COST_MULTIPLIER; + this._minMovementCost = cheapest < Infinity ? cheapest : DEFAULT_MOVEMENT_COST_MULTIPLIER; + return this._minMovementCost; } } diff --git a/shared/text_format.js b/shared/text_format.js index fb609e8..e925ab7 100644 --- a/shared/text_format.js +++ b/shared/text_format.js @@ -2,7 +2,8 @@ // scripts/classes/text_format.gd. export function groupDigits(value) { - const digits = String(Math.abs(Math.trunc(value))); + const truncated = Math.trunc(value); + const digits = String(Math.abs(truncated)); let grouped = ""; let count = 0; for (let i = digits.length - 1; i >= 0; i--) { @@ -10,7 +11,7 @@ export function groupDigits(value) { count += 1; if (count % 3 === 0 && i > 0) grouped = " " + grouped; } - return (value < 0 ? "-" : "") + grouped; + return (truncated < 0 ? "-" : "") + grouped; } function threeSignificant(value) { diff --git a/tests/app_test.js b/tests/app_test.js index 40e1d17..1cd8fd5 100644 --- a/tests/app_test.js +++ b/tests/app_test.js @@ -96,6 +96,20 @@ export class AppTest extends TestCase { } } + async test_civilisation_picker_is_alphabetical() { + const env = await setupDom(); + try { + FakeWebSocket.reset(); + await import(`../client/js/app.js?sort=${Date.now()}`); + const names = env.$("#new-civs li span").map((_, el) => env.$(el).text()).get(); + const expected = names.slice().sort((a, b) => a.localeCompare(b)); + this.assertEqual(names, expected); + this.assertEqual(env.$("#new-civs li.selected").attr("data-civ-id"), CIVILISATIONS[0].id); + } finally { + teardownDom(env); + } + } + async test_create_flow_sends_setup_and_renders() { const env = await setupDom(); try { diff --git a/tests/data_test.js b/tests/data_test.js index 075b2df..718af99 100644 --- a/tests/data_test.js +++ b/tests/data_test.js @@ -13,6 +13,8 @@ import { effectValue, statusById, STATUS_BATTLE, + PILLAGE, + pillageSteps, } from "../shared/data.js"; export class DataTest extends TestCase { @@ -49,6 +51,20 @@ export class DataTest extends TestCase { this.assertEqual(battle.upkeepMultiplier, 3); } + test_pillage_steps_compound_per_unit() { + const one = pillageSteps(100000, 1); + this.assertApprox(one.steps[0].removed, 10000, 1e-6); + this.assertApprox(one.steps[0].heal, 10, 1e-9); + this.assertApprox(one.gdpMultiplier, PILLAGE.gdpPenalty, 1e-9); + + const two = pillageSteps(100000, 2); + this.assertApprox(two.steps[0].removed, 10000, 1e-6); + this.assertApprox(two.steps[1].removed, 9000, 1e-6); + this.assertApprox(two.steps[1].heal, 9, 1e-9); + this.assertApprox(two.steps[1].remaining, 81000, 1e-6); + this.assertApprox(two.gdpMultiplier, 0.49, 1e-9); + } + test_trainable_units_exist_in_the_catalogue() { this.assertNotEmpty(TRAINABLE_UNIT_IDS); for (const id of TRAINABLE_UNIT_IDS) { diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js index b52fc81..dfa4166 100644 --- a/tests/game_screen_test.js +++ b/tests/game_screen_test.js @@ -57,7 +57,7 @@ export class GameScreenTest extends TestCase { const unit = state.units.find((u) => u.civ === 0); const goal = adjacentLand(state, unit); - screen.map.onMoveOrdered(unit.id, goal); + await screen.map.onMoveOrdered([unit.id], goal); this.assertEqual(network.orders[1], { type: "move", unit: unit.id, coords: [goal.x, goal.y] }); this.assertTrue(screen.map._unitMotion.get(unit.id).predicting, "the move was predicted"); @@ -131,7 +131,7 @@ export class GameScreenTest extends TestCase { this.assertFalse(env.$("#train-status").hasClass("hidden")); this.assertTrue(env.$("#train-label").text().includes("Training")); - state.training.get(capital.id).elapsedHours = 3; + state.training.get(capital.id)[0].elapsedHours = 3; screen.onState(state.snapshot(0)); this.assertTrue(env.$("#train-label").text().includes("left")); @@ -144,6 +144,68 @@ export class GameScreenTest extends TestCase { } } + async test_attack_and_scheduled_move_orders_are_serialised() { + const env = await setupDom(); + try { + const network = stubNetwork(); + const screen = new GameScreen(network, makeConfig()); + screen.enter(); + const state = defaultState(); + screen.onState(state.snapshot(0)); + const raw = state.units.find((u) => u.civ === 0); + const unit = screen._unit(raw.id); + const goal = adjacentLand(state, raw); + await screen._sendMove([unit], goal, null, "attack"); + this.assertEqual(network.orders[0], { + type: "attack", + unit: unit.id, + coords: [goal.x, goal.y], + }); + await screen._sendMove([unit], goal, null, "schedule"); + this.assertEqual(network.orders[1], { + type: "schedule", + unit: unit.id, + coords: [goal.x, goal.y], + goals: [[goal.x, goal.y]], + append: true, + }); + screen.leave(); + } finally { + teardownDom(env); + } + } + + async test_training_modal_lists_the_queue_and_cancels() { + const env = await setupDom(); + try { + const network = stubNetwork(); + const screen = new GameScreen(network, makeConfig()); + screen.enter(); + const state = defaultState(); + screen.onState(state.snapshot(0)); + const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); + screen.map.onCitySelected(capital.id); + env.$("#city-train").click(); + state.requestTrain(capital.id, 0); + state.requestTrain(capital.id, 0); + screen.onState(state.snapshot(0)); + this.assertSize(env.$("#train-queue .queue-row"), 1, "contiguous identical units merge"); + this.assertTrue( + env.$("#train-queue .queue-row").first().text().includes("×2"), + "the merged row shows how many units it holds" + ); + this.assertTrue( + env.$("#train-queue .queue-row").first().text().includes("all ready in"), + "the merged row forecasts the whole run" + ); + env.$("#train-queue .queue-row").first().find("button").click(); + this.assertEqual(network.orders[0], { type: "cancel_train", city: capital.id, index: 1 }); + screen.leave(); + } finally { + teardownDom(env); + } + } + async test_bottom_panel_information() { const env = await setupDom(); try { @@ -160,6 +222,11 @@ export class GameScreenTest extends TestCase { screen.map.onUnitSelected(unit.id); this.assertFalse(env.$("#unit-info").hasClass("hidden")); this.assertTrue(env.$("#unit-name").text().length > 0); + this.assertEqual( + env.$("#unit-health-label").text(), + `${Math.round(unit.hp)} / ${Math.round(unit.maxHp)} HP`, + "the panel shows hit points next to the bar" + ); screen._showUnit(null); this.assertTrue(env.$("#unit-info").hasClass("hidden")); screen._showCity(null); @@ -314,7 +381,7 @@ export class GameScreenTest extends TestCase { screen.map.predictMove = (u, coords) => { predicted = { u, coords }; }; - screen.map.onMoveOrdered(unit.id, goal); + await screen.map.onMoveOrdered([unit.id], goal); this.assertFalse(env.$("#modal-confirm").hasClass("hidden"), "the war prompt opens"); this.assertEmpty(network.orders, "nothing is sent before confirming"); this.assertTrue(env.$("#confirm-message").text().includes("Britain")); @@ -340,7 +407,7 @@ export class GameScreenTest extends TestCase { const goal = { x: 902, y: 902 }; screen.map.territory.set("902,902", 1); screen.map.planMove = () => [{ x: 1, y: 1 }, goal]; - screen.map.onMoveOrdered(unit.id, goal); + await screen.map.onMoveOrdered([unit.id], goal); env.$("#confirm-cancel").click(); this.assertEmpty(network.orders, "cancelling keeps the peace"); screen.leave(); @@ -363,7 +430,7 @@ export class GameScreenTest extends TestCase { screen.map.territory.set("901,901", 1); screen.map.planMove = () => [{ x: 1, y: 1 }, goal]; screen.map.predictMove = () => null; - screen.map.onMoveOrdered(unit.id, goal); + await screen.map.onMoveOrdered([unit.id], goal); this.assertTrue(env.$("#modal-confirm").hasClass("hidden")); this.assertEqual(network.orders[0], { type: "move", unit: unit.id, coords: [901, 901] }); screen.leave(); @@ -381,7 +448,7 @@ export class GameScreenTest extends TestCase { screen.onState(defaultState().snapshot(0)); env.$("#civ-flag").click(); env.$("#modal-nation .tab[data-tab=diplomacy]").click(); - const row = env.$("#diplomacy-list .row-card").first(); + const row = env.$("#diplomacy-list tbody tr").first(); this.assertTrue(row.find(".name").text().length > 0); row.find("button").click(); this.assertEqual(network.orders[0].type, "declare_war"); @@ -448,4 +515,150 @@ export class GameScreenTest extends TestCase { teardownDom(env); } } + + async test_stack_menu_selects_all_and_splits_one() { + const env = await setupDom(); + try { + const screen = new GameScreen(stubNetwork(), makeConfig()); + screen.enter(); + const state = defaultState(); + const unit = state.units.find((u) => u.civ === 0); + const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); + screen.onState(state.snapshot(0)); + + screen.map.onStackMenu(unit.coords, [unit.id, second.id], 100, 120); + this.assertFalse(env.$("#stack-menu").hasClass("hidden")); + this.assertSize(env.$("#stack-menu .stack-item"), 3, "two units plus select all"); + + env.$("#stack-menu .stack-all").click(); + this.assertEqual(screen.selectedUnitIds, [unit.id, second.id]); + this.assertTrue(env.$("#unit-name").text().includes("2 units selected")); + this.assertTrue(env.$("#stack-menu").hasClass("hidden"), "the menu closes on pick"); + + screen.map.onStackMenu(unit.coords, [unit.id, second.id], 100, 120); + env.$("#stack-menu .stack-item").not(".stack-move").not(".stack-all").first().click(); + this.assertEqual(screen.selectedUnitIds, [unit.id], "picking one splits the stack"); + screen.leave(); + } finally { + teardownDom(env); + } + } + + async test_clicking_a_stacked_unit_selects_the_whole_stack() { + const env = await setupDom(); + try { + const network = stubNetwork(); + const screen = new GameScreen(network, makeConfig()); + screen.enter(); + const state = defaultState(); + const unit = state.units.find((u) => u.civ === 0); + const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); + screen.onState(state.snapshot(0)); + + screen.map.onUnitSelected(unit.id); + this.assertEqual( + screen.selectedUnitIds.slice().sort((a, b) => a - b), + [unit.id, second.id].sort((a, b) => a - b), + "the whole stack is selected" + ); + + // The next order is given to every unit in the stack. + const goal = adjacentLand(state, unit); + await screen.map._onRightClick(goal, {}); + this.assertEqual(network.orders[0].type, "move"); + this.assertEqual( + network.orders[0].units.slice().sort((a, b) => a - b), + [unit.id, second.id].sort((a, b) => a - b) + ); + screen.leave(); + } finally { + teardownDom(env); + } + } + + async test_group_move_uses_the_slowest_speed() { + const env = await setupDom(); + try { + const network = stubNetwork(); + const screen = new GameScreen(network, makeConfig()); + screen.enter(); + const state = defaultState(); + const capital = state.units.find((u) => u.civ === 0); + const infantry = state._spawnUnit(capital.coords, 0, state.protoUnits[0]); + const artillery = state._spawnUnit(capital.coords, 0, state.protoUnits[2]); + screen.onState(state.snapshot(0)); + + screen._selectUnits([infantry.id, artillery.id]); + const goal = adjacentLand(state, capital); + await screen.map.onMoveOrdered([infantry.id, artillery.id], goal); + + this.assertEqual(network.orders[0].type, "move"); + this.assertEqual(network.orders[0].units, [infantry.id, artillery.id]); + const infHours = screen.map._unitMotion.get(infantry.id).segmentHours[0]; + const artHours = screen.map._unitMotion.get(artillery.id).segmentHours[0]; + this.assertApprox(infHours, artHours, 1e-9, "both move at the slowest member's pace"); + screen.leave(); + } finally { + teardownDom(env); + } + } + + async test_pillage_button_forecasts_and_sends_the_order() { + const env = await setupDom(); + try { + const network = stubNetwork(); + const screen = new GameScreen(network, makeConfig()); + screen.enter(); + const state = defaultState(); + const coords = findEnemyTile(state); + this.assertNotNull(coords, "found an enemy tile"); + state.tilePopulation.set(`${coords.x},${coords.y}`, 100000); + const first = state._spawnUnit(coords, 0, state.protoUnits[0]); + const second = state._spawnUnit(coords, 0, state.protoUnits[0]); + first.hp = 100; + second.hp = 90; + screen.onState(state.snapshot(0)); + + screen._selectUnits([first.id, second.id]); + const $button = env.$("#unit-pillage"); + this.assertFalse($button.hasClass("hidden"), "the pillage button shows"); + const tooltip = $button.attr("data-tooltip") || ""; + this.assertTrue(tooltip.includes("19 000"), `forecast removes 19k: ${tooltip}`); + this.assertTrue(tooltip.includes("0.49"), "forecast shows the GDP multiplier"); + this.assertTrue(tooltip.includes("+10") && tooltip.includes("+9"), "forecast shows both heals"); + + $button.click(); + this.assertEqual(network.orders[0], { type: "pillage", units: [first.id, second.id] }); + screen.leave(); + } finally { + teardownDom(env); + } + } + + async test_pillage_button_hides_in_friendly_land() { + const env = await setupDom(); + try { + const screen = new GameScreen(stubNetwork(), makeConfig()); + screen.enter(); + const state = defaultState(); + const unit = state.units.find((u) => u.civ === 0); + screen.onState(state.snapshot(0)); + screen._selectUnits([unit.id]); + this.assertTrue(env.$("#unit-pillage").hasClass("hidden")); + screen.leave(); + } finally { + teardownDom(env); + } + } +} + +function findEnemyTile(state) { + for (const [k, owner] of state.territory) { + if (owner !== 1) continue; + const i = k.indexOf(","); + const coords = { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) }; + if (state.unitAt(coords) || state.cityAt(coords)) continue; + return coords; + } + return null; } diff --git a/tests/game_server_test.js b/tests/game_server_test.js index 46a4ea9..ee8f208 100644 --- a/tests/game_server_test.js +++ b/tests/game_server_test.js @@ -23,6 +23,18 @@ function makeServer(civIds = ["france", "britain"]) { return { server, network }; } +function emptyLandPair(state) { + for (const cell of state.landCells) { + if (state.unitAt(cell) || state.cityAt(cell)) continue; + for (const neighbour of state.topology.neighbours(cell.x, cell.y)) { + if (!state._isLand(neighbour)) continue; + if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue; + return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } }; + } + } + throw new Error("no empty land pair"); +} + export class GameServerTest extends TestCase { test_configure_game_builds_a_state() { const { server } = makeServer(); @@ -82,13 +94,24 @@ export class GameServerTest extends TestCase { this.assertFalse(server.handleOrder(1, { type: "move", unit: enemy.id, coords: [goal.x, goal.y] })); } - test_train_order_for_own_city_is_accepted_once() { + test_train_orders_queue_for_own_city() { const { server } = makeServer(); server.peerCiv.set(1, 0); const city = server.state.cities[0]; const order = { type: "train", city: city.id, proto: 0 }; this.assertTrue(server.handleOrder(1, order)); - this.assertFalse(server.handleOrder(1, order), "city is already training"); + this.assertTrue(server.handleOrder(1, order), "a second unit joins the queue"); + this.assertSize(server.state.getTraining(city.id), 2); + } + + test_cancel_train_order_for_own_city_is_accepted() { + const { server } = makeServer(); + server.peerCiv.set(1, 0); + const city = server.state.cities[0]; + server.handleOrder(1, { type: "train", city: city.id, proto: 0 }); + server.handleOrder(1, { type: "train", city: city.id, proto: 0 }); + this.assertTrue(server.handleOrder(1, { type: "cancel_train", city: city.id, index: 0 })); + this.assertSize(server.state.getTraining(city.id), 1); } test_train_order_for_enemy_city_is_rejected() { @@ -161,6 +184,38 @@ export class GameServerOrdersTest extends TestCase { this.assertEqual(typeof result, "boolean"); } + test_attack_order_targets_an_adjacent_enemy() { + const { server } = makeServer(); + server.peerCiv.set(1, 0); + const pair = emptyLandPair(server.state); + const attacker = server.state._spawnUnit(pair.a, 0, server.state.protoUnits[0]); + server.state._spawnUnit(pair.b, 1, server.state.protoUnits[0]); + this.assertTrue( + server.handleOrder(1, { type: "attack", unit: attacker.id, coords: [pair.b.x, pair.b.y] }) + ); + } + + test_attack_order_without_a_target_is_rejected() { + const { server } = makeServer(); + server.peerCiv.set(1, 0); + const pair = emptyLandPair(server.state); + const attacker = server.state._spawnUnit(pair.a, 0, server.state.protoUnits[0]); + this.assertFalse( + server.handleOrder(1, { type: "attack", unit: attacker.id, coords: [pair.b.x, pair.b.y] }) + ); + } + + test_schedule_order_queues_destinations() { + const { server } = makeServer(); + server.peerCiv.set(1, 0); + const unit = server.state.units.find((u) => u.civ === 0); + const goal = adjacentLand(server.state, unit); + const payload = { type: "schedule", unit: unit.id, goals: [[goal.x, goal.y]], append: true }; + this.assertTrue(server.handleOrder(1, payload)); + this.assertTrue(server.handleOrder(1, payload)); + this.assertSize(unit.waypoints, 1, "the appended destination is queued"); + } + test_extra_players_become_spectators() { const { server, network } = makeServer(); server.authenticate(1, "Host", "pw"); diff --git a/tests/game_state_test.js b/tests/game_state_test.js index f0c66d8..e458099 100644 --- a/tests/game_state_test.js +++ b/tests/game_state_test.js @@ -1,4 +1,5 @@ import { TestCase } from "./framework/test_case.js"; +import { TRAINING_QUEUE_LIMIT } from "../shared/game_state.js"; import { smallState, adjacentLand, @@ -38,6 +39,30 @@ export class GameStateTest extends TestCase { this.assertEqual(a.territory, b.territory); } + test_foreign_territory_is_a_wall_unless_targeted() { + const state = smallState(["france", "britain", "poland"]); + const mover = state.units.find((u) => u.civ === 0); + const tile = state.landCells.find( + (c) => + !state.unitAt(c) && + !state.cityAt(c) && + (c.x !== mover.coords.x || c.y !== mover.coords.y) + ); + state.territory.set(`${tile.x},${tile.y}`, 2); + this.assertFalse( + state._canUnitEnter(mover, tile, false, 1), + "a third party's land blocks transit" + ); + this.assertTrue( + state._canUnitEnter(mover, tile, false, 2), + "the targeted civ's land may be crossed" + ); + this.assertTrue( + state._canUnitEnter(mover, tile, true, 1), + "the goal tile itself is always enterable" + ); + } + test_territory_only_claims_land() { const state = smallState(); this.assertNotEmpty(state.territory); @@ -55,6 +80,12 @@ export class GameStateTest extends TestCase { } } + test_city_names_are_unique_across_the_world() { + const state = smallState(["france", "britain", "switzerland", "new_hampshire"]); + const names = state.cities.map((city) => city.name); + this.assertEqual(new Set(names).size, names.length, "no two cities share a name"); + } + test_everything_starts_explored_and_units_are_visible() { const state = smallState(); for (const k in state.tiles) { @@ -75,6 +106,25 @@ export class GameStateTest extends TestCase { this.assertEqual(state.getBudget(0), 0); } + test_civilisations_start_with_equal_cities_population_and_gdp_per_capita() { + const state = smallState(["france", "britain", "slovenia"]); + const count = state.civilisations.length; + const cities = []; + const populations = []; + const perCapitas = []; + for (let civ = 0; civ < count; civ++) { + cities.push(state.cities.filter((c) => c.civ === civ).length); + const population = state.getPlayerPopulation(civ); + populations.push(population); + perCapitas.push(state.getPlayerGdp(civ) / population); + } + this.assertEqual(cities, [2, 2, 2], "every civ starts with the same number of cities"); + this.assertEqual(populations, [populations[0], populations[0], populations[0]], "every civ starts with the same population"); + for (const perCapita of perCapitas) { + this.assertApprox(perCapita, perCapitas[0], 1e-6, "every civ starts with the same GDP per capita"); + } + } + test_can_enter_land_but_not_water() { const state = smallState(); const proto = state.protoUnits[0]; @@ -146,14 +196,43 @@ export class GameStateTest extends TestCase { const before = state.units.length; const beforeUnits = unitsOf(state, city.civ); this.assertTrue(state.requestTrain(city.id, 0)); - this.assertFalse(state.requestTrain(city.id, 0), "city is busy"); - const entry = state.getTraining(city.id); - this.assertNotNull(entry, "training queue started"); - for (let i = 0; i < Math.ceil(entry.totalHours) + 1 && state.units.length === before; i++) { + this.assertTrue(state.requestTrain(city.id, 0), "a second unit joins the queue"); + const queue = state.getTraining(city.id); + this.assertNotNull(queue, "training queue started"); + this.assertSize(queue, 2); + const total = queue.reduce((sum, entry) => sum + entry.totalHours, 0); + for (let i = 0; i < Math.ceil(total) + 1 && state.units.length < before + 2; i++) { state.advanceHour(); } - this.assertEqual(state.units.length, before + 1); - this.assertEqual(unitsOf(state, city.civ), beforeUnits + 1); + this.assertEqual(state.units.length, before + 2); + this.assertEqual(unitsOf(state, city.civ), beforeUnits + 2); + this.assertNull(state.getTraining(city.id), "the queue empties once it has finished"); + } + + test_training_queue_is_capped_at_one_hundred() { + const state = smallState(); + const city = state.cities[0]; + for (let i = 0; i < TRAINING_QUEUE_LIMIT; i++) { + this.assertTrue(state.requestTrain(city.id, 0), `order ${i} accepted`); + } + this.assertFalse(state.requestTrain(city.id, 0), "the queue is full"); + this.assertSize(state.getTraining(city.id), TRAINING_QUEUE_LIMIT); + } + + test_cancel_train_removes_only_that_unit_and_refunds_nothing() { + const state = smallState(); + const city = state.cities[0]; + state.requestTrain(city.id, 0); + state.requestTrain(city.id, 0); + state.requestTrain(city.id, 0); + const budget = state.getBudget(city.civ); + this.assertTrue(state.cancelTrain(city.id, 1)); + this.assertSize(state.getTraining(city.id), 2, "only one unit was cancelled"); + this.assertEqual(state.getBudget(city.civ), budget, "nothing is refunded"); + this.assertFalse(state.cancelTrain(city.id, 5), "out-of-range index is rejected"); + this.assertTrue(state.cancelTrain(city.id, 0)); + this.assertTrue(state.cancelTrain(city.id, 0)); + this.assertNull(state.getTraining(city.id), "cancelling the last unit clears the queue"); } test_training_rejects_unknown_targets() { diff --git a/tests/map_generator_test.js b/tests/map_generator_test.js index 8177a45..8a2d7c9 100644 --- a/tests/map_generator_test.js +++ b/tests/map_generator_test.js @@ -1,6 +1,6 @@ import { TestCase } from "./framework/test_case.js"; import { MapGenerator } from "../shared/map_generator.js"; -import { MapTopology, key } from "../shared/hex.js"; +import { MapTopology, key, parseKey } from "../shared/hex.js"; import { MAP_CONFIG } from "../shared/data.js"; import { Random } from "../shared/rng.js"; @@ -21,6 +21,56 @@ function generate(cfg = config()) { return generator; } +// Sizes of the connected bodies of one terrain class, in tiles. `matches` is +// either a terrain class name or a predicate over tiles. +function componentSizes(generator, matches) { + const test = typeof matches === "function" ? matches : (tile) => tile.terrainClass === matches; + const seen = new Set(); + const sizes = []; + for (const k in generator.tiles) { + if (seen.has(k) || !test(generator.tiles[k])) continue; + const frontier = [parseKey(k)]; + seen.add(k); + let size = 0; + while (frontier.length > 0) { + const coords = frontier.pop(); + size += 1; + for (const neighbour of generator.topology.neighbours(coords.x, coords.y)) { + const nk = key(neighbour.x, neighbour.y); + if (seen.has(nk)) continue; + const tile = generator.tiles[nk]; + if (!tile || !test(tile)) continue; + seen.add(nk); + frontier.push(neighbour); + } + } + sizes.push(size); + } + return sizes; +} + +// Number of separate connected bodies of sea on the generated map. +function seaBodies(generator) { + return componentSizes(generator, "Sea").length; +} + +// Number of land tiles that touch a sea tile: a proxy for how rough the coast +// is, since a wavier shore has more land-water edges. +function coastLength(generator) { + let edges = 0; + for (const k in generator.tiles) { + if (generator.tiles[k].terrainClass !== "Land") continue; + const i = k.indexOf(","); + const x = Number(k.slice(0, i)); + const y = Number(k.slice(i + 1)); + for (const neighbour of generator.topology.neighbours(x, y)) { + const tile = generator.tiles[key(neighbour.x, neighbour.y)]; + if (tile && tile.terrainClass !== "Land") edges += 1; + } + } + return edges; +} + export class MapGeneratorTest extends TestCase { test_generate_is_deterministic_for_a_seed() { const a = generate(); @@ -71,6 +121,17 @@ export class MapGeneratorTest extends TestCase { this.assertGreaterOrEqual(distance, cfg.minCapitalDistance, "capitals kept apart"); } + test_pick_cities_gives_every_civilisation_the_same_count() { + const cfg = config({ citiesPerCiv: 3, citySpacing: 5 }); + const generator = generate(cfg); + const capitals = generator.pickCapitals(3, new Random(1)); + this.assertSize(capitals, 3); + const sites = generator.pickCities(capitals, cfg.citiesPerCiv, cfg.citySpacing); + const counts = [0, 0, 0]; + for (const site of sites) counts[site.civIndex] += 1; + this.assertEqual(counts, [3, 3, 3], "every civ got the same number of cities"); + } + test_pick_cities_includes_one_site_per_capital() { const cfg = config({ citiesPerCiv: 3, citySpacing: 2 }); const generator = generate(cfg); @@ -116,4 +177,71 @@ export class MapGeneratorTest extends TestCase { this.assertApprox(generator._applyFalloff({ x: 0, y: 0 }, 1.0, false), 1.0, 1e-9); this.assertApprox(generator._applyFalloff({ x: 16, y: 12 }, 1.0, false), 0.0, 1e-9); } + + test_raw_terrain_can_have_separate_seas() { + const separate = seaBodies(generate(config({ connectOceans: false, terrainSeed: 1 }))); + this.assertGreater(separate, 1, "this seed really needs joining up"); + } + + test_every_sea_is_joined_into_one_ocean() { + const bodies = seaBodies(generate(config({ connectOceans: true, terrainSeed: 1 }))); + this.assertEqual(bodies, 1, "one connected ocean"); + } + + test_ocean_connection_is_deterministic() { + const a = generate(config({ connectOceans: true })); + const b = generate(config({ connectOceans: true })); + this.assertEqual(a.tiles, b.tiles); + this.assertEqual(a.landCells, b.landCells); + } + + test_small_islands_and_lakes_are_smoothed_away() { + const cfg = config({ connectOceans: false, minIslandFraction: 0.03, minLakeFraction: 0.03 }); + const generator = generate(cfg); + const min = Math.round(cfg.mapSize.x * cfg.mapSize.y * 0.03); + const land = componentSizes(generator, "Land"); + const sea = componentSizes(generator, "Sea"); + this.assertNotEmpty(land, "land remains"); + this.assertNotEmpty(sea, "sea remains"); + for (const size of land) this.assertGreaterOrEqual(size, min, `landmass of ${size} smoothed away`); + for (const size of sea) this.assertGreaterOrEqual(size, min, `lake of ${size} smoothed away`); + } + + test_every_biome_is_placed_on_the_land() { + const generator = generate(); + const terrainTypes = new Set(); + for (const coords of generator.landCells) { + terrainTypes.add(generator.tiles[key(coords.x, coords.y)].terrainType); + } + for (const type of ["Land", "Forest", "Hills", "Mountain", "Desert"]) { + this.assertTrue(terrainTypes.has(type), `${type} appears`); + } + } + + test_biome_clusters_stay_small() { + const generator = generate(); + for (const biome of MAP_CONFIG.terrainBiomes) { + const largest = Math.round(biome.size * 1.5); + const sizes = componentSizes( + generator, + (tile) => tile.terrainType === biome.terrainType + ); + for (const size of sizes) { + this.assertTrue( + size <= largest, + `${biome.terrainType} cluster of ${size} exceeds ${largest}` + ); + } + } + } + + test_a_rough_coast_has_more_shore_than_a_smooth_one() { + const smooth = generate(config({ coastRoughness: 0 })); + const rough = generate(config({ coastRoughness: 0.4 })); + this.assertGreater( + coastLength(rough), + coastLength(smooth), + "the roughened coast has a longer shore" + ); + } } diff --git a/tests/map_view_test.js b/tests/map_view_test.js index 48e3923..d9db37b 100644 --- a/tests/map_view_test.js +++ b/tests/map_view_test.js @@ -21,10 +21,15 @@ export class MapViewTest extends TestCase { const state = defaultState(); const { env, map, snapshot } = await this.build(state); try { - this.assertSize(env.$("#layer-terrain .hex"), 64 * 40); - this.assertSize(map.territory, snapshot.territory.length); - this.assertSize(env.$("#layer-fog .hex"), 64 * 40 - snapshot.visible.length); + const rendered = []; + for (const rec of map._chunks.values()) rendered.push(...rec.tileKeys); + this.assertGreater(rendered.length, 0, "chunks near the camera are built"); + this.assertLess(rendered.length, Object.keys(map.tiles).length, "the whole map is never built"); + this.assertSize(env.$("#layer-terrain .hex"), rendered.length); + const visibleRendered = rendered.filter((k) => map.visible.has(k)).length; + this.assertSize(env.$("#layer-fog .hex"), rendered.length - visibleRendered); this.assertGreater(env.$("#layer-borders .border-seg").length, 0); + this.assertSize(map.territory, snapshot.territory.length); this.assertSize(env.$("#layer-entities .city"), snapshot.cities.length); this.assertSize(env.$("#layer-entities .unit"), snapshot.units.length); this.assertEqual(map.localCiv, 0); @@ -81,7 +86,9 @@ export class MapViewTest extends TestCase { const { env, map } = await this.build(state); try { const coords = map.tileAtScreen(100000, 300); - this.assert(coords.x >= -32 && coords.x <= 31, `x ${coords.x} wrapped`); + const minX = map.topology.originX; + const maxX = map.topology.originX + map.topology.width - 1; + this.assert(coords.x >= minX && coords.x <= maxX, `x ${coords.x} wrapped`); } finally { teardownDom(env); } @@ -92,41 +99,53 @@ export class MapViewTest extends TestCase { const { env, map } = await this.build(state); try { this.assertGreater(map._period, 0, "the map has a wrap period"); - const west = map._terrainWrap.find((item) => item.centerX < -1000); - this.assertNotNull(west, "found a west-edge tile"); - const beforeShift = west.shift; + const rec = map._chunks.values().next().value; + this.assertNotNull(rec, "a chunk is materialised"); + const chunkKey = rec.cx + "," + rec.cy; + const beforeShift = rec.shift; - // Scrolling exactly one period must land on the same picture: every tile + // Scrolling exactly one period must land on the same picture: every chunk // advances to its next wrapped copy, cancelling the camera movement. map.panBy(map._period, 0); - this.assertApprox(west.shift, beforeShift + map._period, 1e-6); + const after = map._chunks.get(chunkKey); + this.assertNotNull(after, "the same chunk stays materialised one period over"); + this.assertApprox(after.shift, beforeShift + map._period, 1e-6); this.assertTrue( - west.el.style.transform.includes(`translateX(${west.shift}px)`), - `expected translateX(${west.shift}px), got ${west.el.style.transform}` + after.terrainEl.style.transform.includes(`translateX(${after.x + after.shift}px)`), + `expected translateX(${after.x + after.shift}px), got ${after.terrainEl.style.transform}` ); } finally { teardownDom(env); } } - async test_tiles_meet_seamlessly_at_the_wrap() { + async test_chunks_meet_seamlessly_at_the_wrap() { const state = defaultState(); const { env, map } = await this.build(state); try { - const eastX0 = Math.max(...map._terrainWrap.map((item) => item.centerX)); - const westX0 = Math.min(...map._terrainWrap.map((item) => item.centerX)); - const east = map._terrainWrap.find((item) => item.centerX === eastX0); - const west = map._terrainWrap.find((item) => item.centerX === westX0); - this.assertNotNull(east, "found an east-edge tile"); - this.assertNotNull(west, "found a west-edge tile"); - - // Sit on the seam so the wrapped west-edge copy lands next to the east - // edge instead of leaving a gap. - map.camera.x = east.centerX; + // Sit on the seam (just past the east edge) so both the east-edge chunk + // and the wrapped copy of the west-edge chunk are on screen together. + const t = map.topology; + map.camera.x = (t.originX + t.width) * COL_STEP; + map.camera.y = 0; map._applyCamera(); - const eastX = east.centerX + east.shift; - const westX = west.centerX + west.shift; - this.assertApprox(westX - eastX, COL_STEP, 1e-6); + + const rows = new Map(); + for (const rec of map._chunks.values()) { + if (!rows.has(rec.cy)) rows.set(rec.cy, []); + rows.get(rec.cy).push(rec); + } + let row = null; + for (const list of rows.values()) { + if (!row || list.length > row.length) row = list; + } + this.assertGreater(row.length, 1, "several chunks share the seam row"); + row.sort((a, b) => (a.x + a.shift) - (b.x + b.shift)); + for (let i = 1; i < row.length; i++) { + const previousRight = row[i - 1].x + row[i - 1].shift + row[i - 1].px; + const currentLeft = row[i].x + row[i].shift; + this.assertApprox(currentLeft - previousRight, COL_STEP, 1e-6); + } } finally { teardownDom(env); } @@ -136,17 +155,17 @@ export class MapViewTest extends TestCase { const state = defaultState(); const { env, map } = await this.build(state); try { - const west = map._terrainWrap.find((item) => item.centerX < -1000); - const beforeLeft = west.el.style.left; + const rec = map._chunks.values().next().value; + const beforeLeft = rec.terrainEl.style.left; - // The drag path updates the camera but defers per-element wrapping to the - // render loop; either way the element must not be laid out again. + // The drag path updates the camera but defers the chunk sync to the render + // loop; either way the chunk must be moved with a transform, not layout. map.camera.x += map._period; map._applyCamera(false); map.animate(0); - this.assertEqual(west.el.style.left, beforeLeft, "left is never rewritten"); - this.assertTrue(west.el.style.transform.includes("translateX")); + this.assertEqual(rec.terrainEl.style.left, beforeLeft, "left is never rewritten"); + this.assertTrue(rec.terrainEl.style.transform.includes("translateX")); } finally { teardownDom(env); } @@ -171,6 +190,12 @@ export class MapViewTest extends TestCase { const state = defaultState(); const { env, map } = await this.build(state); try { + this.assertFalse(map.lod, "detailed at close zoom"); + this.assertSize(env.$("#layer-overview .overview-block"), 0, "no overview until it is needed"); + + map.camera.zoom = 0.01; + map._applyCamera(); + this.assertTrue(map.lod, "overview at far zoom"); this.assertGreater( env.$("#layer-overview .overview-block").length, 0, @@ -181,11 +206,6 @@ export class MapViewTest extends TestCase { Object.keys(map.tiles).length, "overview is simpler than the full map" ); - this.assertFalse(map.lod, "detailed at close zoom"); - - map.camera.zoom = 0.2; - map._applyCamera(); - this.assertTrue(map.lod, "overview at far zoom"); this.assertTrue(env.$("#map-world").hasClass("lod")); this.assertTrue(env.$("#layer-terrain").hasClass("detail-layer")); @@ -209,6 +229,10 @@ export class MapViewTest extends TestCase { const state = defaultState(); const { env, map } = await this.build(state); try { + // Bring the two-tile territory into view so its chunk is materialised. + map.camera.x = 0; + map.camera.y = 0; + map._applyCamera(); map.civilisations = [{ primaryColour: "#111111" }, { primaryColour: "#eeeeee" }]; map.territory = new Map([["0,0", 0], ["1,0", 1]]); map._renderBorders(); @@ -248,7 +272,7 @@ export class MapViewTest extends TestCase { const unit = snapshot.units.find((u) => u.civ === 0); const stateUnit = state.units.find((u) => u.id === unit.id); const goal = adjacentLand(state, stateUnit); - const path = map.predictMove(unit, goal); + const path = await map.predictMove(unit, goal); this.assertNotNull(path, "a route was predicted"); this.assertGreaterOrEqual(path.length, 2); const motion = map._unitMotion.get(unit.id); @@ -269,12 +293,12 @@ export class MapViewTest extends TestCase { const stateUnit = state.units.find((u) => u.id === unit.id); const goal = adjacentLand(state, stateUnit); const before = map._unitMotion.get(unit.id); - const route = map.planMove(unit, goal); + const route = await map.planMove(unit, goal); this.assertNotNull(route, "a route was planned"); this.assertGreaterOrEqual(route.length, 2); this.assertEqual(map._unitMotion.get(unit.id), before, "planning does not start a prediction"); this.assertFalse(map._unitMotion.get(unit.id).predicting); - const applied = map.predictMove(unit, goal); + const applied = await map.predictMove(unit, goal); this.assertEqual(applied, route, "predicting follows the planned route"); this.assertTrue(map._unitMotion.get(unit.id).predicting); } finally { @@ -313,7 +337,7 @@ export class MapViewTest extends TestCase { this.assertGreaterOrEqual(map._unitMotion.get(unit.id).path.length, 2); // Predicting from a unit that already walks a path reads its position. - map.predictMove(snapshot.units.find((u) => u.id === unit.id), adjacentLand(state, unit)); + await map.predictMove(snapshot.units.find((u) => u.id === unit.id), adjacentLand(state, unit)); this.assertTrue(map._unitMotion.get(unit.id).predicting); } finally { teardownDom(env); @@ -333,14 +357,14 @@ export class MapViewTest extends TestCase { this.assertEqual(selected, unit.id); let order = null; - map.onMoveOrdered = (id, coords) => { - order = { id, coords }; + map.onMoveOrdered = (ids, coords) => { + order = { ids, coords }; }; map.setSelectedUnit(unit.id); const stateUnit = state.units.find((u) => u.id === unit.id); const goal = adjacentLand(state, stateUnit); map._onRightClick(goal); - this.assertEqual(order.id, unit.id); + this.assertEqual(order.ids, [unit.id]); this.assertEqual(order.coords, goal); let tile = null; @@ -419,4 +443,116 @@ export class MapViewTest extends TestCase { teardownDom(env); } } + + async test_stacked_units_show_one_icon_with_a_count_badge() { + const state = defaultState(); + const unit = state.units.find((u) => u.civ === 0); + const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); + const { env, map } = await this.build(state); + try { + const first = map._unitViews.get(unit.id); + const other = map._unitViews.get(second.id); + this.assertEqual(first.querySelector(".unit-count").style.display, "block"); + this.assertEqual(first.querySelector(".unit-count").textContent, "2"); + this.assertEqual(other.style.visibility, "hidden", "the follower is not drawn"); + const ids = map.unitIdsAt({ x: unit.coords.x, y: unit.coords.y }).sort((a, b) => a - b); + this.assertEqual(ids, [unit.id, second.id].sort((a, b) => a - b), "both are selectable"); + } finally { + teardownDom(env); + } + } + + async test_right_click_on_a_friendly_stack_opens_the_menu() { + const state = defaultState(); + const unit = state.units.find((u) => u.civ === 0); + const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); + const { env, map } = await this.build(state); + try { + let menu = null; + map.onStackMenu = (coords, ids, x, y) => { + menu = { coords, ids, x, y }; + }; + map._onRightClick({ x: unit.coords.x, y: unit.coords.y }, { clientX: 11, clientY: 22 }); + this.assertNotNull(menu, "the stack menu opens"); + this.assertEqual(menu.ids.sort((a, b) => a - b), [unit.id, second.id].sort((a, b) => a - b)); + this.assertEqual(menu.x, 11); + this.assertEqual(menu.y, 22); + } finally { + teardownDom(env); + } + } + + async test_stack_ids_group_units_sharing_a_tile() { + const state = defaultState(); + const unit = state.units.find((u) => u.civ === 0); + const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); + const { env, map } = await this.build(state); + try { + const ids = map.stackIds(unit.id).sort((a, b) => a - b); + this.assertEqual(ids, [unit.id, second.id].sort((a, b) => a - b)); + } finally { + teardownDom(env); + } + } + + async test_right_click_on_a_friendly_stack_while_selected_moves_there() { + const state = defaultState(); + const unit = state.units.find((u) => u.civ === 0); + const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); + const { env, map } = await this.build(state); + try { + let menu = null; + let moved = null; + map.onStackMenu = () => { menu = true; }; + map.onMoveOrdered = (ids, coords) => { moved = { ids, coords }; }; + map.setSelectedUnit(second.id); + map._onRightClick({ x: unit.coords.x, y: unit.coords.y }, {}); + this.assertNull(menu, "the stack menu does not open over a selection"); + this.assertNotNull(moved, "the selected stack is ordered onto the friendly tile"); + this.assertEqual(moved.ids, [second.id]); + this.assertEqual(moved.coords, { x: unit.coords.x, y: unit.coords.y }); + } finally { + teardownDom(env); + } + } + + async test_right_click_on_an_enemy_stack_attacks_it() { const state = defaultState(); + const unit = state.units.find((u) => u.civ === 0); + const goal = adjacentLand(state, unit); + state._spawnUnit(goal, 1, state.protoUnits[0]); + const { env, map } = await this.build(state); + try { + let attack = null; + let move = null; + map.onAttackOrdered = (ids, coords) => { attack = { ids, coords }; }; + map.onMoveOrdered = (ids, coords) => { move = { ids, coords }; }; + map.setSelectedUnit(unit.id); + map._onRightClick(goal, {}); + this.assertNotNull(attack, "the enemy stack is attacked"); + this.assertNull(move, "the enemy stack is not treated as a plain move"); + this.assertEqual(attack.coords, goal); + } finally { + teardownDom(env); + } + } + + async test_ctrl_right_click_schedules_a_destination() { + const state = defaultState(); + const unit = state.units.find((u) => u.civ === 0); + const goal = adjacentLand(state, unit); + const { env, map } = await this.build(state); + try { + let schedule = null; + let move = null; + map.onScheduleOrdered = (ids, coords) => { schedule = { ids, coords }; }; + map.onMoveOrdered = (ids, coords) => { move = { ids, coords }; }; + map.setSelectedUnit(unit.id); + map._onRightClick(goal, { ctrlKey: true }); + this.assertNotNull(schedule, "Ctrl-right-click queues a destination"); + this.assertNull(move, "a scheduled click is not a plain move"); + this.assertEqual(schedule.coords, goal); + } finally { + teardownDom(env); + } + } } diff --git a/tests/modals_test.js b/tests/modals_test.js index 653ac8f..b4d0f4c 100644 --- a/tests/modals_test.js +++ b/tests/modals_test.js @@ -32,7 +32,7 @@ export class TrainModalTest extends TestCase { modal.show({ id: 1, name: "Lyon" }, 1.0e9, [{ ...PROTO_UNITS[0], serverIndex: 0 }]); modal.beginTraining("Modern infantry", 10, 2); this.assertFalse(env.$("#train-status").hasClass("hidden")); - this.assertTrue(env.$("#train-list button").prop("disabled")); + this.assertFalse(env.$("#train-list button").prop("disabled"), "more units can still be queued"); modal.setTrainingProgress("Modern infantry", 4, 10); this.assertTrue(env.$("#train-label").text().includes("6 h left")); modal.endTraining(); @@ -42,6 +42,35 @@ export class TrainModalTest extends TestCase { teardownDom(env); } } + + async test_queue_merges_only_contiguous_identical_units() { + const env = await setupDom(); + try { + const modal = new TrainModal(); + const cancelled = []; + modal.onCancel = (cityId, index) => cancelled.push([cityId, index]); + const unit = (i) => ({ ...PROTO_UNITS[i], serverIndex: i }); + const queue = [ + { protoIndex: 0, elapsedHours: 0, totalHours: 10 }, + { protoIndex: 0, elapsedHours: 0, totalHours: 10 }, + { protoIndex: 2, elapsedHours: 0, totalHours: 30 }, + { protoIndex: 0, elapsedHours: 0, totalHours: 10 }, + ]; + modal.show({ id: 7, name: "Lyon" }, 1.0e9, [unit(0), unit(2)], queue); + const rows = env.$("#train-queue .queue-row"); + this.assertSize(rows, 3, "only adjacent runs share a row"); + this.assertTrue(rows.eq(0).text().includes("Modern infantry ×2"), "the run is labelled with its size"); + this.assertTrue(rows.eq(0).text().includes("Next in"), "a run shows when its first unit lands"); + this.assertTrue(rows.eq(0).text().includes("all ready in"), "and when the run completes"); + this.assertTrue(rows.eq(1).text().includes("Artillery"), "a different type starts a row"); + this.assertTrue(rows.eq(2).text().includes("Modern infantry"), "a later run of the same type is separate"); + this.assertFalse(rows.eq(2).text().includes("×"), "the lone trailing unit is not a run"); + rows.eq(0).find("button").click(); + this.assertEqual(cancelled, [[7, 1]], "cancelling a run removes its last unit"); + } finally { + teardownDom(env); + } + } } export class BuildingsModalTest extends TestCase { @@ -200,20 +229,36 @@ export class NationModalTest extends TestCase { }; const civs = [{ name: "France" }, { name: "Britain" }, { name: "Poland" }]; const relations = [[0, 1, "peace"], [0, 2, "war"], [1, 2, "peace"]]; + const stats = [ + { population: 2.0e6, gdp: 5.0e9, gdpPerCapita: 2500 }, + { population: 1.5e6, gdp: 3.0e9, gdpPerCapita: 2000 }, + { population: 1.0e6, gdp: 1.5e9, gdpPerCapita: 1500 }, + ]; modal.show( GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, {}, - { civs, localCiv: 0, relations } + { civs, localCiv: 0, relations, stats } ); env.$("#modal-nation .tab[data-tab=diplomacy]").click(); this.assertTrue(env.$("#tab-diplomacy").hasClass("active")); - const rows = env.$("#diplomacy-list .row-card"); - this.assertSize(rows, 2, "every other nation is listed"); - this.assertTrue(rows.eq(0).find(".name").text() === "Britain"); - this.assertTrue(rows.eq(0).find(".desc").text().includes("At peace")); - this.assertTrue(rows.eq(1).find(".name").text() === "Poland"); - this.assertTrue(rows.eq(1).find(".desc").text().includes("At war")); - this.assertTrue(rows.eq(1).find("button").prop("disabled"), "already at war"); + const rows = env.$("#diplomacy-list tbody tr"); + this.assertSize(rows, 3, "every nation, including ours, is listed"); + this.assertTrue(rows.eq(0).find(".name").text() === "Britain", "alphabetical by default"); + this.assertTrue(rows.eq(0).find(".status").text().includes("At peace")); + const numbers = rows.eq(0).find("td.numeric"); + this.assertTrue(numbers.eq(0).text().includes("€3B"), "Britain's GDP shows"); + this.assertTrue(numbers.eq(1).text().includes("€2K"), "Britain's GDP/capita shows"); + this.assertTrue(numbers.eq(2).text().includes("1.5M"), "Britain's population shows"); + + const own = rows.eq(1); + this.assertTrue(own.find(".name").text() === "France", "our own nation is listed"); + this.assertTrue(own.hasClass("local-nation"), "our row is highlighted"); + this.assertTrue(own.find(".status").text().includes("Your nation")); + this.assertSize(own.find("button"), 0, "we cannot declare war on ourselves"); + + this.assertTrue(rows.eq(2).find(".name").text() === "Poland"); + this.assertTrue(rows.eq(2).find(".status").text().includes("At war")); + this.assertTrue(rows.eq(2).find("button").prop("disabled"), "already at war"); this.assertTrue(env.$("#diplomacy-sub").text().includes("at war with 1")); rows.eq(0).find("button").click(); @@ -224,6 +269,47 @@ export class NationModalTest extends TestCase { teardownDom(env); } } + + async test_diplomacy_table_sorts_by_every_column() { + const env = await setupDom(); + try { + const modal = new NationModal(); + const civs = [{ name: "France" }, { name: "Britain" }, { name: "Poland" }]; + const stats = [ + { population: 1.0e6, gdp: 1.0e9, gdpPerCapita: 1000 }, + { population: 3.0e6, gdp: 2.0e9, gdpPerCapita: 2000 }, + { population: 2.0e6, gdp: 3.0e9, gdpPerCapita: 3000 }, + ]; + modal.show( + GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, {}, + { civs, localCiv: 0, relations: [], stats } + ); + const names = () => env.$("#diplomacy-list tbody tr .name") + .map((_, el) => env.$(el).text()).get(); + this.assertEqual(names(), ["Britain", "France", "Poland"], "alphabetical by default"); + this.assertTrue(env.$("#diplomacy-list th[data-sort=name]").hasClass("sorted-asc")); + + // GDP ascending first, descending on the second click. + env.$("#diplomacy-list th[data-sort=gdp]").click(); + this.assertEqual(names(), ["France", "Britain", "Poland"]); + this.assertTrue(env.$("#diplomacy-list th[data-sort=gdp]").hasClass("sorted-asc")); + env.$("#diplomacy-list th[data-sort=gdp]").click(); + this.assertEqual(names(), ["Poland", "Britain", "France"]); + this.assertTrue(env.$("#diplomacy-list th[data-sort=gdp]").hasClass("sorted-desc")); + + // A different column starts ascending. + env.$("#diplomacy-list th[data-sort=population]").click(); + this.assertEqual(names(), ["France", "Poland", "Britain"]); + this.assertTrue(env.$("#diplomacy-list th[data-sort=population]").hasClass("sorted-asc")); + + // Back to alphabetical. + env.$("#diplomacy-list th[data-sort=name]").click(); + this.assertEqual(names(), ["Britain", "France", "Poland"]); + modal.close(); + } finally { + teardownDom(env); + } + } } export class ConfirmModalTest extends TestCase { diff --git a/tests/run_tests.js b/tests/run_tests.js index 87f0bb3..2a34167 100644 --- a/tests/run_tests.js +++ b/tests/run_tests.js @@ -16,12 +16,15 @@ import { GameClockTest } from "./game_clock_test.js"; import { LoginManagerTest } from "./login_manager_test.js"; import { RulesTest } from "./rules_test.js"; import { CombatTest } from "./combat_test.js"; +import { StackingTest, PillageTest } from "./stacking_test.js"; import { SiegeTest } from "./siege_test.js"; +import { AttackOrderTest, ScheduledOrderTest } from "./orders_test.js"; import { GameStateTest } from "./game_state_test.js"; import { GameStateBuildingsTest } from "./game_state_buildings_test.js"; import { GameStateGovernmentTest } from "./game_state_government_test.js"; import { GameStateTechnologyTest } from "./game_state_technology_test.js"; import { DiplomacyStateTest, DiplomacyServerTest } from "./diplomacy_test.js"; +import { ConflictsStateTest, ConflictsModalTest } from "./conflicts_test.js"; import { GameServerTest, GameServerOrdersTest, GameServerLoginTest, GameServerLobbyTest } from "./game_server_test.js"; import { ServerStaticTest, ServerLoopTest, ServerProtocolTest } from "./server_test.js"; import { WebSocketProtocolTest } from "./websocket_test.js"; @@ -31,6 +34,14 @@ import { TrainModalTest, BuildingsModalTest, NationModalTest, ConfirmModalTest, import { GameScreenTest } from "./game_screen_test.js"; import { GameFeedTest } from "./feed_test.js"; import { AppTest } from "./app_test.js"; +import { + OrderStateRegressionTest, + EconomyOrderEdgesTest, + AttritionTest, + CasualtyRecordingTest, + CatalogueSplitTest, + TextFormatRegressionTest, +} from "./regression_test.js"; const SUITES = [ HexTest, @@ -46,13 +57,19 @@ const SUITES = [ LoginManagerTest, RulesTest, CombatTest, + StackingTest, + PillageTest, SiegeTest, + AttackOrderTest, + ScheduledOrderTest, GameStateTest, GameStateBuildingsTest, GameStateGovernmentTest, GameStateTechnologyTest, DiplomacyStateTest, DiplomacyServerTest, + ConflictsStateTest, + ConflictsModalTest, GameServerTest, GameServerOrdersTest, GameServerLoginTest, @@ -71,6 +88,12 @@ const SUITES = [ GameScreenTest, GameFeedTest, AppTest, + OrderStateRegressionTest, + EconomyOrderEdgesTest, + AttritionTest, + CasualtyRecordingTest, + CatalogueSplitTest, + TextFormatRegressionTest, ]; let passed = 0;