Better map generation

This commit is contained in:
2026-09-17 22:01:30 +02:00
parent b08074a2f3
commit 4e2eae3306
23 changed files with 1766 additions and 4363 deletions
+10 -4
View File
@@ -59,11 +59,17 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
server: `hex.js` (topology/geometry), `map_generator.js`, `hex_pathfinder.js`,
`terrain_stats.js`, `rules.js`, `economy` figures in `game_state.js`,
`game_clock.js`, `rng.js`, `noise.js`, `text_format.js`, `login_manager.js`.
Data-driven content (civilisations, units, buildings, governments,
technologies) lives in `shared/data.js`.
Data-driven content lives in `shared/data/` (one small module per topic:
civilisations, units, buildings, governments, technologies, terrain, ...).
`shared/data.js` is a barrel re-exporting the whole catalogue, so keep
importing from `shared/data.js` and edit the file under `shared/data/`.
`shared/game_state.js` is likewise a thin class composed from the mixins in
`shared/game_state/` (world, territory, economy, movement, combat, status,
diplomacy, orders, visibility, serialization).
- `client/js/` splits the UI: `app.js` (screens and flow), `net.js` (transport
wrapper), `game_screen.js` (HUD and panels), `map_view.js` (canvas/DOM map),
`modals.js` (training, buildings, government/technology modals).
wrapper), `game_screen.js` + `game_screen/` (HUD and panels), `map_view.js` +
`map_view/` (DOM map), `modals.js` + `modals/` (training, buildings,
nation/technology, confirm, news).
- Tests live in `tests/` as `*_test.js` files extending the `TestCase` base in
`tests/framework/`; `tests/run_tests.js` discovers and runs them. Add a new
`tests/<topic>_test.js` for each new pure behaviour.
+164 -10
View File
@@ -155,6 +155,9 @@ input:focus, select:focus { border-color: var(--accent); }
avoids holding a huge composited layer in memory. */
#map-viewport.dragging #map-world { will-change: transform; }
.layer { position: absolute; left: 0; top: 0; }
/* A materialised block of the detailed map. Positioned at its world origin and
moved with `transform`; its tiles are positioned relative to it. */
.chunk { position: absolute; left: 0; top: 0; }
/* Zoomed-out level of detail: the detailed hex/fog/border layers are hidden and
a single simplified canvas is shown instead. */
@@ -167,16 +170,25 @@ input:focus, select:focus { border-color: var(--accent); }
position: absolute;
width: var(--hex-w);
height: var(--hex-h);
clip-path: polygon(100% 50%, 75% 0, 25% 0, 0 50%, 25% 100%, 75% 100%);
/* Terrain and fog tiles are drawn from pre-masked art (transparent corners),
so no per-tile clip-path is needed. Clipping thousands of hexes forced a
repaint of the whole map on every pan frame. */
background-repeat: no-repeat;
}
.hex.terrain {
background-image: url("../assets/terrain.png");
background-repeat: no-repeat;
background-size: 441px 56px;
}
.hex.fog { pointer-events: none; }
.fog-unexplored { background: #04040a; }
.fog-explored { background: rgba(4, 6, 18, 0.62); }
.hex.fog {
pointer-events: none;
background-size: var(--hex-w) var(--hex-h);
}
.fog-unexplored {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='63' height='56'%3E%3Cpolygon points='63,28 47.25,0 15.75,0 0,28 15.75,56 47.25,56' fill='%2304040a'/%3E%3C/svg%3E");
}
.fog-explored {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='63' height='56'%3E%3Cpolygon points='63,28 47.25,0 15.75,0 0,28 15.75,56 47.25,56' fill='%23040612' fill-opacity='0.62'/%3E%3C/svg%3E");
}
.border-seg {
position: absolute;
@@ -241,16 +253,18 @@ input:focus, select:focus { border-color: var(--accent); }
justify-content: center;
}
.unit .body img { width: 78%; height: 78%; image-rendering: auto; }
/* Owner's flag pinned to the top-left quarter of the unit. */
/* Owner's flag centred on the unit's top-left corner, so only a sliver of it
overlaps the unit and the unit icon stays readable. */
.unit .unit-flag {
position: absolute;
top: 0;
left: 0;
width: 50%;
height: 50%;
transform: translate(-50%, -50%);
object-fit: cover;
border: 1px solid rgba(0, 0, 0, 0.65);
border-radius: 6px 0 4px 0;
border-radius: 3px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
pointer-events: none;
z-index: 3;
@@ -269,6 +283,39 @@ input:focus, select:focus { border-color: var(--accent); }
overflow: hidden;
}
.unit .health .fill { height: 100%; background: #46d36a; }
/* Stack size, sitting on the lower-right corner of the unit icon. */
.unit .unit-count {
position: absolute;
right: 0;
bottom: 0;
min-width: 14px;
height: 14px;
padding: 0 3px;
box-sizing: border-box;
border-radius: 7px;
background: #000000;
color: #ffffff;
font-size: 10px;
font-weight: 700;
line-height: 14px;
text-align: center;
pointer-events: none;
z-index: 4;
}
/* On a battlefield both stacks shrink to half size and face each other across
the battle icon. */
.unit.in-battle { transform: scale(0.5); }
.battle-marker {
position: absolute;
width: 22px;
height: 22px;
margin-left: -11px;
margin-top: -11px;
z-index: 4;
pointer-events: none;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9));
}
.battle-marker img { width: 100%; height: 100%; }
.city {
--city-size: 26px;
@@ -383,8 +430,10 @@ input:focus, select:focus { border-color: var(--accent); }
.info-section.hidden { display: none !important; }
.info-title { font-size: 17px; font-weight: 600; margin-bottom: 4px; }
#bottom-panel .row { justify-content: flex-start; margin-top: 10px; }
.health-row { display: flex; align-items: center; gap: 8px; }
.health-bar { width: 220px; height: 12px; background: rgba(30, 0, 0, 0.8); border-radius: 6px; overflow: hidden; margin-top: 6px; }
#unit-health-fill { height: 100%; background: linear-gradient(180deg, #6fe08a, #2fa04d); }
#unit-health-label { margin-top: 6px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
.unit-stats { display: flex; gap: 16px; margin-top: 6px; }
.unit-stat {
@@ -395,8 +444,9 @@ input:focus, select:focus { border-color: var(--accent); }
}
.unit-stat:hover { color: var(--text); }
/* Chat-like feed of short-lived news in the lower-left corner. */
.game-feed {
/* Chat-like feed of short-lived news in the lower-left corner, with the News
button anchored beneath it. */
.game-corner {
position: absolute;
left: 14px;
bottom: 14px;
@@ -404,10 +454,22 @@ input:focus, select:focus { border-color: var(--accent); }
max-width: calc(50vw - 30px);
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
gap: 8px;
pointer-events: none;
z-index: 40;
}
.game-feed {
width: 100%;
display: flex;
flex-direction: column;
gap: 4px;
}
.game-corner .news-button {
pointer-events: auto;
padding: 6px 14px;
font-size: 13px;
}
.feed-line {
align-self: flex-start;
max-width: 100%;
@@ -451,6 +513,37 @@ input:focus, select:focus { border-color: var(--accent); }
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.6);
}
/* Right-click menu for the units sharing a tile. */
.stack-menu {
position: absolute;
z-index: 70;
min-width: 200px;
padding: 4px;
border-radius: 8px;
background: var(--panel-bg);
border: 1px solid var(--panel-border);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55);
display: flex;
flex-direction: column;
gap: 2px;
}
.stack-item {
text-align: left;
padding: 7px 10px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--text);
font-size: 13px;
cursor: pointer;
}
.stack-item:hover { background: rgba(110, 175, 255, 0.2); }
.stack-item.stack-all {
margin-top: 2px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
color: #ffcf7a;
}
/* --------------------------------------------------------------- modals -- */
.modal {
@@ -533,7 +626,68 @@ input:focus, select:focus { border-color: var(--accent); }
.row-card .budget-detail { margin-top: 4px; }
.row-card .budget-line { font-size: 12px; color: var(--muted); margin-top: 2px; }
.row-card.conflict-card { display: block; cursor: pointer; }
.row-card.conflict-card:hover { background: rgba(255, 255, 255, 0.09); }
.conflict-detail {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.conflict-detail .conflict-line { font-size: 12px; color: var(--muted); }
.conflict-detail .conflict-side { font-size: 13px; margin-top: 4px; }
.diplomacy-table { width: 100%; border-collapse: collapse; }
.diplomacy-table th,
.diplomacy-table td {
padding: 8px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
text-align: left;
}
.diplomacy-table th {
cursor: pointer;
user-select: none;
color: var(--muted);
font-weight: 600;
white-space: nowrap;
}
.diplomacy-table th:hover { color: var(--text); }
.diplomacy-table th.sorted-asc,
.diplomacy-table th.sorted-desc { color: var(--text); }
.diplomacy-table th.numeric,
.diplomacy-table td.numeric { text-align: right; }
.diplomacy-table .sort-arrow { font-size: 10px; margin-left: 4px; }
.diplomacy-table tbody tr:hover { background: rgba(255, 255, 255, 0.05); }
.diplomacy-table tbody tr.local-nation { background: rgba(120, 170, 255, 0.16); }
.diplomacy-table tbody tr.local-nation:hover { background: rgba(120, 170, 255, 0.24); }
.diplomacy-table tbody tr.local-nation td.name { font-weight: 600; }
.diplomacy-table td.status { white-space: nowrap; }
.diplomacy-table td.status .aero { margin-left: 8px; padding: 4px 10px; }
.training-status { padding: 0 18px 16px; }
.training-queue { padding-top: 0; }
.training-queue .queue-empty { padding: 4px 0; }
.training-queue .queue-row { padding: 8px 12px; }
.training-queue .queue-row .actions .aero { min-width: 110px; }
/* The training modal keeps a fixed, slightly larger size. The unit buttons sit
in a fixed, non-scrolling box; the queue is the flexible part and scrolls, so
adding units never stretches the card or pushes the buttons out of reach. */
#modal-train .modal-card {
width: 680px;
height: min(80vh, 740px);
}
#modal-train #train-list {
flex: 0 0 auto;
width: 100%;
height: 120px;
max-height: none;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 8px;
}
#modal-train .training-status { flex: 0 0 auto; }
#modal-train .training-queue { flex: 1 1 auto; min-height: 0; }
.progress { height: 14px; margin-top: 8px; background: rgba(0, 0, 0, 0.5); border-radius: 7px; overflow: hidden; }
#train-progress, #buildings-progress { height: 100%; width: 0; background: linear-gradient(180deg, #7fc4ff, #2f7fd0); }
+20 -3
View File
@@ -108,7 +108,6 @@
<button id="civ-flag" class="flag-button" title="Government and technology">
<img id="civ-flag-img" alt="" />
</button>
<button id="news-button" class="aero" title="News">News</button>
<div class="top-text">
<div id="civ-name" class="civ-name">—</div>
<div id="player-name" class="player-name"></div>
@@ -147,14 +146,26 @@
<span id="unit-attack" class="unit-stat">Attack —</span>
<span id="unit-defense" class="unit-stat">Defense —</span>
</div>
<div class="health-bar"><div id="unit-health-fill"></div></div>
<div class="health-row">
<div class="health-bar"><div id="unit-health-fill"></div></div>
<span id="unit-health-label" class="health-label"></span>
</div>
<div class="row">
<button id="unit-pillage" class="aero hidden">Pillage</button>
</div>
</div>
</div>
<div id="stat-tooltip" class="tooltip hidden"></div>
</div>
<!-- Right-click menu listing the units sharing a tile -->
<div id="stack-menu" class="stack-menu hidden"></div>
<!-- Chat-like feed of short-lived news, pinned to the lower left -->
<div id="game-feed" class="game-feed"></div>
<div class="game-corner">
<div id="game-feed" class="game-feed"></div>
<button id="news-button" class="aero news-button" title="News">News</button>
</div>
<!-- Training modal -->
<div id="modal-train" class="modal hidden">
@@ -168,6 +179,7 @@
<div id="train-label"></div>
<div class="progress"><div id="train-progress"></div></div>
</div>
<div id="train-queue" class="modal-body training-queue"></div>
</div>
</div>
@@ -220,6 +232,7 @@
<button class="tab active" data-tab="government">Government</button>
<button class="tab" data-tab="technology">Technology</button>
<button class="tab" data-tab="diplomacy">Diplomacy</button>
<button class="tab" data-tab="conflicts">Ongoing conflicts</button>
<button class="tab" data-tab="budget">Budget</button>
</div>
<div class="tab-page active" id="tab-government">
@@ -234,6 +247,10 @@
<div id="diplomacy-sub" class="modal-sub"></div>
<div id="diplomacy-list" class="modal-body"></div>
</div>
<div class="tab-page" id="tab-conflicts">
<div id="conflicts-sub" class="modal-sub"></div>
<div id="conflicts-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>
+5 -2
View File
@@ -41,7 +41,10 @@ $("#menu-join").on("click", () => {
// ---------------------------------------------------------- new game ------
const $civSelect = $("#new-civs");
for (const civ of CIVILISATIONS) {
// The picker reads alphabetically; the catalogue order is left alone so it can
// still drive the default roster.
const menuCivs = CIVILISATIONS.slice().sort((a, b) => a.name.localeCompare(b.name));
for (const civ of menuCivs) {
const $item = civOption(civ);
$item.on("click", () => {
selectedCivId = civ.id;
@@ -50,7 +53,7 @@ for (const civ of CIVILISATIONS) {
});
$civSelect.append($item);
}
$civSelect.children().first().addClass("selected");
$civSelect.children().filter(`[data-civ-id="${selectedCivId}"]`).addClass("selected");
$("#new-count").attr("max", CIVILISATIONS.length);
// A fresh game starts with every civilisation in the world.
$("#new-count").val(CIVILISATIONS.length);
+90 -387
View File
@@ -2,22 +2,16 @@
// to the snapshots the server broadcasts, and turns player input into orders.
// It owns no simulation: movement between snapshots is dead reckoning with the
// same cost model as the server.
//
// The HUD, selection and modal behaviour lives in ./game_screen/panels.js and is
// composed onto the prototype below; this file keeps the screen lifecycle, the
// snapshot plumbing and the order flow.
import { MapView } from "./map_view.js";
import { TrainModal, BuildingsModal, NationModal, ConfirmModal, NewsModal, describeNews } from "./modals.js";
import { TrainModal, BuildingsModal, NationModal, ConfirmModal, NewsModal } from "./modals.js";
import { GameFeed } from "./feed.js";
import { compact, groupDigits, improvements as fmtImprovements } from "../../shared/text_format.js";
import {
CIVILISATIONS,
PROTO_UNITS,
TRAINABLE_UNIT_IDS,
BUILDINGS,
GOVERNMENTS,
TECHNOLOGIES,
NEWS_WAR,
} from "../../shared/data.js";
import { defenseFactors, unitDefense } from "../../shared/rules.js";
import { dateString } from "../../shared/game_clock.js";
import { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.js";
import { panelMethods } from "./game_screen/panels.js";
export class GameScreen {
constructor(network, config) {
@@ -28,14 +22,16 @@ export class GameScreen {
this.localCiv = 0;
this.civilisations = [];
this.protoUnits = PROTO_UNITS;
this.selectedUnitId = 0;
this.selectedUnitIds = [];
this.selectedCityId = 0;
this._trainingCityId = 0;
this._cityBuildings = new Map();
this._population = new Map();
this._territoryOwner = new Map();
this._battles = new Set();
this._researched = new Set();
this._wars = new Set();
this._conflicts = [];
this._news = [];
this._seenNews = null;
this._entered = false;
@@ -62,6 +58,7 @@ export class GameScreen {
this.confirmModal = new ConfirmModal();
this.newsModal = new NewsModal();
this.feed = new GameFeed($("#game-feed"));
this.$stackMenu = $("#stack-menu");
this.$statTooltip = $("#stat-tooltip");
this._hoveredStat = null;
@@ -79,9 +76,12 @@ export class GameScreen {
leave() {
this._entered = false;
this.map.cancelPathfinding();
this.$screen.removeClass("active");
this.feed.clear();
this._seenNews = null;
this._hideStackMenu();
$(document).off("mousedown.stackmenu");
}
// ------------------------------------------------------------- orders ---
@@ -113,6 +113,7 @@ export class GameScreen {
);
this._population = new Map(state.population.map((e) => [`${e[0]},${e[1]}`, e[2]]));
this._territoryOwner = new Map(state.territory.map((e) => [`${e[0]},${e[1]}`, e[2]]));
this._battles = new Set((state.battles || []).map((e) => `${e[0]},${e[1]}`));
this._cityBuildings = new Map();
for (const city of state.cities) {
this._cityBuildings.set(city.id, { ...city.buildings });
@@ -127,12 +128,14 @@ export class GameScreen {
const b = entry[1];
this._wars.add(a < b ? `${a}:${b}` : `${b}:${a}`);
}
this._conflicts = state.conflicts || [];
this._news = state.news || [];
this._updateFeed(this._news);
this.map.setCatalogue(this.civilisations, this.protoUnits);
this.map.applySnapshot(state);
this.map.setSelectedUnit(this.selectedUnitId);
this.selectedUnitIds = this.selectedUnitIds.filter((id) => this._unit(id));
this.map.setSelectedUnits(this.selectedUnitIds);
this._updateTopPanel(state);
this._updateBottomPanel();
this._updateTrainModal();
@@ -141,407 +144,105 @@ export class GameScreen {
this._updateNewsModal();
}
// ---------------------------------------------------------------- HUD ----
_bindHud() {
$("#city-train").on("click", () => this._openTrainModal());
$("#city-build").on("click", () => this._openBuildingsModal());
$("#civ-flag").on("click", () => this._openNationModal());
$("#news-button").on("click", () => this._openNewsModal());
this.trainModal.onTrain = (cityId, protoIndex) =>
this.sendOrder({ type: "train", city: cityId, proto: protoIndex });
this.buildingsModal.onBuild = (cityId, index) =>
this.sendOrder({ type: "build", city: cityId, building: index });
this.buildingsModal.onDemolish = (cityId, index) =>
this.sendOrder({ type: "demolish", city: cityId, building: index });
this.nationModal.onGovernment = (index) =>
this.sendOrder({ type: "government", government: index });
this.nationModal.onTechnology = (index) =>
this.sendOrder({ type: "technology", technology: index });
this.nationModal.onDeclareWar = (target) =>
this.sendOrder({ type: "declare_war", target });
$("#unit-attack")
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
.on("mouseleave", () => this._hideStatTooltip());
$("#unit-defense")
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
.on("mouseleave", () => this._hideStatTooltip());
$(window).on("keydown.game", (event) => this._onKey(event));
}
// A small hover tooltip anchored above a stat, fed by its data-tooltip text.
_showStatTooltip($stat) {
const text = $stat.attr("data-tooltip");
if (!text) return;
this._hoveredStat = $stat;
this.$statTooltip.text(text).removeClass("hidden");
const offset = $stat.offset() || { left: 0, top: 0 };
const width = this.$statTooltip.outerWidth() || 0;
const height = this.$statTooltip.outerHeight() || 0;
this.$statTooltip.css({
left: `${Math.max(8, offset.left + $stat.outerWidth() / 2 - width / 2)}px`,
top: `${Math.max(8, offset.top - height - 8)}px`,
});
}
_hideStatTooltip() {
this._hoveredStat = null;
this.$statTooltip.addClass("hidden");
}
_bindMap() {
this.map.onUnitSelected = (id) => {
if (!id) {
this.selectedUnitId = 0;
this._showUnit(null);
this._selectUnits([]);
return;
}
const unit = (this.snapshot.units || []).find((u) => u.id === id);
const unit = this._unit(id);
if (!unit || unit.civ !== this.localCiv) {
this.map.setSelectedUnit(0);
this.selectedUnitId = 0;
this._selectUnits([]);
return;
}
this.selectedUnitId = id;
this.selectedCityId = 0;
this.map.setSelectedUnit(id);
this._showUnit(unit);
// Clicking any member of a stack selects the whole stack, so the next
// order is given to every unit sharing the tile.
const stack = this.map.stackIds(unit.id).filter((stackId) => {
const data = this._unit(stackId);
return data && data.civ === this.localCiv;
});
this._selectUnits(stack.length > 0 ? stack : [unit.id]);
};
this.map.onCitySelected = (id) => {
const city = (this.snapshot.cities || []).find((c) => c.id === id);
if (!city || city.civ !== this.localCiv) return;
this.selectedCityId = id;
this.selectedUnitId = 0;
this.map.setSelectedUnit(0);
this.selectedUnitIds = [];
this.map.setSelectedUnits([]);
this._hideStackMenu();
this._showCity(city);
};
this.map.onTileRequested = (coords) => this._showTile(coords);
this.map.onMoveOrdered = (unitId, coords) => {
const unit = (this.snapshot.units || []).find((u) => u.id === unitId);
if (!unit || unit.civ !== this.localCiv) return;
const targets = this._unalliedTerritoryOnRoute(unit, coords);
if (targets.length > 0) {
const names = targets.map((index) => this._civName(index)).join(", ");
const demonyms = targets.map((index) => this._civDemonym(index)).join(", ");
this.confirmModal.show(
"Declare war?",
`Marching into ${demonyms} territory will declare war on ${names}. Continue?`,
() => this._sendMove(unit, coords)
);
return;
}
this._sendMove(unit, coords);
this.map.onStackMenu = (coords, ids, x, y) => this._openStackMenu(coords, ids, x, y);
this.map.onMoveOrdered = (unitIds, coords) => {
const units = unitIds.map((id) => this._unit(id)).filter((u) => u && u.civ === this.localCiv);
if (units.length === 0) return;
return this._orderMove(units, coords);
};
this.map.onAttackOrdered = (unitIds, coords) => {
const units = unitIds.map((id) => this._unit(id)).filter((u) => u && u.civ === this.localCiv);
if (units.length === 0) return;
return this._orderMove(units, coords, "attack");
};
this.map.onScheduleOrdered = (unitIds, coords) => {
const units = unitIds.map((id) => this._unit(id)).filter((u) => u && u.civ === this.localCiv);
if (units.length === 0) return;
return this._orderMove(units, coords, "schedule");
};
}
_sendMove(unit, coords) {
this.sendOrder({ type: "move", unit: unit.id, coords: [coords.x, coords.y] });
this.map.predictMove(unit, coords);
}
// The civilisations, other than our own, whose territory the predicted route
// would enter while we are at peace with them. Only military units can start
// a war this way; civilians wander in without one.
_unalliedTerritoryOnRoute(unit, coords) {
if (!this._isMilitaryUnit(unit)) return [];
const route = this.map.planMove(unit, coords);
if (!route || route.length < 2) return [];
const targets = [];
for (const point of route) {
const owner = this.map.territory.get(`${point.x},${point.y}`);
if (owner === undefined || owner < 0 || owner === unit.civ) continue;
if (this._isAtWar(unit.civ, owner)) continue;
if (!targets.includes(owner)) targets.push(owner);
}
return targets;
}
_isMilitaryUnit(unit) {
const proto = this.protoUnits[unit.proto];
return !!(proto && proto.military);
}
_isAtWar(a, b) {
if (a < 0 || b < 0 || a === b) return false;
return this._wars.has(a < b ? `${a}:${b}` : `${b}:${a}`);
}
_civName(index) {
const civ = this.civilisations[index];
return civ ? civ.name : `Nation ${index + 1}`;
}
_civDemonym(index) {
const civ = this.civilisations[index];
return civ && civ.demonym ? civ.demonym : this._civName(index);
}
_updateTopPanel(state) {
const civ = this.civilisations[this.localCiv];
if (civ) {
$("#civ-name").text(civ.name);
const flag = civ.flag || (CIVILISATIONS.find((c) => c.id === civ.id) || {}).flag;
if (flag) $("#civ-flag-img").attr("src", `assets/flags/${flag}`);
}
$("#player-name").text(this.config.playerName || (this.network.players[this.network.localPlayerId] || {}).name || "");
$("#admin-label").toggle(this.network.isAdmin);
$("#stat-date").text(dateString(state.totalHours));
const stats = state.viewerStats || {};
$("#stat-pop").text(compact(stats.population || 0));
$("#stat-gdp").text(`€${compact(stats.gdp || 0)}`);
$("#stat-gdppc").text(`€${compact(stats.gdpPerCapita || 0)}`);
$("#stat-budget").text(`€${compact(stats.budget || 0)}`);
}
_updateBottomPanel() {
if (this.selectedUnitId) {
const unit = this._unit(this.selectedUnitId);
if (unit) this._showUnit(unit);
} else if (this.selectedCityId) {
const city = this._city(this.selectedCityId);
if (city) this._showCity(city);
}
}
_showUnit(unit) {
if (!unit) {
$("#unit-info").addClass("hidden");
this._hideStatTooltip();
this._refreshBottomVisibility();
// Issues a move for the group, prompting first when the route would enter
// foreign territory at peace. `type` is "move", "attack" or "schedule".
async _orderMove(units, coords, type = "move") {
const { targets, speed, routes } = await this._groupMovePlan(units, coords);
if (targets.length > 0) {
const names = targets.map((index) => this._civName(index)).join(", ");
const demonyms = targets.map((index) => this._civDemonym(index)).join(", ");
this.confirmModal.show(
"Declare war?",
`Marching into ${demonyms} territory will declare war on ${names}. Continue?`,
() => this._sendMove(units, coords, speed, type, routes)
);
return;
}
const proto = this.protoUnits[unit.proto];
$("#unit-name").text(proto ? proto.name : "Unit");
this._showUnitStats(unit, proto);
const ratio = unit.maxHp > 0 ? (unit.hp / unit.maxHp) * 100 : 0;
$("#unit-health-fill").css("width", `${Math.max(0, Math.min(100, ratio))}%`);
$("#tile-info, #city-info").addClass("hidden");
$("#unit-info").removeClass("hidden");
this._refreshBottomVisibility();
return this._sendMove(units, coords, speed, type, routes);
}
// Attack and defense of the unit where it stands, with the hovering tooltips
// listing each factor that shaped the number.
_showUnitStats(unit, proto) {
const coords = { x: unit.coords[0], y: unit.coords[1] };
const tile = this.map.getTile(coords);
const hasCity = ((this.snapshot && this.snapshot.cities) || []).some(
(city) => city.coords[0] === coords.x && city.coords[1] === coords.y
);
const baseAttack = proto && typeof proto.attack === "number" ? proto.attack : 0;
const baseDefense = proto && typeof proto.defense === "number" ? proto.defense : 0;
const baseRange = proto && typeof proto.range === "number" ? proto.range : 1;
const attackParts = [`Base ${Math.round(baseAttack)}`];
if (baseRange > 1) attackParts.push(`Range ${baseRange}`);
$("#unit-attack")
.text(`Attack ${Math.round(baseAttack)}`)
.attr("data-tooltip", attackParts.join(", "));
const factors = defenseFactors(tile, hasCity);
const defense = unitDefense(baseDefense, tile, hasCity);
const parts = [`Base ${Math.round(baseDefense)}`];
for (const factor of factors) {
parts.push(`${factor.label} +${Math.round(baseDefense * factor.bonus)}`);
_sendMove(units, coords, speed = null, type = "move", routes = null) {
const ids = units.map((unit) => unit.id);
const order = { type, coords: [coords.x, coords.y] };
if (ids.length === 1) order.unit = ids[0];
else order.units = ids;
if (type === "schedule") {
order.goals = [[coords.x, coords.y]];
order.append = true;
}
$("#unit-defense")
.text(`Defense ${Math.round(defense)}`)
.attr("data-tooltip", parts.join(", "));
// Keep an open tooltip in step with the unit it describes across snapshots.
if (this._hoveredStat && !this.$statTooltip.hasClass("hidden")) {
const text = this._hoveredStat.attr("data-tooltip");
if (text) this.$statTooltip.text(text);
} else {
this._hideStatTooltip();
this.sendOrder(order);
const pending = [];
for (const unit of units) {
// An append joins the queue behind the leg already under way, so only an
// idle stack gets a fresh prediction.
const motion = this.map._unitMotion.get(unit.id);
if (type === "schedule" && motion && motion.predicting) continue;
// Reuse the route the war check already found instead of searching again.
const route = routes && routes.get(unit.id);
if (route) this.map.predictMove(unit, coords, speed, route);
else pending.push(unit);
}
}
_showCity(city) {
if (!city) {
$("#city-info").addClass("hidden");
this._refreshBottomVisibility();
return;
}
$("#city-name").text(city.name);
$("#city-population").text(`Population: ${groupDigits(city.population)}`);
$("#city-improvements").text(`Improvements: ${fmtImprovements(city.improvements)}`);
$("#tile-info, #unit-info").addClass("hidden");
$("#city-info").removeClass("hidden");
this._refreshBottomVisibility();
}
_showTile(coords) {
const k = `${coords.x},${coords.y}`;
const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1;
const civ = this.civilisations[owner];
const tile = this.map.getTile(coords);
const city = (this.snapshot.cities || []).find(
(c) => c.coords[0] === coords.x && c.coords[1] === coords.y
);
$("#tile-coords").text(`Tile (${coords.x}, ${coords.y})`);
$("#tile-owner").text(`Owner: ${civ ? civ.name : "Unclaimed"}`);
$("#tile-terrain").text(`Terrain: ${tile ? tile.terrainType : "Unknown"}`);
$("#tile-population").text(`Population: ${groupDigits(this._population.get(k) || 0)}`);
$("#tile-improvements").text(`Improvements: ${fmtImprovements(city ? city.improvements : [])}`);
$("#city-info, #unit-info").addClass("hidden");
$("#tile-info").removeClass("hidden");
this._refreshBottomVisibility();
}
_refreshBottomVisibility() {
const visible =
!$("#tile-info").hasClass("hidden") ||
!$("#city-info").hasClass("hidden") ||
!$("#unit-info").hasClass("hidden");
$("#bottom-panel").toggleClass("hidden", !visible);
}
_unit(id) {
return (this.snapshot.units || []).find((u) => u.id === id) || null;
}
_city(id) {
return (this.snapshot.cities || []).find((c) => c.id === id) || null;
}
// -------------------------------------------------------------- modals ---
_openTrainModal() {
const city = this._city(this.selectedCityId);
if (!city) return;
const trainable = TRAINABLE_UNIT_IDS.map((id) => {
const index = this.protoUnits.findIndex((p) => p.id === id);
return index >= 0 ? { ...this.protoUnits[index], serverIndex: index } : null;
}).filter(Boolean);
this.buildingsModal.close();
this.trainModal.show(
{ id: city.id, name: city.name },
(this.snapshot.viewerStats || {}).gdp || 0,
trainable
);
this._updateTrainModal();
}
_updateTrainModal() {
if (!this.snapshot || !this.selectedCityId) return;
if (!this.trainModal.isShowing(this.selectedCityId)) {
this._trainingCityId = 0;
return;
}
const entry = (this.snapshot.training || {})[this.selectedCityId];
if (!entry) {
if (this._trainingCityId === this.selectedCityId) {
this.trainModal.endTraining();
this._trainingCityId = 0;
}
return;
}
const proto = this.protoUnits[entry.protoIndex];
const name = proto ? proto.name : "unit";
if (this._trainingCityId !== this.selectedCityId) {
this._trainingCityId = this.selectedCityId;
this.trainModal.beginTraining(name, entry.totalHours, entry.elapsedHours);
} else {
this.trainModal.setTrainingProgress(name, entry.elapsedHours, entry.totalHours);
}
}
_openBuildingsModal() {
const city = this._city(this.selectedCityId);
if (!city) return;
this.trainModal.close();
this.buildingsModal.show(
{ id: city.id, name: city.name },
(this.snapshot.viewerStats || {}).gdp || 0,
(this.snapshot.viewerStats || {}).budget || 0,
BUILDINGS,
this._cityBuildings.get(city.id) || {}
// A fresh search cancels any other, so units still without a planned route
// wait their turn rather than fighting for the single search slot.
return pending.reduce(
(chain, unit) => chain.then(() => this.map.predictMove(unit, coords, speed)),
Promise.resolve()
);
}
_updateBuildingsModal() {
if (!this.snapshot || !this.buildingsModal.isShowing(this.selectedCityId)) return;
const stats = this.snapshot.viewerStats || {};
this.buildingsModal.refresh(
stats.gdp || 0,
stats.budget || 0,
this._cityBuildings.get(this.selectedCityId) || {}
);
}
// ------------------------------------------------------- pillage -------
_openNationModal() {
const stats = this.snapshot.viewerStats || {};
this.nationModal.show(
GOVERNMENTS,
stats.government || 0,
TECHNOLOGIES,
this._researched,
stats.gdp || 0,
stats.budget || 0,
stats.research || 0,
this._finances(stats),
this._diplomacyInfo()
);
}
_updateNationModal() {
if (!this.snapshot || !this.nationModal.isShowing()) return;
const stats = this.snapshot.viewerStats || {};
this.nationModal.refresh(
stats.government || 0,
this._researched,
stats.gdp || 0,
stats.budget || 0,
stats.research || 0,
this._finances(stats),
this._diplomacyInfo()
);
}
_diplomacyInfo() {
return {
civs: this.civilisations,
localCiv: this.localCiv,
relations: (this.snapshot && this.snapshot.diplomacy) || [],
};
}
_openNewsModal() {
this.newsModal.show(this._news, this.civilisations);
}
_updateNewsModal() {
if (!this.snapshot || !this.newsModal.isShowing()) return;
this.newsModal.refresh(this._news, this.civilisations);
}
// Pops newly arrived news into the corner feed. The first snapshot only
// records what is already known, so history never floods the screen.
_updateFeed(news) {
if (this._seenNews === null) {
this._seenNews = new Set(news.map((entry) => entry.id));
return;
}
for (const entry of news) {
if (this._seenNews.has(entry.id)) continue;
this._seenNews.add(entry.id);
this._pushNews(entry);
}
}
_pushNews(entry) {
if (entry.type === NEWS_WAR && entry.target === this.localCiv) {
this.feed.push(`${this._civName(entry.actor)} has declared war on you!`, { alert: true });
return;
}
this.feed.push(describeNews(entry, this.civilisations));
}
_finances(stats) {
return stats.breakdown || null;
_pillageSelected() {
const units = this._selectedUnits();
if (units.length === 0) return;
this.sendOrder({ type: "pillage", units: units.map((unit) => unit.id) });
}
// --------------------------------------------------------------- input ---
@@ -594,3 +295,5 @@ export class GameScreen {
requestAnimationFrame((t) => this._frame(t));
}
}
Object.assign(GameScreen.prototype, panelMethods);
+44 -1206
View File
File diff suppressed because it is too large Load Diff
+13 -517
View File
@@ -1,518 +1,14 @@
// The three modal panels of the game HUD: city training, city buildings, and
// the nation panel (government + technology + budget). They only display state
// supplied by the game screen and forward the player's choices as orders.
// The modal panels of the game HUD: city training, city buildings, the nation
// panel (government + technology + budget + diplomacy) and the generic confirm
// and news dialogs. They only display state supplied by the game screen and
// forward the player's choices as orders.
//
// Each panel lives in its own module in ./modals/; this file is the stable
// import path.
import { groupDigits, hours } from "../../shared/text_format.js";
import { ECONOMY } from "../../shared/data.js";
import {
buildingBuildCost,
buildingRefund,
buildingUpkeep,
governmentChangeCost,
prerequisitesMet,
} from "../../shared/rules.js";
function formatEffect(effect, level) {
const value = level <= 0 ? 0 : effect.baseValue * Math.pow(level, effect.exponent);
switch (effect.stat) {
case "gdp":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% GDP`;
case "budget_income":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% budget income`;
case "research":
return `${value >= 0 ? "+" : ""}${value.toFixed(2)} research/h`;
case "culture":
return `${value >= 0 ? "+" : ""}${value.toFixed(2)} culture/h`;
default:
return `${effect.stat} ${value.toFixed(2)}`;
}
}
function effectsText(effects, level) {
const parts = effects.map((e) => formatEffect(e, level));
return parts.length > 0 ? parts.join(" · ") : "No effect";
}
const SOURCE_LABELS = { building: "building", government: "government", technology: "research" };
function signedMoney(value) {
return `${value < 0 ? "−" : "+"}€${groupDigits(Math.round(Math.abs(value)))}/h`;
}
function percent(value) {
return `${value < 0 ? "−" : "+"}${(Math.abs(value) * 100).toFixed(1)}%`;
}
export class TrainModal {
constructor() {
this.$modal = $("#modal-train");
this.$title = $("#train-title");
this.$list = $("#train-list");
this.$status = $("#train-status");
this.$label = $("#train-label");
this.$progress = $("#train-progress");
this.cityId = 0;
this.gdp = 0;
this.onTrain = () => {};
this.$modal.find(".modal-close").on("click", () => this.close());
}
isShowing(cityId) {
return !this.$modal.hasClass("hidden") && this.cityId === cityId;
}
show(city, gdp, trainableUnits) {
this.cityId = city.id;
this.gdp = gdp;
this.$title.text(`${city.name} — Train`);
this.$list.empty();
this.$status.addClass("hidden");
for (let index = 0; index < trainableUnits.length; index++) {
const proto = trainableUnits[index];
if (!proto.cost && proto.cost !== 0) continue;
const button = $("<button class='aero'></button>");
const duration = ECONOMY.trainingHours(gdp, proto.cost);
button.text(`${proto.name} — Cost ${groupDigits(proto.cost)} — ${hours(duration)}`);
const serverIndex = proto.serverIndex !== undefined ? proto.serverIndex : index;
button.on("click", () => this.onTrain(city.id, serverIndex));
this.$list.append(button);
}
this.$modal.removeClass("hidden");
}
beginTraining(name, totalHours, elapsedHours = 0) {
this.$label.text(`Training ${name}…`);
this.$progress.css("width", `${Math.min(100, (elapsedHours / Math.max(totalHours, 0.0001)) * 100)}%`);
this.$status.removeClass("hidden");
this.$list.find("button").prop("disabled", true);
}
setTrainingProgress(name, elapsedHours, totalHours) {
const remaining = Math.max(0, Math.ceil(totalHours - elapsedHours));
this.$label.text(`Training ${name}… (${remaining} h left)`);
this.$progress.css("width", `${Math.min(100, (elapsedHours / Math.max(totalHours, 0.0001)) * 100)}%`);
}
endTraining() {
this.$label.text("Training complete");
this.$progress.css("width", "100%");
this.$list.find("button").prop("disabled", false);
}
close() {
this.$modal.addClass("hidden");
this.cityId = 0;
}
}
export class BuildingsModal {
constructor() {
this.$modal = $("#modal-buildings");
this.$title = $("#buildings-title");
this.$budget = $("#buildings-budget");
this.$list = $("#buildings-list");
this.cityId = 0;
this.cityName = "";
this.buildings = [];
this.rows = [];
this.onBuild = () => {};
this.onDemolish = () => {};
this.$modal.find(".modal-close").on("click", () => this.close());
}
isShowing(cityId) {
return !this.$modal.hasClass("hidden") && this.cityId === cityId;
}
show(city, gdp, budget, buildings, levels) {
this.cityId = city.id;
this.cityName = city.name;
this.buildings = buildings;
this.$title.text(`${city.name} — Buildings`);
this.$list.empty();
this.rows = buildings.map((proto, index) => this._makeRow(proto, index));
this.refresh(gdp, budget, levels);
this.$modal.removeClass("hidden");
}
_makeRow(proto, index) {
const $card = $("<div class='row-card'></div>");
const $info = $("<div class='info'></div>");
const $name = $("<div class='name'></div>");
const $desc = $("<div class='desc'></div>").text(proto.description);
const $effects = $("<div class='effects'></div>");
const $cost = $("<div class='cost'></div>");
$info.append($name, $desc, $effects, $cost);
const $actions = $("<div class='actions'></div>");
const $upgrade = $("<button class='aero'></button>");
const $downgrade = $("<button class='aero'></button>");
$upgrade.on("click", () => this.onBuild(this.cityId, index));
$downgrade.on("click", () => this.onDemolish(this.cityId, index));
$actions.append($upgrade, $downgrade);
$card.append($info, $actions);
this.$list.append($card);
return { proto, index, $name, $effects, $cost, $upgrade, $downgrade };
}
refresh(gdp, budget, levels) {
this.$budget.text(`Budget: €${groupDigits(Math.round(budget))}`);
for (const row of this.rows) {
const level = levels[row.index] || 0;
row.$name.text(`${row.proto.name} — Level ${level} / ${row.proto.maxLevel}`);
row.$effects.text(effectsText(row.proto.effects, level > 0 ? level : 1));
const parts = [];
if (level > 0) parts.push(`Upkeep €${groupDigits(Math.round(buildingUpkeep(row.proto, gdp, level)))}/h`);
if (level < row.proto.maxLevel) {
parts.push(`Build cost €${groupDigits(Math.round(buildingBuildCost(row.proto, gdp, level)))}`);
}
row.$cost.text(parts.join(" · "));
if (level < row.proto.maxLevel) {
const cost = buildingBuildCost(row.proto, gdp, level);
row.$upgrade.text(`Upgrade €${groupDigits(Math.round(cost))}`).prop("disabled", budget < cost);
} else {
row.$upgrade.text("Max level").prop("disabled", true);
}
if (level > 0) {
row.$downgrade
.text(`Downgrade +€${groupDigits(Math.round(buildingRefund(row.proto, gdp, level)))}`)
.prop("disabled", false);
} else {
row.$downgrade.text("Not built").prop("disabled", true);
}
}
}
close() {
this.$modal.addClass("hidden");
this.cityId = 0;
}
}
export class NationModal {
constructor() {
this.$modal = $("#modal-nation");
this.$governmentSub = $("#government-sub");
this.$governmentList = $("#government-list");
this.$technologySub = $("#technology-sub");
this.$technologyList = $("#technology-list");
this.$budgetSub = $("#budget-sub");
this.$budgetList = $("#budget-list");
this.$diplomacySub = $("#diplomacy-sub");
this.$diplomacyList = $("#diplomacy-list");
this.governments = [];
this.technologies = [];
this.currentGovernment = 0;
this.researched = new Set();
this.governmentRows = [];
this.technologyRows = [];
this.diplomacy = null;
this.budgetRows = {
income: this._makeBudgetRow("Tax income", "State revenue drawn from the nation's GDP."),
building: this._makeBudgetRow("Building upkeep", "Upkeep of every building across your cities."),
units: this._makeBudgetRow("Military upkeep", "Upkeep of all your units."),
net: this._makeBudgetRow("Net balance", "Projected change to the treasury each hour."),
};
this.onGovernment = () => {};
this.onTechnology = () => {};
this.onDeclareWar = () => {};
this.$modal.find(".modal-close").on("click", () => this.close());
this.$modal.find(".tab").on("click", (event) => {
const tab = $(event.currentTarget).data("tab");
this.$modal.find(".tab").removeClass("active");
$(event.currentTarget).addClass("active");
this.$modal.find(".tab-page").removeClass("active");
$(`#tab-${tab}`).addClass("active");
});
}
isShowing() {
return !this.$modal.hasClass("hidden");
}
show(governments, currentGovernment, technologies, researched, gdp, budget, research, finances, diplomacy = null) {
this.governments = governments;
this.technologies = technologies;
this.diplomacy = diplomacy;
this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government"));
this.technologyRows = technologies.map((proto, index) => this._makeRow(this.$technologyList, proto, index, "technology"));
this.refresh(currentGovernment, researched, gdp, budget, research, finances, diplomacy);
this.$modal.removeClass("hidden");
}
_makeBudgetRow(name, description) {
const $card = $("<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>");
const $name = $("<div class='name'></div>");
const $desc = $("<div class='desc'></div>").text(proto.description || "");
const $effects = $("<div class='effects'></div>");
const $cost = $("<div class='cost'></div>");
$info.append($name, $desc, $effects, $cost);
const $actions = $("<div class='actions'></div>");
const $action = $("<button class='aero'></button>");
$action.on("click", () => {
if (kind === "government") this.onGovernment(index);
else this.onTechnology(index);
});
$actions.append($action);
$card.append($info, $actions);
$list.append($card);
return { proto, index, kind, $name, $effects, $cost, $action };
}
refresh(currentGovernment, researched, gdp, budget, research, finances = {}, diplomacy = null) {
this.currentGovernment = currentGovernment;
this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number));
if (diplomacy !== null) this.diplomacy = diplomacy;
this.$governmentSub.text(
`Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: €${groupDigits(Math.round(budget))}`
);
this.$technologySub.text(`Research points: ${groupDigits(Math.round(research))}`);
const breakdown = finances || {};
const income = breakdown.income || { rate: 0, base: 0, modifier: 0, total: 0, sources: [] };
const building = breakdown.buildingUpkeep || { total: 0, sources: [] };
const units = breakdown.unitUpkeep || { total: 0, sources: [] };
const net = breakdown.net || 0;
this.$budgetSub.text(
`Treasury: €${groupDigits(Math.round(budget))} · Net: ${signedMoney(net)}`
);
this._renderBudgetRow(this.budgetRows.income, income.total, [
`Tax rate ${(income.rate * 100).toFixed(2)}% of GDP — base €${groupDigits(Math.round(income.base))}/h`,
...this._sourceLines(
income.sources,
(s) => `${s.label} (${SOURCE_LABELS[s.kind] || s.kind}) ${percent(s.effect)} → ${signedMoney(s.amount)}`,
"No bonuses"
),
]);
this._renderBudgetRow(
this.budgetRows.building,
-building.total,
this._sourceLines(
building.sources,
(s) => `${s.label} — €${groupDigits(Math.round(s.amount))}/h`,
"No buildings"
)
);
this._renderBudgetRow(
this.budgetRows.units,
-units.total,
this._sourceLines(
units.sources,
(s) => `${s.label} ×${s.count} — €${groupDigits(Math.round(s.amount))}/h`,
"No units"
)
);
this._renderBudgetRow(this.budgetRows.net, net, [
`Income ${signedMoney(income.total)}`,
`Expenses −€${groupDigits(Math.round(building.total + units.total))}/h`,
]);
for (const row of this.governmentRows) {
const proto = row.proto;
row.$name.text(proto.name);
row.$effects.text(effectsText(proto.effects, 1));
if (row.index === currentGovernment) {
row.$cost.text("Currently in power");
row.$action.text("Current government").prop("disabled", true);
} else {
const cost = governmentChangeCost(proto, gdp);
row.$cost.text(`Adoption cost €${groupDigits(Math.round(cost))}`);
row.$action.text(`Adopt €${groupDigits(Math.round(cost))}`).prop("disabled", budget < cost);
}
}
for (const row of this.technologyRows) {
const proto = row.proto;
row.$name.text(proto.name);
row.$effects.text(effectsText(proto.effects, 1));
if (this.researched.has(row.index)) {
row.$cost.text("Researched");
row.$action.text("Researched").prop("disabled", true);
} else if (!prerequisitesMet(proto, this.researched, this.technologies)) {
const names = proto.prerequisites
.map((id) => {
const found = this.technologies.find((t) => t.id === id);
return found ? found.name : id;
})
.join(", ");
row.$cost.text(`Requires: ${names}`);
row.$action.text("Locked").prop("disabled", true);
} else {
row.$cost.text(`Cost ${groupDigits(Math.round(proto.cost))} research points`);
row.$action.text(`Research ${groupDigits(Math.round(proto.cost))}`).prop("disabled", research < proto.cost);
}
}
this._renderDiplomacy();
}
// Lists every other nation with its relation to the player and a button to
// declare war. `diplomacy` is { civs, localCiv, relations }, where relations
// is the snapshot's [a, b, status] matrix.
_renderDiplomacy() {
if (!this.$diplomacyList || !this.$diplomacyList.length) return;
this.$diplomacyList.empty();
const info = this.diplomacy;
if (!info) return;
const civs = info.civs || [];
const localCiv = info.localCiv;
const wars = new Set();
for (const entry of info.relations || []) {
if (entry[2] !== "war") continue;
if (entry[0] === localCiv) wars.add(entry[1]);
else if (entry[1] === localCiv) wars.add(entry[0]);
}
if (this.$diplomacySub && this.$diplomacySub.length) {
const atWar = wars.size;
this.$diplomacySub.text(
atWar === 0
? "Your nation is at peace with every other nation."
: `Your nation is at war with ${atWar} nation${atWar === 1 ? "" : "s"}.`
);
}
for (let index = 0; index < civs.length; index++) {
if (index === localCiv) continue;
const $card = $("<div class='row-card'></div>");
const $info = $("<div class='info'></div>");
const $name = $("<div class='name'></div>").text(civs[index].name);
const atWar = wars.has(index);
const $status = $("<div class='desc'></div>").text(atWar ? "At war" : "At peace");
$info.append($name, $status);
const $actions = $("<div class='actions'></div>");
const $action = $("<button class='aero'></button>");
if (atWar) {
$action.text("At war").prop("disabled", true);
} else {
$action.text("Declare war");
$action.on("click", () => this.onDeclareWar(index));
}
$actions.append($action);
$card.append($info, $actions);
this.$diplomacyList.append($card);
}
}
close() {
this.$modal.addClass("hidden");
}
}
// A generic yes/no prompt. `show` takes the callbacks it should run when the
// player confirms; cancelling (or closing) runs nothing.
export class ConfirmModal {
constructor() {
this.$modal = $("#modal-confirm");
this.$title = $("#confirm-title");
this.$message = $("#confirm-message");
this._onConfirm = null;
this.$modal.find(".modal-close").on("click", () => this.close());
this.$modal.find("#confirm-cancel").on("click", () => this.close());
this.$modal.find("#confirm-ok").on("click", () => {
const callback = this._onConfirm;
this.close();
if (callback) callback();
});
}
isShowing() {
return !this.$modal.hasClass("hidden");
}
show(title, message, onConfirm) {
this.$title.text(title);
this.$message.text(message);
this._onConfirm = onConfirm || null;
this.$modal.removeClass("hidden");
}
close() {
this.$modal.addClass("hidden");
this._onConfirm = null;
}
}
// The news feed: a reverse-chronological list of newsworthy events. Civ indices
// are resolved to names through the catalogue the game screen passes in.
export class NewsModal {
constructor() {
this.$modal = $("#modal-news");
this.$list = $("#news-list");
this.$modal.find(".modal-close").on("click", () => this.close());
}
isShowing() {
return !this.$modal.hasClass("hidden");
}
show(news, civs) {
this.refresh(news, civs);
this.$modal.removeClass("hidden");
}
refresh(news, civs = []) {
if (!this.$list || !this.$list.length) return;
this.$list.empty();
const entries = (news || []).slice().reverse();
if (entries.length === 0) {
this.$list.append($("<div class='news-empty'></div>").text("No news yet."));
return;
}
for (const entry of entries) {
const $item = $("<div class='news-item'></div>");
$item.append($("<div class='news-text'></div>").text(describeNews(entry, civs)));
$item.append($("<div class='news-date'></div>").text(entry.date || ""));
this.$list.append($item);
}
}
close() {
this.$modal.addClass("hidden");
}
}
export function describeNews(entry, civs) {
const name = (index) => (civs[index] ? civs[index].name : `Nation ${index + 1}`);
const people = (index) => {
const civ = civs[index];
return civ && civ.demonym ? `The ${civ.demonym}` : name(index);
};
switch (entry.type) {
case "war":
return `${people(entry.actor)} declared war on ${name(entry.target)}`;
case "capture":
return `${people(entry.to)} captured ${entry.city} from ${name(entry.from)}`;
default:
return entry.type;
}
}
export { TrainModal } from "./modals/train.js";
export { BuildingsModal } from "./modals/buildings.js";
export { NationModal } from "./modals/nation.js";
export { ConfirmModal } from "./modals/confirm.js";
export { NewsModal } from "./modals/news.js";
export { describeNews } from "./modals/format.js";
+63 -15
View File
@@ -9,11 +9,6 @@ import { LoginManager } from "../shared/login_manager.js";
import { CIVILISATIONS } from "../shared/data.js";
import { dateString } from "../shared/game_clock.js";
const DEFAULT_CIV_IDS = [
"france", "britain", "slovenia", "poland", "switzerland",
"ticino", "galicia", "germany", "iran",
];
export class GameServer extends EventEmitter {
constructor(network) {
super();
@@ -87,13 +82,8 @@ export class GameServer extends EventEmitter {
const civ = CIVILISATIONS.find((c) => c.id === id);
if (civ) civs.push(civ);
}
if (civs.length === 0) {
for (const id of DEFAULT_CIV_IDS) {
const civ = CIVILISATIONS.find((c) => c.id === id);
if (civ) civs.push(civ);
}
}
return civs;
// An empty list means the project's full roster.
return civs.length === 0 ? CIVILISATIONS.slice() : civs;
}
handleOrder(peerId, order) {
@@ -104,8 +94,16 @@ export class GameServer extends EventEmitter {
switch (type) {
case "move":
return this._handleMove(civ, order);
case "attack":
return this._handleAttack(civ, order);
case "schedule":
return this._handleSchedule(civ, order);
case "pillage":
return this._handlePillage(civ, order);
case "train":
return this._handleTrain(civ, order);
case "cancel_train":
return this._handleCancelTrain(civ, order);
case "build":
return this._handleBuild(civ, order);
case "demolish":
@@ -122,9 +120,53 @@ export class GameServer extends EventEmitter {
}
_handleMove(civ, order) {
const unit = this.state.findUnit(Number(order.unit));
if (!unit || unit.civ !== civ) return false;
return this.state.requestMove(unit.id, coordsOf(order.coords));
const ids = Array.isArray(order.units) ? order.units : [order.unit];
const units = [];
for (const id of ids) {
const unit = this.state.findUnit(Number(id));
if (!unit || unit.civ !== civ) return false;
units.push(unit.id);
}
if (units.length === 0) return false;
return this.state.requestGroupMove(units, coordsOf(order.coords));
}
_handleAttack(civ, order) {
const units = this._ownUnitIds(civ, order);
if (units.length === 0) return false;
return this.state.requestAttack(units, coordsOf(order.coords));
}
_handleSchedule(civ, order) {
const units = this._ownUnitIds(civ, order);
if (units.length === 0) return false;
const goals = (Array.isArray(order.goals) ? order.goals : [])
.filter((goal) => Array.isArray(goal) && goal.length >= 2)
.map(coordsOf);
return this.state.requestScheduledMove(units, goals, !!order.append);
}
_ownUnitIds(civ, order) {
const ids = Array.isArray(order.units) ? order.units : [order.unit];
const units = [];
for (const id of ids) {
const unit = this.state.findUnit(Number(id));
if (!unit || unit.civ !== civ) continue;
units.push(unit.id);
}
return units;
}
_handlePillage(civ, order) {
const ids = Array.isArray(order.units) ? order.units : [order.unit];
const units = [];
for (const id of ids) {
const unit = this.state.findUnit(Number(id));
if (!unit || unit.civ !== civ) return false;
units.push(unit.id);
}
if (units.length === 0) return false;
return this.state.requestPillage(units);
}
_handleTrain(civ, order) {
@@ -133,6 +175,12 @@ export class GameServer extends EventEmitter {
return this.state.requestTrain(city.id, Number(order.proto));
}
_handleCancelTrain(civ, order) {
const city = this.state.findCity(Number(order.city));
if (!city || city.civ !== civ) return false;
return this.state.cancelTrain(city.id, Number(order.index));
}
_handleBuild(civ, order) {
const city = this.state.findCity(Number(order.city));
if (!city || city.civ !== civ) return false;
+5 -452
View File
@@ -1,456 +1,9 @@
// Data-driven content for civilisations, units, buildings, governments and
// technologies. The server and the browser both read these plain objects, so
// the catalogue text, costs and effects can never disagree between the two.
//
// The catalogue itself now lives in ./data/, split by concern, so each slice is
// small enough to edit comfortably. This file stays as the stable import path:
// keep importing `shared/data.js` and reach for ./data/<topic>.js to edit.
export const EFFECT_GDP = "gdp";
export const EFFECT_RESEARCH = "research";
export const EFFECT_CULTURE = "culture";
export const EFFECT_BUDGET_INCOME = "budget_income";
// Diplomatic relations between two civilisations. Only peace and war exist for
// now, but the catalogue is data-driven so further statuses (alliances,
// truces, ...) can be slotted in without touching the simulation.
export const RELATION_PEACE = "peace";
export const RELATION_WAR = "war";
export const RELATIONS = [RELATION_PEACE, RELATION_WAR];
// Kinds of news item shown in the feed. Newsworthy events are recorded in
// GameState with their in-game hour so every client can render them.
export const NEWS_WAR = "war";
export const NEWS_CAPTURE = "capture";
function effect(stat, baseValue, exponent = 1.0) {
return { stat, baseValue, exponent };
}
export function effectValue(e, level) {
if (level <= 0) return 0;
return e.baseValue * Math.pow(level, e.exponent);
}
export const CIVILISATIONS = [
{
id: "france",
name: "France",
demonym: "French",
primaryColour: "#0d0d5c",
accentColour: "#8c8c82",
flag: "flag_france.png",
cityNames: ["Lyon", "Marseille", "Toulouse", "Nice", "Nantes", "Montpellier", "Strasbourg", "Bordeaux", "Lille"],
},
{
id: "britain",
name: "Britain",
demonym: "British",
primaryColour: "#a81818",
accentColour: "#0722ff",
flag: "flag_britain.png",
cityNames: ["Birmingham", "Manchester", "Glasgow", "Liverpool", "Leeds", "Sheffield", "Edinburgh", "Bristol", "Cardiff"],
},
{
id: "slovenia",
name: "Slovenia",
demonym: "Slovenian",
primaryColour: "#005da4",
accentColour: "#ed1c24",
flag: "flag_slovenia.png",
cityNames: ["Ljubljana", "Maribor", "Celje", "Kranj", "Koper", "Velenje", "Novo Mesto", "Ptuj", "Kamnik", "Jesenice"],
},
{
id: "poland",
name: "Poland",
demonym: "Polish",
primaryColour: "#a6000b",
accentColour: "#ffffff",
flag: "flag_poland.png",
cityNames: ["Warsaw", "Kraków", "Łódź", "Wrocław", "Poznań", "Gdańsk", "Szczecin", "Bydgoszcz", "Lublin", "Katowice"],
},
{
id: "switzerland",
name: "Switzerland",
demonym: "Swiss",
primaryColour: "#da291c",
accentColour: "#ffffff",
flag: "flag_switzerland.png",
cityNames: ["Bern", "Zürich", "Geneva", "Basel", "Lausanne", "Lucerne", "Winterthur", "St. Gallen", "Lugano", "Biel"],
},
{
id: "ticino",
name: "Ticino",
demonym: "Ticinese",
primaryColour: "#4646e0",
accentColour: "#a6000b",
flag: "flag_ticino.png",
cityNames: ["Bellinzona", "Lugano", "Locarno", "Mendrisio", "Chiasso", "Airolo", "Biasca", "Ascona", "Faido", "Giubiasco"],
},
{
id: "galicia",
name: "Galicia",
demonym: "Galician",
primaryColour: "#006dad",
accentColour: "#ffffff",
flag: "flag_galicia.png",
cityNames: ["Santiago de Compostela", "Vigo", "A Coruña", "Ourense", "Lugo", "Pontevedra", "Ferrol", "Vilagarcía de Arousa", "Ribeira", "Redondela"],
},
{
id: "germany",
name: "Germany",
demonym: "German",
primaryColour: "#141414",
accentColour: "#ffce00",
flag: "flag_germany.png",
cityNames: ["Berlin", "Hamburg", "Munich", "Cologne", "Frankfurt", "Stuttgart", "Düsseldorf", "Leipzig", "Dortmund", "Essen"],
},
{
id: "iran",
name: "Iran",
demonym: "Iranian",
primaryColour: "#18a645",
accentColour: "#f01c21",
flag: "flag_iran.png",
cityNames: ["Tehran", "Mashhad", "Isfahan", "Karaj", "Shiraz", "Tabriz", "Qom", "Ahvaz", "Kermanshah", "Urmia"],
},
];
export const PROTO_UNITS = [
{
id: "modern_infantry",
name: "Modern infantry",
maxHp: 500,
cost: 100_000_000,
upkeep: 5000,
moveable: true,
speed: 0.5,
// Only military units fight, capture cities and may enter an enemy
// military unit's tile. `attack` is the damage dealt per combat round and
// `defense` is the damage absorbed per round; a defender's defense is
// doubled (the 100% defensive bonus). `range` is how many tiles away the
// unit can strike (1 is melee).
military: true,
attack: 100,
defense: 40,
range: 1,
traversableTerrains: ["Land"],
icon: "icon_modern_infantry.svg",
},
{
id: "worker",
name: "Worker",
maxHp: 100,
cost: 0,
upkeep: 0,
moveable: true,
speed: 0.5,
military: false,
attack: 0,
defense: 0,
range: 1,
traversableTerrains: ["Land"],
icon: "icon_settler.svg",
},
{
id: "artillery",
name: "Artillery",
maxHp: 400,
cost: 300_000_000,
upkeep: 12_000,
moveable: true,
speed: 0.25,
military: true,
attack: 250,
// Barely armoured: nearly every hit gets through.
defense: 5,
// Outranges infantry, so melee units cannot strike back from two tiles.
range: 2,
traversableTerrains: ["Land"],
icon: "icon_artillery.svg",
},
];
// Only units in this list can be trained from the city screen, matching
// GameState.TRAINABLE_UNITS.
export const TRAINABLE_UNIT_IDS = ["modern_infantry", "artillery"];
// Status effects a unit or city can carry. They are data-driven so new ones
// can be added without touching the simulation or the renderer: the server
// derives the active ids, applies their fields, and ships the ids in
// snapshots; the browser draws one icon per id. A status may define
// `hpLossPerDay` (a fraction of maximum hit points taken each day), a
// `cityHpLossPerDay` override for when the unit stands in a city, and an
// `upkeepMultiplier` applied to the unit's upkeep while it is active.
export const STATUS_ENCIRCLED = "encircled";
export const STATUS_BESIEGED = "besieged";
export const STATUS_BATTLE = "battle";
export const STATUSES = [
{
id: STATUS_ENCIRCLED,
label: "Encircled",
icon: "encirclement.svg",
description: "Cut off from every allied city while a hostile unit is adjacent. Loses 10% of its maximum hit points each day, or 2% inside a city.",
hpLossPerDay: 0.10,
cityHpLossPerDay: 0.02,
},
{
id: STATUS_BESIEGED,
label: "Besieged",
icon: "encirclement.svg",
description: "Cut off from every other allied city while a hostile unit is adjacent.",
},
{
id: STATUS_BATTLE,
label: "In battle",
icon: "icon_battle.svg",
description: "Fighting an enemy unit in range. Upkeep is tripled while the battle rages.",
upkeepMultiplier: 3,
},
];
export function statusById(id) {
return STATUSES.find((status) => status.id === id) || null;
}
export const BUILDINGS = [
{
id: "market",
name: "Market",
description: "A hub of local trade. Raises national GDP, with diminishing returns.",
maxLevel: 10,
baseCost: 0,
costGdpFraction: 0.01,
costAlpha: 1.6,
baseUpkeep: 0,
upkeepGdpFraction: 0.00003,
upkeepAlpha: 1.5,
refundFraction: 0.5,
effects: [effect(EFFECT_GDP, 0.04, 0.75)],
},
{
id: "bank",
name: "Bank",
description: "Channels private wealth into the state coffers. Raises budget income.",
maxLevel: 10,
baseCost: 0,
costGdpFraction: 0.02,
costAlpha: 1.7,
baseUpkeep: 0,
upkeepGdpFraction: 0.00004,
upkeepAlpha: 1.6,
refundFraction: 0.5,
effects: [effect(EFFECT_BUDGET_INCOME, 0.05, 0.7)],
},
{
id: "research_lab",
name: "Research lab",
description: "Applies science to industry and government. Produces research points.",
maxLevel: 10,
baseCost: 0,
costGdpFraction: 0.015,
costAlpha: 1.65,
baseUpkeep: 0,
upkeepGdpFraction: 0.00005,
upkeepAlpha: 1.55,
refundFraction: 0.5,
effects: [effect(EFFECT_RESEARCH, 1.0, 0.9)],
},
{
id: "university",
name: "University",
description: "Higher education produces research and a little culture.",
maxLevel: 10,
baseCost: 0,
costGdpFraction: 0.025,
costAlpha: 1.7,
baseUpkeep: 0,
upkeepGdpFraction: 0.00008,
upkeepAlpha: 1.6,
refundFraction: 0.5,
effects: [effect(EFFECT_RESEARCH, 2.0, 0.8), effect(EFFECT_CULTURE, 0.5, 0.7)],
},
{
id: "museum",
name: "Museum",
description: "Preserves national heritage, strengthening culture against foreign ideas.",
maxLevel: 10,
baseCost: 0,
costGdpFraction: 0.02,
costAlpha: 1.6,
baseUpkeep: 0,
upkeepGdpFraction: 0.00005,
upkeepAlpha: 1.55,
refundFraction: 0.5,
effects: [effect(EFFECT_CULTURE, 1.5, 0.85)],
},
{
id: "cinema_studio",
name: "Cinema studio",
description: "Mass media spreads the nation's culture at home and abroad.",
maxLevel: 10,
baseCost: 0,
costGdpFraction: 0.03,
costAlpha: 1.75,
baseUpkeep: 0,
upkeepGdpFraction: 0.0001,
upkeepAlpha: 1.65,
refundFraction: 0.5,
effects: [effect(EFFECT_CULTURE, 3.0, 0.7)],
},
];
export const GOVERNMENTS = [
{
id: "republic",
name: "Republic",
description: "Rule by elected representatives. A balanced government that spreads its support across the economy, the treasury and the sciences.",
baseCost: 0,
costGdpFraction: 0.02,
effects: [effect(EFFECT_GDP, 0.05, 1.0), effect(EFFECT_BUDGET_INCOME, 0.1, 1.0), effect(EFFECT_RESEARCH, 1.0, 1.0)],
},
{
id: "monarchy",
name: "Monarchy",
description: "Rule by a hereditary sovereign. The crown favours commerce and tradition, lifting the economy and the nation's cultural output.",
baseCost: 0,
costGdpFraction: 0.03,
effects: [effect(EFFECT_GDP, 0.1, 1.0), effect(EFFECT_CULTURE, 1.0, 1.0)],
},
{
id: "technocracy",
name: "Technocracy",
description: "Rule by technical experts. A single-minded drive for progress pours resources into research while economic growth takes second place.",
baseCost: 0,
costGdpFraction: 0.04,
effects: [effect(EFFECT_RESEARCH, 3.0, 1.0), effect(EFFECT_GDP, 0.03, 1.0)],
},
];
export const TECHNOLOGIES = [
{
id: "scientific_method",
name: "Scientific Method",
description: "A disciplined approach to experiment and evidence. Lays the groundwork for every later technology.",
cost: 20,
prerequisites: [],
effects: [effect(EFFECT_RESEARCH, 1.0, 1.0), effect(EFFECT_GDP, 0.02, 1.0)],
},
{
id: "industrial_automation",
name: "Industrial Automation",
description: "Robotics and control systems take over the factory floor, lifting national output.",
cost: 60,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_GDP, 0.08, 1.0)],
},
{
id: "renewable_energy",
name: "Renewable Energy",
description: "Clean generation cuts waste and strengthens the public finances.",
cost: 80,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_GDP, 0.03, 1.0), effect(EFFECT_BUDGET_INCOME, 0.05, 1.0)],
},
{
id: "quantum_computing",
name: "Quantum Computing",
description: "Machines that reason in superposition. A step change in the pace of discovery.",
cost: 120,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_RESEARCH, 4.0, 1.0)],
},
{
id: "space_program",
name: "Space Program",
description: "Reaching orbit inspires the nation and spins off technology across the economy.",
cost: 300,
prerequisites: ["industrial_automation", "quantum_computing"],
effects: [effect(EFFECT_CULTURE, 3.0, 1.0), effect(EFFECT_GDP, 0.05, 1.0)],
},
];
export const ECONOMY = {
baseGdpPerCapita: 10_000,
basePopulation: 100_000,
cityTileGdpMultiplier: 2.0,
capitalTileGdpMultiplier: 2.5,
citySurroundingGdpBonus: 0.3,
gdpPerProductionPoint: 100_000,
budgetGdpRate: 0.001,
startingBudget: 0,
basePopulationGrowthRate: 0.005,
productionCapacity(gdp) {
if (this.gdpPerProductionPoint <= 0) return 0;
return gdp / this.gdpPerProductionPoint;
},
trainingHours(gdp, cost) {
const capacity = this.productionCapacity(gdp);
if (capacity <= 0) return 0;
return cost / capacity;
},
budgetIncome(gdp, hours = 1) {
return gdp * this.budgetGdpRate * hours;
},
populationGrowthFactor(hoursPerYear, hours = 1) {
if (this.basePopulationGrowthRate <= 0 || hoursPerYear <= 0 || hours <= 0) return 0;
return Math.pow(1 + this.basePopulationGrowthRate, hours / hoursPerYear) - 1;
},
};
export const MAP_CONFIG = {
mapSize: { x: 64, y: 40 },
topology: "cylindrical",
terrainSeed: 0,
seaLevel: -0.2,
falloffEnabled: true,
falloffStart: 0.55,
falloffStrength: 0.6,
iceLatitude: 0.9,
noise: {
frequency: 0.06,
octaves: 4,
lacunarity: 2.0,
gain: 0.5,
},
capitalMargin: 2,
minCapitalDistance: 8,
minCapitalLandmass: 40,
citiesPerCiv: 10,
citySpacing: 8,
};
// The terrain TileSet, flattened. `col` is the atlas column in terrain.png
// (one row, 7 tiles of 63x56); the class decides land/sea/ice and the
// minElevation bands place land tiles. `defenseBonus` is the fraction added to
// a unit's defense while it stands on the tile.
export const TERRAIN_TILES = [
{ col: 0, terrainType: "Forest", terrainClass: "Land", populationMultiplier: 0.3, gdpMultiplier: 0.5, movementCostMultiplier: 1.5, defenseBonus: 0.33, minElevation: 0.06 },
{ col: 1, terrainType: "Hills", terrainClass: "Land", populationMultiplier: 0.7, gdpMultiplier: 0.8, movementCostMultiplier: 1.8, defenseBonus: 0.33, minElevation: 0.2 },
{ col: 2, terrainType: "Ice", terrainClass: "Ice", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 4.0, defenseBonus: 0, minElevation: 0 },
{ col: 3, terrainType: "Land", terrainClass: "Land", populationMultiplier: 1.0, gdpMultiplier: 1.0, movementCostMultiplier: 1.0, defenseBonus: 0, minElevation: -0.08 },
{ col: 4, terrainType: "Land", terrainClass: "Land", populationMultiplier: 0.05, gdpMultiplier: 0.2, movementCostMultiplier: 3.0, defenseBonus: 0, minElevation: -0.2 },
{ col: 5, terrainType: "Sea", terrainClass: "Sea", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 0, defenseBonus: 0, minElevation: 0 },
{ col: 6, terrainType: "Mountain", terrainClass: "Land", populationMultiplier: 0.1, gdpMultiplier: 0.8, movementCostMultiplier: 5.0, defenseBonus: 1.0, minElevation: 0.34 },
];
// A city standing on a tile adds this fraction on top of the terrain's own
// defensive bonus.
export const CITY_DEFENSE_BONUS = 0.5;
export function protoUnitById(id) {
return PROTO_UNITS.find((u) => u.id === id) || null;
}
export function buildingById(id) {
return BUILDINGS.find((b) => b.id === id) || null;
}
export function governmentById(id) {
return GOVERNMENTS.find((g) => g.id === id) || null;
}
export function technologyById(id) {
return TECHNOLOGIES.find((t) => t.id === id) || null;
}
export * from "./data/index.js";
+128 -1629
View File
File diff suppressed because it is too large Load Diff
+25 -13
View File
@@ -69,6 +69,16 @@ class MinHeap {
export class HexPathfinder {
findPath(start, goal, neighbours, canEnter, stepCost, heuristic) {
const search = this.findPathIter(start, goal, neighbours, canEnter, stepCost, heuristic);
let step = search.next();
while (!step.done) step = search.next();
return step.value;
}
// The same search exposed as an iterator, suspending after every expanded
// tile. A caller on the main thread can drive it a few tiles at a time and
// stay responsive instead of freezing on one long search.
*findPathIter(start, goal, neighbours, canEnter, stepCost, heuristic) {
const startKey = key(start.x, start.y);
const goalKey = key(goal.x, goal.y);
if (startKey === goalKey || !canEnter(goal, true)) return [];
@@ -81,21 +91,23 @@ export class HexPathfinder {
while (!open.isEmpty()) {
const current = open.pop();
const currentKey = key(current.x, current.y);
if (closed.has(currentKey)) continue;
if (currentKey === goalKey) return reconstruct(cameFrom, current);
closed.add(currentKey);
for (const neighbour of neighbours(current)) {
const neighbourKey = key(neighbour.x, neighbour.y);
if (closed.has(neighbourKey)) continue;
if (!canEnter(neighbour, neighbourKey === goalKey)) continue;
const tentative = (gScore.has(currentKey) ? gScore.get(currentKey) : Infinity) +
stepCost(current, neighbour);
if (tentative < (gScore.has(neighbourKey) ? gScore.get(neighbourKey) : Infinity)) {
cameFrom.set(neighbourKey, current);
gScore.set(neighbourKey, tentative);
open.push(tentative + heuristic(neighbour, goal), neighbour);
if (!closed.has(currentKey)) {
if (currentKey === goalKey) return reconstruct(cameFrom, current);
closed.add(currentKey);
for (const neighbour of neighbours(current)) {
const neighbourKey = key(neighbour.x, neighbour.y);
if (closed.has(neighbourKey)) continue;
if (!canEnter(neighbour, neighbourKey === goalKey)) continue;
const tentative = (gScore.has(currentKey) ? gScore.get(currentKey) : Infinity) +
stepCost(current, neighbour);
if (tentative < (gScore.has(neighbourKey) ? gScore.get(neighbourKey) : Infinity)) {
cameFrom.set(neighbourKey, current);
gScore.set(neighbourKey, tentative);
open.push(tentative + heuristic(neighbour, goal), neighbour);
}
}
}
yield;
}
return [];
}
+373 -54
View File
@@ -5,11 +5,15 @@
import { SimplexNoise } from "./noise.js";
import { Random } from "./rng.js";
import { key } from "./hex.js";
import { key, parseKey } from "./hex.js";
import { TERRAIN_TILES } from "./data.js";
const TAU = Math.PI * 2;
// Hard floor on the spacing between any two cities; cities are spread from a
// neutral layout and then handed to the players in clusters.
const CITY_MIN_DISTANCE = 5;
function smoothstep(edge0, edge1, x) {
const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
@@ -23,35 +27,52 @@ export class MapGenerator {
this.tiles = {};
this._landmassSize = new Map();
this._palette = buildPalette();
if (!this._palette.plains || !this._palette.sea) {
// A catalogue without land or sea cannot tile the map; fail with a clear
// message instead of a null dereference deep in generate().
throw new Error("TERRAIN_TILES must define at least one Land and one Sea tile");
}
}
// Three passes: a smooth continent field decides land and sea, a finer field
// roughens the coastline, and clusters of terrain are then scattered over the
// land independently of the continent shape.
generate(seed) {
const noise = new SimplexNoise(seed);
const halfX = Math.floor(this.config.mapSize.x / 2);
const halfY = Math.floor(this.config.mapSize.y / 2);
const wrapped = this.topology.isCylindrical();
const radius = this.config.mapSize.x / TAU;
this.seed = seed;
this.landCells = [];
this.tiles = {};
for (let row = 0; row < this.config.mapSize.y; row++) {
for (let column = 0; column < this.config.mapSize.x; column++) {
const coords = { x: column - halfX, y: row - halfY };
let value = this._sampleNoise(noise, column, coords, wrapped, radius);
let value = this._sampleNoise(noise, column, coords, wrapped, radius, this.config.noise);
value = this._applyFalloff(coords, value, wrapped);
const tile = this._chooseTile(coords, value);
let elevation = value;
if (this.config.coastNoise) {
const coast = this._sampleNoise(noise, column, coords, wrapped, radius, this.config.coastNoise);
elevation += (this.config.coastRoughness || 0) * coast;
}
const tile = this._chooseTile(coords, elevation);
this.tiles[key(coords.x, coords.y)] = tile;
if (tile.terrainClass === "Land") this.landCells.push(coords);
}
}
this._removeSmallFeatures();
if (this.config.connectOceans !== false) this._connectOceans();
this._placeTerrainClusters();
this._computeLandmasses();
}
_sampleNoise(noise, column, coords, wrapped, radius) {
_sampleNoise(noise, column, coords, wrapped, radius, config) {
if (!wrapped) {
return noise.fbm(coords.x, coords.y, undefined, this.config.noise);
return noise.fbm(coords.x, coords.y, undefined, config);
}
const angle = (TAU * column) / this.config.mapSize.x;
return noise.fbm(Math.cos(angle) * radius, Math.sin(angle) * radius, coords.y, this.config.noise);
return noise.fbm(Math.cos(angle) * radius, Math.sin(angle) * radius, coords.y, config);
}
_applyFalloff(coords, value, wrapped) {
@@ -72,22 +93,77 @@ export class MapGenerator {
return Math.abs(coords.y) / half >= this.config.iceLatitude;
}
_chooseTile(coords, value) {
_chooseTile(coords, elevation) {
const palette = this._palette;
if (palette.ice && this._isPolar(coords)) return palette.ice;
if (value >= this.config.seaLevel) {
return this._landTileFor(value);
}
return palette.sea;
if (elevation < this.config.seaLevel) return palette.sea;
// Land starts as plains; the terrain clusters are painted over it later.
return palette.plains;
}
_landTileFor(elevation) {
const land = this._palette.land;
if (land.length === 0) return null;
for (let i = land.length - 1; i >= 0; i--) {
if (land[i].minElevation <= elevation) return land[i];
// Third pass: scatter clusters of each biome over the land. Clusters are
// seeded from random land tiles and grown one tile at a time into an
// irregular blob, in priority order (earlier entries win overlaps) and
// clipped to the land, so terrain types have nothing to do with the
// continent shape.
_placeTerrainClusters() {
const biomes = this.config.terrainBiomes;
if (!biomes || biomes.length === 0 || this.landCells.length === 0) return;
const rng = new Random(this.seed ^ 0x5bd1e995);
for (const biome of biomes) {
const tile = this._palette.byType[biome.terrainType];
if (!tile) continue;
const size = Math.max(1, Math.round(biome.size || 1));
const count = Math.max(
1,
Math.round((biome.coverage * this.landCells.length) / size)
);
for (let i = 0; i < count; i++) {
const center = this.landCells[rng.range(0, this.landCells.length - 1)];
// Vary the size so the patches do not all look the same.
const grown = Math.max(1, Math.round(size * (0.5 + rng.float())));
this._growCluster(center, grown, tile, rng);
}
}
return land[0];
}
// Grows one irregular blob of `size` tiles from `center`. At every step it
// paints a random tile on the blob's edge, so the outline wanders instead of
// closing into a hexagon, while the interior fills in rather than leaving
// holes. It stops at the coast and at tiles already claimed by a
// higher-priority biome.
_growCluster(center, size, tile, rng) {
if (!this._isPaintable(center)) return;
const frontier = [];
const queued = new Set();
const startKey = key(center.x, center.y);
queued.add(startKey);
this.tiles[startKey] = tile;
this._queueClusterNeighbours(center, frontier, queued);
let painted = 1;
while (painted < size && frontier.length > 0) {
const index = rng.range(0, frontier.length - 1);
const coords = frontier[index];
frontier[index] = frontier[frontier.length - 1];
frontier.pop();
if (!this._isPaintable(coords)) continue;
this.tiles[key(coords.x, coords.y)] = tile;
painted += 1;
this._queueClusterNeighbours(coords, frontier, queued);
}
}
_queueClusterNeighbours(coords, frontier, queued) {
for (const neighbour of this.topology.neighbours(coords.x, coords.y)) {
const nk = key(neighbour.x, neighbour.y);
if (queued.has(nk) || !this._isPaintable(neighbour)) continue;
queued.add(nk);
frontier.push(neighbour);
}
}
_isPaintable(coords) {
return this._isLand(coords) && this.tiles[key(coords.x, coords.y)] === this._palette.plains;
}
pickCapitals(count, rng) {
@@ -111,48 +187,150 @@ export class MapGenerator {
}
pickCities(capitals, citiesPerCiv, minSpacing) {
const sites = [];
if (capitals.length === 0) return sites;
const occupied = [];
for (let i = 0; i < capitals.length; i++) {
sites.push({ coords: capitals[i], civIndex: i, isCapital: true });
occupied.push(capitals[i]);
const capitalsOnly = () =>
capitals.map((coords, civIndex) => ({ coords, civIndex, isCapital: true }));
if (capitals.length === 0) return [];
if (citiesPerCiv <= 1) return capitalsOnly();
// Lay out neutral cities first, evenly spread and never closer than the
// minimum distance, then cluster them onto the players so each civ ends up
// with a compact block of cities instead of scattered exclaves.
const minDistance = Math.max(minSpacing || 0, CITY_MIN_DISTANCE);
// Work one landmass at a time: each coast is given as many neutral sites as
// its capitals need, so the cluster pass can hand every civ the same count.
const groups = this._capitalGroups(capitals);
let perCiv = citiesPerCiv;
for (const group of groups) {
group.layout = this._neutralCitySites(
capitals,
group.civs,
group.candidates,
group.civs.length * citiesPerCiv,
minDistance
);
perCiv = Math.min(perCiv, Math.floor(group.layout.length / group.civs.length));
}
if (citiesPerCiv <= 1) return sites;
const candidateLists = capitals.map((capital) => this._cityCandidates(capital));
const counts = capitals.map(() => 1);
let placedAny = true;
while (placedAny) {
placedAny = false;
for (let i = 0; i < capitals.length; i++) {
if (counts[i] >= citiesPerCiv) continue;
const candidates = candidateLists[i];
while (candidates.length > 0) {
const coords = candidates.shift();
if (!this._isFarEnough(coords, occupied, minSpacing)) continue;
sites.push({ coords, civIndex: i, isCapital: false });
occupied.push(coords);
counts[i] += 1;
placedAny = true;
break;
if (perCiv <= 1) return capitalsOnly();
// The assignment pass emits the capitals too, so it is the only source of
// cities here.
const cities = [];
for (const group of groups) {
this._assignCitySites(capitals, group, perCiv, cities);
}
return cities;
}
// Groups the capitals by the connected landmass they stand on, with that
// landmass's tiles as the pool cities may be drawn from, so no city ends up
// stranded on an island no capital can reach.
_capitalGroups(capitals) {
const components = this._components("Land");
const indexByKey = new Map();
for (let i = 0; i < components.length; i++) {
for (const coords of components[i]) indexByKey.set(key(coords.x, coords.y), i);
}
const groups = components.map((candidates) => ({ civs: [], candidates, layout: [] }));
for (let civ = 0; civ < capitals.length; civ++) {
const group = indexByKey.get(key(capitals[civ].x, capitals[civ].y));
if (group !== undefined) groups[group].civs.push(civ);
}
return groups.filter((group) => group.civs.length > 0);
}
// Neutral layout: the capitals plus the land tile furthest from every city so
// far, until there is one site per slot. Farthest-point sampling keeps them
// evenly spread and at least `minDistance` apart.
_neutralCitySites(capitals, civs, candidates, count, minDistance) {
const sites = civs.map((civ) => ({ coords: capitals[civ], civIndex: civ, isCapital: true }));
if (sites.length >= count) return sites;
const centers = civs.map((civ) => capitals[civ]);
const distance = candidates.map((coords) => this._nearestDistance(coords, centers));
while (sites.length < count) {
let best = -1;
let bestDistance = -1;
for (let i = 0; i < candidates.length; i++) {
if (distance[i] < minDistance) continue;
if (distance[i] > bestDistance) {
bestDistance = distance[i];
best = i;
}
}
if (best < 0) break;
const coords = candidates[best];
sites.push({ coords, civIndex: -1, isCapital: false });
for (let i = 0; i < candidates.length; i++) {
const d = this.topology.tileDistance(candidates[i], coords);
if (d < distance[i]) distance[i] = d;
}
}
return sites;
}
_cityCandidates(capital) {
const candidates = this.landCells.filter((c) => c.x !== capital.x || c.y !== capital.y);
candidates.sort((a, b) => {
const da = this.topology.tileDistance(capital, a);
const db = this.topology.tileDistance(capital, b);
if (Math.abs(da - db) < 1e-9) {
if (a.x === b.x) return a.y - b.y;
return a.x - b.x;
// Hands a landmass's neutral layout to its players: each capital keeps its
// site, and every other site goes to the nearest capital by land that still
// has room. Working by distance keeps each civ's cities together; a site is
// dropped rather than sent far away if every nearby civ is full.
_assignCitySites(capitals, group, perCiv, cities) {
const layout = group.layout;
const civs = group.civs;
const distances = civs.map((civ) => this._landDistances(capitals[civ]));
const capacity = civs.map(() => perCiv - 1);
const owner = layout.map((site) => (site.isCapital ? site.civIndex : -1));
const pairs = [];
for (let site = 0; site < layout.length; site++) {
if (layout[site].isCapital) continue;
const k = key(layout[site].coords.x, layout[site].coords.y);
for (let index = 0; index < civs.length; index++) {
const distance = distances[index].get(k);
if (distance === undefined) continue;
pairs.push({ site, index, distance });
}
return da - db;
});
return candidates;
}
pairs.sort((a, b) => a.distance - b.distance || a.site - b.site || a.index - b.index);
for (const pair of pairs) {
if (owner[pair.site] !== -1 || capacity[pair.index] <= 0) continue;
owner[pair.site] = civs[pair.index];
capacity[pair.index] -= 1;
}
for (let site = 0; site < layout.length; site++) {
if (owner[site] < 0) continue;
cities.push({
coords: layout[site].coords,
civIndex: owner[site],
isCapital: layout[site].isCapital,
});
}
}
// Land-path distance from one capital to every tile it can reach, so the
// assignment measures closeness along the coast rather than as the crow flies
// across a bay.
_landDistances(capital) {
const distances = new Map();
distances.set(key(capital.x, capital.y), 0);
let frontier = [capital];
while (frontier.length > 0) {
const next = [];
for (const coords of frontier) {
const distance = distances.get(key(coords.x, coords.y));
for (const neighbour of this.topology.neighbours(coords.x, coords.y)) {
const nk = key(neighbour.x, neighbour.y);
if (distances.has(nk) || !this._isLand(neighbour)) continue;
distances.set(nk, distance + 1);
next.push(neighbour);
}
}
frontier = next;
}
return distances;
}
_nearestDistance(coords, centers) {
let nearest = Infinity;
for (const center of centers) {
const distance = this.topology.tileDistance(coords, center);
if (distance < nearest) nearest = distance;
}
return nearest;
}
_capitalCandidates(ignoreMargin) {
@@ -187,6 +365,137 @@ export class MapGenerator {
return true;
}
// Joins every separate sea into one connected ocean, so no stretch of water
// is cut off from the rest. The largest sea is the trunk; a 0-1 search spreads
// out from it where crossing open water is free and crossing land costs the
// one tile that would have to be dug. Each sea is then linked by carving the
// cheapest channel to water already reachable, which keeps the new canals
// short. The result is deterministic, so the browser rebuilds the same coast.
_connectOceans() {
const components = this._seaComponents();
if (components.length <= 1) return;
let mainIndex = 0;
for (let i = 1; i < components.length; i++) {
if (components[i].length > components[mainIndex].length) mainIndex = i;
}
const dist = new Map();
const parent = new Map();
const buckets = [];
const place = (k, d, from) => {
if (dist.has(k) && dist.get(k) <= d) return;
dist.set(k, d);
parent.set(k, from);
(buckets[d] || (buckets[d] = [])).push(k);
};
for (const coords of components[mainIndex]) place(key(coords.x, coords.y), 0, undefined);
for (let d = 0; d < buckets.length; d++) {
const bucket = buckets[d];
if (!bucket) continue;
while (bucket.length > 0) {
const k = bucket.pop();
if (dist.get(k) !== d) continue;
const coords = parseKey(k);
for (const neighbour of this.topology.neighbours(coords.x, coords.y)) {
const nk = key(neighbour.x, neighbour.y);
const tile = this.tiles[nk];
if (!tile) continue;
place(nk, d + (tile.terrainClass === "Sea" ? 0 : 1), k);
}
}
}
// Dig each sea's cheapest route back to the ocean. The routes may share
// tiles, which is harmless.
for (let i = 0; i < components.length; i++) {
if (i === mainIndex) continue;
let endpoint = null;
let best = Infinity;
for (const coords of components[i]) {
const k = key(coords.x, coords.y);
const d = dist.get(k);
if (d !== undefined && d < best) {
best = d;
endpoint = k;
}
}
let k = endpoint;
while (k !== undefined) {
if (this.tiles[k].terrainClass !== "Sea") this.tiles[k] = this._palette.sea;
k = parent.get(k);
}
}
this._rebuildLandCells();
}
// Every sea tile grouped into its connected body, in scan order.
_seaComponents() {
return this._components("Sea");
}
// Every tile of the given terrain class grouped into connected bodies, in
// scan order. Both the ocean search and the small-feature cleanup use it.
_components(terrainClass) {
const seen = new Set();
const components = [];
for (const k in this.tiles) {
if (seen.has(k) || this.tiles[k].terrainClass !== terrainClass) continue;
const component = [];
let frontier = [parseKey(k)];
seen.add(k);
while (frontier.length > 0) {
const next = [];
for (const coords of frontier) {
component.push(coords);
for (const neighbour of this.topology.neighbours(coords.x, coords.y)) {
const nk = key(neighbour.x, neighbour.y);
if (seen.has(nk)) continue;
const tile = this.tiles[nk];
if (!tile || tile.terrainClass !== terrainClass) continue;
seen.add(nk);
next.push(neighbour);
}
}
frontier = next;
}
components.push(component);
}
return components;
}
// Smooths away the speckle: islands below the configured fraction of the map
// become sea, and seas below it become land. Small maps round the thresholds
// to one tile, which leaves them untouched.
_removeSmallFeatures() {
const area = this.config.mapSize.x * this.config.mapSize.y;
const minIsland = Math.max(1, Math.round(area * (this.config.minIslandFraction || 0)));
const minLake = Math.max(1, Math.round(area * (this.config.minLakeFraction || 0)));
if (minIsland <= 1 && minLake <= 1) return;
let changed = false;
if (minIsland > 1) {
for (const component of this._components("Land")) {
if (component.length >= minIsland) continue;
for (const coords of component) this.tiles[key(coords.x, coords.y)] = this._palette.sea;
changed = true;
}
}
if (minLake > 1) {
const fill = this._palette.plains;
for (const component of this._components("Sea")) {
if (component.length >= minLake) continue;
for (const coords of component) this.tiles[key(coords.x, coords.y)] = fill;
changed = true;
}
}
if (changed) this._rebuildLandCells();
}
// Recomputes the land cells after carving bridges through land.
_rebuildLandCells() {
this.landCells = [];
for (const k in this.tiles) {
if (this.tiles[k].terrainClass === "Land") this.landCells.push(parseKey(k));
}
}
_computeLandmasses() {
this._landmassSize = new Map();
const landSet = new Set(this.landCells.map((c) => key(c.x, c.y)));
@@ -212,12 +521,22 @@ export class MapGenerator {
}
}
}
_isLand(coords) {
const tile = this.tiles[key(coords.x, coords.y)];
return !!tile && tile.terrainClass === "Land";
}
}
function buildPalette() {
const land = TERRAIN_TILES.filter((t) => t.terrainClass === "Land").slice();
land.sort((a, b) => a.minElevation - b.minElevation);
const landTiles = TERRAIN_TILES.filter((t) => t.terrainClass === "Land");
const byType = {};
for (const tile of landTiles) {
// The first entry of a type is the canonical one (there is more than one
// plain "Land" sprite only through the atlas, not the model).
if (!byType[tile.terrainType]) byType[tile.terrainType] = tile;
}
const plains = byType.Land || landTiles[0] || null;
const sea = TERRAIN_TILES.find((t) => t.terrainClass === "Sea") || null;
const ice = TERRAIN_TILES.find((t) => t.terrainClass === "Ice") || null;
return { land, sea, ice };
return { plains, byType, sea, ice };
}
+6 -2
View File
@@ -7,6 +7,7 @@ export const DEFAULT_MOVEMENT_COST_MULTIPLIER = 1.0;
export class TerrainStats {
constructor(tiles) {
this._tiles = tiles;
this._minMovementCost = undefined;
}
_value(coords, field) {
@@ -28,8 +29,10 @@ export class TerrainStats {
}
// Cheapest authored movement cost across every tile in use, so the A*
// heuristic never overestimates a path.
// heuristic never overestimates a path. The terrain is fixed once generated,
// so the scan is done once and cached.
minMovementCostMultiplier() {
if (this._minMovementCost !== undefined) return this._minMovementCost;
let cheapest = Infinity;
for (const k in this._tiles) {
const value = this.movementCostMultiplier({
@@ -38,6 +41,7 @@ export class TerrainStats {
});
if (value < cheapest) cheapest = value;
}
return cheapest < Infinity ? cheapest : DEFAULT_MOVEMENT_COST_MULTIPLIER;
this._minMovementCost = cheapest < Infinity ? cheapest : DEFAULT_MOVEMENT_COST_MULTIPLIER;
return this._minMovementCost;
}
}
+3 -2
View File
@@ -2,7 +2,8 @@
// scripts/classes/text_format.gd.
export function groupDigits(value) {
const digits = String(Math.abs(Math.trunc(value)));
const truncated = Math.trunc(value);
const digits = String(Math.abs(truncated));
let grouped = "";
let count = 0;
for (let i = digits.length - 1; i >= 0; i--) {
@@ -10,7 +11,7 @@ export function groupDigits(value) {
count += 1;
if (count % 3 === 0 && i > 0) grouped = " " + grouped;
}
return (value < 0 ? "-" : "") + grouped;
return (truncated < 0 ? "-" : "") + grouped;
}
function threeSignificant(value) {
+14
View File
@@ -96,6 +96,20 @@ export class AppTest extends TestCase {
}
}
async test_civilisation_picker_is_alphabetical() {
const env = await setupDom();
try {
FakeWebSocket.reset();
await import(`../client/js/app.js?sort=${Date.now()}`);
const names = env.$("#new-civs li span").map((_, el) => env.$(el).text()).get();
const expected = names.slice().sort((a, b) => a.localeCompare(b));
this.assertEqual(names, expected);
this.assertEqual(env.$("#new-civs li.selected").attr("data-civ-id"), CIVILISATIONS[0].id);
} finally {
teardownDom(env);
}
}
async test_create_flow_sends_setup_and_renders() {
const env = await setupDom();
try {
+16
View File
@@ -13,6 +13,8 @@ import {
effectValue,
statusById,
STATUS_BATTLE,
PILLAGE,
pillageSteps,
} from "../shared/data.js";
export class DataTest extends TestCase {
@@ -49,6 +51,20 @@ export class DataTest extends TestCase {
this.assertEqual(battle.upkeepMultiplier, 3);
}
test_pillage_steps_compound_per_unit() {
const one = pillageSteps(100000, 1);
this.assertApprox(one.steps[0].removed, 10000, 1e-6);
this.assertApprox(one.steps[0].heal, 10, 1e-9);
this.assertApprox(one.gdpMultiplier, PILLAGE.gdpPenalty, 1e-9);
const two = pillageSteps(100000, 2);
this.assertApprox(two.steps[0].removed, 10000, 1e-6);
this.assertApprox(two.steps[1].removed, 9000, 1e-6);
this.assertApprox(two.steps[1].heal, 9, 1e-9);
this.assertApprox(two.steps[1].remaining, 81000, 1e-6);
this.assertApprox(two.gdpMultiplier, 0.49, 1e-9);
}
test_trainable_units_exist_in_the_catalogue() {
this.assertNotEmpty(TRAINABLE_UNIT_IDS);
for (const id of TRAINABLE_UNIT_IDS) {
+219 -6
View File
@@ -57,7 +57,7 @@ export class GameScreenTest extends TestCase {
const unit = state.units.find((u) => u.civ === 0);
const goal = adjacentLand(state, unit);
screen.map.onMoveOrdered(unit.id, goal);
await screen.map.onMoveOrdered([unit.id], goal);
this.assertEqual(network.orders[1], { type: "move", unit: unit.id, coords: [goal.x, goal.y] });
this.assertTrue(screen.map._unitMotion.get(unit.id).predicting, "the move was predicted");
@@ -131,7 +131,7 @@ export class GameScreenTest extends TestCase {
this.assertFalse(env.$("#train-status").hasClass("hidden"));
this.assertTrue(env.$("#train-label").text().includes("Training"));
state.training.get(capital.id).elapsedHours = 3;
state.training.get(capital.id)[0].elapsedHours = 3;
screen.onState(state.snapshot(0));
this.assertTrue(env.$("#train-label").text().includes("left"));
@@ -144,6 +144,68 @@ export class GameScreenTest extends TestCase {
}
}
async test_attack_and_scheduled_move_orders_are_serialised() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = defaultState();
screen.onState(state.snapshot(0));
const raw = state.units.find((u) => u.civ === 0);
const unit = screen._unit(raw.id);
const goal = adjacentLand(state, raw);
await screen._sendMove([unit], goal, null, "attack");
this.assertEqual(network.orders[0], {
type: "attack",
unit: unit.id,
coords: [goal.x, goal.y],
});
await screen._sendMove([unit], goal, null, "schedule");
this.assertEqual(network.orders[1], {
type: "schedule",
unit: unit.id,
coords: [goal.x, goal.y],
goals: [[goal.x, goal.y]],
append: true,
});
screen.leave();
} finally {
teardownDom(env);
}
}
async test_training_modal_lists_the_queue_and_cancels() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = defaultState();
screen.onState(state.snapshot(0));
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
screen.map.onCitySelected(capital.id);
env.$("#city-train").click();
state.requestTrain(capital.id, 0);
state.requestTrain(capital.id, 0);
screen.onState(state.snapshot(0));
this.assertSize(env.$("#train-queue .queue-row"), 1, "contiguous identical units merge");
this.assertTrue(
env.$("#train-queue .queue-row").first().text().includes("×2"),
"the merged row shows how many units it holds"
);
this.assertTrue(
env.$("#train-queue .queue-row").first().text().includes("all ready in"),
"the merged row forecasts the whole run"
);
env.$("#train-queue .queue-row").first().find("button").click();
this.assertEqual(network.orders[0], { type: "cancel_train", city: capital.id, index: 1 });
screen.leave();
} finally {
teardownDom(env);
}
}
async test_bottom_panel_information() {
const env = await setupDom();
try {
@@ -160,6 +222,11 @@ export class GameScreenTest extends TestCase {
screen.map.onUnitSelected(unit.id);
this.assertFalse(env.$("#unit-info").hasClass("hidden"));
this.assertTrue(env.$("#unit-name").text().length > 0);
this.assertEqual(
env.$("#unit-health-label").text(),
`${Math.round(unit.hp)} / ${Math.round(unit.maxHp)} HP`,
"the panel shows hit points next to the bar"
);
screen._showUnit(null);
this.assertTrue(env.$("#unit-info").hasClass("hidden"));
screen._showCity(null);
@@ -314,7 +381,7 @@ export class GameScreenTest extends TestCase {
screen.map.predictMove = (u, coords) => {
predicted = { u, coords };
};
screen.map.onMoveOrdered(unit.id, goal);
await screen.map.onMoveOrdered([unit.id], goal);
this.assertFalse(env.$("#modal-confirm").hasClass("hidden"), "the war prompt opens");
this.assertEmpty(network.orders, "nothing is sent before confirming");
this.assertTrue(env.$("#confirm-message").text().includes("Britain"));
@@ -340,7 +407,7 @@ export class GameScreenTest extends TestCase {
const goal = { x: 902, y: 902 };
screen.map.territory.set("902,902", 1);
screen.map.planMove = () => [{ x: 1, y: 1 }, goal];
screen.map.onMoveOrdered(unit.id, goal);
await screen.map.onMoveOrdered([unit.id], goal);
env.$("#confirm-cancel").click();
this.assertEmpty(network.orders, "cancelling keeps the peace");
screen.leave();
@@ -363,7 +430,7 @@ export class GameScreenTest extends TestCase {
screen.map.territory.set("901,901", 1);
screen.map.planMove = () => [{ x: 1, y: 1 }, goal];
screen.map.predictMove = () => null;
screen.map.onMoveOrdered(unit.id, goal);
await screen.map.onMoveOrdered([unit.id], goal);
this.assertTrue(env.$("#modal-confirm").hasClass("hidden"));
this.assertEqual(network.orders[0], { type: "move", unit: unit.id, coords: [901, 901] });
screen.leave();
@@ -381,7 +448,7 @@ export class GameScreenTest extends TestCase {
screen.onState(defaultState().snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab=diplomacy]").click();
const row = env.$("#diplomacy-list .row-card").first();
const row = env.$("#diplomacy-list tbody tr").first();
this.assertTrue(row.find(".name").text().length > 0);
row.find("button").click();
this.assertEqual(network.orders[0].type, "declare_war");
@@ -448,4 +515,150 @@ export class GameScreenTest extends TestCase {
teardownDom(env);
}
}
async test_stack_menu_selects_all_and_splits_one() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = defaultState();
const unit = state.units.find((u) => u.civ === 0);
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
screen.onState(state.snapshot(0));
screen.map.onStackMenu(unit.coords, [unit.id, second.id], 100, 120);
this.assertFalse(env.$("#stack-menu").hasClass("hidden"));
this.assertSize(env.$("#stack-menu .stack-item"), 3, "two units plus select all");
env.$("#stack-menu .stack-all").click();
this.assertEqual(screen.selectedUnitIds, [unit.id, second.id]);
this.assertTrue(env.$("#unit-name").text().includes("2 units selected"));
this.assertTrue(env.$("#stack-menu").hasClass("hidden"), "the menu closes on pick");
screen.map.onStackMenu(unit.coords, [unit.id, second.id], 100, 120);
env.$("#stack-menu .stack-item").not(".stack-move").not(".stack-all").first().click();
this.assertEqual(screen.selectedUnitIds, [unit.id], "picking one splits the stack");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_clicking_a_stacked_unit_selects_the_whole_stack() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = defaultState();
const unit = state.units.find((u) => u.civ === 0);
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
screen.onState(state.snapshot(0));
screen.map.onUnitSelected(unit.id);
this.assertEqual(
screen.selectedUnitIds.slice().sort((a, b) => a - b),
[unit.id, second.id].sort((a, b) => a - b),
"the whole stack is selected"
);
// The next order is given to every unit in the stack.
const goal = adjacentLand(state, unit);
await screen.map._onRightClick(goal, {});
this.assertEqual(network.orders[0].type, "move");
this.assertEqual(
network.orders[0].units.slice().sort((a, b) => a - b),
[unit.id, second.id].sort((a, b) => a - b)
);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_group_move_uses_the_slowest_speed() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = defaultState();
const capital = state.units.find((u) => u.civ === 0);
const infantry = state._spawnUnit(capital.coords, 0, state.protoUnits[0]);
const artillery = state._spawnUnit(capital.coords, 0, state.protoUnits[2]);
screen.onState(state.snapshot(0));
screen._selectUnits([infantry.id, artillery.id]);
const goal = adjacentLand(state, capital);
await screen.map.onMoveOrdered([infantry.id, artillery.id], goal);
this.assertEqual(network.orders[0].type, "move");
this.assertEqual(network.orders[0].units, [infantry.id, artillery.id]);
const infHours = screen.map._unitMotion.get(infantry.id).segmentHours[0];
const artHours = screen.map._unitMotion.get(artillery.id).segmentHours[0];
this.assertApprox(infHours, artHours, 1e-9, "both move at the slowest member's pace");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_pillage_button_forecasts_and_sends_the_order() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = defaultState();
const coords = findEnemyTile(state);
this.assertNotNull(coords, "found an enemy tile");
state.tilePopulation.set(`${coords.x},${coords.y}`, 100000);
const first = state._spawnUnit(coords, 0, state.protoUnits[0]);
const second = state._spawnUnit(coords, 0, state.protoUnits[0]);
first.hp = 100;
second.hp = 90;
screen.onState(state.snapshot(0));
screen._selectUnits([first.id, second.id]);
const $button = env.$("#unit-pillage");
this.assertFalse($button.hasClass("hidden"), "the pillage button shows");
const tooltip = $button.attr("data-tooltip") || "";
this.assertTrue(tooltip.includes("19 000"), `forecast removes 19k: ${tooltip}`);
this.assertTrue(tooltip.includes("0.49"), "forecast shows the GDP multiplier");
this.assertTrue(tooltip.includes("+10") && tooltip.includes("+9"), "forecast shows both heals");
$button.click();
this.assertEqual(network.orders[0], { type: "pillage", units: [first.id, second.id] });
screen.leave();
} finally {
teardownDom(env);
}
}
async test_pillage_button_hides_in_friendly_land() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = defaultState();
const unit = state.units.find((u) => u.civ === 0);
screen.onState(state.snapshot(0));
screen._selectUnits([unit.id]);
this.assertTrue(env.$("#unit-pillage").hasClass("hidden"));
screen.leave();
} finally {
teardownDom(env);
}
}
}
function findEnemyTile(state) {
for (const [k, owner] of state.territory) {
if (owner !== 1) continue;
const i = k.indexOf(",");
const coords = { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) };
if (state.unitAt(coords) || state.cityAt(coords)) continue;
return coords;
}
return null;
}
+57 -2
View File
@@ -23,6 +23,18 @@ function makeServer(civIds = ["france", "britain"]) {
return { server, network };
}
function emptyLandPair(state) {
for (const cell of state.landCells) {
if (state.unitAt(cell) || state.cityAt(cell)) continue;
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
if (!state._isLand(neighbour)) continue;
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } };
}
}
throw new Error("no empty land pair");
}
export class GameServerTest extends TestCase {
test_configure_game_builds_a_state() {
const { server } = makeServer();
@@ -82,13 +94,24 @@ export class GameServerTest extends TestCase {
this.assertFalse(server.handleOrder(1, { type: "move", unit: enemy.id, coords: [goal.x, goal.y] }));
}
test_train_order_for_own_city_is_accepted_once() {
test_train_orders_queue_for_own_city() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const city = server.state.cities[0];
const order = { type: "train", city: city.id, proto: 0 };
this.assertTrue(server.handleOrder(1, order));
this.assertFalse(server.handleOrder(1, order), "city is already training");
this.assertTrue(server.handleOrder(1, order), "a second unit joins the queue");
this.assertSize(server.state.getTraining(city.id), 2);
}
test_cancel_train_order_for_own_city_is_accepted() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const city = server.state.cities[0];
server.handleOrder(1, { type: "train", city: city.id, proto: 0 });
server.handleOrder(1, { type: "train", city: city.id, proto: 0 });
this.assertTrue(server.handleOrder(1, { type: "cancel_train", city: city.id, index: 0 }));
this.assertSize(server.state.getTraining(city.id), 1);
}
test_train_order_for_enemy_city_is_rejected() {
@@ -161,6 +184,38 @@ export class GameServerOrdersTest extends TestCase {
this.assertEqual(typeof result, "boolean");
}
test_attack_order_targets_an_adjacent_enemy() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const pair = emptyLandPair(server.state);
const attacker = server.state._spawnUnit(pair.a, 0, server.state.protoUnits[0]);
server.state._spawnUnit(pair.b, 1, server.state.protoUnits[0]);
this.assertTrue(
server.handleOrder(1, { type: "attack", unit: attacker.id, coords: [pair.b.x, pair.b.y] })
);
}
test_attack_order_without_a_target_is_rejected() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const pair = emptyLandPair(server.state);
const attacker = server.state._spawnUnit(pair.a, 0, server.state.protoUnits[0]);
this.assertFalse(
server.handleOrder(1, { type: "attack", unit: attacker.id, coords: [pair.b.x, pair.b.y] })
);
}
test_schedule_order_queues_destinations() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const unit = server.state.units.find((u) => u.civ === 0);
const goal = adjacentLand(server.state, unit);
const payload = { type: "schedule", unit: unit.id, goals: [[goal.x, goal.y]], append: true };
this.assertTrue(server.handleOrder(1, payload));
this.assertTrue(server.handleOrder(1, payload));
this.assertSize(unit.waypoints, 1, "the appended destination is queued");
}
test_extra_players_become_spectators() {
const { server, network } = makeServer();
server.authenticate(1, "Host", "pw");
+85 -6
View File
@@ -1,4 +1,5 @@
import { TestCase } from "./framework/test_case.js";
import { TRAINING_QUEUE_LIMIT } from "../shared/game_state.js";
import {
smallState,
adjacentLand,
@@ -38,6 +39,30 @@ export class GameStateTest extends TestCase {
this.assertEqual(a.territory, b.territory);
}
test_foreign_territory_is_a_wall_unless_targeted() {
const state = smallState(["france", "britain", "poland"]);
const mover = state.units.find((u) => u.civ === 0);
const tile = state.landCells.find(
(c) =>
!state.unitAt(c) &&
!state.cityAt(c) &&
(c.x !== mover.coords.x || c.y !== mover.coords.y)
);
state.territory.set(`${tile.x},${tile.y}`, 2);
this.assertFalse(
state._canUnitEnter(mover, tile, false, 1),
"a third party's land blocks transit"
);
this.assertTrue(
state._canUnitEnter(mover, tile, false, 2),
"the targeted civ's land may be crossed"
);
this.assertTrue(
state._canUnitEnter(mover, tile, true, 1),
"the goal tile itself is always enterable"
);
}
test_territory_only_claims_land() {
const state = smallState();
this.assertNotEmpty(state.territory);
@@ -55,6 +80,12 @@ export class GameStateTest extends TestCase {
}
}
test_city_names_are_unique_across_the_world() {
const state = smallState(["france", "britain", "switzerland", "new_hampshire"]);
const names = state.cities.map((city) => city.name);
this.assertEqual(new Set(names).size, names.length, "no two cities share a name");
}
test_everything_starts_explored_and_units_are_visible() {
const state = smallState();
for (const k in state.tiles) {
@@ -75,6 +106,25 @@ export class GameStateTest extends TestCase {
this.assertEqual(state.getBudget(0), 0);
}
test_civilisations_start_with_equal_cities_population_and_gdp_per_capita() {
const state = smallState(["france", "britain", "slovenia"]);
const count = state.civilisations.length;
const cities = [];
const populations = [];
const perCapitas = [];
for (let civ = 0; civ < count; civ++) {
cities.push(state.cities.filter((c) => c.civ === civ).length);
const population = state.getPlayerPopulation(civ);
populations.push(population);
perCapitas.push(state.getPlayerGdp(civ) / population);
}
this.assertEqual(cities, [2, 2, 2], "every civ starts with the same number of cities");
this.assertEqual(populations, [populations[0], populations[0], populations[0]], "every civ starts with the same population");
for (const perCapita of perCapitas) {
this.assertApprox(perCapita, perCapitas[0], 1e-6, "every civ starts with the same GDP per capita");
}
}
test_can_enter_land_but_not_water() {
const state = smallState();
const proto = state.protoUnits[0];
@@ -146,14 +196,43 @@ export class GameStateTest extends TestCase {
const before = state.units.length;
const beforeUnits = unitsOf(state, city.civ);
this.assertTrue(state.requestTrain(city.id, 0));
this.assertFalse(state.requestTrain(city.id, 0), "city is busy");
const entry = state.getTraining(city.id);
this.assertNotNull(entry, "training queue started");
for (let i = 0; i < Math.ceil(entry.totalHours) + 1 && state.units.length === before; i++) {
this.assertTrue(state.requestTrain(city.id, 0), "a second unit joins the queue");
const queue = state.getTraining(city.id);
this.assertNotNull(queue, "training queue started");
this.assertSize(queue, 2);
const total = queue.reduce((sum, entry) => sum + entry.totalHours, 0);
for (let i = 0; i < Math.ceil(total) + 1 && state.units.length < before + 2; i++) {
state.advanceHour();
}
this.assertEqual(state.units.length, before + 1);
this.assertEqual(unitsOf(state, city.civ), beforeUnits + 1);
this.assertEqual(state.units.length, before + 2);
this.assertEqual(unitsOf(state, city.civ), beforeUnits + 2);
this.assertNull(state.getTraining(city.id), "the queue empties once it has finished");
}
test_training_queue_is_capped_at_one_hundred() {
const state = smallState();
const city = state.cities[0];
for (let i = 0; i < TRAINING_QUEUE_LIMIT; i++) {
this.assertTrue(state.requestTrain(city.id, 0), `order ${i} accepted`);
}
this.assertFalse(state.requestTrain(city.id, 0), "the queue is full");
this.assertSize(state.getTraining(city.id), TRAINING_QUEUE_LIMIT);
}
test_cancel_train_removes_only_that_unit_and_refunds_nothing() {
const state = smallState();
const city = state.cities[0];
state.requestTrain(city.id, 0);
state.requestTrain(city.id, 0);
state.requestTrain(city.id, 0);
const budget = state.getBudget(city.civ);
this.assertTrue(state.cancelTrain(city.id, 1));
this.assertSize(state.getTraining(city.id), 2, "only one unit was cancelled");
this.assertEqual(state.getBudget(city.civ), budget, "nothing is refunded");
this.assertFalse(state.cancelTrain(city.id, 5), "out-of-range index is rejected");
this.assertTrue(state.cancelTrain(city.id, 0));
this.assertTrue(state.cancelTrain(city.id, 0));
this.assertNull(state.getTraining(city.id), "cancelling the last unit clears the queue");
}
test_training_rejects_unknown_targets() {
+129 -1
View File
@@ -1,6 +1,6 @@
import { TestCase } from "./framework/test_case.js";
import { MapGenerator } from "../shared/map_generator.js";
import { MapTopology, key } from "../shared/hex.js";
import { MapTopology, key, parseKey } from "../shared/hex.js";
import { MAP_CONFIG } from "../shared/data.js";
import { Random } from "../shared/rng.js";
@@ -21,6 +21,56 @@ function generate(cfg = config()) {
return generator;
}
// Sizes of the connected bodies of one terrain class, in tiles. `matches` is
// either a terrain class name or a predicate over tiles.
function componentSizes(generator, matches) {
const test = typeof matches === "function" ? matches : (tile) => tile.terrainClass === matches;
const seen = new Set();
const sizes = [];
for (const k in generator.tiles) {
if (seen.has(k) || !test(generator.tiles[k])) continue;
const frontier = [parseKey(k)];
seen.add(k);
let size = 0;
while (frontier.length > 0) {
const coords = frontier.pop();
size += 1;
for (const neighbour of generator.topology.neighbours(coords.x, coords.y)) {
const nk = key(neighbour.x, neighbour.y);
if (seen.has(nk)) continue;
const tile = generator.tiles[nk];
if (!tile || !test(tile)) continue;
seen.add(nk);
frontier.push(neighbour);
}
}
sizes.push(size);
}
return sizes;
}
// Number of separate connected bodies of sea on the generated map.
function seaBodies(generator) {
return componentSizes(generator, "Sea").length;
}
// Number of land tiles that touch a sea tile: a proxy for how rough the coast
// is, since a wavier shore has more land-water edges.
function coastLength(generator) {
let edges = 0;
for (const k in generator.tiles) {
if (generator.tiles[k].terrainClass !== "Land") continue;
const i = k.indexOf(",");
const x = Number(k.slice(0, i));
const y = Number(k.slice(i + 1));
for (const neighbour of generator.topology.neighbours(x, y)) {
const tile = generator.tiles[key(neighbour.x, neighbour.y)];
if (tile && tile.terrainClass !== "Land") edges += 1;
}
}
return edges;
}
export class MapGeneratorTest extends TestCase {
test_generate_is_deterministic_for_a_seed() {
const a = generate();
@@ -71,6 +121,17 @@ export class MapGeneratorTest extends TestCase {
this.assertGreaterOrEqual(distance, cfg.minCapitalDistance, "capitals kept apart");
}
test_pick_cities_gives_every_civilisation_the_same_count() {
const cfg = config({ citiesPerCiv: 3, citySpacing: 5 });
const generator = generate(cfg);
const capitals = generator.pickCapitals(3, new Random(1));
this.assertSize(capitals, 3);
const sites = generator.pickCities(capitals, cfg.citiesPerCiv, cfg.citySpacing);
const counts = [0, 0, 0];
for (const site of sites) counts[site.civIndex] += 1;
this.assertEqual(counts, [3, 3, 3], "every civ got the same number of cities");
}
test_pick_cities_includes_one_site_per_capital() {
const cfg = config({ citiesPerCiv: 3, citySpacing: 2 });
const generator = generate(cfg);
@@ -116,4 +177,71 @@ export class MapGeneratorTest extends TestCase {
this.assertApprox(generator._applyFalloff({ x: 0, y: 0 }, 1.0, false), 1.0, 1e-9);
this.assertApprox(generator._applyFalloff({ x: 16, y: 12 }, 1.0, false), 0.0, 1e-9);
}
test_raw_terrain_can_have_separate_seas() {
const separate = seaBodies(generate(config({ connectOceans: false, terrainSeed: 1 })));
this.assertGreater(separate, 1, "this seed really needs joining up");
}
test_every_sea_is_joined_into_one_ocean() {
const bodies = seaBodies(generate(config({ connectOceans: true, terrainSeed: 1 })));
this.assertEqual(bodies, 1, "one connected ocean");
}
test_ocean_connection_is_deterministic() {
const a = generate(config({ connectOceans: true }));
const b = generate(config({ connectOceans: true }));
this.assertEqual(a.tiles, b.tiles);
this.assertEqual(a.landCells, b.landCells);
}
test_small_islands_and_lakes_are_smoothed_away() {
const cfg = config({ connectOceans: false, minIslandFraction: 0.03, minLakeFraction: 0.03 });
const generator = generate(cfg);
const min = Math.round(cfg.mapSize.x * cfg.mapSize.y * 0.03);
const land = componentSizes(generator, "Land");
const sea = componentSizes(generator, "Sea");
this.assertNotEmpty(land, "land remains");
this.assertNotEmpty(sea, "sea remains");
for (const size of land) this.assertGreaterOrEqual(size, min, `landmass of ${size} smoothed away`);
for (const size of sea) this.assertGreaterOrEqual(size, min, `lake of ${size} smoothed away`);
}
test_every_biome_is_placed_on_the_land() {
const generator = generate();
const terrainTypes = new Set();
for (const coords of generator.landCells) {
terrainTypes.add(generator.tiles[key(coords.x, coords.y)].terrainType);
}
for (const type of ["Land", "Forest", "Hills", "Mountain", "Desert"]) {
this.assertTrue(terrainTypes.has(type), `${type} appears`);
}
}
test_biome_clusters_stay_small() {
const generator = generate();
for (const biome of MAP_CONFIG.terrainBiomes) {
const largest = Math.round(biome.size * 1.5);
const sizes = componentSizes(
generator,
(tile) => tile.terrainType === biome.terrainType
);
for (const size of sizes) {
this.assertTrue(
size <= largest,
`${biome.terrainType} cluster of ${size} exceeds ${largest}`
);
}
}
}
test_a_rough_coast_has_more_shore_than_a_smooth_one() {
const smooth = generate(config({ coastRoughness: 0 }));
const rough = generate(config({ coastRoughness: 0.4 }));
this.assertGreater(
coastLength(rough),
coastLength(smooth),
"the roughened coast has a longer shore"
);
}
}
+179 -43
View File
@@ -21,10 +21,15 @@ export class MapViewTest extends TestCase {
const state = defaultState();
const { env, map, snapshot } = await this.build(state);
try {
this.assertSize(env.$("#layer-terrain .hex"), 64 * 40);
this.assertSize(map.territory, snapshot.territory.length);
this.assertSize(env.$("#layer-fog .hex"), 64 * 40 - snapshot.visible.length);
const rendered = [];
for (const rec of map._chunks.values()) rendered.push(...rec.tileKeys);
this.assertGreater(rendered.length, 0, "chunks near the camera are built");
this.assertLess(rendered.length, Object.keys(map.tiles).length, "the whole map is never built");
this.assertSize(env.$("#layer-terrain .hex"), rendered.length);
const visibleRendered = rendered.filter((k) => map.visible.has(k)).length;
this.assertSize(env.$("#layer-fog .hex"), rendered.length - visibleRendered);
this.assertGreater(env.$("#layer-borders .border-seg").length, 0);
this.assertSize(map.territory, snapshot.territory.length);
this.assertSize(env.$("#layer-entities .city"), snapshot.cities.length);
this.assertSize(env.$("#layer-entities .unit"), snapshot.units.length);
this.assertEqual(map.localCiv, 0);
@@ -81,7 +86,9 @@ export class MapViewTest extends TestCase {
const { env, map } = await this.build(state);
try {
const coords = map.tileAtScreen(100000, 300);
this.assert(coords.x >= -32 && coords.x <= 31, `x ${coords.x} wrapped`);
const minX = map.topology.originX;
const maxX = map.topology.originX + map.topology.width - 1;
this.assert(coords.x >= minX && coords.x <= maxX, `x ${coords.x} wrapped`);
} finally {
teardownDom(env);
}
@@ -92,41 +99,53 @@ export class MapViewTest extends TestCase {
const { env, map } = await this.build(state);
try {
this.assertGreater(map._period, 0, "the map has a wrap period");
const west = map._terrainWrap.find((item) => item.centerX < -1000);
this.assertNotNull(west, "found a west-edge tile");
const beforeShift = west.shift;
const rec = map._chunks.values().next().value;
this.assertNotNull(rec, "a chunk is materialised");
const chunkKey = rec.cx + "," + rec.cy;
const beforeShift = rec.shift;
// Scrolling exactly one period must land on the same picture: every tile
// Scrolling exactly one period must land on the same picture: every chunk
// advances to its next wrapped copy, cancelling the camera movement.
map.panBy(map._period, 0);
this.assertApprox(west.shift, beforeShift + map._period, 1e-6);
const after = map._chunks.get(chunkKey);
this.assertNotNull(after, "the same chunk stays materialised one period over");
this.assertApprox(after.shift, beforeShift + map._period, 1e-6);
this.assertTrue(
west.el.style.transform.includes(`translateX(${west.shift}px)`),
`expected translateX(${west.shift}px), got ${west.el.style.transform}`
after.terrainEl.style.transform.includes(`translateX(${after.x + after.shift}px)`),
`expected translateX(${after.x + after.shift}px), got ${after.terrainEl.style.transform}`
);
} finally {
teardownDom(env);
}
}
async test_tiles_meet_seamlessly_at_the_wrap() {
async test_chunks_meet_seamlessly_at_the_wrap() {
const state = defaultState();
const { env, map } = await this.build(state);
try {
const eastX0 = Math.max(...map._terrainWrap.map((item) => item.centerX));
const westX0 = Math.min(...map._terrainWrap.map((item) => item.centerX));
const east = map._terrainWrap.find((item) => item.centerX === eastX0);
const west = map._terrainWrap.find((item) => item.centerX === westX0);
this.assertNotNull(east, "found an east-edge tile");
this.assertNotNull(west, "found a west-edge tile");
// Sit on the seam so the wrapped west-edge copy lands next to the east
// edge instead of leaving a gap.
map.camera.x = east.centerX;
// Sit on the seam (just past the east edge) so both the east-edge chunk
// and the wrapped copy of the west-edge chunk are on screen together.
const t = map.topology;
map.camera.x = (t.originX + t.width) * COL_STEP;
map.camera.y = 0;
map._applyCamera();
const eastX = east.centerX + east.shift;
const westX = west.centerX + west.shift;
this.assertApprox(westX - eastX, COL_STEP, 1e-6);
const rows = new Map();
for (const rec of map._chunks.values()) {
if (!rows.has(rec.cy)) rows.set(rec.cy, []);
rows.get(rec.cy).push(rec);
}
let row = null;
for (const list of rows.values()) {
if (!row || list.length > row.length) row = list;
}
this.assertGreater(row.length, 1, "several chunks share the seam row");
row.sort((a, b) => (a.x + a.shift) - (b.x + b.shift));
for (let i = 1; i < row.length; i++) {
const previousRight = row[i - 1].x + row[i - 1].shift + row[i - 1].px;
const currentLeft = row[i].x + row[i].shift;
this.assertApprox(currentLeft - previousRight, COL_STEP, 1e-6);
}
} finally {
teardownDom(env);
}
@@ -136,17 +155,17 @@ export class MapViewTest extends TestCase {
const state = defaultState();
const { env, map } = await this.build(state);
try {
const west = map._terrainWrap.find((item) => item.centerX < -1000);
const beforeLeft = west.el.style.left;
const rec = map._chunks.values().next().value;
const beforeLeft = rec.terrainEl.style.left;
// The drag path updates the camera but defers per-element wrapping to the
// render loop; either way the element must not be laid out again.
// The drag path updates the camera but defers the chunk sync to the render
// loop; either way the chunk must be moved with a transform, not layout.
map.camera.x += map._period;
map._applyCamera(false);
map.animate(0);
this.assertEqual(west.el.style.left, beforeLeft, "left is never rewritten");
this.assertTrue(west.el.style.transform.includes("translateX"));
this.assertEqual(rec.terrainEl.style.left, beforeLeft, "left is never rewritten");
this.assertTrue(rec.terrainEl.style.transform.includes("translateX"));
} finally {
teardownDom(env);
}
@@ -171,6 +190,12 @@ export class MapViewTest extends TestCase {
const state = defaultState();
const { env, map } = await this.build(state);
try {
this.assertFalse(map.lod, "detailed at close zoom");
this.assertSize(env.$("#layer-overview .overview-block"), 0, "no overview until it is needed");
map.camera.zoom = 0.01;
map._applyCamera();
this.assertTrue(map.lod, "overview at far zoom");
this.assertGreater(
env.$("#layer-overview .overview-block").length,
0,
@@ -181,11 +206,6 @@ export class MapViewTest extends TestCase {
Object.keys(map.tiles).length,
"overview is simpler than the full map"
);
this.assertFalse(map.lod, "detailed at close zoom");
map.camera.zoom = 0.2;
map._applyCamera();
this.assertTrue(map.lod, "overview at far zoom");
this.assertTrue(env.$("#map-world").hasClass("lod"));
this.assertTrue(env.$("#layer-terrain").hasClass("detail-layer"));
@@ -209,6 +229,10 @@ export class MapViewTest extends TestCase {
const state = defaultState();
const { env, map } = await this.build(state);
try {
// Bring the two-tile territory into view so its chunk is materialised.
map.camera.x = 0;
map.camera.y = 0;
map._applyCamera();
map.civilisations = [{ primaryColour: "#111111" }, { primaryColour: "#eeeeee" }];
map.territory = new Map([["0,0", 0], ["1,0", 1]]);
map._renderBorders();
@@ -248,7 +272,7 @@ export class MapViewTest extends TestCase {
const unit = snapshot.units.find((u) => u.civ === 0);
const stateUnit = state.units.find((u) => u.id === unit.id);
const goal = adjacentLand(state, stateUnit);
const path = map.predictMove(unit, goal);
const path = await map.predictMove(unit, goal);
this.assertNotNull(path, "a route was predicted");
this.assertGreaterOrEqual(path.length, 2);
const motion = map._unitMotion.get(unit.id);
@@ -269,12 +293,12 @@ export class MapViewTest extends TestCase {
const stateUnit = state.units.find((u) => u.id === unit.id);
const goal = adjacentLand(state, stateUnit);
const before = map._unitMotion.get(unit.id);
const route = map.planMove(unit, goal);
const route = await map.planMove(unit, goal);
this.assertNotNull(route, "a route was planned");
this.assertGreaterOrEqual(route.length, 2);
this.assertEqual(map._unitMotion.get(unit.id), before, "planning does not start a prediction");
this.assertFalse(map._unitMotion.get(unit.id).predicting);
const applied = map.predictMove(unit, goal);
const applied = await map.predictMove(unit, goal);
this.assertEqual(applied, route, "predicting follows the planned route");
this.assertTrue(map._unitMotion.get(unit.id).predicting);
} finally {
@@ -313,7 +337,7 @@ export class MapViewTest extends TestCase {
this.assertGreaterOrEqual(map._unitMotion.get(unit.id).path.length, 2);
// Predicting from a unit that already walks a path reads its position.
map.predictMove(snapshot.units.find((u) => u.id === unit.id), adjacentLand(state, unit));
await map.predictMove(snapshot.units.find((u) => u.id === unit.id), adjacentLand(state, unit));
this.assertTrue(map._unitMotion.get(unit.id).predicting);
} finally {
teardownDom(env);
@@ -333,14 +357,14 @@ export class MapViewTest extends TestCase {
this.assertEqual(selected, unit.id);
let order = null;
map.onMoveOrdered = (id, coords) => {
order = { id, coords };
map.onMoveOrdered = (ids, coords) => {
order = { ids, coords };
};
map.setSelectedUnit(unit.id);
const stateUnit = state.units.find((u) => u.id === unit.id);
const goal = adjacentLand(state, stateUnit);
map._onRightClick(goal);
this.assertEqual(order.id, unit.id);
this.assertEqual(order.ids, [unit.id]);
this.assertEqual(order.coords, goal);
let tile = null;
@@ -419,4 +443,116 @@ export class MapViewTest extends TestCase {
teardownDom(env);
}
}
async test_stacked_units_show_one_icon_with_a_count_badge() {
const state = defaultState();
const unit = state.units.find((u) => u.civ === 0);
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
const { env, map } = await this.build(state);
try {
const first = map._unitViews.get(unit.id);
const other = map._unitViews.get(second.id);
this.assertEqual(first.querySelector(".unit-count").style.display, "block");
this.assertEqual(first.querySelector(".unit-count").textContent, "2");
this.assertEqual(other.style.visibility, "hidden", "the follower is not drawn");
const ids = map.unitIdsAt({ x: unit.coords.x, y: unit.coords.y }).sort((a, b) => a - b);
this.assertEqual(ids, [unit.id, second.id].sort((a, b) => a - b), "both are selectable");
} finally {
teardownDom(env);
}
}
async test_right_click_on_a_friendly_stack_opens_the_menu() {
const state = defaultState();
const unit = state.units.find((u) => u.civ === 0);
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
const { env, map } = await this.build(state);
try {
let menu = null;
map.onStackMenu = (coords, ids, x, y) => {
menu = { coords, ids, x, y };
};
map._onRightClick({ x: unit.coords.x, y: unit.coords.y }, { clientX: 11, clientY: 22 });
this.assertNotNull(menu, "the stack menu opens");
this.assertEqual(menu.ids.sort((a, b) => a - b), [unit.id, second.id].sort((a, b) => a - b));
this.assertEqual(menu.x, 11);
this.assertEqual(menu.y, 22);
} finally {
teardownDom(env);
}
}
async test_stack_ids_group_units_sharing_a_tile() {
const state = defaultState();
const unit = state.units.find((u) => u.civ === 0);
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
const { env, map } = await this.build(state);
try {
const ids = map.stackIds(unit.id).sort((a, b) => a - b);
this.assertEqual(ids, [unit.id, second.id].sort((a, b) => a - b));
} finally {
teardownDom(env);
}
}
async test_right_click_on_a_friendly_stack_while_selected_moves_there() {
const state = defaultState();
const unit = state.units.find((u) => u.civ === 0);
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
const { env, map } = await this.build(state);
try {
let menu = null;
let moved = null;
map.onStackMenu = () => { menu = true; };
map.onMoveOrdered = (ids, coords) => { moved = { ids, coords }; };
map.setSelectedUnit(second.id);
map._onRightClick({ x: unit.coords.x, y: unit.coords.y }, {});
this.assertNull(menu, "the stack menu does not open over a selection");
this.assertNotNull(moved, "the selected stack is ordered onto the friendly tile");
this.assertEqual(moved.ids, [second.id]);
this.assertEqual(moved.coords, { x: unit.coords.x, y: unit.coords.y });
} finally {
teardownDom(env);
}
}
async test_right_click_on_an_enemy_stack_attacks_it() { const state = defaultState();
const unit = state.units.find((u) => u.civ === 0);
const goal = adjacentLand(state, unit);
state._spawnUnit(goal, 1, state.protoUnits[0]);
const { env, map } = await this.build(state);
try {
let attack = null;
let move = null;
map.onAttackOrdered = (ids, coords) => { attack = { ids, coords }; };
map.onMoveOrdered = (ids, coords) => { move = { ids, coords }; };
map.setSelectedUnit(unit.id);
map._onRightClick(goal, {});
this.assertNotNull(attack, "the enemy stack is attacked");
this.assertNull(move, "the enemy stack is not treated as a plain move");
this.assertEqual(attack.coords, goal);
} finally {
teardownDom(env);
}
}
async test_ctrl_right_click_schedules_a_destination() {
const state = defaultState();
const unit = state.units.find((u) => u.civ === 0);
const goal = adjacentLand(state, unit);
const { env, map } = await this.build(state);
try {
let schedule = null;
let move = null;
map.onScheduleOrdered = (ids, coords) => { schedule = { ids, coords }; };
map.onMoveOrdered = (ids, coords) => { move = { ids, coords }; };
map.setSelectedUnit(unit.id);
map._onRightClick(goal, { ctrlKey: true });
this.assertNotNull(schedule, "Ctrl-right-click queues a destination");
this.assertNull(move, "a scheduled click is not a plain move");
this.assertEqual(schedule.coords, goal);
} finally {
teardownDom(env);
}
}
}
+95 -9
View File
@@ -32,7 +32,7 @@ export class TrainModalTest extends TestCase {
modal.show({ id: 1, name: "Lyon" }, 1.0e9, [{ ...PROTO_UNITS[0], serverIndex: 0 }]);
modal.beginTraining("Modern infantry", 10, 2);
this.assertFalse(env.$("#train-status").hasClass("hidden"));
this.assertTrue(env.$("#train-list button").prop("disabled"));
this.assertFalse(env.$("#train-list button").prop("disabled"), "more units can still be queued");
modal.setTrainingProgress("Modern infantry", 4, 10);
this.assertTrue(env.$("#train-label").text().includes("6 h left"));
modal.endTraining();
@@ -42,6 +42,35 @@ export class TrainModalTest extends TestCase {
teardownDom(env);
}
}
async test_queue_merges_only_contiguous_identical_units() {
const env = await setupDom();
try {
const modal = new TrainModal();
const cancelled = [];
modal.onCancel = (cityId, index) => cancelled.push([cityId, index]);
const unit = (i) => ({ ...PROTO_UNITS[i], serverIndex: i });
const queue = [
{ protoIndex: 0, elapsedHours: 0, totalHours: 10 },
{ protoIndex: 0, elapsedHours: 0, totalHours: 10 },
{ protoIndex: 2, elapsedHours: 0, totalHours: 30 },
{ protoIndex: 0, elapsedHours: 0, totalHours: 10 },
];
modal.show({ id: 7, name: "Lyon" }, 1.0e9, [unit(0), unit(2)], queue);
const rows = env.$("#train-queue .queue-row");
this.assertSize(rows, 3, "only adjacent runs share a row");
this.assertTrue(rows.eq(0).text().includes("Modern infantry ×2"), "the run is labelled with its size");
this.assertTrue(rows.eq(0).text().includes("Next in"), "a run shows when its first unit lands");
this.assertTrue(rows.eq(0).text().includes("all ready in"), "and when the run completes");
this.assertTrue(rows.eq(1).text().includes("Artillery"), "a different type starts a row");
this.assertTrue(rows.eq(2).text().includes("Modern infantry"), "a later run of the same type is separate");
this.assertFalse(rows.eq(2).text().includes("×"), "the lone trailing unit is not a run");
rows.eq(0).find("button").click();
this.assertEqual(cancelled, [[7, 1]], "cancelling a run removes its last unit");
} finally {
teardownDom(env);
}
}
}
export class BuildingsModalTest extends TestCase {
@@ -200,20 +229,36 @@ export class NationModalTest extends TestCase {
};
const civs = [{ name: "France" }, { name: "Britain" }, { name: "Poland" }];
const relations = [[0, 1, "peace"], [0, 2, "war"], [1, 2, "peace"]];
const stats = [
{ population: 2.0e6, gdp: 5.0e9, gdpPerCapita: 2500 },
{ population: 1.5e6, gdp: 3.0e9, gdpPerCapita: 2000 },
{ population: 1.0e6, gdp: 1.5e9, gdpPerCapita: 1500 },
];
modal.show(
GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, {},
{ civs, localCiv: 0, relations }
{ civs, localCiv: 0, relations, stats }
);
env.$("#modal-nation .tab[data-tab=diplomacy]").click();
this.assertTrue(env.$("#tab-diplomacy").hasClass("active"));
const rows = env.$("#diplomacy-list .row-card");
this.assertSize(rows, 2, "every other nation is listed");
this.assertTrue(rows.eq(0).find(".name").text() === "Britain");
this.assertTrue(rows.eq(0).find(".desc").text().includes("At peace"));
this.assertTrue(rows.eq(1).find(".name").text() === "Poland");
this.assertTrue(rows.eq(1).find(".desc").text().includes("At war"));
this.assertTrue(rows.eq(1).find("button").prop("disabled"), "already at war");
const rows = env.$("#diplomacy-list tbody tr");
this.assertSize(rows, 3, "every nation, including ours, is listed");
this.assertTrue(rows.eq(0).find(".name").text() === "Britain", "alphabetical by default");
this.assertTrue(rows.eq(0).find(".status").text().includes("At peace"));
const numbers = rows.eq(0).find("td.numeric");
this.assertTrue(numbers.eq(0).text().includes("€3B"), "Britain's GDP shows");
this.assertTrue(numbers.eq(1).text().includes("€2K"), "Britain's GDP/capita shows");
this.assertTrue(numbers.eq(2).text().includes("1.5M"), "Britain's population shows");
const own = rows.eq(1);
this.assertTrue(own.find(".name").text() === "France", "our own nation is listed");
this.assertTrue(own.hasClass("local-nation"), "our row is highlighted");
this.assertTrue(own.find(".status").text().includes("Your nation"));
this.assertSize(own.find("button"), 0, "we cannot declare war on ourselves");
this.assertTrue(rows.eq(2).find(".name").text() === "Poland");
this.assertTrue(rows.eq(2).find(".status").text().includes("At war"));
this.assertTrue(rows.eq(2).find("button").prop("disabled"), "already at war");
this.assertTrue(env.$("#diplomacy-sub").text().includes("at war with 1"));
rows.eq(0).find("button").click();
@@ -224,6 +269,47 @@ export class NationModalTest extends TestCase {
teardownDom(env);
}
}
async test_diplomacy_table_sorts_by_every_column() {
const env = await setupDom();
try {
const modal = new NationModal();
const civs = [{ name: "France" }, { name: "Britain" }, { name: "Poland" }];
const stats = [
{ population: 1.0e6, gdp: 1.0e9, gdpPerCapita: 1000 },
{ population: 3.0e6, gdp: 2.0e9, gdpPerCapita: 2000 },
{ population: 2.0e6, gdp: 3.0e9, gdpPerCapita: 3000 },
];
modal.show(
GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, {},
{ civs, localCiv: 0, relations: [], stats }
);
const names = () => env.$("#diplomacy-list tbody tr .name")
.map((_, el) => env.$(el).text()).get();
this.assertEqual(names(), ["Britain", "France", "Poland"], "alphabetical by default");
this.assertTrue(env.$("#diplomacy-list th[data-sort=name]").hasClass("sorted-asc"));
// GDP ascending first, descending on the second click.
env.$("#diplomacy-list th[data-sort=gdp]").click();
this.assertEqual(names(), ["France", "Britain", "Poland"]);
this.assertTrue(env.$("#diplomacy-list th[data-sort=gdp]").hasClass("sorted-asc"));
env.$("#diplomacy-list th[data-sort=gdp]").click();
this.assertEqual(names(), ["Poland", "Britain", "France"]);
this.assertTrue(env.$("#diplomacy-list th[data-sort=gdp]").hasClass("sorted-desc"));
// A different column starts ascending.
env.$("#diplomacy-list th[data-sort=population]").click();
this.assertEqual(names(), ["France", "Poland", "Britain"]);
this.assertTrue(env.$("#diplomacy-list th[data-sort=population]").hasClass("sorted-asc"));
// Back to alphabetical.
env.$("#diplomacy-list th[data-sort=name]").click();
this.assertEqual(names(), ["Britain", "France", "Poland"]);
modal.close();
} finally {
teardownDom(env);
}
}
}
export class ConfirmModalTest extends TestCase {
+23
View File
@@ -16,12 +16,15 @@ import { GameClockTest } from "./game_clock_test.js";
import { LoginManagerTest } from "./login_manager_test.js";
import { RulesTest } from "./rules_test.js";
import { CombatTest } from "./combat_test.js";
import { StackingTest, PillageTest } from "./stacking_test.js";
import { SiegeTest } from "./siege_test.js";
import { AttackOrderTest, ScheduledOrderTest } from "./orders_test.js";
import { GameStateTest } from "./game_state_test.js";
import { GameStateBuildingsTest } from "./game_state_buildings_test.js";
import { GameStateGovernmentTest } from "./game_state_government_test.js";
import { GameStateTechnologyTest } from "./game_state_technology_test.js";
import { DiplomacyStateTest, DiplomacyServerTest } from "./diplomacy_test.js";
import { ConflictsStateTest, ConflictsModalTest } from "./conflicts_test.js";
import { GameServerTest, GameServerOrdersTest, GameServerLoginTest, GameServerLobbyTest } from "./game_server_test.js";
import { ServerStaticTest, ServerLoopTest, ServerProtocolTest } from "./server_test.js";
import { WebSocketProtocolTest } from "./websocket_test.js";
@@ -31,6 +34,14 @@ import { TrainModalTest, BuildingsModalTest, NationModalTest, ConfirmModalTest,
import { GameScreenTest } from "./game_screen_test.js";
import { GameFeedTest } from "./feed_test.js";
import { AppTest } from "./app_test.js";
import {
OrderStateRegressionTest,
EconomyOrderEdgesTest,
AttritionTest,
CasualtyRecordingTest,
CatalogueSplitTest,
TextFormatRegressionTest,
} from "./regression_test.js";
const SUITES = [
HexTest,
@@ -46,13 +57,19 @@ const SUITES = [
LoginManagerTest,
RulesTest,
CombatTest,
StackingTest,
PillageTest,
SiegeTest,
AttackOrderTest,
ScheduledOrderTest,
GameStateTest,
GameStateBuildingsTest,
GameStateGovernmentTest,
GameStateTechnologyTest,
DiplomacyStateTest,
DiplomacyServerTest,
ConflictsStateTest,
ConflictsModalTest,
GameServerTest,
GameServerOrdersTest,
GameServerLoginTest,
@@ -71,6 +88,12 @@ const SUITES = [
GameScreenTest,
GameFeedTest,
AppTest,
OrderStateRegressionTest,
EconomyOrderEdgesTest,
AttritionTest,
CasualtyRecordingTest,
CatalogueSplitTest,
TextFormatRegressionTest,
];
let passed = 0;