Artillery + encirclement + budget

This commit is contained in:
2026-09-16 22:41:57 +02:00
parent 0b8c5bee68
commit 06df4be96f
19 changed files with 1398 additions and 81 deletions
+7
View File
@@ -79,3 +79,10 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
`/client/index.html` and proxying `/ws` to the local Node server. The browser `/client/index.html` and proxying `/ws` to the local Node server. The browser
client connects to the page's own origin, so HTTPS gives it `wss://`. client connects to the page's own origin, so HTTPS gives it `wss://`.
- Build locally with `nix build .#server` and `nix build .#web`. - Build locally with `nix build .#server` and `nix build .#web`.
- Updating without a rebuild: set `services.battle-for-tismo.appDirectory` to a
writable checkout (e.g. `/opt/battle-for-tismo`); the unit then runs
`node <dir>/server/server.js` instead of the Nix-store copy. Serve nginx
`client/` and `shared/` from that same checkout (e.g. `alias` locations), then
`git pull` and `systemctl restart battle-for-tismo`. Keep the client and
server trees on the same commit: the client mirrors the server's movement
model, so a mismatched pair makes prediction fight the server.
+61 -2
View File
@@ -169,12 +169,29 @@ input:focus, select:focus { border-color: var(--accent); }
.entity { position: absolute; } .entity { position: absolute; }
.unit { .unit {
--unit-size: 38px;
width: 38px; width: 38px;
height: 38px; height: 38px;
margin-left: -19px; margin-left: -19px;
margin-top: -19px; margin-top: -19px;
cursor: pointer; cursor: pointer;
} }
.unit .unit-statuses,
.city .city-statuses {
position: absolute;
display: flex;
gap: 1px;
pointer-events: none;
z-index: 2;
}
.unit .unit-statuses { top: -4px; right: -4px; }
.city .city-statuses { top: -6px; left: 50%; transform: translateX(-50%); }
.status-icon {
width: calc(var(--unit-size, 38px) / 5);
height: calc(var(--unit-size, 38px) / 5);
display: block;
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9));
}
.unit .body { .unit .body {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -216,12 +233,19 @@ input:focus, select:focus { border-color: var(--accent); }
cursor: pointer; cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.7); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.7);
} }
.city .label { /* City names live in their own layer above every unit, marker and status icon,
margin-top: 2px; so they stay legible instead of being covered by whatever is nearby. */
.city-label-layer { pointer-events: none; }
.city-label {
position: absolute;
transform: translate(-50%, 0);
margin-top: 15px;
font-size: 11px; font-size: 11px;
color: #fff; color: #fff;
text-align: center;
text-shadow: 0 1px 2px #000, 0 0 4px #000; text-shadow: 0 1px 2px #000, 0 0 4px #000;
white-space: nowrap; white-space: nowrap;
pointer-events: none;
} }
.path-seg { .path-seg {
@@ -304,6 +328,30 @@ input:focus, select:focus { border-color: var(--accent); }
.health-bar { width: 220px; height: 12px; background: rgba(30, 0, 0, 0.8); border-radius: 6px; overflow: hidden; margin-top: 6px; } .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-fill { height: 100%; background: linear-gradient(180deg, #6fe08a, #2fa04d); }
.unit-stats { display: flex; gap: 16px; margin-top: 6px; }
.unit-stat {
font-size: 13px;
color: var(--muted);
border-bottom: 1px dotted rgba(159, 176, 200, 0.5);
cursor: help;
}
.unit-stat:hover { color: var(--text); }
.tooltip {
position: absolute;
z-index: 80;
max-width: 280px;
padding: 6px 10px;
border-radius: 7px;
background: rgba(8, 12, 20, 0.97);
border: 1px solid var(--panel-border);
color: var(--text);
font-size: 12px;
white-space: nowrap;
pointer-events: none;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.6);
}
/* --------------------------------------------------------------- modals -- */ /* --------------------------------------------------------------- modals -- */
.modal { .modal {
@@ -372,6 +420,17 @@ input:focus, select:focus { border-color: var(--accent); }
.row-card .cost { font-size: 12px; color: var(--muted); margin-top: 3px; } .row-card .cost { font-size: 12px; color: var(--muted); margin-top: 3px; }
.row-card .actions { display: flex; flex-direction: column; gap: 6px; justify-content: center; } .row-card .actions { display: flex; flex-direction: column; gap: 6px; justify-content: center; }
.row-card .actions .aero { min-width: 170px; } .row-card .actions .aero { min-width: 170px; }
.row-card .value {
display: flex;
align-items: flex-start;
justify-content: flex-end;
min-width: 140px;
font-weight: 600;
}
.row-card .value.positive { color: #8fe3a8; }
.row-card .value.negative { color: #ff9a9a; }
.row-card .budget-detail { margin-top: 4px; }
.row-card .budget-line { font-size: 12px; color: var(--muted); margin-top: 2px; }
.training-status { padding: 0 18px 16px; } .training-status { padding: 0 18px 16px; }
.progress { height: 14px; margin-top: 8px; background: rgba(0, 0, 0, 0.5); border-radius: 7px; overflow: hidden; } .progress { height: 14px; margin-top: 8px; background: rgba(0, 0, 0, 0.5); border-radius: 7px; overflow: hidden; }
+12 -1
View File
@@ -87,6 +87,7 @@
<div class="layer" id="layer-fog"></div> <div class="layer" id="layer-fog"></div>
<div class="layer" id="layer-paths"></div> <div class="layer" id="layer-paths"></div>
<div class="layer" id="layer-entities"></div> <div class="layer" id="layer-entities"></div>
<div class="layer city-label-layer" id="layer-labels"></div>
</div> </div>
</div> </div>
@@ -129,9 +130,14 @@
</div> </div>
<div id="unit-info" class="info-section hidden"> <div id="unit-info" class="info-section hidden">
<div id="unit-name" class="info-title"></div> <div id="unit-name" class="info-title"></div>
<div class="unit-stats">
<span id="unit-attack" class="unit-stat">Attack —</span>
<span id="unit-defense" class="unit-stat">Defense —</span>
</div>
<div class="health-bar"><div id="unit-health-fill"></div></div> <div class="health-bar"><div id="unit-health-fill"></div></div>
</div> </div>
</div> </div>
<div id="stat-tooltip" class="tooltip hidden"></div>
</div> </div>
<!-- Training modal --> <!-- Training modal -->
@@ -161,7 +167,7 @@
</div> </div>
</div> </div>
<!-- Nation modal: government and technology tabs --> <!-- Nation modal: government, technology and budget tabs -->
<div id="modal-nation" class="modal hidden"> <div id="modal-nation" class="modal hidden">
<div class="modal-card"> <div class="modal-card">
<div class="modal-head"> <div class="modal-head">
@@ -171,6 +177,7 @@
<div class="tabs"> <div class="tabs">
<button class="tab active" data-tab="government">Government</button> <button class="tab active" data-tab="government">Government</button>
<button class="tab" data-tab="technology">Technology</button> <button class="tab" data-tab="technology">Technology</button>
<button class="tab" data-tab="budget">Budget</button>
</div> </div>
<div class="tab-page active" id="tab-government"> <div class="tab-page active" id="tab-government">
<div id="government-sub" class="modal-sub"></div> <div id="government-sub" class="modal-sub"></div>
@@ -180,6 +187,10 @@
<div id="technology-sub" class="modal-sub"></div> <div id="technology-sub" class="modal-sub"></div>
<div id="technology-list" class="modal-body"></div> <div id="technology-list" class="modal-body"></div>
</div> </div>
<div class="tab-page" id="tab-budget">
<div id="budget-sub" class="modal-sub"></div>
<div id="budget-list" class="modal-body"></div>
</div>
</div> </div>
</div> </div>
+81 -5
View File
@@ -14,6 +14,7 @@ import {
GOVERNMENTS, GOVERNMENTS,
TECHNOLOGIES, TECHNOLOGIES,
} from "../../shared/data.js"; } from "../../shared/data.js";
import { defenseFactors, unitDefense } from "../../shared/rules.js";
import { dateString } from "../../shared/game_clock.js"; import { dateString } from "../../shared/game_clock.js";
export class GameScreen { export class GameScreen {
@@ -48,11 +49,14 @@ export class GameScreen {
fog: $("#layer-fog"), fog: $("#layer-fog"),
paths: $("#layer-paths"), paths: $("#layer-paths"),
entities: $("#layer-entities"), entities: $("#layer-entities"),
labels: $("#layer-labels"),
} }
); );
this.trainModal = new TrainModal(); this.trainModal = new TrainModal();
this.buildingsModal = new BuildingsModal(); this.buildingsModal = new BuildingsModal();
this.nationModal = new NationModal(); this.nationModal = new NationModal();
this.$statTooltip = $("#stat-tooltip");
this._hoveredStat = null;
this._bindMap(); this._bindMap();
this._bindHud(); this._bindHud();
@@ -136,9 +140,36 @@ export class GameScreen {
this.nationModal.onTechnology = (index) => this.nationModal.onTechnology = (index) =>
this.sendOrder({ type: "technology", technology: index }); this.sendOrder({ type: "technology", technology: index });
$("#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)); $(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() { _bindMap() {
this.map.onUnitSelected = (id) => { this.map.onUnitSelected = (id) => {
if (!id) { if (!id) {
@@ -204,11 +235,13 @@ export class GameScreen {
_showUnit(unit) { _showUnit(unit) {
if (!unit) { if (!unit) {
$("#unit-info").addClass("hidden"); $("#unit-info").addClass("hidden");
this._hideStatTooltip();
this._refreshBottomVisibility(); this._refreshBottomVisibility();
return; return;
} }
const proto = this.protoUnits[unit.proto]; const proto = this.protoUnits[unit.proto];
$("#unit-name").text(proto ? proto.name : "Unit"); $("#unit-name").text(proto ? proto.name : "Unit");
this._showUnitStats(unit, proto);
const ratio = unit.maxHp > 0 ? (unit.hp / unit.maxHp) * 100 : 0; const ratio = unit.maxHp > 0 ? (unit.hp / unit.maxHp) * 100 : 0;
$("#unit-health-fill").css("width", `${Math.max(0, Math.min(100, ratio))}%`); $("#unit-health-fill").css("width", `${Math.max(0, Math.min(100, ratio))}%`);
$("#tile-info, #city-info").addClass("hidden"); $("#tile-info, #city-info").addClass("hidden");
@@ -216,6 +249,42 @@ export class GameScreen {
this._refreshBottomVisibility(); this._refreshBottomVisibility();
} }
// 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)}`);
}
$("#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();
}
}
_showCity(city) { _showCity(city) {
if (!city) { if (!city) {
$("#city-info").addClass("hidden"); $("#city-info").addClass("hidden");
@@ -330,14 +399,16 @@ export class GameScreen {
} }
_openNationModal() { _openNationModal() {
const stats = this.snapshot.viewerStats || {};
this.nationModal.show( this.nationModal.show(
GOVERNMENTS, GOVERNMENTS,
(this.snapshot.viewerStats || {}).government || 0, stats.government || 0,
TECHNOLOGIES, TECHNOLOGIES,
this._researched, this._researched,
(this.snapshot.viewerStats || {}).gdp || 0, stats.gdp || 0,
(this.snapshot.viewerStats || {}).budget || 0, stats.budget || 0,
(this.snapshot.viewerStats || {}).research || 0 stats.research || 0,
this._finances(stats)
); );
} }
@@ -349,10 +420,15 @@ export class GameScreen {
this._researched, this._researched,
stats.gdp || 0, stats.gdp || 0,
stats.budget || 0, stats.budget || 0,
stats.research || 0 stats.research || 0,
this._finances(stats)
); );
} }
_finances(stats) {
return stats.breakdown || null;
}
// --------------------------------------------------------------- input --- // --------------------------------------------------------------- input ---
_onKey(event) { _onKey(event) {
+149 -17
View File
@@ -8,7 +8,7 @@ import { MapTopology, mapToLocal, key, COL_STEP, HEX_W, HEX_H } from "../../shar
import { MapGenerator } from "../../shared/map_generator.js"; import { MapGenerator } from "../../shared/map_generator.js";
import { TerrainStats } from "../../shared/terrain_stats.js"; import { TerrainStats } from "../../shared/terrain_stats.js";
import { HexPathfinder } from "../../shared/hex_pathfinder.js"; import { HexPathfinder } from "../../shared/hex_pathfinder.js";
import { MAP_CONFIG, protoUnitById } from "../../shared/data.js"; import { MAP_CONFIG, protoUnitById, statusById } from "../../shared/data.js";
const RESYNC_HOURS = 0.75; const RESYNC_HOURS = 0.75;
const PREDICTION_GRACE_HOURS = 2.0; const PREDICTION_GRACE_HOURS = 2.0;
@@ -26,6 +26,7 @@ const OVERVIEW_BLOCK = 4;
const OVERVIEW_COLOURS = { const OVERVIEW_COLOURS = {
Forest: "#2f6b3a", Forest: "#2f6b3a",
Hills: "#7a6a44", Hills: "#7a6a44",
Mountain: "#7d7469",
Ice: "#dcecf5", Ice: "#dcecf5",
Land: "#4f8a4f", Land: "#4f8a4f",
Sea: "#1b3f6b", Sea: "#1b3f6b",
@@ -52,6 +53,15 @@ export class MapView {
this.$overview = $('<div id="layer-overview" class="layer overview"></div>'); this.$overview = $('<div id="layer-overview" class="layer overview"></div>');
this.$world.prepend(this.$overview); this.$world.prepend(this.$overview);
} }
// City names live on their own layer above the entities so they are never
// hidden behind a unit or city marker.
this.$labels = layers.labels && layers.labels.length
? layers.labels
: $("#layer-labels");
if (!this.$labels.length) {
this.$labels = $('<div id="layer-labels" class="layer city-label-layer"></div>');
this.$world.append(this.$labels);
}
this.seed = -1; this.seed = -1;
this.topology = null; this.topology = null;
@@ -71,7 +81,9 @@ export class MapView {
this._unitViews = new Map(); this._unitViews = new Map();
this._unitMotion = new Map(); this._unitMotion = new Map();
this._unitData = new Map();
this._cityViews = new Map(); this._cityViews = new Map();
this._cityCiv = new Map();
this._predictNeighbourCache = new Map(); this._predictNeighbourCache = new Map();
this._maxStepLength = 1.0; this._maxStepLength = 1.0;
@@ -147,8 +159,12 @@ export class MapView {
this.$borders.empty(); this.$borders.empty();
this.$fog.empty(); this.$fog.empty();
this.$entities.empty(); this.$entities.empty();
this.$labels.empty();
this._unitViews.clear(); this._unitViews.clear();
this._unitMotion.clear();
this._unitData.clear();
this._cityViews.clear(); this._cityViews.clear();
this._cityCiv.clear();
this._centered = false; this._centered = false;
this._drawOverview(true); this._drawOverview(true);
this._applyWrap(true); this._applyWrap(true);
@@ -309,6 +325,7 @@ export class MapView {
_syncCities(cities) { _syncCities(cities) {
const seen = new Set(); const seen = new Set();
this._cityCiv = new Map();
for (const data of cities) { for (const data of cities) {
const id = data.id; const id = data.id;
seen.add(id); seen.add(id);
@@ -318,27 +335,35 @@ export class MapView {
this._cityViews.set(id, view); this._cityViews.set(id, view);
} }
const coords = { x: data.coords[0], y: data.coords[1] }; 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); const local = mapToLocal(coords.x, coords.y);
view._localX = local.x; view._localX = local.x;
view._localY = local.y; view._localY = local.y;
view.style.left = `${local.x + this._wrapShift(local.x)}px`; const wrappedX = local.x + this._wrapShift(local.x);
view.style.left = `${wrappedX}px`;
view.style.top = `${local.y}px`; view.style.top = `${local.y}px`;
const owned = data.civ === this.localCiv; const owned = data.civ === this.localCiv;
const label = view.querySelector(".label"); const label = view._label;
label.textContent = data.isCapital ? `${data.name} (capital)` : data.name; label.textContent = data.isCapital ? `${data.name} (capital)` : data.name;
label.style.left = `${wrappedX}px`;
label.style.top = `${local.y}px`;
const marker = view.querySelector(".marker"); const marker = view.querySelector(".marker");
marker.style.background = this.civilisations[data.civ] marker.style.background = this.civilisations[data.civ]
? this.civilisations[data.civ].primaryColour ? this.civilisations[data.civ].primaryColour
: "#444"; : "#444";
marker.style.cursor = owned ? "pointer" : "default"; marker.style.cursor = owned ? "pointer" : "default";
view.dataset.owned = owned ? "1" : "0"; 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. // Foreign cities are only shown when their tile has been explored.
const k = key(coords.x, coords.y); const k = key(coords.x, coords.y);
view.style.display = owned || this.explored.has(k) ? "" : "none"; 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)) { for (const [id, view] of Array.from(this._cityViews)) {
if (seen.has(id)) continue; if (seen.has(id)) continue;
view.remove(); view.remove();
if (view._label) view._label.remove();
this._cityViews.delete(id); this._cityViews.delete(id);
} }
} }
@@ -349,15 +374,19 @@ export class MapView {
const marker = document.createElement("div"); const marker = document.createElement("div");
marker.className = "marker"; marker.className = "marker";
marker.style.backgroundImage = "url(assets/icon_modern_city.svg)"; marker.style.backgroundImage = "url(assets/icon_modern_city.svg)";
const label = document.createElement("div"); const statuses = document.createElement("div");
label.className = "label"; statuses.className = "city-statuses";
view.appendChild(marker); view.appendChild(marker);
view.appendChild(label); view.appendChild(statuses);
marker.addEventListener("click", (event) => { marker.addEventListener("click", (event) => {
event.stopPropagation(); event.stopPropagation();
if (view.dataset.owned === "1") this.onCitySelected(data.id); if (view.dataset.owned === "1") this.onCitySelected(data.id);
}); });
this.$entities[0].appendChild(view); this.$entities[0].appendChild(view);
const label = document.createElement("div");
label.className = "city-label";
this.$labels[0].appendChild(label);
view._label = label;
return view; return view;
} }
@@ -366,6 +395,7 @@ export class MapView {
for (const data of units) { for (const data of units) {
const id = data.id; const id = data.id;
seen.add(id); seen.add(id);
this._unitData.set(id, data);
let view = this._unitViews.get(id); let view = this._unitViews.get(id);
if (!view) { if (!view) {
view = this._createUnitView(data); view = this._createUnitView(data);
@@ -374,10 +404,11 @@ export class MapView {
const proto = this.protoUnits[data.proto]; const proto = this.protoUnits[data.proto];
const civ = this.civilisations[data.civ]; const civ = this.civilisations[data.civ];
view.querySelector(".body").style.background = civ ? civ.primaryColour : "#666"; view.querySelector(".body").style.background = civ ? civ.primaryColour : "#666";
view.querySelector("img").src = proto ? `assets/${proto.icon}` : "assets/person.png"; view.querySelector(".body img").src = proto ? `assets/${proto.icon}` : "assets/person.png";
view.querySelector(".health .fill").style.width = view.querySelector(".health .fill").style.width =
`${Math.max(0, Math.min(100, (data.hp / data.maxHp) * 100))}%`; `${Math.max(0, Math.min(100, (data.hp / data.maxHp) * 100))}%`;
view.classList.toggle("selected", id === this._selectedUnitId); view.classList.toggle("selected", id === this._selectedUnitId);
this._syncUnitStatuses(view, data);
this._updateUnitMotion(data); this._updateUnitMotion(data);
// Foreign units are only shown inside currently visible tiles. // Foreign units are only shown inside currently visible tiles.
const k = key(data.coords[0], data.coords[1]); const k = key(data.coords[0], data.coords[1]);
@@ -388,6 +419,7 @@ export class MapView {
view.remove(); view.remove();
this._unitViews.delete(id); this._unitViews.delete(id);
this._unitMotion.delete(id); this._unitMotion.delete(id);
this._unitData.delete(id);
} }
} }
@@ -407,8 +439,11 @@ export class MapView {
const fill = document.createElement("div"); const fill = document.createElement("div");
fill.className = "fill"; fill.className = "fill";
health.appendChild(fill); health.appendChild(fill);
const statuses = document.createElement("div");
statuses.className = "unit-statuses";
view.appendChild(body); view.appendChild(body);
view.appendChild(health); view.appendChild(health);
view.appendChild(statuses);
view.addEventListener("mousedown", (event) => { view.addEventListener("mousedown", (event) => {
event.stopPropagation(); event.stopPropagation();
if (event.button === 0) this.onUnitSelected(data.id); if (event.button === 0) this.onUnitSelected(data.id);
@@ -417,6 +452,31 @@ export class MapView {
return 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 --- // ------------------------------------------------------------- motion ---
_updateUnitMotion(data) { _updateUnitMotion(data) {
@@ -480,8 +540,13 @@ export class MapView {
} }
for (const view of this._cityViews.values()) { for (const view of this._cityViews.values()) {
if (view._localX === undefined) continue; if (view._localX === undefined) continue;
view.style.left = `${view._localX + this._wrapShift(view._localX)}px`; const left = view._localX + this._wrapShift(view._localX);
view.style.left = `${left}px`;
view.style.top = `${view._localY}px`; view.style.top = `${view._localY}px`;
if (view._label) {
view._label.style.left = `${left}px`;
view._label.style.top = `${view._localY}px`;
}
} }
} }
@@ -893,9 +958,11 @@ export class MapView {
} }
return this._predictNeighbourCache.get(k); return this._predictNeighbourCache.get(k);
}, },
(coords) => { (coords, isGoal) => {
const k = key(coords.x, coords.y); const k = key(coords.x, coords.y) + (isGoal ? ":goal" : "");
if (!enterCache.has(k)) enterCache.set(k, this._canEnter(civ, proto, coords)); if (!enterCache.has(k)) {
enterCache.set(k, this._canEnter(civ, proto, coords, unitData.id, isGoal));
}
return enterCache.get(k); return enterCache.get(k);
}, },
(_from, to) => { (_from, to) => {
@@ -915,10 +982,36 @@ export class MapView {
} }
); );
if (path.length < 2) return null; 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 = []; const segmentHours = [];
for (let i = 1; i < path.length; i++) segmentHours.push(this._stepHours(proto, civ, path[i])); for (let i = 1; i < route.length; i++) {
segmentHours.push(this._stepHours(proto, civ, route[i]));
}
this._unitMotion.set(unitData.id, { this._unitMotion.set(unitData.id, {
path, path: route,
segmentHours, segmentHours,
pathHours: 0, pathHours: 0,
coords: start, coords: start,
@@ -926,7 +1019,31 @@ export class MapView {
predictAge: 0, predictAge: 0,
}); });
this.animate(0); this.animate(0);
return path; return route;
}
_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) { _unitCoords(id) {
@@ -939,12 +1056,27 @@ export class MapView {
return motion.coords; return motion.coords;
} }
_canEnter(civ, proto, 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); const k = key(coords.x, coords.y);
if (!this.explored.has(k)) return false; if (!this.explored.has(k)) return false;
const tile = this.tiles[k]; const tile = this.tiles[k];
if (!tile) return false; if (!tile) return false;
return proto.traversableTerrains.includes(tile.terrainClass); 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) { _stepHours(proto, civ, coords) {
+93 -5
View File
@@ -1,6 +1,6 @@
// The three modal panels of the game HUD: city training, city buildings, and // The three modal panels of the game HUD: city training, city buildings, and
// the nation panel (government + technology). They only display state supplied // the nation panel (government + technology + budget). They only display state
// by the game screen and forward the player's choices as orders. // supplied by the game screen and forward the player's choices as orders.
import { groupDigits, hours } from "../../shared/text_format.js"; import { groupDigits, hours } from "../../shared/text_format.js";
import { ECONOMY } from "../../shared/data.js"; import { ECONOMY } from "../../shared/data.js";
@@ -33,6 +33,16 @@ function effectsText(effects, level) {
return parts.length > 0 ? parts.join(" · ") : "No effect"; 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 { export class TrainModal {
constructor() { constructor() {
this.$modal = $("#modal-train"); this.$modal = $("#modal-train");
@@ -185,12 +195,20 @@ export class NationModal {
this.$governmentList = $("#government-list"); this.$governmentList = $("#government-list");
this.$technologySub = $("#technology-sub"); this.$technologySub = $("#technology-sub");
this.$technologyList = $("#technology-list"); this.$technologyList = $("#technology-list");
this.$budgetSub = $("#budget-sub");
this.$budgetList = $("#budget-list");
this.governments = []; this.governments = [];
this.technologies = []; this.technologies = [];
this.currentGovernment = 0; this.currentGovernment = 0;
this.researched = new Set(); this.researched = new Set();
this.governmentRows = []; this.governmentRows = [];
this.technologyRows = []; this.technologyRows = [];
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.onGovernment = () => {};
this.onTechnology = () => {}; this.onTechnology = () => {};
this.$modal.find(".modal-close").on("click", () => this.close()); this.$modal.find(".modal-close").on("click", () => this.close());
@@ -207,15 +225,44 @@ export class NationModal {
return !this.$modal.hasClass("hidden"); return !this.$modal.hasClass("hidden");
} }
show(governments, currentGovernment, technologies, researched, gdp, budget, research) { show(governments, currentGovernment, technologies, researched, gdp, budget, research, finances) {
this.governments = governments; this.governments = governments;
this.technologies = technologies; this.technologies = technologies;
this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government")); 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.technologyRows = technologies.map((proto, index) => this._makeRow(this.$technologyList, proto, index, "technology"));
this.refresh(currentGovernment, researched, gdp, budget, research); this.refresh(currentGovernment, researched, gdp, budget, research, finances);
this.$modal.removeClass("hidden"); this.$modal.removeClass("hidden");
} }
_makeBudgetRow(name, description) {
const $card = $("<div class='row-card'></div>");
const $info = $("<div class='info'></div>");
const $name = $("<div class='name'></div>").text(name);
const $desc = $("<div class='desc'></div>").text(description);
const $details = $("<div class='budget-detail'></div>");
const $value = $("<div class='value'></div>");
$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($("<div class='budget-line'></div>").text(line));
}
}
_sourceLines(sources, format, empty) {
if (!sources || sources.length === 0) return [empty];
return sources.map((source) => format(source));
}
_makeRow($list, proto, index, kind) { _makeRow($list, proto, index, kind) {
const $card = $("<div class='row-card'></div>"); const $card = $("<div class='row-card'></div>");
const $info = $("<div class='info'></div>"); const $info = $("<div class='info'></div>");
@@ -236,7 +283,7 @@ export class NationModal {
return { proto, index, kind, $name, $effects, $cost, $action }; return { proto, index, kind, $name, $effects, $cost, $action };
} }
refresh(currentGovernment, researched, gdp, budget, research) { refresh(currentGovernment, researched, gdp, budget, research, finances = {}) {
this.currentGovernment = currentGovernment; this.currentGovernment = currentGovernment;
this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number)); this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number));
this.$governmentSub.text( this.$governmentSub.text(
@@ -244,6 +291,47 @@ export class NationModal {
); );
this.$technologySub.text(`Research points: ${groupDigits(Math.round(research))}`); 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) { for (const row of this.governmentRows) {
const proto = row.proto; const proto = row.proto;
row.$name.text(proto.name); row.$name.text(proto.name);
+39 -4
View File
@@ -6,6 +6,15 @@
# #
# The web client connects to the page's own origin, so nginx serves the static # The web client connects to the page's own origin, so nginx serves the static
# files at "/" and forwards "/ws" to the game server. # files at "/" and forwards "/ws" to the game server.
#
# By default the server runs the immutable copy packaged in the Nix store, so
# updating the game means rebuilding the system configuration. To update the
# game on its own instead, point the service at a writable checkout:
#
# services.battle-for-tismo.appDirectory = "/opt/battle-for-tismo";
#
# then `git -C /opt/battle-for-tismo pull` and
# `systemctl restart battle-for-tismo`.
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
let let
@@ -16,10 +25,28 @@ in
options.services.battle-for-tismo = { options.services.battle-for-tismo = {
enable = lib.mkEnableOption "the Battle for 'Tismo game server"; enable = lib.mkEnableOption "the Battle for 'Tismo game server";
appDirectory = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/opt/battle-for-tismo";
description = ''
Directory containing a writable checkout of the game (`client/`,
`server/` and `shared/`). When set, the server runs straight from this
directory with the Node.js from Nixpkgs, so the game can be updated
with a Git pull and a service restart instead of a system rebuild.
When null (the default) the immutable copy packaged in the Nix store
is used, and updates require a rebuild.
'';
};
package = lib.mkOption { package = lib.mkOption {
type = lib.types.package; type = lib.types.package;
default = pkg.server; default = pkg.server;
description = "Package providing the `battle-for-tismo-server` command."; description = ''
Package providing the `battle-for-tismo-server` command. Ignored when
`appDirectory` is set.
'';
}; };
port = lib.mkOption { port = lib.mkOption {
@@ -41,13 +68,16 @@ in
enable = lib.mkOption { enable = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
default = true; default = true;
description = "Serve the exported web client through nginx."; description = "Serve the static web client through nginx.";
}; };
package = lib.mkOption { package = lib.mkOption {
type = lib.types.package; type = lib.types.package;
default = pkg.web; default = pkg.web;
description = "Directory containing the static web client (client/index.html)."; description = ''
Directory containing the static web client (client/index.html). Point
this at the `appDirectory` to serve the mutable checkout as well.
'';
}; };
hostName = lib.mkOption { hostName = lib.mkOption {
@@ -77,7 +107,12 @@ in
after = [ "network.target" ]; after = [ "network.target" ];
environment.HOME = "%S/battle-for-tismo"; environment.HOME = "%S/battle-for-tismo";
serviceConfig = { serviceConfig = {
ExecStart = "${cfg.package}/bin/battle-for-tismo-server --port ${toString cfg.port} --bind ${cfg.bindAddress}"; ExecStart =
if cfg.appDirectory != null then
"${pkgs.nodejs}/bin/node ${toString cfg.appDirectory}/server/server.js"
+ " --port ${toString cfg.port} --bind ${cfg.bindAddress}"
else
"${cfg.package}/bin/battle-for-tismo-server --port ${toString cfg.port} --bind ${cfg.bindAddress}";
DynamicUser = true; DynamicUser = true;
StateDirectory = "battle-for-tismo"; StateDirectory = "battle-for-tismo";
Restart = "on-failure"; Restart = "on-failure";
+79 -19
View File
@@ -91,20 +91,24 @@ export const CIVILISATIONS = [
}, },
]; ];
export const CITY_DESCRIPTIONS = {
france: { name: "Paris", population: 11200000, improvements: ["Eiffel Tower", "Louvre", "Notre-Dame"] },
britain: { name: "London", population: 9500000, improvements: ["Big Ben", "Tower Bridge", "Buckingham Palace"] },
};
export const PROTO_UNITS = [ export const PROTO_UNITS = [
{ {
id: "modern_infantry", id: "modern_infantry",
name: "Modern infantry", name: "Modern infantry",
maxHp: 500, maxHp: 500,
cost: 500000, cost: 100_000_000,
upkeep: 50000000, upkeep: 5000,
moveable: true, moveable: true,
speed: 0.5, 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"], traversableTerrains: ["Land"],
icon: "icon_modern_infantry.svg", icon: "icon_modern_infantry.svg",
}, },
@@ -116,14 +120,65 @@ export const PROTO_UNITS = [
upkeep: 0, upkeep: 0,
moveable: true, moveable: true,
speed: 0.5, speed: 0.5,
military: false,
attack: 0,
defense: 0,
range: 1,
traversableTerrains: ["Land"], traversableTerrains: ["Land"],
icon: "icon_settler.svg", 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 // Only units in this list can be trained from the city screen, matching
// GameState.TRAINABLE_UNITS. // GameState.TRAINABLE_UNITS.
export const TRAINABLE_UNIT_IDS = ["modern_infantry"]; 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 once a day, applies their daily 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) and a
// `cityHpLossPerDay` override for when the unit stands in a city.
export const STATUS_ENCIRCLED = "encircled";
export const STATUS_BESIEGED = "besieged";
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.",
},
];
export function statusById(id) {
return STATUSES.find((status) => status.id === id) || null;
}
export const BUILDINGS = [ export const BUILDINGS = [
{ {
@@ -283,12 +338,12 @@ export const TECHNOLOGIES = [
]; ];
export const ECONOMY = { export const ECONOMY = {
baseGdpPerCapita: 10000, baseGdpPerCapita: 10_000,
basePopulation: 100000, basePopulation: 100_000,
cityTileGdpMultiplier: 2.0, cityTileGdpMultiplier: 2.0,
capitalTileGdpMultiplier: 2.5, capitalTileGdpMultiplier: 2.5,
citySurroundingGdpBonus: 0.3, citySurroundingGdpBonus: 0.3,
gdpPerProductionPoint: 100000000, gdpPerProductionPoint: 100_000,
budgetGdpRate: 0.001, budgetGdpRate: 0.001,
startingBudget: 0, startingBudget: 0,
basePopulationGrowthRate: 0.005, basePopulationGrowthRate: 0.005,
@@ -338,17 +393,22 @@ export const MAP_CONFIG = {
// The terrain TileSet, flattened. `col` is the atlas column in terrain.png // 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 // (one row, 7 tiles of 63x56); the class decides land/sea/ice and the
// minElevation bands place land tiles. // 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 = [ export const TERRAIN_TILES = [
{ col: 0, terrainType: "Forest", terrainClass: "Land", populationMultiplier: 0.3, gdpMultiplier: 0.5, movementCostMultiplier: 1.5, minElevation: 0.06 }, { 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, minElevation: 0.2 }, { 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, minElevation: 0 }, { 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, minElevation: -0.08 }, { 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, minElevation: -0.2 }, { 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, minElevation: 0 }, { col: 5, terrainType: "Sea", terrainClass: "Sea", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 0, defenseBonus: 0, minElevation: 0 },
{ col: 6, terrainType: "Land", terrainClass: "Land", populationMultiplier: 0.1, gdpMultiplier: 0.8, movementCostMultiplier: 5.0, minElevation: 0.34 }, { 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) { export function protoUnitById(id) {
return PROTO_UNITS.find((u) => u.id === id) || null; return PROTO_UNITS.find((u) => u.id === id) || null;
} }
+586 -11
View File
@@ -17,13 +17,15 @@ import {
BUILDINGS, BUILDINGS,
GOVERNMENTS, GOVERNMENTS,
TECHNOLOGIES, TECHNOLOGIES,
CITY_DESCRIPTIONS,
EFFECT_GDP, EFFECT_GDP,
EFFECT_BUDGET_INCOME, EFFECT_BUDGET_INCOME,
EFFECT_RESEARCH, EFFECT_RESEARCH,
EFFECT_CULTURE, EFFECT_CULTURE,
STATUS_ENCIRCLED,
STATUS_BESIEGED,
statusById,
} from "./data.js"; } from "./data.js";
import { buildingBuildCost, buildingRefund, buildingUpkeep, prerequisitesMet } from "./rules.js"; import { buildingBuildCost, buildingRefund, buildingUpkeep, prerequisitesMet, defenseMultiplier } from "./rules.js";
export const UNIT_VISION = 2; export const UNIT_VISION = 2;
export const TERRITORY_VISION = 1; export const TERRITORY_VISION = 1;
@@ -33,6 +35,8 @@ export const HOURS_PER_YEAR = HOURS_PER_DAY * DAYS_PER_YEAR;
export const CONTROLLED_SPEED_MULTIPLIER = 1.5; export const CONTROLLED_SPEED_MULTIPLIER = 1.5;
export const DEFAULT_UNIT_SPEED = 0.5; export const DEFAULT_UNIT_SPEED = 0.5;
export const MIN_STEP_COST = 0.0001; 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 class GameState { export class GameState {
constructor() { constructor() {
@@ -147,6 +151,7 @@ export class GameState {
this._configured = true; this._configured = true;
this._placeSettlements(); this._placeSettlements();
this._refreshStatuses();
this._revealAll(); this._revealAll();
this._refreshVisibility(); this._refreshVisibility();
this._visibilityDirty = false; this._visibilityDirty = false;
@@ -234,8 +239,18 @@ export class GameState {
if (!unit) return false; if (!unit) return false;
const proto = this.unitProto(unit); const proto = this.unitProto(unit);
if (!proto || !proto.moveable) return false; 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); const path = this.findPath(unit, unit.coords, goal);
if (path.length < 2) return false; if (path.length < 2) {
unit.attackTarget = null;
return false;
}
unit.path = path; unit.path = path;
unit.pathIndex = 0; unit.pathIndex = 0;
unit.progressHours = 0; unit.progressHours = 0;
@@ -250,12 +265,23 @@ export class GameState {
advanceMovement(hours) { advanceMovement(hours) {
if (hours <= 0) return; 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) { for (const unit of this.units) {
const path = unit.path; const path = unit.path;
if (!path || path.length === 0) continue; if (!path || path.length === 0) continue;
if (engaged.has(unit)) continue;
let hoursLeft = hours; let hoursLeft = hours;
while (hoursLeft > 0 && unit.pathIndex < path.length - 1) { while (hoursLeft > 0 && unit.pathIndex < 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 = path[unit.pathIndex + 1]; const next = path[unit.pathIndex + 1];
// Any other unit on the next tile blocks the step. Military pairs are
// resolved by the daily combat round instead; the rest simply wait.
const occupant = this.unitAt(next);
if (occupant && occupant !== unit) break;
const cost = Math.max(this._tileTravelHours(unit, next), MIN_STEP_COST); const cost = Math.max(this._tileTravelHours(unit, next), MIN_STEP_COST);
const needed = cost - unit.progressHours; const needed = cost - unit.progressHours;
if (hoursLeft >= needed) { if (hoursLeft >= needed) {
@@ -264,6 +290,7 @@ export class GameState {
unit.progressHours = 0; unit.progressHours = 0;
unit.coords = next; unit.coords = next;
this._visibilityDirty = true; this._visibilityDirty = true;
this._onUnitArrived(unit);
} else { } else {
unit.progressHours += hoursLeft; unit.progressHours += hoursLeft;
hoursLeft = 0; hoursLeft = 0;
@@ -277,6 +304,309 @@ export class GameState {
} }
} }
_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;
}
// 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 damage = new Map();
for (const unit of this.units) {
const statuses = unit.statuses || [];
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() { tickHour() {
this.totalHours += 1; this.totalHours += 1;
this._tickTraining(); this._tickTraining();
@@ -287,6 +617,9 @@ export class GameState {
if (this.totalHours % HOURS_PER_DAY === 0) { if (this.totalHours % HOURS_PER_DAY === 0) {
this._tickPopulation(); this._tickPopulation();
this._tickEconomy(); this._tickEconomy();
this._refreshStatuses();
this._applyDailyStatusEffects();
this._resolveCombatRound();
} }
this._tickBuildings(); this._tickBuildings();
this._emitChanged(); this._emitChanged();
@@ -412,6 +745,106 @@ export class GameState {
return city.buildings[protoIndex] || 0; 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();
for (const unit of this.units) {
if (unit.civ !== civ) continue;
const proto = this.unitProto(unit);
if (!proto || !proto.upkeep) continue;
const entry = sources.get(unit.proto) || { label: proto.name, kind: "unit", amount: 0, count: 0 };
entry.amount += proto.upkeep;
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) { getCivBuildingModifiers(civ) {
const totals = {}; const totals = {};
for (const city of this.cities) { for (const city of this.cities) {
@@ -519,6 +952,64 @@ export class GameState {
return proto.traversableTerrains.includes(tile.terrainClass); 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) { findPath(unit, start, goal) {
const proto = this.unitProto(unit); const proto = this.unitProto(unit);
const civ = unit.civ; const civ = unit.civ;
@@ -529,9 +1020,9 @@ export class GameState {
start, start,
goal, goal,
(coords) => this._neighbours(coords), (coords) => this._neighbours(coords),
(coords) => { (coords, isGoal) => {
const k = key(coords.x, coords.y); const k = key(coords.x, coords.y) + (isGoal ? ":goal" : "");
if (!enterCache.has(k)) enterCache.set(k, this.canEnter(civ, proto, coords)); if (!enterCache.has(k)) enterCache.set(k, this._canUnitEnter(unit, coords, isGoal));
return enterCache.get(k); return enterCache.get(k);
}, },
(_from, to) => { (_from, to) => {
@@ -584,17 +1075,14 @@ export class GameState {
population: description.population, population: description.population,
improvements: description.improvements.slice(), improvements: description.improvements.slice(),
buildings: {}, buildings: {},
statuses: [],
}; };
this.cities.push(city); this.cities.push(city);
this._cityById.set(this._nextCityId, city); this._cityById.set(this._nextCityId, city);
this._nextCityId += 1; this._nextCityId += 1;
} }
_cityDescriptionFor(civ, isCapital, center) { _cityDescriptionFor(civ, _isCapital, center) {
if (isCapital && CITY_DESCRIPTIONS[civ.id]) {
const named = CITY_DESCRIPTIONS[civ.id];
return { name: named.name, population: named.population, improvements: named.improvements };
}
return this._generatedCityDescription(civ, center); return this._generatedCityDescription(civ, center);
} }
@@ -639,10 +1127,13 @@ export class GameState {
path: [], path: [],
pathIndex: 0, pathIndex: 0,
progressHours: 0, progressHours: 0,
attackTarget: null,
statuses: [],
}; };
this.units.push(unit); this.units.push(unit);
this._unitById.set(this._nextUnitId, unit); this._unitById.set(this._nextUnitId, unit);
this._nextUnitId += 1; this._nextUnitId += 1;
return unit;
} }
_recomputeTerritory() { _recomputeTerritory() {
@@ -678,6 +1169,86 @@ export class GameState {
this._serializedTerritory = serializeCells(this.territory); 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._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() { _generatePopulation() {
this.tilePopulation = new Map(); this.tilePopulation = new Map();
for (const k in this.tiles) { for (const k in this.tiles) {
@@ -912,6 +1483,7 @@ export class GameState {
return { return {
population: 0, gdp: 0, gdpPerCapita: 0, budget: 0, population: 0, gdp: 0, gdpPerCapita: 0, budget: 0,
upkeep: 0, research: 0, culture: 0, government: 0, upkeep: 0, research: 0, culture: 0, government: 0,
breakdown: null,
}; };
} }
const aggregates = this._playerAggregates(viewerCiv); const aggregates = this._playerAggregates(viewerCiv);
@@ -926,6 +1498,7 @@ export class GameState {
research: this.getResearch(viewerCiv), research: this.getResearch(viewerCiv),
culture: this.getCulture(viewerCiv), culture: this.getCulture(viewerCiv),
government: this.getGovernment(viewerCiv), government: this.getGovernment(viewerCiv),
breakdown: this.budgetBreakdown(viewerCiv),
}; };
} }
@@ -939,6 +1512,7 @@ export class GameState {
population: city.population, population: city.population,
improvements: city.improvements, improvements: city.improvements,
buildings: { ...city.buildings }, buildings: { ...city.buildings },
statuses: city.statuses || [],
})); }));
} }
@@ -962,6 +1536,7 @@ export class GameState {
coords: [unit.coords.x, unit.coords.y], coords: [unit.coords.x, unit.coords.y],
hp: unit.hp, hp: unit.hp,
maxHp: unit.maxHp, maxHp: unit.maxHp,
statuses: unit.statuses || [],
path, path,
pathIndex, pathIndex,
progressHours: unit.progressHours, progressHours: unit.progressHours,
+7 -5
View File
@@ -2,12 +2,14 @@
// the graph with callbacks so the algorithm stays pure and testable: // the graph with callbacks so the algorithm stays pure and testable:
// //
// neighbours(coords) -> [{x,y}, ...] // neighbours(coords) -> [{x,y}, ...]
// canEnter(coords) -> bool // canEnter(coords, isGoal) -> bool
// stepCost(from, to) -> number // stepCost(from, to) -> number
// heuristic(from, to) -> number // heuristic(from, to) -> number
// //
// findPath returns the cells from start to goal inclusive, or [] when the goal // canEnter is told whether the tile is the destination, so a caller can allow
// cannot be entered or is unreachable. // entering a tile it would otherwise route around (a hostile unit or city) only
// as the very goal. findPath returns the cells from start to goal inclusive, or
// [] when the goal cannot be entered or is unreachable.
import { key } from "./hex.js"; import { key } from "./hex.js";
@@ -69,7 +71,7 @@ export class HexPathfinder {
findPath(start, goal, neighbours, canEnter, stepCost, heuristic) { findPath(start, goal, neighbours, canEnter, stepCost, heuristic) {
const startKey = key(start.x, start.y); const startKey = key(start.x, start.y);
const goalKey = key(goal.x, goal.y); const goalKey = key(goal.x, goal.y);
if (startKey === goalKey || !canEnter(goal)) return []; if (startKey === goalKey || !canEnter(goal, true)) return [];
const open = new MinHeap(); const open = new MinHeap();
const cameFrom = new Map(); const cameFrom = new Map();
const gScore = new Map(); const gScore = new Map();
@@ -85,7 +87,7 @@ export class HexPathfinder {
for (const neighbour of neighbours(current)) { for (const neighbour of neighbours(current)) {
const neighbourKey = key(neighbour.x, neighbour.y); const neighbourKey = key(neighbour.x, neighbour.y);
if (closed.has(neighbourKey)) continue; if (closed.has(neighbourKey)) continue;
if (neighbourKey !== goalKey && !canEnter(neighbour)) continue; if (!canEnter(neighbour, neighbourKey === goalKey)) continue;
const tentative = (gScore.has(currentKey) ? gScore.get(currentKey) : Infinity) + const tentative = (gScore.has(currentKey) ? gScore.get(currentKey) : Infinity) +
stepCost(current, neighbour); stepCost(current, neighbour);
if (tentative < (gScore.has(neighbourKey) ? gScore.get(neighbourKey) : Infinity)) { if (tentative < (gScore.has(neighbourKey) ? gScore.get(neighbourKey) : Infinity)) {
+27 -1
View File
@@ -1,7 +1,7 @@
// Catalogue maths shared by the server (which charges costs) and the browser // Catalogue maths shared by the server (which charges costs) and the browser
// (which displays and disables buttons) so the numbers always agree. // (which displays and disables buttons) so the numbers always agree.
import { effectValue } from "./data.js"; import { effectValue, CITY_DEFENSE_BONUS } from "./data.js";
export function buildingBuildCost(proto, gdp, level) { export function buildingBuildCost(proto, gdp, level) {
return (proto.baseCost + proto.costGdpFraction * Math.max(gdp, 0)) * return (proto.baseCost + proto.costGdpFraction * Math.max(gdp, 0)) *
@@ -56,3 +56,29 @@ export function prerequisitesMet(technology, researchedSet, catalogue) {
} }
return true; return true;
} }
// Every defense modifier of the tile a unit stands on, in display order. The
// tile carries its own terrain bonus and a city adds another; each entry is a
// fraction of the unit's base defense. Shared by the server (which fights with
// them) and the browser (which shows them) so the numbers always agree.
export function defenseFactors(tile, hasCity) {
const factors = [];
if (tile && tile.defenseBonus) {
factors.push({ label: tile.terrainType, bonus: tile.defenseBonus });
}
if (hasCity) factors.push({ label: "City", bonus: CITY_DEFENSE_BONUS });
return factors;
}
// Multiplier applied to a unit's base defense for the tile it occupies.
export function defenseMultiplier(tile, hasCity) {
let total = 0;
for (const factor of defenseFactors(tile, hasCity)) total += factor.bonus;
return 1 + total;
}
// A unit's defense on a tile, before any role bonus (the attacker/defender
// distinction is a combat rule, not part of the stat shown to the player).
export function unitDefense(baseDefense, tile, hasCity) {
return baseDefense * defenseMultiplier(tile, hasCity);
}
+14 -6
View File
@@ -6,7 +6,6 @@ import {
GOVERNMENTS, GOVERNMENTS,
TECHNOLOGIES, TECHNOLOGIES,
TRAINABLE_UNIT_IDS, TRAINABLE_UNIT_IDS,
CITY_DESCRIPTIONS,
protoUnitById, protoUnitById,
buildingById, buildingById,
governmentById, governmentById,
@@ -47,11 +46,20 @@ export class DataTest extends TestCase {
} }
} }
test_named_capitals_are_defined_for_france_and_britain() { test_artillery_is_expensive_slow_and_long_ranged() {
this.assertNotNull(CITY_DESCRIPTIONS.france); const infantry = protoUnitById("modern_infantry");
this.assertNotNull(CITY_DESCRIPTIONS.britain); const artillery = protoUnitById("artillery");
this.assertEqual(CITY_DESCRIPTIONS.france.name, "Paris"); this.assertNotNull(artillery, "artillery exists");
this.assertGreater(CITY_DESCRIPTIONS.france.population, 0); this.assertTrue(artillery.military, "artillery is military");
this.assertGreater(artillery.cost, infantry.cost * 2, "much more expensive");
this.assertGreater(artillery.upkeep, infantry.upkeep, "costlier to keep");
this.assertLess(artillery.speed, infantry.speed, "slower than infantry");
this.assertLess(artillery.defense, infantry.defense / 4, "nearly no defense");
this.assertGreater(artillery.attack, infantry.attack, "hits harder");
this.assertGreater(artillery.range, infantry.range, "outranges infantry");
this.assertEqual(artillery.range, 2);
this.assertTrue(TRAINABLE_UNIT_IDS.includes("artillery"), "artillery is trainable");
this.assertTrue(artillery.icon.endsWith(".svg"));
} }
test_buildings_and_governments_carry_effects() { test_buildings_and_governments_carry_effects() {
+1
View File
@@ -60,6 +60,7 @@ export function mapLayers($) {
fog: $("#layer-fog"), fog: $("#layer-fog"),
paths: $("#layer-paths"), paths: $("#layer-paths"),
entities: $("#layer-entities"), entities: $("#layer-entities"),
labels: $("#layer-labels"),
}; };
} }
+70 -3
View File
@@ -51,7 +51,8 @@ export class GameScreenTest extends TestCase {
env.$("#city-train").click(); env.$("#city-train").click();
this.assertFalse(env.$("#modal-train").hasClass("hidden")); this.assertFalse(env.$("#modal-train").hasClass("hidden"));
env.$("#train-list button").click(); this.assertSize(env.$("#train-list button"), 2, "infantry and artillery can be trained");
env.$("#train-list button").first().click();
this.assertEqual(network.orders[0], { type: "train", city: capital.id, proto: 0 }); this.assertEqual(network.orders[0], { type: "train", city: capital.id, proto: 0 });
const unit = state.units.find((u) => u.civ === 0); const unit = state.units.find((u) => u.civ === 0);
@@ -205,8 +206,7 @@ export class GameScreenTest extends TestCase {
} }
} }
async test_snapshot_refreshes_researched_and_buildings() { async test_snapshot_refreshes_researched_and_buildings() { const env = await setupDom();
const env = await setupDom();
try { try {
const screen = new GameScreen(stubNetwork(), makeConfig()); const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter(); screen.enter();
@@ -230,4 +230,71 @@ export class GameScreenTest extends TestCase {
teardownDom(env); teardownDom(env);
} }
} }
async test_unit_panel_shows_terrain_defense_and_tooltip() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = defaultState();
const mountain = state.landCells.find(
(c) => state.tiles[`${c.x},${c.y}`].terrainType === "Mountain"
);
this.assertNotNull(mountain, "the map has a mountain");
const unit = state._spawnUnit(mountain, 0, state.protoUnits[0]);
screen.onState(state.snapshot(0));
const data = state.snapshot(0).units.find((u) => u.id === unit.id);
screen._showUnit(data);
const proto = screen.protoUnits[unit.proto];
this.assertEqual(env.$("#unit-attack").text(), `Attack ${proto.attack}`);
this.assertEqual(env.$("#unit-attack").attr("data-tooltip"), `Base ${proto.attack}`);
this.assertEqual(env.$("#unit-defense").text(), `Defense ${proto.defense * 2}`);
this.assertEqual(
env.$("#unit-defense").attr("data-tooltip"),
`Base ${proto.defense}, Mountain +${proto.defense}`
);
env.$("#unit-defense").trigger("mouseenter");
this.assertFalse(env.$("#stat-tooltip").hasClass("hidden"));
this.assertEqual(
env.$("#stat-tooltip").text(),
`Base ${proto.defense}, Mountain +${proto.defense}`
);
env.$("#unit-defense").trigger("mouseleave");
this.assertTrue(env.$("#stat-tooltip").hasClass("hidden"));
screen.leave();
} finally {
teardownDom(env);
}
}
async test_unit_panel_shows_city_defense_bonus() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = defaultState();
const plain = state.landCells.find(
(c) => state.tiles[`${c.x},${c.y}`].terrainType === "Land" &&
!state.cityAt(c) && state.topology.neighbours(c.x, c.y).some((n) => state._isLand(n))
);
this.assertNotNull(plain, "found a plain tile");
state._spawnCity({ civIndex: 0, coords: { x: plain.x, y: plain.y }, isCapital: false });
state._rebuildCityIndex();
const unit = state._spawnUnit(plain, 0, state.protoUnits[0]);
screen.onState(state.snapshot(0));
screen._showUnit(state.snapshot(0).units.find((u) => u.id === unit.id));
const proto = screen.protoUnits[unit.proto];
this.assertEqual(env.$("#unit-defense").text(), `Defense ${Math.round(proto.defense * 1.5)}`);
this.assertEqual(
env.$("#unit-defense").attr("data-tooltip"),
`Base ${proto.defense}, City +${Math.round(proto.defense * 0.5)}`
);
screen.leave();
} finally {
teardownDom(env);
}
}
} }
+41 -1
View File
@@ -1,6 +1,6 @@
import { TestCase } from "./framework/test_case.js"; import { TestCase } from "./framework/test_case.js";
import { smallState, cityOf, gdpBuilding } from "./framework/helpers.js"; import { smallState, cityOf, gdpBuilding } from "./framework/helpers.js";
import { EFFECT_RESEARCH } from "../shared/data.js"; import { ECONOMY, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH } from "../shared/data.js";
const BIG_BUDGET = 1.0e15; const BIG_BUDGET = 1.0e15;
@@ -90,6 +90,46 @@ export class GameStateBuildingsTest extends TestCase {
this.assertApprox(state.getResearch(0) - first - governmentRate, 2.0); this.assertApprox(state.getResearch(0) - first - governmentRate, 2.0);
} }
test_viewer_stats_split_income_and_upkeep() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const index = this.addBuilding(
state,
gdpBuilding({ baseCost: 0, costGdpFraction: 0, baseUpkeep: 100, upkeepAlpha: 1.0, effects: [] })
);
this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
const breakdown = state.viewerStats(0).breakdown;
this.assertHas(breakdown, "income");
this.assertHas(breakdown, "buildingUpkeep");
this.assertHas(breakdown, "unitUpkeep");
this.assertApprox(breakdown.buildingUpkeep.total, 100);
this.assertApprox(
breakdown.unitUpkeep.total,
state.getPlayerUpkeep(0) - breakdown.buildingUpkeep.total
);
const expected = state.getPlayerGdp(0) * ECONOMY.budgetGdpRate * (1 + breakdown.income.modifier);
this.assertApprox(breakdown.income.total, expected);
}
test_budget_breakdown_names_income_sources() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const effect = { stat: EFFECT_BUDGET_INCOME, baseValue: 0.05, exponent: 1.0 };
const index = this.addBuilding(
state,
gdpBuilding({ baseCost: 0, costGdpFraction: 0, baseUpkeep: 0, effects: [effect] })
);
this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
const breakdown = state.viewerStats(0).breakdown;
const source = breakdown.income.sources.find((s) => s.label === "Test market");
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);
this.assertApprox(breakdown.income.total, breakdown.income.base * (1 + breakdown.income.modifier));
}
test_snapshot_exposes_buildings_and_resources() { test_snapshot_exposes_buildings_and_resources() {
const state = smallState(); const state = smallState();
const snapshot = state.snapshot(0); const snapshot = state.snapshot(0);
+16
View File
@@ -64,6 +64,22 @@ export class HexPathfinderTest extends TestCase {
this.assertTrue(isConnected(path)); this.assertTrue(isConnected(path));
} }
test_a_tile_off_limits_for_transit_is_avoided() {
const canEnter = (c, isGoal) => !(c.x === 1 && c.y === 0) || isGoal;
const path = find({ x: 0, y: 0 }, { x: 2, y: 0 }, canEnter);
this.assertTrue(path.length > 3, "the path detours around the blocked tile");
for (const cell of path) {
this.assertFalse(cell.x === 1 && cell.y === 0, "never steps onto the blocked tile");
}
}
test_the_goal_may_be_off_limits_for_transit() {
const canEnter = (c, isGoal) => !(c.x === 1 && c.y === 0) || isGoal;
const path = find({ x: 0, y: 0 }, { x: 1, y: 0 }, canEnter);
this.assertSize(path, 2);
this.assertEqual(path[1], { x: 1, y: 0 });
}
test_prefers_a_cheaper_detour() { test_prefers_a_cheaper_detour() {
// The straight road is expensive; a one-tile detour must be chosen. // The straight road is expensive; a one-tile detour must be chosen.
const expensive = new Set(["1,0", "2,0"]); const expensive = new Set(["1,0", "2,0"]);
+54
View File
@@ -135,4 +135,58 @@ export class NationModalTest extends TestCase {
teardownDom(env); teardownDom(env);
} }
} }
async test_budget_tab_shows_income_and_upkeep_breakdown() {
const env = await setupDom();
try {
const modal = new NationModal();
const finances = {
income: {
rate: 0.001,
base: 900,
modifier: 0.15,
total: 1035,
sources: [
{ label: "Bank", kind: "building", effect: 0.05, amount: 45 },
{ label: "Republic", kind: "government", effect: 0.1, amount: 90 },
],
},
buildingUpkeep: { total: 400, sources: [{ label: "Bank", kind: "building", amount: 400 }] },
unitUpkeep: {
total: 150,
sources: [{ label: "Modern infantry", kind: "unit", amount: 150, count: 3 }],
},
net: 485,
};
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"));
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"));
this.assertTrue(env.$("#budget-sub").text().includes("Treasury"));
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: [] },
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"));
} finally {
teardownDom(env);
}
}
} }
+57 -1
View File
@@ -8,8 +8,19 @@ import {
governmentEffects, governmentEffects,
technologyEffects, technologyEffects,
prerequisitesMet, prerequisitesMet,
defenseFactors,
defenseMultiplier,
unitDefense,
} from "../shared/rules.js"; } from "../shared/rules.js";
import { effectValue, GOVERNMENTS, TECHNOLOGIES, EFFECT_GDP, EFFECT_RESEARCH, EFFECT_CULTURE } from "../shared/data.js"; import {
effectValue,
GOVERNMENTS,
TECHNOLOGIES,
TERRAIN_TILES,
EFFECT_GDP,
EFFECT_RESEARCH,
EFFECT_CULTURE,
} from "../shared/data.js";
function proto(overrides = {}) { function proto(overrides = {}) {
return { return {
@@ -118,4 +129,49 @@ export class RulesTest extends TestCase {
this.assertEmpty(method.prerequisites); this.assertEmpty(method.prerequisites);
this.assertTrue(prerequisitesMet(method, new Set(), TECHNOLOGIES)); this.assertTrue(prerequisitesMet(method, new Set(), TECHNOLOGIES));
} }
test_defense_factors_come_from_the_tile_and_its_city() {
const mountain = TERRAIN_TILES.find((t) => t.terrainType === "Mountain");
const plains = TERRAIN_TILES.find((t) => t.terrainType === "Land");
this.assertSize(defenseFactors(plains, false), 0, "open land defends normally");
this.assertSize(defenseFactors(null, false), 0, "no tile, no factor");
const mountainFactors = defenseFactors(mountain, false);
this.assertSize(mountainFactors, 1);
this.assertEqual(mountainFactors[0].label, "Mountain");
this.assertApprox(mountainFactors[0].bonus, 1.0);
const cityFactors = defenseFactors(mountain, true);
this.assertSize(cityFactors, 2);
this.assertEqual(cityFactors[1].label, "City");
this.assertApprox(cityFactors[1].bonus, 0.5);
}
test_defense_multiplier_adds_terrain_and_city_bonuses() {
const forest = TERRAIN_TILES.find((t) => t.terrainType === "Forest");
const mountain = TERRAIN_TILES.find((t) => t.terrainType === "Mountain");
const plains = TERRAIN_TILES.find((t) => t.terrainType === "Land");
this.assertApprox(defenseMultiplier(plains, false), 1.0);
this.assertApprox(defenseMultiplier(forest, false), 1.33);
this.assertApprox(defenseMultiplier(mountain, false), 2.0);
this.assertApprox(defenseMultiplier(mountain, true), 2.5);
}
test_unit_defense_scales_the_base_stat() {
const mountain = TERRAIN_TILES.find((t) => t.terrainType === "Mountain");
this.assertApprox(unitDefense(100, mountain, false), 200);
this.assertApprox(unitDefense(100, mountain, true), 250);
this.assertApprox(unitDefense(100, null, false), 100);
}
test_terrain_defense_bonuses_match_the_spec() {
const bonusOf = (type) => {
const tile = TERRAIN_TILES.find((t) => t.terrainType === type);
return tile ? tile.defenseBonus : 0;
};
this.assertApprox(bonusOf("Forest"), 0.33);
this.assertApprox(bonusOf("Hills"), 0.33);
this.assertApprox(bonusOf("Mountain"), 1.0);
this.assertApprox(bonusOf("Land"), 0);
}
} }
+4
View File
@@ -15,6 +15,8 @@ import { TextFormatTest } from "./text_format_test.js";
import { GameClockTest } from "./game_clock_test.js"; import { GameClockTest } from "./game_clock_test.js";
import { LoginManagerTest } from "./login_manager_test.js"; import { LoginManagerTest } from "./login_manager_test.js";
import { RulesTest } from "./rules_test.js"; import { RulesTest } from "./rules_test.js";
import { CombatTest } from "./combat_test.js";
import { SiegeTest } from "./siege_test.js";
import { GameStateTest } from "./game_state_test.js"; import { GameStateTest } from "./game_state_test.js";
import { GameStateBuildingsTest } from "./game_state_buildings_test.js"; import { GameStateBuildingsTest } from "./game_state_buildings_test.js";
import { GameStateGovernmentTest } from "./game_state_government_test.js"; import { GameStateGovernmentTest } from "./game_state_government_test.js";
@@ -41,6 +43,8 @@ const SUITES = [
GameClockTest, GameClockTest,
LoginManagerTest, LoginManagerTest,
RulesTest, RulesTest,
CombatTest,
SiegeTest,
GameStateTest, GameStateTest,
GameStateBuildingsTest, GameStateBuildingsTest,
GameStateGovernmentTest, GameStateGovernmentTest,