Added the devlog, fixed the map labels and reworked joining
- Political map country names were invisible because the label SVG had a zero-sized viewport, which some browsers never paint; size it to the world and guard it with a test. - Added a Devlog button showing the ten latest commits, generated into client/js/devlog_data.js by scripts/generate-devlog.js and refreshed by the pre-commit hook. - Worked through FIXME.md: the city button and a unit double-click now open the city, and the stat tooltip escapes the HUD so the unit panel cannot cover it. - The server creates its game at startup and clients only join it; the Create game screen and the configure_game path are gone, and testing is a --testing server flag. - A page on a loopback host signs in as adrien, claims France and gets the free, instant testing buttons, while the server grants free orders only to a loopback socket whose Origin is also loopback.
This commit is contained in:
@@ -28,15 +28,15 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
|
||||
|
||||
### Local testing mode
|
||||
|
||||
- Opening the client from localhost (`http://127.0.0.1:27015/`) adds a
|
||||
**Testing mode** checkbox to the New game screen. When the admin starts such a
|
||||
game, every city's train and build rows gain a second "Free & instant" button.
|
||||
- This is gated on the server, never trusted from the client: `server/server.js`
|
||||
only sets `setup.testing` when the admin's WebSocket peer is loopback
|
||||
(`isLoopback`, handling `127.x`, `::1` and `::ffff:127.0.0.1`), strips a `free`
|
||||
flag from any non-loopback order, and `GameServer` honours it only while
|
||||
`this.testing` is set. `GameState.requestTrain/requestBuild(..., free)` then
|
||||
spawn or raise immediately without cost or queue (placement rules still apply).
|
||||
- The server creates its game at startup; add `--testing` to
|
||||
`node server/server.js --testing` to make it a test game, where every city's
|
||||
train and build rows gain a second "Free & instant" button.
|
||||
- The free shortcut is gated on the server, never trusted from the client:
|
||||
`server/server.js` strips a `free` flag from any non-loopback order
|
||||
(`isLoopback`, handling `127.x`, `::1` and `::ffff:127.0.0.1`), and `GameServer`
|
||||
honours it only while `this.testing` is set.
|
||||
`GameState.requestTrain/requestBuild(..., free)` then spawn or raise
|
||||
immediately without cost or queue (placement rules still apply).
|
||||
- The `testing` flag is shipped in the snapshot so joiners see the same buttons.
|
||||
|
||||
## Gotchas
|
||||
|
||||
+35
-6
@@ -78,7 +78,7 @@ html, body {
|
||||
|
||||
/* ---------------------------------------------------------------- menus -- */
|
||||
|
||||
#screen-menu, #screen-new, #screen-join, #screen-choose {
|
||||
#screen-menu, #screen-join, #screen-choose {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -86,7 +86,7 @@ html, body {
|
||||
radial-gradient(circle at 50% 20%, rgba(60, 90, 140, 0.35), transparent 60%),
|
||||
linear-gradient(180deg, #0b1220, #05070c);
|
||||
}
|
||||
#screen-menu.active, #screen-new.active, #screen-join.active, #screen-choose.active { display: flex; }
|
||||
#screen-menu.active, #screen-join.active, #screen-choose.active { display: flex; }
|
||||
|
||||
/* The main menu sits on one of the game's key-art pieces, dimmed so the card
|
||||
stays legible; the setup, join and choose screens keep the plain gradient
|
||||
@@ -538,8 +538,9 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
layer needs an explicit z-index because those icons give their own children
|
||||
z-index 2-4, which would otherwise paint over this layer. */
|
||||
/* Country names for the far-out political map, drawn as stretched SVG text on
|
||||
their own layer below the city labels. The SVG has no size of its own; the
|
||||
overflow is visible so the rotated text can sit anywhere on the map. */
|
||||
their own layer below the city labels. The SVG viewport is sized to the world
|
||||
by the renderer (a zero-sized one is not painted at all in some browsers) and
|
||||
the overflow is visible so the rotated text can sit anywhere on the map. */
|
||||
/* No z-index on purpose: the layer is first of the icon layers in the DOM, so
|
||||
it paints beneath the selection, paths, targets, units, cities and labels. */
|
||||
.country-label-layer { pointer-events: none; }
|
||||
@@ -547,8 +548,6 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.political-label-svg text {
|
||||
@@ -1036,6 +1035,36 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.credits-entries a:hover { text-decoration: underline; }
|
||||
.credits-detail { display: block; margin-top: 2px; color: var(--muted); font-size: 12px; }
|
||||
|
||||
/* Devlog list opened from the main menu. A commit's header is a button that
|
||||
expands to show the full message, so the list stays compact. */
|
||||
#modal-devlog .modal-card { width: 640px; }
|
||||
.devlog-entry { border-bottom: 1px solid rgba(255, 255, 255, 0.06); }
|
||||
.devlog-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 6px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.devlog-header:hover { background: rgba(255, 255, 255, 0.04); }
|
||||
.devlog-date {
|
||||
flex: 0 0 auto;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.devlog-subject { flex: 1 1 auto; font-size: 14px; font-weight: 600; }
|
||||
.devlog-toggle { flex: 0 0 auto; color: var(--accent); font-weight: 700; }
|
||||
.devlog-body { padding: 0 6px 12px; color: var(--muted); font-size: 13px; }
|
||||
.devlog-message { margin: 0 0 6px; white-space: pre-wrap; }
|
||||
.devlog-hash { font-family: monospace; font-size: 11px; }
|
||||
|
||||
/* Capability report: one row per WebGL/WASM feature with a status badge. */
|
||||
.capability-row {
|
||||
display: flex;
|
||||
|
||||
+14
-35
@@ -14,10 +14,10 @@
|
||||
<div class="menu-card">
|
||||
<h1>Battle for 'Tismo</h1>
|
||||
<p class="tagline">A persistent, continuous-time strategy game.</p>
|
||||
<button id="menu-create" class="aero big">Create game</button>
|
||||
<button id="menu-join" class="aero big">Join game</button>
|
||||
<div class="row">
|
||||
<button id="menu-credits" class="aero">Credits</button>
|
||||
<button id="menu-devlog" class="aero">Devlog</button>
|
||||
<button id="menu-audio" class="aero">Sound: On</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,40 +32,16 @@
|
||||
<div id="credits-list" class="modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- New game setup -->
|
||||
<section id="screen-new" class="screen">
|
||||
<div class="menu-card">
|
||||
<h1>New game</h1>
|
||||
<label class="field">
|
||||
<span>Your civilisation</span>
|
||||
<ul id="new-civs" class="civ-list"></ul>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Nations in the world</span>
|
||||
<input id="new-count" type="number" min="1" max="9" value="9" />
|
||||
</label>
|
||||
<label class="field hidden" id="new-testing-row">
|
||||
<span>Testing mode</span>
|
||||
<span class="inline">
|
||||
<input id="new-testing" type="checkbox" />
|
||||
Free & instant construction and training
|
||||
</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Player name</span>
|
||||
<input id="new-name" type="text" autocomplete="off" value="adrien" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<input id="new-password" type="password" value="admin" />
|
||||
</label>
|
||||
<div class="row">
|
||||
<button id="new-back" class="aero">Back</button>
|
||||
<button id="new-start" class="aero primary">Start game</button>
|
||||
<!-- Devlog: the latest commits, opened from the main menu -->
|
||||
<div id="modal-devlog" class="modal hidden">
|
||||
<div class="modal-card">
|
||||
<div class="modal-head">
|
||||
<span>Devlog</span>
|
||||
<button class="modal-close aero">✕</button>
|
||||
</div>
|
||||
<div id="devlog-list" class="modal-body"></div>
|
||||
</div>
|
||||
<p id="new-status" class="status"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -91,7 +67,6 @@
|
||||
</label>
|
||||
<div class="row">
|
||||
<button id="join-back" class="aero">Back</button>
|
||||
<button id="join-create" class="aero">Create game</button>
|
||||
<button id="join-go" class="aero primary">Join</button>
|
||||
</div>
|
||||
<p id="join-status" class="status"></p>
|
||||
@@ -180,9 +155,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="stat-tooltip" class="tooltip hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- The stat tooltip sits outside #hud on purpose: the unit panel lives in
|
||||
#left-dock, a sibling with its own z-index, so a tooltip trapped in
|
||||
the HUD's stacking context would paint behind it. -->
|
||||
<div id="stat-tooltip" class="tooltip hidden"></div>
|
||||
|
||||
<!-- Right-click menu listing the units sharing a tile -->
|
||||
<div id="stack-menu" class="stack-menu hidden"></div>
|
||||
|
||||
|
||||
+89
-118
@@ -1,38 +1,39 @@
|
||||
// Application bootstrap: screen routing plus the session state that the new
|
||||
// game and join screens fill in (the menu/new-game/join flow).
|
||||
// Application bootstrap: the main menu and the join flow. A page served from
|
||||
// the local machine signs in as the developer and claims France without showing
|
||||
// the join form, and the server grants it the testing shortcuts.
|
||||
|
||||
import { Network } from "./net.js";
|
||||
import { GameScreen } from "./game_screen.js";
|
||||
import { bootLog } from "./loading.js";
|
||||
import { audio } from "./audio.js";
|
||||
import { renderCredits } from "./credits.js";
|
||||
import { DEVLOG } from "./devlog_data.js";
|
||||
import { renderDevlog } from "./devlog.js";
|
||||
import { chooseMenuBackground } from "./menu_background.js";
|
||||
import { CIVILISATIONS } from "../../shared/data.js";
|
||||
|
||||
const network = new Network();
|
||||
// A page on the local machine signs in automatically. The server independently
|
||||
// checks the connection's origin before honouring any free order.
|
||||
function isLoopbackHost(hostname) {
|
||||
const host = String(hostname || "").replace(/^\[|\]$/g, "");
|
||||
return host === "localhost" || host === "::1" || host.startsWith("127.");
|
||||
}
|
||||
const isLocalhost = isLoopbackHost(window.location.hostname);
|
||||
const LOCAL_NAME = "adrien";
|
||||
const LOCAL_PASSWORD = "admin";
|
||||
const LOCAL_CIV = "france";
|
||||
const config = {
|
||||
playerName: "",
|
||||
password: "",
|
||||
playerCivId: CIVILISATIONS[0].id,
|
||||
civilisations: [],
|
||||
seed: 0,
|
||||
// Set when the creator ticks testing mode on a locally served page.
|
||||
testing: false,
|
||||
// Shows the free, instant testing buttons in the city and tile panels.
|
||||
testing: isLocalhost,
|
||||
};
|
||||
|
||||
// Testing tools are only offered to a page served from the local machine; the
|
||||
// server likewise refuses to enable them for a remote connection.
|
||||
const isLocalhost = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(
|
||||
window.location.hostname
|
||||
);
|
||||
$("#new-testing-row").toggleClass("hidden", !isLocalhost);
|
||||
|
||||
let gameScreen = null;
|
||||
let joining = false;
|
||||
let mode = "join";
|
||||
let games = [];
|
||||
let selectedGame = -1;
|
||||
let selectedCivId = CIVILISATIONS[0].id;
|
||||
|
||||
function showScreen(id) {
|
||||
$(".screen").removeClass("active");
|
||||
@@ -70,86 +71,33 @@ renderCredits($("#credits-list"));
|
||||
const $creditsModal = $("#modal-credits");
|
||||
$("#menu-credits").on("click", () => $creditsModal.removeClass("hidden"));
|
||||
$creditsModal.find(".modal-close").on("click", () => $creditsModal.addClass("hidden"));
|
||||
|
||||
// ------------------------------------------------------------- devlog -----
|
||||
|
||||
renderDevlog($("#devlog-list"), DEVLOG);
|
||||
const $devlogModal = $("#modal-devlog");
|
||||
$("#menu-devlog").on("click", () => $devlogModal.removeClass("hidden"));
|
||||
$devlogModal.find(".modal-close").on("click", () => $devlogModal.addClass("hidden"));
|
||||
|
||||
$(document).on("keydown", (event) => {
|
||||
if (event.key === "Escape") $creditsModal.addClass("hidden");
|
||||
if (event.key !== "Escape") return;
|
||||
$creditsModal.addClass("hidden");
|
||||
$devlogModal.addClass("hidden");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- menu ------
|
||||
|
||||
$("#menu-create").on("click", () => showScreen("screen-new"));
|
||||
// The server creates its game at startup, so the menu only has Join. A page on
|
||||
// the local machine skips the form and signs in directly.
|
||||
$("#menu-join").on("click", () => {
|
||||
if (isLocalhost) {
|
||||
autoJoinLocal();
|
||||
return;
|
||||
}
|
||||
showScreen("screen-join");
|
||||
refreshGames();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------- new game ------
|
||||
|
||||
const $civSelect = $("#new-civs");
|
||||
// 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;
|
||||
$civSelect.children().removeClass("selected");
|
||||
$item.addClass("selected");
|
||||
});
|
||||
$civSelect.append($item);
|
||||
}
|
||||
$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);
|
||||
|
||||
function civOption(civ) {
|
||||
return $("<li class='civ-option'></li>")
|
||||
.attr("data-civ-id", civ.id)
|
||||
.append($("<img alt=''>").attr("src", `assets/flags/${civ.flag}`))
|
||||
.append($("<span></span>").text(civ.name));
|
||||
}
|
||||
|
||||
$("#new-back").on("click", () => {
|
||||
network.close();
|
||||
showScreen("screen-menu");
|
||||
});
|
||||
|
||||
$("#new-start").on("click", () => {
|
||||
const civId = selectedCivId;
|
||||
const count = Math.max(1, Math.min(CIVILISATIONS.length, Number($("#new-count").val()) || 1));
|
||||
const name = $("#new-name").val().trim();
|
||||
const password = $("#new-password").val();
|
||||
if (!name || !password) {
|
||||
status("new-status", "Enter a name and a password.");
|
||||
return;
|
||||
}
|
||||
const player = CIVILISATIONS.find((c) => c.id === civId) || CIVILISATIONS[0];
|
||||
const list = [player];
|
||||
for (const civ of CIVILISATIONS) {
|
||||
if (list.length >= count) break;
|
||||
if (civ.id !== player.id) list.push(civ);
|
||||
}
|
||||
config.playerName = name;
|
||||
config.password = password;
|
||||
config.playerCivId = player.id;
|
||||
config.civilisations = list;
|
||||
config.seed = 0;
|
||||
config.testing = isLocalhost && $("#new-testing").is(":checked");
|
||||
mode = "create";
|
||||
setNewEnabled(false);
|
||||
status("new-status", "Starting the game...");
|
||||
network.createGame(name, password).catch(() => {
|
||||
setNewEnabled(true);
|
||||
status("new-status", "Could not connect to the game server.");
|
||||
});
|
||||
});
|
||||
|
||||
function setNewEnabled(enabled) {
|
||||
$("#new-start, #new-back").prop("disabled", !enabled);
|
||||
$("#new-count, #new-name, #new-password, #new-testing").prop("disabled", !enabled);
|
||||
$("#new-civs").css("pointer-events", enabled ? "" : "none");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- join ------
|
||||
|
||||
$("#join-server").val(network.defaultServerUrl());
|
||||
@@ -159,7 +107,6 @@ $("#join-back").on("click", () => {
|
||||
network.close();
|
||||
showScreen("screen-menu");
|
||||
});
|
||||
$("#join-create").on("click", () => showScreen("screen-new"));
|
||||
$("#join-go").on("click", joinSelected);
|
||||
$("#choose-back").on("click", () => {
|
||||
network.close();
|
||||
@@ -186,7 +133,7 @@ function refreshGames(message = "") {
|
||||
|
||||
function setJoinEnabled(enabled) {
|
||||
$("#join-server, #join-name, #join-password").prop("disabled", !enabled);
|
||||
$("#join-refresh, #join-go, #join-create").prop("disabled", !enabled);
|
||||
$("#join-refresh, #join-go").prop("disabled", !enabled);
|
||||
}
|
||||
|
||||
function renderGames(list) {
|
||||
@@ -228,9 +175,6 @@ function joinSelected() {
|
||||
}
|
||||
config.playerName = name;
|
||||
config.password = password;
|
||||
config.playerCivId = null;
|
||||
config.civilisations = [];
|
||||
mode = "join";
|
||||
joining = true;
|
||||
setJoinEnabled(false);
|
||||
status("join-status", "Connecting...");
|
||||
@@ -240,24 +184,50 @@ function joinSelected() {
|
||||
});
|
||||
}
|
||||
|
||||
// The local fast path: no server list, no login form and no nation picker. The
|
||||
// server still decides what a local peer is allowed to do.
|
||||
function autoJoinLocal() {
|
||||
config.playerName = LOCAL_NAME;
|
||||
config.password = LOCAL_PASSWORD;
|
||||
joining = true;
|
||||
status("join-status", "Connecting...");
|
||||
network.joinGame(network.defaultServerUrl(), LOCAL_NAME, LOCAL_PASSWORD).catch(() => {
|
||||
fallBackToJoin("Could not reach the game server.");
|
||||
});
|
||||
}
|
||||
|
||||
// The local fast path failed (a name claimed with a different password, a busy
|
||||
// server): show the ordinary join screen so it can be fixed by hand.
|
||||
function fallBackToJoin(message) {
|
||||
config.testing = false;
|
||||
joining = false;
|
||||
showScreen("screen-join");
|
||||
refreshGames(message);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ sessions ----
|
||||
|
||||
network.on("session_started", () => {
|
||||
if (mode === "create") {
|
||||
status("new-status", "");
|
||||
startGameScreen(true);
|
||||
} else {
|
||||
// The server follows up with the unclaimed civilisations to pick from.
|
||||
status("join-status", "");
|
||||
}
|
||||
// The server follows up with the unclaimed civilisations to pick from.
|
||||
status("join-status", "");
|
||||
});
|
||||
|
||||
// Offer the unclaimed civilisations; picking one claims it for this player.
|
||||
// Offer the unclaimed civilisations; picking one claims it for this player. A
|
||||
// local page claims France (or the first free nation) without showing a picker.
|
||||
network.on("choose_civ_received", (civs) => {
|
||||
if (mode !== "join") return;
|
||||
if (config.testing) {
|
||||
const pick = (civs || []).find((c) => c.id === LOCAL_CIV) || (civs || [])[0];
|
||||
if (pick) {
|
||||
network.claimCiv(pick.index);
|
||||
return;
|
||||
}
|
||||
status("choose-status", "No unclaimed nation left — joining as a spectator.");
|
||||
startGameScreen();
|
||||
return;
|
||||
}
|
||||
if (!civs || civs.length === 0) {
|
||||
status("choose-status", "No unclaimed nation left — joining as a spectator.");
|
||||
startGameScreen(false);
|
||||
startGameScreen();
|
||||
return;
|
||||
}
|
||||
renderCivChoices(civs);
|
||||
@@ -265,12 +235,19 @@ network.on("choose_civ_received", (civs) => {
|
||||
});
|
||||
|
||||
network.on("civ_assigned", () => {
|
||||
if (mode === "join" && gameScreen === null) {
|
||||
if (gameScreen === null) {
|
||||
status("choose-status", "");
|
||||
startGameScreen(false);
|
||||
startGameScreen();
|
||||
}
|
||||
});
|
||||
|
||||
function civOption(civ) {
|
||||
return $("<li class='civ-option'></li>")
|
||||
.attr("data-civ-id", civ.id)
|
||||
.append($("<img alt=''>").attr("src", `assets/flags/${civ.flag}`))
|
||||
.append($("<span></span>").text(civ.name));
|
||||
}
|
||||
|
||||
function renderCivChoices(civs) {
|
||||
const $list = $("#choose-civs").empty();
|
||||
for (const entry of civs) {
|
||||
@@ -288,14 +265,13 @@ function renderCivChoices(civs) {
|
||||
}
|
||||
|
||||
network.on("session_failed", (message) => {
|
||||
if (mode === "create") {
|
||||
setNewEnabled(true);
|
||||
status("new-status", message);
|
||||
} else {
|
||||
setJoinEnabled(true);
|
||||
status("join-status", message);
|
||||
if (joining) refreshGames(message);
|
||||
if (isLocalhost && config.testing) {
|
||||
fallBackToJoin(message);
|
||||
return;
|
||||
}
|
||||
setJoinEnabled(true);
|
||||
status("join-status", message);
|
||||
if (joining) refreshGames(message);
|
||||
});
|
||||
|
||||
network.on("game_list_received", (list) => renderGames(list));
|
||||
@@ -306,7 +282,7 @@ network.on("server_disconnected", () => {
|
||||
$("#disconnect-banner").text("Disconnected from the server.").removeClass("hidden");
|
||||
});
|
||||
|
||||
function startGameScreen(isAdmin) {
|
||||
function startGameScreen() {
|
||||
if (gameScreen) {
|
||||
gameScreen.leave();
|
||||
$(window).off("keydown.game");
|
||||
@@ -320,13 +296,8 @@ function startGameScreen(isAdmin) {
|
||||
showScreen("screen-game");
|
||||
gameScreen.enter();
|
||||
bootLog.step("Opened the game screen");
|
||||
if (isAdmin) {
|
||||
gameScreen.sendSetup();
|
||||
bootLog.step("Requested the world from the server");
|
||||
} else {
|
||||
bootLog.step("Waiting for the world from the server");
|
||||
}
|
||||
bootLog.step("Waiting for the world from the server");
|
||||
}
|
||||
|
||||
// Reconnect notice clears once the player navigates back to the menu.
|
||||
$("#menu-create, #menu-join").on("click", () => $("#disconnect-banner").addClass("hidden"));
|
||||
$("#menu-join").on("click", () => $("#disconnect-banner").addClass("hidden"));
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// The devlog opened from the main menu: the most recent repository commits,
|
||||
// generated into ./devlog_data.js by scripts/generate-devlog.js. Every commit
|
||||
// shows its date and header; clicking a header expands the full message. The
|
||||
// renderer takes its list as an argument so it stays free of the generated
|
||||
// data and can be tested with a fixture.
|
||||
|
||||
const MONTHS = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
// `2026-09-21` -> `21 Sep 2026`, so the date reads without consulting a locale
|
||||
// (which would differ between the server and a test runner).
|
||||
export function formatCommitDate(iso) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso || "");
|
||||
if (!match) return iso || "";
|
||||
const [, year, month, day] = match;
|
||||
const name = MONTHS[Number(month) - 1];
|
||||
return name ? `${Number(day)} ${name} ${year}` : iso;
|
||||
}
|
||||
|
||||
// Renders one collapsible entry per commit into the modal's list element.
|
||||
export function renderDevlog($list, commits) {
|
||||
$list.empty();
|
||||
for (const commit of commits || []) {
|
||||
const $entry = $("<div class='devlog-entry'></div>");
|
||||
const $header = $("<button type='button' class='devlog-header'></button>");
|
||||
$header.attr("aria-expanded", "false");
|
||||
$header.append($("<span class='devlog-date'></span>").text(formatCommitDate(commit.date)));
|
||||
$header.append($("<span class='devlog-subject'></span>").text(commit.subject || ""));
|
||||
$header.append($("<span class='devlog-toggle' aria-hidden='true'></span>").text("+"));
|
||||
const $body = $("<div class='devlog-body hidden'></div>");
|
||||
if (commit.body) {
|
||||
$body.append($("<p class='devlog-message'></p>").text(commit.body));
|
||||
}
|
||||
$body.append($("<span class='devlog-hash'></span>").text(commit.hash || ""));
|
||||
$header.on("click", () => {
|
||||
const opening = $body.hasClass("hidden");
|
||||
$body.toggleClass("hidden", !opening);
|
||||
$header.toggleClass("open", opening);
|
||||
$header.attr("aria-expanded", opening ? "true" : "false");
|
||||
$header.find(".devlog-toggle").text(opening ? "-" : "+");
|
||||
});
|
||||
$entry.append($header);
|
||||
$entry.append($body);
|
||||
$list.append($entry);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Generated by scripts/generate-devlog.js from `git log`; do not edit.
|
||||
// The pre-commit hook refreshes it so the main menu shows the latest commits.
|
||||
export const DEVLOG = [
|
||||
{
|
||||
"hash": "0a2dbb2",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Worked through the FIXME list",
|
||||
"body": "- The tile panel's City button now opens the city of a selected unit standing on it; double-clicking that unit opens the city too.\n- The stat tooltip moved out of #hud so the unit panel, a sibling with its own z-index, can no longer cover it.\n- The server creates its game at startup and clients only join it: the Create game button and its setup screen are gone, along with the configure_game wire path. Testing mode is a --testing server flag."
|
||||
},
|
||||
{
|
||||
"hash": "a97d2db",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Added a devlog to the main menu",
|
||||
"body": "The Devlog button opens the ten latest commits, each showing its date and header and expanding to the full message on click. Since the client has no build step, scripts/generate-devlog.js writes the list into client/js/devlog_data.js, and the pre-commit hook refreshes and stages it so the shipped client stays current."
|
||||
},
|
||||
{
|
||||
"hash": "ec1fbb9",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Made the political country names visible",
|
||||
"body": "An outer SVG clips to its viewport, and a zero-sized one is not painted at all in some browsers, so the country-name layer's width: 0; height: 0 left every name laid out but unseen. Size the label SVG to the world and guard it with a test."
|
||||
},
|
||||
{
|
||||
"hash": "bc18598",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Grew continents from seeded domes and gave every small island a nation",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"hash": "75bdddd",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Named the actors behind government news",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"hash": "2005cac",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Added Texas and Alabama",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"hash": "f2b52fc",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Added sound and credits, bombardment audio, and hid foreign production",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"hash": "a33dfe8",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Chose one of two main-menu backgrounds at launch",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"hash": "a6b49a5",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Tracked automatic refocus for repeatable technologies",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"hash": "36e1e40",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Document the reconstructed feature roadmap",
|
||||
"body": ""
|
||||
}
|
||||
];
|
||||
@@ -25,6 +25,9 @@ export class GameScreen {
|
||||
this.$screen = $("#screen-game");
|
||||
this.snapshot = null;
|
||||
this.localCiv = 0;
|
||||
// Whether the free, instant testing buttons are offered: true for a test
|
||||
// game and for a page served from the local machine.
|
||||
this.testing = !!(config && config.testing);
|
||||
this.civilisations = [];
|
||||
this.protoUnits = PROTO_UNITS;
|
||||
this.selectedUnitIds = [];
|
||||
@@ -151,22 +154,12 @@ export class GameScreen {
|
||||
this.network.sendOrder(order);
|
||||
}
|
||||
|
||||
sendSetup() {
|
||||
const ids = this.config.civilisations.map((c) => c.id);
|
||||
const playerIndex = Math.max(0, ids.indexOf(this.config.playerCivId));
|
||||
this.network.sendGameSetup({
|
||||
seed: this.config.seed || 0,
|
||||
player_civ: playerIndex,
|
||||
civilisations: ids,
|
||||
testing: !!this.config.testing,
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- snapshots ---
|
||||
|
||||
onState(state) {
|
||||
this.snapshot = state;
|
||||
this.localCiv = state.viewer;
|
||||
this.testing = !!(state.testing || (this.config && this.config.testing));
|
||||
this.civilisations = state.civs.map(
|
||||
(c) => CIVILISATIONS.find((full) => full.id === c.id) || { id: c.id, name: c.name, primaryColour: "#888" }
|
||||
);
|
||||
|
||||
@@ -879,6 +879,9 @@ export const panelMethods = {
|
||||
(c) => c.coords[0] === coords.x && c.coords[1] === coords.y
|
||||
);
|
||||
if (city) {
|
||||
// Keep the city id selected as well as the units, so the city panel's
|
||||
// button still knows which city to open.
|
||||
this.selectedCityId = city.id;
|
||||
this._showCity(city, { keepUnits: true });
|
||||
return;
|
||||
}
|
||||
@@ -1034,7 +1037,7 @@ export const panelMethods = {
|
||||
$row.empty();
|
||||
if (!buildable) return;
|
||||
const budget = (this.snapshot.viewerStats || {}).budget || 0;
|
||||
const testing = !!(this.snapshot && this.snapshot.testing);
|
||||
const testing = !!this.testing;
|
||||
for (const improvement of TRANSPORT_IMPROVEMENTS) {
|
||||
const isCurrent = current === improvement.id;
|
||||
const affordable = budget >= improvement.buildCost;
|
||||
@@ -1476,7 +1479,7 @@ export const panelMethods = {
|
||||
budget: stats.budget || 0,
|
||||
gdp: stats.gdp || 0,
|
||||
coastal: !!city.coastal,
|
||||
testing: !!(this.snapshot && this.snapshot.testing),
|
||||
testing: !!this.testing,
|
||||
trainable: this._trainableUnits(city),
|
||||
buildings: BUILDINGS,
|
||||
levels: this._cityBuildings.get(city.id) || {},
|
||||
|
||||
@@ -593,6 +593,15 @@ export const entityMethods = {
|
||||
event.stopPropagation();
|
||||
this.onUnitSelected(data.id);
|
||||
});
|
||||
view.addEventListener("dblclick", (event) => {
|
||||
// A unit standing on one of our cities yields to it: double-clicking the
|
||||
// unit opens the city, exactly as double-clicking the city icon would.
|
||||
const current = this._unitData.get(data.id) || data;
|
||||
const city = this.cityAt({ x: current.coords[0], y: current.coords[1] });
|
||||
if (!city) return;
|
||||
event.stopPropagation();
|
||||
this.onCityOpened(city.id);
|
||||
});
|
||||
this.$entities[0].appendChild(view);
|
||||
return view;
|
||||
},
|
||||
|
||||
@@ -252,6 +252,12 @@ export const politicalMethods = {
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
const svg = doc.createElementNS(ns, "svg");
|
||||
svg.setAttribute("class", "political-label-svg");
|
||||
// An outer SVG clips to its viewport, and a zero-sized one is not painted
|
||||
// at all in some browsers, so the viewport is given the world's size (the
|
||||
// labels' coordinate space). `overflow: visible` still shows the wrapped
|
||||
// copies that fall outside it.
|
||||
svg.setAttribute("width", String(this._worldWidth || 1));
|
||||
svg.setAttribute("height", String(this._worldHeight || 1));
|
||||
// Attach before measuring: a detached text node has no layout to measure.
|
||||
$layer[0].appendChild(svg);
|
||||
for (const label of labels) {
|
||||
|
||||
@@ -117,22 +117,12 @@ export class Network {
|
||||
});
|
||||
}
|
||||
|
||||
createGame(name, password) {
|
||||
return this.connect(this.defaultServerUrl()).then(() => {
|
||||
this._send({ t: "login", name, password });
|
||||
});
|
||||
}
|
||||
|
||||
joinGame(url, name, password) {
|
||||
return this.connect(url).then(() => {
|
||||
this._send({ t: "login", name, password });
|
||||
});
|
||||
}
|
||||
|
||||
sendGameSetup(setup) {
|
||||
this._send({ t: "configure_game", setup });
|
||||
}
|
||||
|
||||
claimCiv(index) {
|
||||
this._send({ t: "claim_civ", civ: index });
|
||||
}
|
||||
@@ -197,9 +187,6 @@ export class Network {
|
||||
this._emit("session_failed", this._loginFailure);
|
||||
this.close();
|
||||
break;
|
||||
case "game_configured":
|
||||
this._emit("game_configured");
|
||||
break;
|
||||
case "choose_civ":
|
||||
this._emit("choose_civ_received", message.civs || []);
|
||||
break;
|
||||
|
||||
+17
-2
@@ -1,7 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Pre-commit hook: run the Battle for 'Tismo test suite and refuse the
|
||||
# commit when anything fails. Installed by scripts/install-git-hooks.sh, which
|
||||
# points the repository at this directory via core.hooksPath.
|
||||
# commit when anything fails, then refresh the generated devlog the main menu
|
||||
# shows. Installed by scripts/install-git-hooks.sh, which points the repository
|
||||
# at this directory via core.hooksPath.
|
||||
#
|
||||
# Skip it once with: git commit --no-verify
|
||||
|
||||
@@ -26,3 +27,17 @@ else
|
||||
echo "pre-commit: install Node.js, or commit with --no-verify to skip the tests." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Refresh the devlog the main menu shows and stage it, so the committed client
|
||||
# always carries the latest commits. A failure here must not block the commit.
|
||||
echo "pre-commit: refreshing the devlog..."
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
node scripts/generate-devlog.js
|
||||
elif command -v nodejs >/dev/null 2>&1; then
|
||||
nodejs scripts/generate-devlog.js
|
||||
else
|
||||
nix-shell -p nodejs --run "node scripts/generate-devlog.js"
|
||||
fi || echo "pre-commit: skipped the devlog refresh." >&2
|
||||
if [ -f client/js/devlog_data.js ]; then
|
||||
git add -- client/js/devlog_data.js
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
// Regenerates the devlog data module from the last few commits. The client has
|
||||
// no build step, so the list the main menu shows has to live in the tree; this
|
||||
// script keeps it current and is run by the pre-commit hook (and by hand with
|
||||
// `node scripts/generate-devlog.js`).
|
||||
//
|
||||
// The file is written only when it changes, so an unchanged history does not
|
||||
// touch the working tree.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const OUT = join(ROOT, "client", "js", "devlog_data.js");
|
||||
// How many commits the Devlog shows.
|
||||
const COUNT = 10;
|
||||
// Field separator inside a commit record and the record separator between
|
||||
// commits. Neither byte is legal in a git commit message.
|
||||
const FIELD = "\x1f";
|
||||
const RECORD = "\x1e";
|
||||
|
||||
function lastCommits() {
|
||||
const pretty = `--pretty=format:%h${FIELD}%cs${FIELD}%s${FIELD}%b${RECORD}`;
|
||||
const out = execFileSync("git", ["log", `-n${COUNT}`, pretty], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
});
|
||||
return out
|
||||
.split(RECORD)
|
||||
// `git log` puts a newline between records; drop the leading ones.
|
||||
.map((record) => record.replace(/^\n+/, ""))
|
||||
.filter((record) => record.trim().length > 0)
|
||||
.map((record) => {
|
||||
const [hash, date, subject, body = ""] = record.split(FIELD);
|
||||
return {
|
||||
hash: hash.trim(),
|
||||
date: date.trim(),
|
||||
subject: subject.trim(),
|
||||
body: body.trim(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function source(commits) {
|
||||
return (
|
||||
"// Generated by scripts/generate-devlog.js from `git log`; do not edit.\n" +
|
||||
"// The pre-commit hook refreshes it so the main menu shows the latest commits.\n" +
|
||||
`export const DEVLOG = ${JSON.stringify(commits, null, 2)};\n`
|
||||
);
|
||||
}
|
||||
|
||||
let commits;
|
||||
try {
|
||||
commits = lastCommits();
|
||||
} catch (error) {
|
||||
console.warn(`generate-devlog: could not read the git history (${error.message})`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const next = source(commits);
|
||||
let previous = "";
|
||||
try {
|
||||
previous = readFileSync(OUT, "utf8");
|
||||
} catch {
|
||||
// First run: the file does not exist yet.
|
||||
}
|
||||
if (next === previous) {
|
||||
console.log(`generate-devlog: ${OUT} is already up to date (${commits.length} commits)`);
|
||||
} else {
|
||||
writeFileSync(OUT, next);
|
||||
console.log(`generate-devlog: wrote ${commits.length} commits to ${OUT}`);
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
# ./scripts/install-git-hooks.sh
|
||||
#
|
||||
# After this, every `git commit` runs the test suite first and aborts if it
|
||||
# fails. Skip once with `git commit --no-verify`.
|
||||
# fails, then refreshes the generated devlog (client/js/devlog_data.js) and
|
||||
# stages it. Skip once with `git commit --no-verify`.
|
||||
#
|
||||
# Uninstall with:
|
||||
# git config --unset core.hooksPath
|
||||
@@ -39,4 +40,5 @@ if ! command -v node >/dev/null 2>&1 && command -v nix-shell >/dev/null 2>&1; th
|
||||
echo " note: 'node' is not on PATH; the hook will fall back to 'nix-shell -p nodejs'."
|
||||
fi
|
||||
echo ""
|
||||
echo "Every commit now runs the test suite. Skip once with: git commit --no-verify"
|
||||
echo "Every commit now runs the test suite and refreshes the devlog."
|
||||
echo "Skip once with: git commit --no-verify"
|
||||
|
||||
+25
-9
@@ -37,6 +37,9 @@ export class GameServer extends EventEmitter {
|
||||
// True for a local test game, where free, instant orders are allowed.
|
||||
this.testing = false;
|
||||
this.peerCiv = new Map();
|
||||
// Peers whose page runs on the local machine. They may use the free, instant
|
||||
// testing orders even when the game as a whole is not a test game.
|
||||
this._localPeers = new Set();
|
||||
this.login = new LoginManager();
|
||||
this.adminName = "";
|
||||
this.peerNames = new Map();
|
||||
@@ -176,13 +179,13 @@ export class GameServer extends EventEmitter {
|
||||
case "pillage":
|
||||
return this._handlePillage(civ, order);
|
||||
case "train":
|
||||
return this._handleTrain(civ, order);
|
||||
return this._handleTrain(peerId, civ, order);
|
||||
case "cancel_train":
|
||||
return this._handleCancelTrain(civ, order);
|
||||
case "build":
|
||||
return this._handleBuild(civ, order);
|
||||
return this._handleBuild(peerId, civ, order);
|
||||
case "build_improvement":
|
||||
return this._handleBuildImprovement(civ, order);
|
||||
return this._handleBuildImprovement(peerId, civ, order);
|
||||
case "remove_improvement":
|
||||
return this._handleRemoveImprovement(civ, order);
|
||||
case "dig_trench":
|
||||
@@ -277,10 +280,10 @@ export class GameServer extends EventEmitter {
|
||||
return this.state.requestPillage(units);
|
||||
}
|
||||
|
||||
_handleTrain(civ, order) {
|
||||
_handleTrain(peerId, civ, order) {
|
||||
const city = this.state.findCity(Number(order.city));
|
||||
if (!city || city.civ !== civ) return false;
|
||||
const free = !!order.free && this.testing;
|
||||
const free = !!order.free && this._freeAllowed(peerId);
|
||||
return this.state.requestTrain(city.id, Number(order.proto), free);
|
||||
}
|
||||
|
||||
@@ -290,10 +293,10 @@ export class GameServer extends EventEmitter {
|
||||
return this.state.cancelTrain(city.id, Number(order.index));
|
||||
}
|
||||
|
||||
_handleBuild(civ, order) {
|
||||
_handleBuild(peerId, civ, order) {
|
||||
const city = this.state.findCity(Number(order.city));
|
||||
if (!city || city.civ !== civ) return false;
|
||||
const free = !!order.free && this.testing;
|
||||
const free = !!order.free && this._freeAllowed(peerId);
|
||||
return this.state.requestBuild(city.id, Number(order.building), free);
|
||||
}
|
||||
|
||||
@@ -305,8 +308,8 @@ export class GameServer extends EventEmitter {
|
||||
|
||||
// Lays a road or railway on a tile the player controls. GameState enforces
|
||||
// ownership, terrain and cost; the testing shortcut is only honoured locally.
|
||||
_handleBuildImprovement(civ, order) {
|
||||
const free = !!order.free && this.testing;
|
||||
_handleBuildImprovement(peerId, civ, order) {
|
||||
const free = !!order.free && this._freeAllowed(peerId);
|
||||
return this.state.requestBuildImprovement(
|
||||
civ,
|
||||
coordsOf(order.coords),
|
||||
@@ -396,6 +399,18 @@ export class GameServer extends EventEmitter {
|
||||
return this.state.requestRevokePolicy(civ, Number(order.policy));
|
||||
}
|
||||
|
||||
// Marks a peer as connected from a page on the local machine. Local peers may
|
||||
// use the free, instant testing orders even in a normal game.
|
||||
setPeerLocal(peerId, local) {
|
||||
if (local) this._localPeers.add(peerId);
|
||||
else this._localPeers.delete(peerId);
|
||||
}
|
||||
|
||||
// Whether a peer may spend the testing shortcut.
|
||||
_freeAllowed(peerId) {
|
||||
return this.testing || this._localPeers.has(peerId);
|
||||
}
|
||||
|
||||
onPeerJoined(peerId, playerName = "") {
|
||||
// A fresh connection must receive a full snapshot, never a delta against a
|
||||
// previous peer that happened to reuse the id.
|
||||
@@ -421,6 +436,7 @@ export class GameServer extends EventEmitter {
|
||||
onPeerLeft(peerId) {
|
||||
this.peerCiv.delete(peerId);
|
||||
this.peerNames.delete(peerId);
|
||||
this._localPeers.delete(peerId);
|
||||
// A departed player's queued orders must not land after they are gone.
|
||||
if (this._orderQueue.length > 0) {
|
||||
this._orderQueue = this._orderQueue.filter((entry) => entry.peerId !== peerId);
|
||||
|
||||
+40
-17
@@ -84,9 +84,33 @@ export function isLoopback(address) {
|
||||
return value === "::1" || value === "localhost" || value.startsWith("127.");
|
||||
}
|
||||
|
||||
// Whether a connection came from a page served on the local machine. The socket
|
||||
// has to be loopback and the browser's Origin header (which page scripts cannot
|
||||
// forge) has to name a loopback host, so a proxied remote client whose socket
|
||||
// looks local is still treated as remote.
|
||||
export function isLocalRequest(remoteAddress, origin) {
|
||||
if (!isLoopback(remoteAddress)) return false;
|
||||
let host;
|
||||
try {
|
||||
host = new URL(String(origin)).hostname;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return isLoopback(host.replace(/^\[|\]$/g, ""));
|
||||
}
|
||||
|
||||
// Builds the whole server without listening. `listen()` resolves once the port
|
||||
// is bound and yields the bound address.
|
||||
export function startServer({ port = 27015, bind = "127.0.0.1", log = console.log } = {}) {
|
||||
// is bound and yields the bound address. A game is configured here, before any
|
||||
// player connects: the server owns one persistent world and clients only join
|
||||
// it (`seed`, `testing` and `civilisations` exist for tests and operators).
|
||||
export function startServer({
|
||||
port = 27015,
|
||||
bind = "127.0.0.1",
|
||||
log = console.log,
|
||||
seed = 0,
|
||||
testing = false,
|
||||
civilisations = [],
|
||||
} = {}) {
|
||||
const httpServer = createServer(serveStatic);
|
||||
const wsServer = new WebSocketServer(httpServer, { path: "/ws" });
|
||||
const connections = new Map();
|
||||
@@ -102,6 +126,7 @@ export function startServer({ port = 27015, bind = "127.0.0.1", log = console.lo
|
||||
};
|
||||
|
||||
const gameServer = new GameServer(network);
|
||||
gameServer.configureGame({ seed, player_civ: 0, civilisations, testing });
|
||||
|
||||
function broadcastPlayers() {
|
||||
const players = {};
|
||||
@@ -140,26 +165,16 @@ export function startServer({ port = 27015, bind = "127.0.0.1", log = console.lo
|
||||
gameServer.onPeerJoined(connection.id, result.name);
|
||||
break;
|
||||
}
|
||||
case "configure_game": {
|
||||
if (connection.name !== gameServer.adminName) return;
|
||||
// Testing mode is only offered to a game launched from the local
|
||||
// machine; a remote host cannot turn it on.
|
||||
const setup = message.setup || {};
|
||||
const testing = !!setup.testing && isLoopback(connection.remoteAddress);
|
||||
gameServer.configureGame({ ...setup, testing });
|
||||
for (const peer of connections.values()) {
|
||||
peer.send(JSON.stringify({ t: "game_configured" }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "claim_civ":
|
||||
gameServer.claimCiv(connection.id, message.civ);
|
||||
break;
|
||||
case "order": {
|
||||
const order = message.order || {};
|
||||
// Even inside a local test game, the free shortcut is only honoured for
|
||||
// a connection that is itself local.
|
||||
if (order.free && !isLoopback(connection.remoteAddress)) order.free = false;
|
||||
// The free shortcut is only honoured for a page on the local machine,
|
||||
// whatever the game's testing flag says.
|
||||
if (order.free && !isLocalRequest(connection.remoteAddress, connection.origin)) {
|
||||
order.free = false;
|
||||
}
|
||||
gameServer.enqueueOrder(connection.id, order);
|
||||
break;
|
||||
}
|
||||
@@ -171,6 +186,10 @@ export function startServer({ port = 27015, bind = "127.0.0.1", log = console.lo
|
||||
wsServer.on("connection", (connection) => {
|
||||
connections.set(connection.id, connection);
|
||||
connection.name = "";
|
||||
gameServer.setPeerLocal(
|
||||
connection.id,
|
||||
isLocalRequest(connection.remoteAddress, connection.origin)
|
||||
);
|
||||
connection.on("message", (raw) => {
|
||||
let message;
|
||||
try {
|
||||
@@ -241,5 +260,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
||||
startServer({
|
||||
port: Number(argValue("--port", process.env.PORT || 27015)),
|
||||
bind: argValue("--bind", process.env.BIND || "127.0.0.1"),
|
||||
seed: Number(argValue("--seed", process.env.SEED || 0)) | 0,
|
||||
// Free, instant orders for local play; remote peers still have their `free`
|
||||
// flag stripped in the order handler.
|
||||
testing: process.argv.includes("--testing"),
|
||||
});
|
||||
}
|
||||
|
||||
+3
-2
@@ -55,9 +55,10 @@ export class WebSocketServer extends EventEmitter {
|
||||
|
||||
const id = this._nextId++;
|
||||
const connection = new Connection(id, socket, head);
|
||||
// The peer's address, so the server can tell a local connection (which may
|
||||
// use the testing tools) from a remote one.
|
||||
// The peer's address and the page's Origin, so the server can tell a local
|
||||
// connection (which may use the testing tools) from a remote one.
|
||||
connection.remoteAddress = socket.remoteAddress || "";
|
||||
connection.origin = request.headers.origin || "";
|
||||
this._connections.set(id, connection);
|
||||
this.emit("connection", connection);
|
||||
connection.on("close", () => this._connections.delete(id));
|
||||
|
||||
+53
-95
@@ -1,6 +1,5 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { setupDom, teardownDom, FakeWebSocket } from "./framework/dom.js";
|
||||
import { CIVILISATIONS } from "../shared/data.js";
|
||||
import { smallState } from "./framework/helpers.js";
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
@@ -11,8 +10,7 @@ export class AppTest extends TestCase {
|
||||
try {
|
||||
FakeWebSocket.reset();
|
||||
await import(`../client/js/app.js?join=${Date.now()}`);
|
||||
this.assertSize(env.$("#new-civs li"), CIVILISATIONS.length);
|
||||
this.assertTrue(env.$("#new-civs li").first().find("img").length === 1, "civ picker shows flags");
|
||||
this.assertSize(env.$("#menu-create"), 0, "there is no Create game button");
|
||||
|
||||
env.$("#menu-join").click();
|
||||
this.assertTrue(env.$("#screen-join").hasClass("active"));
|
||||
@@ -81,105 +79,65 @@ export class AppTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_new_game_defaults() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
FakeWebSocket.reset();
|
||||
await import(`../client/js/app.js?defaults=${Date.now()}`);
|
||||
env.$("#menu-create").click();
|
||||
this.assertEqual(env.$("#new-name").val(), "adrien");
|
||||
this.assertEqual(env.$("#new-password").val(), "admin");
|
||||
this.assertEqual(Number(env.$("#new-count").val()), CIVILISATIONS.length, "all nations by default");
|
||||
this.assertEqual(env.$("#new-civs li.selected").attr("data-civ-id"), CIVILISATIONS[0].id);
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
FakeWebSocket.reset();
|
||||
await import(`../client/js/app.js?create=${Date.now()}`);
|
||||
env.$("#menu-create").click();
|
||||
this.assertTrue(env.$("#screen-new").hasClass("active"));
|
||||
|
||||
env.$("#new-name").val("Alice");
|
||||
env.$("#new-password").val("pw");
|
||||
env.$("#new-count").val("3");
|
||||
env.$("#new-start").click();
|
||||
const socket = FakeWebSocket.last;
|
||||
socket.simulateOpen();
|
||||
await flush();
|
||||
this.assertEqual(socket.lastSent(), { t: "login", name: "Alice", password: "pw" });
|
||||
|
||||
socket.simulateMessage({ t: "assigned", playerId: 1, admin: true, name: "Alice" });
|
||||
await flush();
|
||||
this.assertTrue(env.$("#screen-game").hasClass("active"));
|
||||
const setupMessage = socket.lastSent();
|
||||
this.assertEqual(setupMessage.t, "configure_game");
|
||||
this.assertEqual(setupMessage.setup.player_civ, 0);
|
||||
this.assertEqual(setupMessage.setup.civilisations, ["france", "britain", "slovenia"]);
|
||||
|
||||
socket.simulateMessage({ t: "game_state", state: smallState(["france", "britain", "slovenia"], 4242).snapshot(0) });
|
||||
this.assertEqual(env.$("#civ-name").text(), "France");
|
||||
this.assertFalse(env.$("#stat-pop").text() === "0");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_testing_mode_is_offered_and_sent_from_localhost() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
FakeWebSocket.reset();
|
||||
await import(`../client/js/app.js?testing=${Date.now()}`);
|
||||
env.$("#menu-create").click();
|
||||
this.assertFalse(env.$("#new-testing-row").hasClass("hidden"), "the toggle is offered locally");
|
||||
env.$("#new-name").val("Alice");
|
||||
env.$("#new-password").val("pw");
|
||||
env.$("#new-testing").prop("checked", true);
|
||||
env.$("#new-start").click();
|
||||
const socket = FakeWebSocket.last;
|
||||
socket.simulateOpen();
|
||||
await flush();
|
||||
socket.simulateMessage({ t: "assigned", playerId: 1, admin: true, name: "Alice" });
|
||||
await flush();
|
||||
const setup = socket.lastSent();
|
||||
this.assertEqual(setup.t, "configure_game");
|
||||
this.assertTrue(setup.setup.testing, "the setup requests testing mode");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_missing_credentials_show_a_message() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
FakeWebSocket.reset();
|
||||
await import(`../client/js/app.js?validation=${Date.now()}`);
|
||||
env.$("#menu-create").click();
|
||||
env.$("#new-name").val("");
|
||||
env.$("#new-password").val("");
|
||||
env.$("#new-start").click();
|
||||
this.assertEqual(env.$("#new-status").text(), "Enter a name and a password.");
|
||||
this.assertEmpty(FakeWebSocket.instances, "no connection is attempted");
|
||||
env.$("#menu-join").click();
|
||||
const listSocket = FakeWebSocket.last;
|
||||
listSocket.simulateOpen();
|
||||
await flush();
|
||||
listSocket.simulateMessage({ t: "game_list", games: [{ name: "Game 999" }] });
|
||||
|
||||
env.$("#join-name").val("");
|
||||
env.$("#join-password").val("");
|
||||
env.$("#join-go").click();
|
||||
this.assertEqual(env.$("#join-status").text(), "Enter a name and a password.");
|
||||
this.assertSize(FakeWebSocket.instances, 1, "no new connection is attempted");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async assertLocalPageAutoJoins(url, tag) {
|
||||
const env = await setupDom({ url });
|
||||
try {
|
||||
FakeWebSocket.reset();
|
||||
await import(`../client/js/app.js?local-${tag}=${Date.now()}`);
|
||||
env.$("#menu-join").click();
|
||||
this.assertFalse(env.$("#screen-join").hasClass("active"), "the login form is skipped");
|
||||
|
||||
const socket = FakeWebSocket.last;
|
||||
socket.simulateOpen();
|
||||
await flush();
|
||||
this.assertEqual(socket.lastSent(), { t: "login", name: "adrien", password: "admin" });
|
||||
|
||||
socket.simulateMessage({ t: "assigned", playerId: 1, admin: true, name: "adrien" });
|
||||
socket.simulateMessage({
|
||||
t: "choose_civ",
|
||||
civs: [
|
||||
{ index: 4, id: "france", name: "France" },
|
||||
{ index: 1, id: "britain", name: "Britain" },
|
||||
],
|
||||
});
|
||||
this.assertEqual(socket.lastSent(), { t: "claim_civ", civ: 4 }, "France is claimed");
|
||||
this.assertFalse(env.$("#screen-choose").hasClass("active"), "the picker is skipped");
|
||||
|
||||
socket.simulateMessage({ t: "civ_assigned", civ: 4 });
|
||||
this.assertTrue(env.$("#screen-game").hasClass("active"));
|
||||
socket.simulateMessage({ t: "game_state", state: smallState().snapshot(0) });
|
||||
this.assertEqual(env.$("#civ-name").text(), "France");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_a_127_page_joins_as_adrien_with_france() {
|
||||
await this.assertLocalPageAutoJoins("http://127.0.0.1/client/index.html", "127");
|
||||
}
|
||||
|
||||
async test_a_localhost_page_joins_as_adrien_with_france() {
|
||||
await this.assertLocalPageAutoJoins("http://localhost/client/index.html", "localhost");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { setupDom, teardownDom } from "./framework/dom.js";
|
||||
import { formatCommitDate, renderDevlog } from "../client/js/devlog.js";
|
||||
import { DEVLOG } from "../client/js/devlog_data.js";
|
||||
|
||||
const FIXTURE = [
|
||||
{
|
||||
hash: "abc1234",
|
||||
date: "2026-09-21",
|
||||
subject: "Fixed the invisible country names",
|
||||
body: "A longer explanation of the fix.",
|
||||
},
|
||||
{
|
||||
hash: "def5678",
|
||||
date: "2026-09-20",
|
||||
subject: "Added the devlog",
|
||||
body: "",
|
||||
},
|
||||
];
|
||||
|
||||
export class DevlogTest extends TestCase {
|
||||
test_dates_read_without_a_locale() {
|
||||
this.assertEqual(formatCommitDate("2026-09-21"), "21 Sep 2026");
|
||||
this.assertEqual(formatCommitDate("2026-01-02"), "2 Jan 2026");
|
||||
this.assertEqual(formatCommitDate("not a date"), "not a date");
|
||||
this.assertEqual(formatCommitDate(""), "");
|
||||
}
|
||||
|
||||
async test_headers_show_the_date_and_subject_and_expand() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
renderDevlog(env.$("#devlog-list"), FIXTURE);
|
||||
const headers = env.$("#devlog-list .devlog-header");
|
||||
this.assertSize(headers, 2, "one header per commit");
|
||||
this.assertTrue(
|
||||
headers.eq(0).find(".devlog-date").text().includes("21 Sep 2026"),
|
||||
"the newest commit shows its date"
|
||||
);
|
||||
this.assertTrue(
|
||||
headers.eq(1).find(".devlog-subject").text().includes("Added the devlog"),
|
||||
"the subject is shown"
|
||||
);
|
||||
|
||||
const bodies = env.$("#devlog-list .devlog-body");
|
||||
this.assertTrue(bodies.eq(0).hasClass("hidden"), "a message starts collapsed");
|
||||
headers.eq(0).trigger("click");
|
||||
this.assertFalse(bodies.eq(0).hasClass("hidden"), "clicking the header expands it");
|
||||
this.assertTrue(
|
||||
bodies.eq(0).find(".devlog-message").text().includes("A longer explanation"),
|
||||
"the full message is shown"
|
||||
);
|
||||
this.assertEqual(headers.eq(0).attr("aria-expanded"), "true", "the header reports it is open");
|
||||
headers.eq(0).trigger("click");
|
||||
this.assertTrue(bodies.eq(0).hasClass("hidden"), "clicking again collapses it");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
test_the_generated_devlog_carries_ten_dated_commits() {
|
||||
this.assertTrue(Array.isArray(DEVLOG), "the devlog data is a list");
|
||||
this.assertGreater(DEVLOG.length, 0, "there is at least one commit");
|
||||
this.assertLess(DEVLOG.length, 11, "at most ten commits are shipped");
|
||||
for (const commit of DEVLOG) {
|
||||
this.assertTrue(/^[0-9a-f]+$/.test(commit.hash), `a short hash: ${commit.hash}`);
|
||||
this.assertTrue(/^\d{4}-\d{2}-\d{2}$/.test(commit.date), `a date: ${commit.date}`);
|
||||
this.assertTrue(typeof commit.subject === "string" && commit.subject.length > 0,
|
||||
"a commit has a subject");
|
||||
this.assertTrue(typeof commit.body === "string", "a commit has a body field");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,10 @@ export async function setupDom(options = {}) {
|
||||
const html = await readFile(INDEX_PATH, "utf8");
|
||||
const jquerySource = await readFile(JQUERY_PATH, "utf8");
|
||||
const dom = new JSDOM(html, {
|
||||
url: "http://localhost/client/index.html",
|
||||
// `url` lets a test pose as a page on the local machine (localhost or
|
||||
// 127.0.0.1), which the client treats differently from the default
|
||||
// non-loopback origin.
|
||||
url: options.url || "http://game.example/client/index.html",
|
||||
runScripts: "outside-only",
|
||||
pretendToBeVisual: false,
|
||||
});
|
||||
|
||||
@@ -7,9 +7,17 @@ import { MAP_CONFIG, CIVILISATIONS } from "../../shared/data.js";
|
||||
import { startServer } from "../../server/server.js";
|
||||
|
||||
// Boots the real server on an ephemeral port for an integration test, then
|
||||
// guarantees it is closed afterwards.
|
||||
export async function withServer(fn) {
|
||||
const server = startServer({ port: 0, bind: "127.0.0.1", log: () => {} });
|
||||
// guarantees it is closed afterwards. The server configures its game at
|
||||
// startup, so a small deterministic roster keeps the fixtures fast and stable.
|
||||
export async function withServer(fn, options = {}) {
|
||||
const server = startServer({
|
||||
port: 0,
|
||||
bind: "127.0.0.1",
|
||||
log: () => {},
|
||||
seed: SEED,
|
||||
civilisations: ["france", "britain"],
|
||||
...options,
|
||||
});
|
||||
const address = await server.listen;
|
||||
try {
|
||||
await fn(address.port, server);
|
||||
|
||||
@@ -14,13 +14,9 @@ function stubNetwork() {
|
||||
localPlayerId: 2,
|
||||
players: { 2: { id: 2, name: "Carol", admin: false } },
|
||||
orders: [],
|
||||
setup: null,
|
||||
sendOrder(order) {
|
||||
this.orders.push(order);
|
||||
},
|
||||
sendGameSetup(setup) {
|
||||
this.setup = setup;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,10 +98,6 @@ export class GameScreenTest extends TestCase {
|
||||
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");
|
||||
|
||||
screen.sendSetup();
|
||||
this.assertEqual(network.setup.player_civ, 0);
|
||||
this.assertEqual(network.setup.civilisations, ["france", "britain"]);
|
||||
|
||||
screen.leave();
|
||||
this.assertFalse(env.$("#screen-game").hasClass("active"));
|
||||
} finally {
|
||||
@@ -842,6 +834,87 @@ export class GameScreenTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_the_city_button_opens_a_city_when_its_unit_is_selected() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
const plain = state.landCells.find(
|
||||
(c) => state.tiles[`${c.x},${c.y}`].terrainType === "Land" &&
|
||||
!state.cityAt(c) && state.topology.neighbours(c.x, c.y).some((n) => state._isLand(n))
|
||||
);
|
||||
state._spawnCity({ civIndex: 0, coords: { x: plain.x, y: plain.y }, isCapital: false });
|
||||
state._rebuildCityIndex();
|
||||
const unit = state._spawnUnit(plain, 0, state.protoUnits[0]);
|
||||
screen.onState(state.snapshot(0));
|
||||
|
||||
screen.map.onUnitSelected(unit.id);
|
||||
this.assertFalse(env.$("#city-info").hasClass("hidden"), "the city panel is shown");
|
||||
this.assertFalse(env.$("#city-manage").hasClass("hidden"), "the city button is available");
|
||||
env.$("#city-manage").click();
|
||||
this.assertFalse(env.$("#modal-city").hasClass("hidden"), "the button opens the city modal");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_double_clicking_a_unit_in_a_city_opens_the_city() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
const plain = state.landCells.find(
|
||||
(c) => state.tiles[`${c.x},${c.y}`].terrainType === "Land" &&
|
||||
!state.cityAt(c) && state.topology.neighbours(c.x, c.y).some((n) => state._isLand(n))
|
||||
);
|
||||
state._spawnCity({ civIndex: 0, coords: { x: plain.x, y: plain.y }, isCapital: false });
|
||||
state._rebuildCityIndex();
|
||||
const unit = state._spawnUnit(plain, 0, state.protoUnits[0]);
|
||||
screen.onState(state.snapshot(0));
|
||||
|
||||
const view = screen.map._unitViews.get(unit.id);
|
||||
this.assertNotNull(view, "the unit is drawn");
|
||||
view.dispatchEvent(new env.window.MouseEvent("dblclick", { bubbles: true }));
|
||||
this.assertFalse(env.$("#modal-city").hasClass("hidden"), "the city opens");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_the_stat_tooltip_sits_outside_the_hud() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
this.assertSize(env.$("#hud #stat-tooltip"), 0, "the tooltip is not trapped in the HUD");
|
||||
this.assertEqual(env.$("#screen-game > #stat-tooltip").length, 1,
|
||||
"it is a sibling of the HUD, so it can paint above the unit panel");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_a_local_config_offers_the_free_buttons() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), { ...makeConfig(), testing: true });
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
screen.onState(state.snapshot(0));
|
||||
this.assertTrue(screen.testing, "a local page turns on the testing buttons");
|
||||
|
||||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||||
screen.map.onCitySelected(capital.id);
|
||||
env.$("#city-manage").click();
|
||||
this.assertGreater(env.$("#modal-city .free-button").length, 0, "the city offers free orders");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_unit_panel_shows_transport_cargo_and_boarding() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
|
||||
+2
-2
@@ -17,7 +17,7 @@ export class NetTest extends TestCase {
|
||||
async test_default_server_url_uses_the_page_origin() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
this.assertEqual(new Network().defaultServerUrl(), "ws://localhost/ws");
|
||||
this.assertEqual(new Network().defaultServerUrl(), "ws://game.example/ws");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export class NetTest extends TestCase {
|
||||
network.on("session_started", (message) => {
|
||||
started = message;
|
||||
});
|
||||
const promise = network.createGame("Alice", "pw");
|
||||
const promise = network.joinGame("ws://localhost/ws", "Alice", "pw");
|
||||
const ws = FakeWebSocket.last;
|
||||
ws.simulateOpen();
|
||||
await promise;
|
||||
|
||||
@@ -170,6 +170,34 @@ export class PoliticalMapTest extends TestCase {
|
||||
this.assertFalse(politicalMode(POLITICAL_ZOOM + 0.01), "closer in is terrain");
|
||||
}
|
||||
|
||||
// A zero-sized outer SVG is not painted at all in some browsers, however
|
||||
// visible its overflow, so the viewport has to have a real size or the names
|
||||
// are drawn but never seen.
|
||||
async test_the_country_name_svg_has_a_non_zero_viewport() {
|
||||
const state = smallState();
|
||||
const env = await setupDom();
|
||||
const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$));
|
||||
map.$viewport.width = () => 800;
|
||||
map.$viewport.height = () => 600;
|
||||
map.setCatalogue(CIVILISATIONS, PROTO_UNITS);
|
||||
try {
|
||||
map.applySnapshot(state.snapshot(0));
|
||||
const entries = [];
|
||||
for (let y = 2; y < 5; y++) {
|
||||
for (let x = 2; x < 7; x++) entries.push([x, y, 0]);
|
||||
}
|
||||
map._applyTerritory(entries);
|
||||
map.setMapMode("political");
|
||||
const svg = env.$("#layer-political-labels svg");
|
||||
this.assertSize(svg, 1, "the country-name svg is built");
|
||||
this.assertGreater(Number(svg.attr("width")), 0, "the viewport has a width");
|
||||
this.assertGreater(Number(svg.attr("height")), 0, "the viewport has a height");
|
||||
this.assertGreater(env.$("#layer-political-labels text").length, 0, "a name is drawn");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_the_dom_map_paints_country_colours_and_clears_them() {
|
||||
const state = smallState();
|
||||
const env = await setupDom();
|
||||
|
||||
+41
-45
@@ -99,13 +99,22 @@ export class ServerStaticTest extends TestCase {
|
||||
}
|
||||
|
||||
export class ServerLoopTest extends TestCase {
|
||||
async test_the_server_configures_its_game_before_listening() {
|
||||
await withServer(async (port, server) => {
|
||||
this.assertTrue(server.gameServer.isConfigured(), "the game exists before anyone connects");
|
||||
this.assertEqual(server.gameServer.state.seed, SEED);
|
||||
this.assertSize(server.gameServer.state.civilisations, 2);
|
||||
});
|
||||
}
|
||||
|
||||
async test_the_testing_flag_reaches_the_game() {
|
||||
await withServer(async (port, server) => {
|
||||
this.assertTrue(server.gameServer.state.testing, "the auto-created game is a test game");
|
||||
}, { testing: true });
|
||||
}
|
||||
|
||||
async test_step_advances_the_clock_after_a_real_second() {
|
||||
await withServer(async (port, server) => {
|
||||
server.gameServer.configureGame({
|
||||
seed: SEED,
|
||||
player_civ: 0,
|
||||
civilisations: ["france", "britain"],
|
||||
});
|
||||
this.assertEqual(server.gameServer.state.totalHours, 0);
|
||||
for (let i = 0; i < 40; i++) server.step(0.05);
|
||||
this.assertGreater(server.gameServer.state.totalHours, 0, "an hour struck after a second");
|
||||
@@ -114,11 +123,6 @@ export class ServerLoopTest extends TestCase {
|
||||
|
||||
async test_snapshots_are_throttled_between_ticks() {
|
||||
await withServer(async (port, server) => {
|
||||
server.gameServer.configureGame({
|
||||
seed: SEED,
|
||||
player_civ: 0,
|
||||
civilisations: ["france", "britain"],
|
||||
});
|
||||
// Count how often the loop actually flushes over one second of 50ms ticks.
|
||||
const flush = server.gameServer.flushIfDirty.bind(server.gameServer);
|
||||
let flushes = 0;
|
||||
@@ -134,14 +138,16 @@ export class ServerLoopTest extends TestCase {
|
||||
}
|
||||
|
||||
export class ServerProtocolTest extends TestCase {
|
||||
async test_lobby_login_and_configure() {
|
||||
async test_lobby_lists_the_game_and_lets_a_player_claim_a_nation() {
|
||||
await withServer(async (port, server) => {
|
||||
const client = makeClient(`ws://127.0.0.1:${port}/ws`);
|
||||
await client.open;
|
||||
|
||||
client.send({ t: "request_games" });
|
||||
const empty = await client.next("game_list");
|
||||
this.assertEmpty(empty.games, "no game before configuration");
|
||||
const listed = await client.next("game_list");
|
||||
this.assertSize(listed.games, 1, "the game exists before anyone joins");
|
||||
this.assertEqual(listed.games[0].seed, SEED);
|
||||
this.assertSize(listed.games[0].civs, 2);
|
||||
|
||||
client.send({ t: "login", name: "Host", password: "pw" });
|
||||
const assigned = await client.next("assigned");
|
||||
@@ -149,21 +155,19 @@ export class ServerProtocolTest extends TestCase {
|
||||
this.assertEqual(assigned.name, "Host");
|
||||
await client.next("players");
|
||||
|
||||
client.send({
|
||||
t: "configure_game",
|
||||
setup: { seed: SEED, player_civ: 0, civilisations: ["france", "britain"] },
|
||||
});
|
||||
await client.next("game_configured");
|
||||
const choose = await client.next("choose_civ");
|
||||
this.assertSize(choose.civs, 2, "every unclaimed nation is offered");
|
||||
client.send({ t: "claim_civ", civ: 0 });
|
||||
const civAssigned = await client.next("civ_assigned");
|
||||
this.assertEqual(civAssigned.civ, 0);
|
||||
const stateMessage = await client.next("game_state");
|
||||
this.assertEqual(stateMessage.state.seed, SEED);
|
||||
this.assertEqual(stateMessage.state.viewer, 0);
|
||||
this.assertSize(stateMessage.state.civs, 2);
|
||||
|
||||
client.send({ t: "request_games" });
|
||||
const listed = await client.next("game_list");
|
||||
this.assertSize(listed.games, 1);
|
||||
this.assertEqual(listed.games[0].seed, SEED);
|
||||
this.assertEqual(listed.games[0].players, 1);
|
||||
const after = await client.next("game_list");
|
||||
this.assertEqual(after.games[0].players, 1, "the connected player is counted");
|
||||
|
||||
client.close();
|
||||
});
|
||||
@@ -175,10 +179,8 @@ export class ServerProtocolTest extends TestCase {
|
||||
await client.open;
|
||||
client.send({ t: "login", name: "Host", password: "pw" });
|
||||
await client.next("assigned");
|
||||
client.send({
|
||||
t: "configure_game",
|
||||
setup: { seed: SEED, player_civ: 0, civilisations: ["france", "britain"] },
|
||||
});
|
||||
await client.next("choose_civ");
|
||||
client.send({ t: "claim_civ", civ: 0 });
|
||||
await client.next("game_state");
|
||||
|
||||
const state = server.gameServer.state;
|
||||
@@ -201,10 +203,8 @@ export class ServerProtocolTest extends TestCase {
|
||||
await client.open;
|
||||
client.send({ t: "login", name: "Host", password: "pw" });
|
||||
await client.next("assigned");
|
||||
client.send({
|
||||
t: "configure_game",
|
||||
setup: { seed: SEED, player_civ: 0, civilisations: ["france", "britain"] },
|
||||
});
|
||||
await client.next("choose_civ");
|
||||
client.send({ t: "claim_civ", civ: 0 });
|
||||
await client.next("game_state");
|
||||
|
||||
const state = server.gameServer.state;
|
||||
@@ -254,10 +254,8 @@ export class ServerProtocolTest extends TestCase {
|
||||
await host.open;
|
||||
host.send({ t: "login", name: "Host", password: "pw" });
|
||||
await host.next("assigned");
|
||||
host.send({
|
||||
t: "configure_game",
|
||||
setup: { seed: SEED, player_civ: 0, civilisations: ["france", "britain"] },
|
||||
});
|
||||
await host.next("choose_civ");
|
||||
host.send({ t: "claim_civ", civ: 0 });
|
||||
await host.next("game_state");
|
||||
|
||||
const guest = makeClient(`ws://127.0.0.1:${port}/ws`);
|
||||
@@ -279,28 +277,26 @@ export class ServerProtocolTest extends TestCase {
|
||||
});
|
||||
}
|
||||
|
||||
async test_non_admin_cannot_configure() {
|
||||
async test_a_taken_nation_cannot_be_claimed() {
|
||||
await withServer(async (port, server) => {
|
||||
const host = makeClient(`ws://127.0.0.1:${port}/ws`);
|
||||
await host.open;
|
||||
host.send({ t: "login", name: "Host", password: "pw" });
|
||||
await host.next("assigned");
|
||||
host.send({
|
||||
t: "configure_game",
|
||||
setup: { seed: SEED, player_civ: 0, civilisations: ["france", "britain"] },
|
||||
});
|
||||
await host.next("choose_civ");
|
||||
host.send({ t: "claim_civ", civ: 0 });
|
||||
await host.next("game_state");
|
||||
|
||||
const guest = makeClient(`ws://127.0.0.1:${port}/ws`);
|
||||
await guest.open;
|
||||
guest.send({ t: "login", name: "Carol", password: "pw" });
|
||||
await guest.next("assigned");
|
||||
guest.send({
|
||||
t: "configure_game",
|
||||
setup: { seed: 999, player_civ: 1, civilisations: [] },
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
this.assertEqual(server.gameServer.state.seed, SEED, "the game was not reset");
|
||||
const choose = await guest.next("choose_civ");
|
||||
this.assertEqual(choose.civs.map((c) => c.index), [1], "only the free nation is offered");
|
||||
guest.send({ t: "claim_civ", civ: 0 });
|
||||
guest.send({ t: "claim_civ", civ: 1 });
|
||||
const civAssigned = await guest.next("civ_assigned");
|
||||
this.assertEqual(civAssigned.civ, 1, "the taken nation is refused, the free one is granted");
|
||||
|
||||
host.close();
|
||||
guest.close();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState } from "./framework/helpers.js";
|
||||
import { GameServer } from "../server/game_server.js";
|
||||
import { isLoopback } from "../server/server.js";
|
||||
import { isLoopback, isLocalRequest } from "../server/server.js";
|
||||
|
||||
const BIG_BUDGET = 1.0e15;
|
||||
|
||||
@@ -29,6 +29,23 @@ export class TestingModeTest extends TestCase {
|
||||
this.assertFalse(isLoopback(undefined), "a missing address is remote");
|
||||
}
|
||||
|
||||
test_a_local_page_is_distinguished_from_a_proxied_remote_client() {
|
||||
this.assertTrue(isLocalRequest("127.0.0.1", "http://127.0.0.1:27015"), "a local page");
|
||||
this.assertTrue(
|
||||
isLocalRequest("::ffff:127.0.0.1", "http://localhost:27015"),
|
||||
"a mapped local page"
|
||||
);
|
||||
this.assertFalse(
|
||||
isLocalRequest("127.0.0.1", "https://tismo.example.com"),
|
||||
"a proxied remote page is remote even though its socket is loopback"
|
||||
);
|
||||
this.assertFalse(
|
||||
isLocalRequest("10.0.0.1", "http://127.0.0.1:27015"),
|
||||
"a remote socket is remote"
|
||||
);
|
||||
this.assertFalse(isLocalRequest("127.0.0.1", ""), "a missing origin is remote");
|
||||
}
|
||||
|
||||
test_free_training_spawns_instantly_and_costs_nothing() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
@@ -104,4 +121,20 @@ export class TestingModeTest extends TestCase {
|
||||
this.assertLess(server.state.getBudget(0), budgetBefore, "the normal cost was charged");
|
||||
this.assertFalse(server.state.testing, "the testing flag stays off");
|
||||
}
|
||||
|
||||
test_a_local_peer_gets_free_orders_in_a_normal_game() {
|
||||
const server = makeServer(false);
|
||||
server.setPeerLocal(1, true);
|
||||
server.peerCiv.set(1, 0);
|
||||
const city = server.state.cities.find((c) => c.civ === 0);
|
||||
const market = server.state.protoBuildings.findIndex((b) => b.id === "market");
|
||||
const budgetBefore = server.state.getBudget(0);
|
||||
this.assertTrue(
|
||||
server.handleOrder(1, { type: "build", city: city.id, building: market, free: true }),
|
||||
"the order is accepted"
|
||||
);
|
||||
this.assertEqual(server.state.getCityBuildingLevel(city, market), 1, "the building went up");
|
||||
this.assertEqual(server.state.getBudget(0), budgetBefore, "it was free");
|
||||
this.assertFalse(server.state.testing, "the game itself stays a normal game");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user