Artillery + encirclement + budget
This commit is contained in:
@@ -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 connects to the page's own origin, so HTTPS gives it `wss://`.
|
||||
- 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
@@ -169,12 +169,29 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
|
||||
.entity { position: absolute; }
|
||||
.unit {
|
||||
--unit-size: 38px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
margin-left: -19px;
|
||||
margin-top: -19px;
|
||||
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 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -216,12 +233,19 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
.city .label {
|
||||
margin-top: 2px;
|
||||
/* City names live in their own layer above every unit, marker and status icon,
|
||||
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;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 2px #000, 0 0 4px #000;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.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; }
|
||||
#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 -- */
|
||||
|
||||
.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 .actions { display: flex; flex-direction: column; gap: 6px; justify-content: center; }
|
||||
.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; }
|
||||
.progress { height: 14px; margin-top: 8px; background: rgba(0, 0, 0, 0.5); border-radius: 7px; overflow: hidden; }
|
||||
|
||||
+12
-1
@@ -87,6 +87,7 @@
|
||||
<div class="layer" id="layer-fog"></div>
|
||||
<div class="layer" id="layer-paths"></div>
|
||||
<div class="layer" id="layer-entities"></div>
|
||||
<div class="layer city-label-layer" id="layer-labels"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -129,9 +130,14 @@
|
||||
</div>
|
||||
<div id="unit-info" class="info-section hidden">
|
||||
<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>
|
||||
</div>
|
||||
<div id="stat-tooltip" class="tooltip hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Training modal -->
|
||||
@@ -161,7 +167,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nation modal: government and technology tabs -->
|
||||
<!-- Nation modal: government, technology and budget tabs -->
|
||||
<div id="modal-nation" class="modal hidden">
|
||||
<div class="modal-card">
|
||||
<div class="modal-head">
|
||||
@@ -171,6 +177,7 @@
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="government">Government</button>
|
||||
<button class="tab" data-tab="technology">Technology</button>
|
||||
<button class="tab" data-tab="budget">Budget</button>
|
||||
</div>
|
||||
<div class="tab-page active" id="tab-government">
|
||||
<div id="government-sub" class="modal-sub"></div>
|
||||
@@ -180,6 +187,10 @@
|
||||
<div id="technology-sub" class="modal-sub"></div>
|
||||
<div id="technology-list" class="modal-body"></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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+149
-17
@@ -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 = $('<div id="layer-overview" class="layer overview"></div>');
|
||||
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.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) {
|
||||
|
||||
+93
-5
@@ -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 = $("<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) {
|
||||
const $card = $("<div class='row-card'></div>");
|
||||
const $info = $("<div class='info'></div>");
|
||||
@@ -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);
|
||||
|
||||
+39
-4
@@ -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";
|
||||
|
||||
+79
-19
@@ -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;
|
||||
}
|
||||
|
||||
+586
-11
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
+27
-1
@@ -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);
|
||||
}
|
||||
|
||||
+14
-6
@@ -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() {
|
||||
|
||||
@@ -60,6 +60,7 @@ export function mapLayers($) {
|
||||
fog: $("#layer-fog"),
|
||||
paths: $("#layer-paths"),
|
||||
entities: $("#layer-entities"),
|
||||
labels: $("#layer-labels"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+57
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user