@@ -171,6 +177,7 @@
Government
Technology
+ Budget
+
diff --git a/client/js/game_screen.js b/client/js/game_screen.js
index 021ed2b..ffac7ef 100644
--- a/client/js/game_screen.js
+++ b/client/js/game_screen.js
@@ -14,6 +14,7 @@ import {
GOVERNMENTS,
TECHNOLOGIES,
} from "../../shared/data.js";
+import { defenseFactors, unitDefense } from "../../shared/rules.js";
import { dateString } from "../../shared/game_clock.js";
export class GameScreen {
@@ -48,11 +49,14 @@ export class GameScreen {
fog: $("#layer-fog"),
paths: $("#layer-paths"),
entities: $("#layer-entities"),
+ labels: $("#layer-labels"),
}
);
this.trainModal = new TrainModal();
this.buildingsModal = new BuildingsModal();
this.nationModal = new NationModal();
+ this.$statTooltip = $("#stat-tooltip");
+ this._hoveredStat = null;
this._bindMap();
this._bindHud();
@@ -136,9 +140,36 @@ export class GameScreen {
this.nationModal.onTechnology = (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));
}
+ // A small hover tooltip anchored above a stat, fed by its data-tooltip text.
+ _showStatTooltip($stat) {
+ const text = $stat.attr("data-tooltip");
+ if (!text) return;
+ this._hoveredStat = $stat;
+ this.$statTooltip.text(text).removeClass("hidden");
+ const offset = $stat.offset() || { left: 0, top: 0 };
+ const width = this.$statTooltip.outerWidth() || 0;
+ const height = this.$statTooltip.outerHeight() || 0;
+ this.$statTooltip.css({
+ left: `${Math.max(8, offset.left + $stat.outerWidth() / 2 - width / 2)}px`,
+ top: `${Math.max(8, offset.top - height - 8)}px`,
+ });
+ }
+
+ _hideStatTooltip() {
+ this._hoveredStat = null;
+ this.$statTooltip.addClass("hidden");
+ }
+
_bindMap() {
this.map.onUnitSelected = (id) => {
if (!id) {
@@ -204,11 +235,13 @@ export class GameScreen {
_showUnit(unit) {
if (!unit) {
$("#unit-info").addClass("hidden");
+ this._hideStatTooltip();
this._refreshBottomVisibility();
return;
}
const proto = this.protoUnits[unit.proto];
$("#unit-name").text(proto ? proto.name : "Unit");
+ this._showUnitStats(unit, proto);
const ratio = unit.maxHp > 0 ? (unit.hp / unit.maxHp) * 100 : 0;
$("#unit-health-fill").css("width", `${Math.max(0, Math.min(100, ratio))}%`);
$("#tile-info, #city-info").addClass("hidden");
@@ -216,6 +249,42 @@ export class GameScreen {
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) {
if (!city) {
$("#city-info").addClass("hidden");
@@ -330,14 +399,16 @@ export class GameScreen {
}
_openNationModal() {
+ const stats = this.snapshot.viewerStats || {};
this.nationModal.show(
GOVERNMENTS,
- (this.snapshot.viewerStats || {}).government || 0,
+ stats.government || 0,
TECHNOLOGIES,
this._researched,
- (this.snapshot.viewerStats || {}).gdp || 0,
- (this.snapshot.viewerStats || {}).budget || 0,
- (this.snapshot.viewerStats || {}).research || 0
+ stats.gdp || 0,
+ stats.budget || 0,
+ stats.research || 0,
+ this._finances(stats)
);
}
@@ -349,10 +420,15 @@ export class GameScreen {
this._researched,
stats.gdp || 0,
stats.budget || 0,
- stats.research || 0
+ stats.research || 0,
+ this._finances(stats)
);
}
+ _finances(stats) {
+ return stats.breakdown || null;
+ }
+
// --------------------------------------------------------------- input ---
_onKey(event) {
diff --git a/client/js/map_view.js b/client/js/map_view.js
index d2d334d..00a3f62 100644
--- a/client/js/map_view.js
+++ b/client/js/map_view.js
@@ -8,7 +8,7 @@ import { MapTopology, mapToLocal, key, COL_STEP, HEX_W, HEX_H } from "../../shar
import { MapGenerator } from "../../shared/map_generator.js";
import { TerrainStats } from "../../shared/terrain_stats.js";
import { HexPathfinder } from "../../shared/hex_pathfinder.js";
-import { MAP_CONFIG, protoUnitById } from "../../shared/data.js";
+import { MAP_CONFIG, protoUnitById, statusById } from "../../shared/data.js";
const RESYNC_HOURS = 0.75;
const PREDICTION_GRACE_HOURS = 2.0;
@@ -26,6 +26,7 @@ const OVERVIEW_BLOCK = 4;
const OVERVIEW_COLOURS = {
Forest: "#2f6b3a",
Hills: "#7a6a44",
+ Mountain: "#7d7469",
Ice: "#dcecf5",
Land: "#4f8a4f",
Sea: "#1b3f6b",
@@ -52,6 +53,15 @@ export class MapView {
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 = $('
');
+ this.$world.append(this.$labels);
+ }
this.seed = -1;
this.topology = null;
@@ -71,7 +81,9 @@ export class MapView {
this._unitViews = new Map();
this._unitMotion = new Map();
+ this._unitData = new Map();
this._cityViews = new Map();
+ this._cityCiv = new Map();
this._predictNeighbourCache = new Map();
this._maxStepLength = 1.0;
@@ -147,8 +159,12 @@ export class MapView {
this.$borders.empty();
this.$fog.empty();
this.$entities.empty();
+ this.$labels.empty();
this._unitViews.clear();
+ this._unitMotion.clear();
+ this._unitData.clear();
this._cityViews.clear();
+ this._cityCiv.clear();
this._centered = false;
this._drawOverview(true);
this._applyWrap(true);
@@ -309,6 +325,7 @@ export class MapView {
_syncCities(cities) {
const seen = new Set();
+ this._cityCiv = new Map();
for (const data of cities) {
const id = data.id;
seen.add(id);
@@ -318,27 +335,35 @@ export class MapView {
this._cityViews.set(id, view);
}
const coords = { x: data.coords[0], y: data.coords[1] };
+ this._cityCiv.set(key(coords.x, coords.y), data.civ);
const local = mapToLocal(coords.x, coords.y);
view._localX = local.x;
view._localY = local.y;
- 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`;
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.style.left = `${wrappedX}px`;
+ label.style.top = `${local.y}px`;
const marker = view.querySelector(".marker");
marker.style.background = this.civilisations[data.civ]
? this.civilisations[data.civ].primaryColour
: "#444";
marker.style.cursor = owned ? "pointer" : "default";
view.dataset.owned = owned ? "1" : "0";
+ this._syncStatusIcons(view.querySelector(".city-statuses"), data.statuses || []);
// Foreign cities are only shown when their tile has been explored.
const k = key(coords.x, coords.y);
- 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)) {
if (seen.has(id)) continue;
view.remove();
+ if (view._label) view._label.remove();
this._cityViews.delete(id);
}
}
@@ -349,15 +374,19 @@ export class MapView {
const marker = document.createElement("div");
marker.className = "marker";
marker.style.backgroundImage = "url(assets/icon_modern_city.svg)";
- const label = document.createElement("div");
- label.className = "label";
+ const statuses = document.createElement("div");
+ statuses.className = "city-statuses";
view.appendChild(marker);
- view.appendChild(label);
+ view.appendChild(statuses);
marker.addEventListener("click", (event) => {
event.stopPropagation();
if (view.dataset.owned === "1") this.onCitySelected(data.id);
});
this.$entities[0].appendChild(view);
+ const label = document.createElement("div");
+ label.className = "city-label";
+ this.$labels[0].appendChild(label);
+ view._label = label;
return view;
}
@@ -366,6 +395,7 @@ export class MapView {
for (const data of units) {
const id = data.id;
seen.add(id);
+ this._unitData.set(id, data);
let view = this._unitViews.get(id);
if (!view) {
view = this._createUnitView(data);
@@ -374,10 +404,11 @@ export class MapView {
const proto = this.protoUnits[data.proto];
const civ = this.civilisations[data.civ];
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 =
`${Math.max(0, Math.min(100, (data.hp / data.maxHp) * 100))}%`;
view.classList.toggle("selected", id === this._selectedUnitId);
+ this._syncUnitStatuses(view, data);
this._updateUnitMotion(data);
// Foreign units are only shown inside currently visible tiles.
const k = key(data.coords[0], data.coords[1]);
@@ -388,6 +419,7 @@ export class MapView {
view.remove();
this._unitViews.delete(id);
this._unitMotion.delete(id);
+ this._unitData.delete(id);
}
}
@@ -407,8 +439,11 @@ export class MapView {
const fill = document.createElement("div");
fill.className = "fill";
health.appendChild(fill);
+ const statuses = document.createElement("div");
+ statuses.className = "unit-statuses";
view.appendChild(body);
view.appendChild(health);
+ view.appendChild(statuses);
view.addEventListener("mousedown", (event) => {
event.stopPropagation();
if (event.button === 0) this.onUnitSelected(data.id);
@@ -417,6 +452,31 @@ export class MapView {
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 ---
_updateUnitMotion(data) {
@@ -480,8 +540,13 @@ export class MapView {
}
for (const view of this._cityViews.values()) {
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`;
+ 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);
},
- (coords) => {
- const k = key(coords.x, coords.y);
- if (!enterCache.has(k)) enterCache.set(k, this._canEnter(civ, proto, coords));
+ (coords, isGoal) => {
+ const k = key(coords.x, coords.y) + (isGoal ? ":goal" : "");
+ if (!enterCache.has(k)) {
+ enterCache.set(k, this._canEnter(civ, proto, coords, unitData.id, isGoal));
+ }
return enterCache.get(k);
},
(_from, to) => {
@@ -915,10 +982,36 @@ export class MapView {
}
);
if (path.length < 2) return null;
+ // The server holds a unit as soon as its target is in range and never
+ // steps onto a living enemy's tile. Mirror that so a ranged unit stops at
+ // its firing distance and a melee one stops beside its opponent.
+ const range = Math.max(1, proto.range || 1);
+ const goalOccupant = this._unitAtCoords(goal, unitData.id);
+ const goalIsEnemy = !!goalOccupant && goalOccupant.civ !== civ &&
+ proto.military === true && this._isMilitaryData(goalOccupant);
+ let route;
+ if (goalIsEnemy) {
+ if (this.topology.tileDistance(start, goal) <= range) return null;
+ const approach = path.slice(0, path.length - 1);
+ let stop = approach.length - 1;
+ for (let i = 1; i < approach.length; i++) {
+ if (this.topology.tileDistance(approach[i], goal) <= range) {
+ stop = i;
+ break;
+ }
+ }
+ route = approach.slice(0, stop + 1);
+ } else {
+ const block = this._firstOccupiedStep(path, unitData.id);
+ route = block >= 1 ? path.slice(0, block) : path;
+ }
+ if (route.length < 2) return null;
const segmentHours = [];
- for (let i = 1; i < 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, {
- path,
+ path: route,
segmentHours,
pathHours: 0,
coords: start,
@@ -926,7 +1019,31 @@ export class MapView {
predictAge: 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) {
@@ -939,12 +1056,27 @@ export class MapView {
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);
if (!this.explored.has(k)) return false;
const tile = this.tiles[k];
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) {
diff --git a/client/js/modals.js b/client/js/modals.js
index 64db3bb..6af3dc6 100644
--- a/client/js/modals.js
+++ b/client/js/modals.js
@@ -1,6 +1,6 @@
// The three modal panels of the game HUD: city training, city buildings, and
-// the nation panel (government + technology). They only display state supplied
-// by the game screen and forward the player's choices as orders.
+// the nation panel (government + technology + budget). They only display state
+// supplied by the game screen and forward the player's choices as orders.
import { groupDigits, hours } from "../../shared/text_format.js";
import { ECONOMY } from "../../shared/data.js";
@@ -33,6 +33,16 @@ function effectsText(effects, level) {
return parts.length > 0 ? parts.join(" · ") : "No effect";
}
+const SOURCE_LABELS = { building: "building", government: "government", technology: "research" };
+
+function signedMoney(value) {
+ return `${value < 0 ? "−" : "+"}€${groupDigits(Math.round(Math.abs(value)))}/h`;
+}
+
+function percent(value) {
+ return `${value < 0 ? "−" : "+"}${(Math.abs(value) * 100).toFixed(1)}%`;
+}
+
export class TrainModal {
constructor() {
this.$modal = $("#modal-train");
@@ -185,12 +195,20 @@ export class NationModal {
this.$governmentList = $("#government-list");
this.$technologySub = $("#technology-sub");
this.$technologyList = $("#technology-list");
+ this.$budgetSub = $("#budget-sub");
+ this.$budgetList = $("#budget-list");
this.governments = [];
this.technologies = [];
this.currentGovernment = 0;
this.researched = new Set();
this.governmentRows = [];
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.onTechnology = () => {};
this.$modal.find(".modal-close").on("click", () => this.close());
@@ -207,15 +225,44 @@ export class NationModal {
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.technologies = technologies;
this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government"));
this.technologyRows = technologies.map((proto, index) => this._makeRow(this.$technologyList, proto, index, "technology"));
- this.refresh(currentGovernment, researched, gdp, budget, research);
+ this.refresh(currentGovernment, researched, gdp, budget, research, finances);
this.$modal.removeClass("hidden");
}
+ _makeBudgetRow(name, description) {
+ const $card = $("
");
+ const $info = $("
");
+ const $name = $("
").text(name);
+ const $desc = $("
").text(description);
+ const $details = $("
");
+ const $value = $("
");
+ $info.append($name, $desc, $details);
+ $card.append($info, $value);
+ this.$budgetList.append($card);
+ return { $value, $details };
+ }
+
+ _renderBudgetRow(row, value, lines) {
+ row.$value
+ .text(signedMoney(value))
+ .toggleClass("positive", value >= 0)
+ .toggleClass("negative", value < 0);
+ row.$details.empty();
+ for (const line of lines) {
+ row.$details.append($("
").text(line));
+ }
+ }
+
+ _sourceLines(sources, format, empty) {
+ if (!sources || sources.length === 0) return [empty];
+ return sources.map((source) => format(source));
+ }
+
_makeRow($list, proto, index, kind) {
const $card = $("
");
const $info = $("
");
@@ -236,7 +283,7 @@ export class NationModal {
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.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number));
this.$governmentSub.text(
@@ -244,6 +291,47 @@ export class NationModal {
);
this.$technologySub.text(`Research points: ${groupDigits(Math.round(research))}`);
+ const breakdown = finances || {};
+ const income = breakdown.income || { rate: 0, base: 0, modifier: 0, total: 0, sources: [] };
+ const building = breakdown.buildingUpkeep || { total: 0, sources: [] };
+ const units = breakdown.unitUpkeep || { total: 0, sources: [] };
+ const net = breakdown.net || 0;
+
+ this.$budgetSub.text(
+ `Treasury: €${groupDigits(Math.round(budget))} · Net: ${signedMoney(net)}`
+ );
+
+ this._renderBudgetRow(this.budgetRows.income, income.total, [
+ `Tax rate ${(income.rate * 100).toFixed(2)}% of GDP — base €${groupDigits(Math.round(income.base))}/h`,
+ ...this._sourceLines(
+ income.sources,
+ (s) => `${s.label} (${SOURCE_LABELS[s.kind] || s.kind}) ${percent(s.effect)} → ${signedMoney(s.amount)}`,
+ "No bonuses"
+ ),
+ ]);
+ this._renderBudgetRow(
+ this.budgetRows.building,
+ -building.total,
+ this._sourceLines(
+ building.sources,
+ (s) => `${s.label} — €${groupDigits(Math.round(s.amount))}/h`,
+ "No buildings"
+ )
+ );
+ this._renderBudgetRow(
+ this.budgetRows.units,
+ -units.total,
+ this._sourceLines(
+ units.sources,
+ (s) => `${s.label} ×${s.count} — €${groupDigits(Math.round(s.amount))}/h`,
+ "No units"
+ )
+ );
+ this._renderBudgetRow(this.budgetRows.net, net, [
+ `Income ${signedMoney(income.total)}`,
+ `Expenses −€${groupDigits(Math.round(building.total + units.total))}/h`,
+ ]);
+
for (const row of this.governmentRows) {
const proto = row.proto;
row.$name.text(proto.name);
diff --git a/nix/module.nix b/nix/module.nix
index 9d8dbc3..26ef794 100644
--- a/nix/module.nix
+++ b/nix/module.nix
@@ -6,6 +6,15 @@
#
# The web client connects to the page's own origin, so nginx serves the static
# 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, ... }:
let
@@ -16,10 +25,28 @@ in
options.services.battle-for-tismo = {
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 {
type = lib.types.package;
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 {
@@ -41,13 +68,16 @@ in
enable = lib.mkOption {
type = lib.types.bool;
default = true;
- description = "Serve the exported web client through nginx.";
+ description = "Serve the static web client through nginx.";
};
package = lib.mkOption {
type = lib.types.package;
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 {
@@ -77,7 +107,12 @@ in
after = [ "network.target" ];
environment.HOME = "%S/battle-for-tismo";
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;
StateDirectory = "battle-for-tismo";
Restart = "on-failure";
diff --git a/shared/data.js b/shared/data.js
index dd158a5..daaab5d 100644
--- a/shared/data.js
+++ b/shared/data.js
@@ -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 = [
{
id: "modern_infantry",
name: "Modern infantry",
maxHp: 500,
- cost: 500000,
- upkeep: 50000000,
+ cost: 100_000_000,
+ upkeep: 5000,
moveable: true,
speed: 0.5,
+ // Only military units fight, capture cities and may enter an enemy
+ // military unit's tile. `attack` is the damage dealt per combat round and
+ // `defense` is the damage absorbed per round; a defender's defense is
+ // doubled (the 100% defensive bonus). `range` is how many tiles away the
+ // unit can strike (1 is melee).
+ military: true,
+ attack: 100,
+ defense: 40,
+ range: 1,
traversableTerrains: ["Land"],
icon: "icon_modern_infantry.svg",
},
@@ -116,14 +120,65 @@ export const PROTO_UNITS = [
upkeep: 0,
moveable: true,
speed: 0.5,
+ military: false,
+ attack: 0,
+ defense: 0,
+ range: 1,
traversableTerrains: ["Land"],
icon: "icon_settler.svg",
},
+ {
+ id: "artillery",
+ name: "Artillery",
+ maxHp: 400,
+ cost: 300_000_000,
+ upkeep: 12_000,
+ moveable: true,
+ speed: 0.25,
+ military: true,
+ attack: 250,
+ // Barely armoured: nearly every hit gets through.
+ defense: 5,
+ // Outranges infantry, so melee units cannot strike back from two tiles.
+ range: 2,
+ traversableTerrains: ["Land"],
+ icon: "icon_artillery.svg",
+ },
];
// Only units in this list can be trained from the city screen, matching
// GameState.TRAINABLE_UNITS.
-export const TRAINABLE_UNIT_IDS = ["modern_infantry"];
+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 = [
{
@@ -283,12 +338,12 @@ export const TECHNOLOGIES = [
];
export const ECONOMY = {
- baseGdpPerCapita: 10000,
- basePopulation: 100000,
+ baseGdpPerCapita: 10_000,
+ basePopulation: 100_000,
cityTileGdpMultiplier: 2.0,
capitalTileGdpMultiplier: 2.5,
citySurroundingGdpBonus: 0.3,
- gdpPerProductionPoint: 100000000,
+ gdpPerProductionPoint: 100_000,
budgetGdpRate: 0.001,
startingBudget: 0,
basePopulationGrowthRate: 0.005,
@@ -338,17 +393,22 @@ export const MAP_CONFIG = {
// The terrain TileSet, flattened. `col` is the atlas column in terrain.png
// (one row, 7 tiles of 63x56); the class decides land/sea/ice and the
-// minElevation bands place land tiles.
+// minElevation bands place land tiles. `defenseBonus` is the fraction added to
+// a unit's defense while it stands on the tile.
export const TERRAIN_TILES = [
- { col: 0, terrainType: "Forest", terrainClass: "Land", populationMultiplier: 0.3, gdpMultiplier: 0.5, movementCostMultiplier: 1.5, minElevation: 0.06 },
- { col: 1, terrainType: "Hills", terrainClass: "Land", populationMultiplier: 0.7, gdpMultiplier: 0.8, movementCostMultiplier: 1.8, minElevation: 0.2 },
- { col: 2, terrainType: "Ice", terrainClass: "Ice", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 4.0, minElevation: 0 },
- { col: 3, terrainType: "Land", terrainClass: "Land", populationMultiplier: 1.0, gdpMultiplier: 1.0, movementCostMultiplier: 1.0, minElevation: -0.08 },
- { col: 4, terrainType: "Land", terrainClass: "Land", populationMultiplier: 0.05, gdpMultiplier: 0.2, movementCostMultiplier: 3.0, minElevation: -0.2 },
- { col: 5, terrainType: "Sea", terrainClass: "Sea", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 0, minElevation: 0 },
- { col: 6, terrainType: "Land", terrainClass: "Land", populationMultiplier: 0.1, gdpMultiplier: 0.8, movementCostMultiplier: 5.0, minElevation: 0.34 },
+ { col: 0, terrainType: "Forest", terrainClass: "Land", populationMultiplier: 0.3, gdpMultiplier: 0.5, movementCostMultiplier: 1.5, defenseBonus: 0.33, minElevation: 0.06 },
+ { col: 1, terrainType: "Hills", terrainClass: "Land", populationMultiplier: 0.7, gdpMultiplier: 0.8, movementCostMultiplier: 1.8, defenseBonus: 0.33, minElevation: 0.2 },
+ { col: 2, terrainType: "Ice", terrainClass: "Ice", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 4.0, defenseBonus: 0, minElevation: 0 },
+ { col: 3, terrainType: "Land", terrainClass: "Land", populationMultiplier: 1.0, gdpMultiplier: 1.0, movementCostMultiplier: 1.0, defenseBonus: 0, minElevation: -0.08 },
+ { col: 4, terrainType: "Land", terrainClass: "Land", populationMultiplier: 0.05, gdpMultiplier: 0.2, movementCostMultiplier: 3.0, defenseBonus: 0, minElevation: -0.2 },
+ { col: 5, terrainType: "Sea", terrainClass: "Sea", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 0, defenseBonus: 0, minElevation: 0 },
+ { col: 6, terrainType: "Mountain", terrainClass: "Land", populationMultiplier: 0.1, gdpMultiplier: 0.8, movementCostMultiplier: 5.0, defenseBonus: 1.0, minElevation: 0.34 },
];
+// A city standing on a tile adds this fraction on top of the terrain's own
+// defensive bonus.
+export const CITY_DEFENSE_BONUS = 0.5;
+
export function protoUnitById(id) {
return PROTO_UNITS.find((u) => u.id === id) || null;
}
diff --git a/shared/game_state.js b/shared/game_state.js
index b1ec004..b924032 100644
--- a/shared/game_state.js
+++ b/shared/game_state.js
@@ -17,13 +17,15 @@ import {
BUILDINGS,
GOVERNMENTS,
TECHNOLOGIES,
- CITY_DESCRIPTIONS,
EFFECT_GDP,
EFFECT_BUDGET_INCOME,
EFFECT_RESEARCH,
EFFECT_CULTURE,
+ STATUS_ENCIRCLED,
+ STATUS_BESIEGED,
+ statusById,
} 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 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 DEFAULT_UNIT_SPEED = 0.5;
export const MIN_STEP_COST = 0.0001;
+// A defending unit's defense is doubled: a 100% defensive bonus.
+export const DEFENDER_DEFENSE_MULTIPLIER = 2.0;
export class GameState {
constructor() {
@@ -147,6 +151,7 @@ export class GameState {
this._configured = true;
this._placeSettlements();
+ this._refreshStatuses();
this._revealAll();
this._refreshVisibility();
this._visibilityDirty = false;
@@ -234,8 +239,18 @@ export class GameState {
if (!unit) return false;
const proto = this.unitProto(unit);
if (!proto || !proto.moveable) return false;
+ // Ordering a move onto an enemy military unit is an attack: remember the
+ // target so the unit stops as soon as it is within range instead of walking
+ // all the way into it.
+ const target = this.unitAt(goal);
+ const attacking = !!target && target !== unit && target.civ !== unit.civ &&
+ this._isMilitary(unit) && this._isMilitary(target);
+ unit.attackTarget = attacking ? target.id : null;
const path = this.findPath(unit, unit.coords, goal);
- if (path.length < 2) return false;
+ if (path.length < 2) {
+ unit.attackTarget = null;
+ return false;
+ }
unit.path = path;
unit.pathIndex = 0;
unit.progressHours = 0;
@@ -250,12 +265,23 @@ export class GameState {
advanceMovement(hours) {
if (hours <= 0) return;
+ // Units in contact are locked in place until the daily combat round
+ // resolves, so neither side can slip away mid-fight.
+ const engaged = this._engagedUnits();
for (const unit of this.units) {
const path = unit.path;
if (!path || path.length === 0) continue;
+ if (engaged.has(unit)) continue;
let hoursLeft = hours;
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];
+ // 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 needed = cost - unit.progressHours;
if (hoursLeft >= needed) {
@@ -264,6 +290,7 @@ export class GameState {
unit.progressHours = 0;
unit.coords = next;
this._visibilityDirty = true;
+ this._onUnitArrived(unit);
} else {
unit.progressHours += hoursLeft;
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() {
this.totalHours += 1;
this._tickTraining();
@@ -287,6 +617,9 @@ export class GameState {
if (this.totalHours % HOURS_PER_DAY === 0) {
this._tickPopulation();
this._tickEconomy();
+ this._refreshStatuses();
+ this._applyDailyStatusEffects();
+ this._resolveCombatRound();
}
this._tickBuildings();
this._emitChanged();
@@ -412,6 +745,106 @@ export class GameState {
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) {
const totals = {};
for (const city of this.cities) {
@@ -519,6 +952,64 @@ export class GameState {
return proto.traversableTerrains.includes(tile.terrainClass);
}
+ // Whether `unit` may occupy `coords`: passable terrain, no friendly unit in
+ // the way and no enemy unit there unless both are military (which turns the
+ // step into a fight). Enemy cities are only open to military units, since
+ // walking one in captures it.
+ //
+ // A hostile unit or city is only a valid destination (`isGoal`), never a tile
+ // to pass through: pathfinding routes around enemies instead of through them.
+ _canUnitEnter(unit, coords, isGoal = false) {
+ const proto = this.unitProto(unit);
+ if (!this.canEnter(unit.civ, proto, coords)) return false;
+ const occupant = this.unitAt(coords);
+ if (occupant && occupant !== unit) {
+ if (occupant.civ === unit.civ) return false;
+ if (!isGoal) return false;
+ if (!this._isMilitary(unit) || !this._isMilitary(occupant)) return false;
+ }
+ const city = this._cityByCoords.get(key(coords.x, coords.y));
+ if (city && city.civ !== unit.civ) {
+ if (!isGoal) return false;
+ if (!this._isMilitary(unit)) return false;
+ }
+ return true;
+ }
+
+ isMilitary(unit) {
+ return this._isMilitary(unit);
+ }
+
+ _isMilitary(unit) {
+ const proto = this.unitProto(unit);
+ return !!(proto && proto.military);
+ }
+
+ _unitAttack(unit) {
+ const proto = this.unitProto(unit);
+ return proto && typeof proto.attack === "number" ? proto.attack : 0;
+ }
+
+ _unitDefense(unit) {
+ const proto = this.unitProto(unit);
+ return proto && typeof proto.defense === "number" ? proto.defense : 0;
+ }
+
+ // Fraction of extra defense granted by the tile a unit occupies (terrain
+ // plus any city), as a multiplier.
+ _defenseMultiplierAt(coords) {
+ const tile = this.tiles[key(coords.x, coords.y)];
+ return defenseMultiplier(tile, !!this.cityAt(coords));
+ }
+
+ // A unit's base defense, boosted by its tile, and doubled when it is the
+ // defending side of the exchange.
+ _effectiveDefense(unit, isDefender) {
+ let defense = this._unitDefense(unit) * this._defenseMultiplierAt(unit.coords);
+ if (isDefender) defense *= DEFENDER_DEFENSE_MULTIPLIER;
+ return defense;
+ }
+
findPath(unit, start, goal) {
const proto = this.unitProto(unit);
const civ = unit.civ;
@@ -529,9 +1020,9 @@ export class GameState {
start,
goal,
(coords) => this._neighbours(coords),
- (coords) => {
- const k = key(coords.x, coords.y);
- if (!enterCache.has(k)) enterCache.set(k, this.canEnter(civ, proto, coords));
+ (coords, isGoal) => {
+ const k = key(coords.x, coords.y) + (isGoal ? ":goal" : "");
+ if (!enterCache.has(k)) enterCache.set(k, this._canUnitEnter(unit, coords, isGoal));
return enterCache.get(k);
},
(_from, to) => {
@@ -584,17 +1075,14 @@ export class GameState {
population: description.population,
improvements: description.improvements.slice(),
buildings: {},
+ statuses: [],
};
this.cities.push(city);
this._cityById.set(this._nextCityId, city);
this._nextCityId += 1;
}
- _cityDescriptionFor(civ, isCapital, center) {
- if (isCapital && CITY_DESCRIPTIONS[civ.id]) {
- const named = CITY_DESCRIPTIONS[civ.id];
- return { name: named.name, population: named.population, improvements: named.improvements };
- }
+ _cityDescriptionFor(civ, _isCapital, center) {
return this._generatedCityDescription(civ, center);
}
@@ -639,10 +1127,13 @@ export class GameState {
path: [],
pathIndex: 0,
progressHours: 0,
+ attackTarget: null,
+ statuses: [],
};
this.units.push(unit);
this._unitById.set(this._nextUnitId, unit);
this._nextUnitId += 1;
+ return unit;
}
_recomputeTerritory() {
@@ -678,6 +1169,86 @@ export class GameState {
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() {
this.tilePopulation = new Map();
for (const k in this.tiles) {
@@ -912,6 +1483,7 @@ export class GameState {
return {
population: 0, gdp: 0, gdpPerCapita: 0, budget: 0,
upkeep: 0, research: 0, culture: 0, government: 0,
+ breakdown: null,
};
}
const aggregates = this._playerAggregates(viewerCiv);
@@ -926,6 +1498,7 @@ export class GameState {
research: this.getResearch(viewerCiv),
culture: this.getCulture(viewerCiv),
government: this.getGovernment(viewerCiv),
+ breakdown: this.budgetBreakdown(viewerCiv),
};
}
@@ -939,6 +1512,7 @@ export class GameState {
population: city.population,
improvements: city.improvements,
buildings: { ...city.buildings },
+ statuses: city.statuses || [],
}));
}
@@ -962,6 +1536,7 @@ export class GameState {
coords: [unit.coords.x, unit.coords.y],
hp: unit.hp,
maxHp: unit.maxHp,
+ statuses: unit.statuses || [],
path,
pathIndex,
progressHours: unit.progressHours,
diff --git a/shared/hex_pathfinder.js b/shared/hex_pathfinder.js
index eeb2d2b..9eb9139 100644
--- a/shared/hex_pathfinder.js
+++ b/shared/hex_pathfinder.js
@@ -2,12 +2,14 @@
// the graph with callbacks so the algorithm stays pure and testable:
//
// neighbours(coords) -> [{x,y}, ...]
-// canEnter(coords) -> bool
+// canEnter(coords, isGoal) -> bool
// stepCost(from, to) -> number
// heuristic(from, to) -> number
//
-// findPath returns the cells from start to goal inclusive, or [] when the goal
-// cannot be entered or is unreachable.
+// canEnter is told whether the tile is the destination, so a caller can allow
+// 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";
@@ -69,7 +71,7 @@ export class HexPathfinder {
findPath(start, goal, neighbours, canEnter, stepCost, heuristic) {
const startKey = key(start.x, start.y);
const goalKey = key(goal.x, goal.y);
- if (startKey === goalKey || !canEnter(goal)) return [];
+ if (startKey === goalKey || !canEnter(goal, true)) return [];
const open = new MinHeap();
const cameFrom = new Map();
const gScore = new Map();
@@ -85,7 +87,7 @@ export class HexPathfinder {
for (const neighbour of neighbours(current)) {
const neighbourKey = key(neighbour.x, neighbour.y);
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) +
stepCost(current, neighbour);
if (tentative < (gScore.has(neighbourKey) ? gScore.get(neighbourKey) : Infinity)) {
diff --git a/shared/rules.js b/shared/rules.js
index bbbc5fc..9fb948f 100644
--- a/shared/rules.js
+++ b/shared/rules.js
@@ -1,7 +1,7 @@
// Catalogue maths shared by the server (which charges costs) and the browser
// (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) {
return (proto.baseCost + proto.costGdpFraction * Math.max(gdp, 0)) *
@@ -56,3 +56,29 @@ export function prerequisitesMet(technology, researchedSet, catalogue) {
}
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);
+}
diff --git a/tests/data_test.js b/tests/data_test.js
index 557db5b..55fe2c7 100644
--- a/tests/data_test.js
+++ b/tests/data_test.js
@@ -6,7 +6,6 @@ import {
GOVERNMENTS,
TECHNOLOGIES,
TRAINABLE_UNIT_IDS,
- CITY_DESCRIPTIONS,
protoUnitById,
buildingById,
governmentById,
@@ -47,11 +46,20 @@ export class DataTest extends TestCase {
}
}
- test_named_capitals_are_defined_for_france_and_britain() {
- this.assertNotNull(CITY_DESCRIPTIONS.france);
- this.assertNotNull(CITY_DESCRIPTIONS.britain);
- this.assertEqual(CITY_DESCRIPTIONS.france.name, "Paris");
- this.assertGreater(CITY_DESCRIPTIONS.france.population, 0);
+ test_artillery_is_expensive_slow_and_long_ranged() {
+ const infantry = protoUnitById("modern_infantry");
+ const artillery = protoUnitById("artillery");
+ this.assertNotNull(artillery, "artillery exists");
+ 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() {
diff --git a/tests/framework/helpers.js b/tests/framework/helpers.js
index 70a682b..ce31792 100644
--- a/tests/framework/helpers.js
+++ b/tests/framework/helpers.js
@@ -60,6 +60,7 @@ export function mapLayers($) {
fog: $("#layer-fog"),
paths: $("#layer-paths"),
entities: $("#layer-entities"),
+ labels: $("#layer-labels"),
};
}
diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js
index 4701abf..617462c 100644
--- a/tests/game_screen_test.js
+++ b/tests/game_screen_test.js
@@ -51,7 +51,8 @@ export class GameScreenTest extends TestCase {
env.$("#city-train").click();
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 });
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() {
- const env = await setupDom();
+ async test_snapshot_refreshes_researched_and_buildings() { const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
@@ -230,4 +230,71 @@ export class GameScreenTest extends TestCase {
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);
+ }
+ }
}
diff --git a/tests/game_state_buildings_test.js b/tests/game_state_buildings_test.js
index 7acb831..701903d 100644
--- a/tests/game_state_buildings_test.js
+++ b/tests/game_state_buildings_test.js
@@ -1,6 +1,6 @@
import { TestCase } from "./framework/test_case.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;
@@ -90,6 +90,46 @@ export class GameStateBuildingsTest extends TestCase {
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() {
const state = smallState();
const snapshot = state.snapshot(0);
diff --git a/tests/hex_pathfinder_test.js b/tests/hex_pathfinder_test.js
index 3cf4ba7..1f2867e 100644
--- a/tests/hex_pathfinder_test.js
+++ b/tests/hex_pathfinder_test.js
@@ -64,6 +64,22 @@ export class HexPathfinderTest extends TestCase {
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() {
// The straight road is expensive; a one-tile detour must be chosen.
const expensive = new Set(["1,0", "2,0"]);
diff --git a/tests/modals_test.js b/tests/modals_test.js
index d5e5ecf..6ffb390 100644
--- a/tests/modals_test.js
+++ b/tests/modals_test.js
@@ -135,4 +135,58 @@ export class NationModalTest extends TestCase {
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);
+ }
+ }
}
diff --git a/tests/rules_test.js b/tests/rules_test.js
index 6e91826..af12851 100644
--- a/tests/rules_test.js
+++ b/tests/rules_test.js
@@ -8,8 +8,19 @@ import {
governmentEffects,
technologyEffects,
prerequisitesMet,
+ defenseFactors,
+ defenseMultiplier,
+ unitDefense,
} 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 = {}) {
return {
@@ -118,4 +129,49 @@ export class RulesTest extends TestCase {
this.assertEmpty(method.prerequisites);
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);
+ }
}
diff --git a/tests/run_tests.js b/tests/run_tests.js
index 01d2623..149a0c9 100644
--- a/tests/run_tests.js
+++ b/tests/run_tests.js
@@ -15,6 +15,8 @@ import { TextFormatTest } from "./text_format_test.js";
import { GameClockTest } from "./game_clock_test.js";
import { LoginManagerTest } from "./login_manager_test.js";
import { RulesTest } from "./rules_test.js";
+import { CombatTest } from "./combat_test.js";
+import { SiegeTest } from "./siege_test.js";
import { GameStateTest } from "./game_state_test.js";
import { GameStateBuildingsTest } from "./game_state_buildings_test.js";
import { GameStateGovernmentTest } from "./game_state_government_test.js";
@@ -41,6 +43,8 @@ const SUITES = [
GameClockTest,
LoginManagerTest,
RulesTest,
+ CombatTest,
+ SiegeTest,
GameStateTest,
GameStateBuildingsTest,
GameStateGovernmentTest,