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