Better map generation
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+13
-517
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+25
-13
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user