diff --git a/AGENTS.md b/AGENTS.md
index 18745c1..78a286c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/client/css/style.css b/client/css/style.css
index 5cca63f..bf3e83e 100644
--- a/client/css/style.css
+++ b/client/css/style.css
@@ -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;
diff --git a/client/index.html b/client/index.html
index 32b4f19..25a4547 100644
--- a/client/index.html
+++ b/client/index.html
@@ -14,10 +14,10 @@
@@ -32,40 +32,16 @@
-
-
-
-
-
+
+
+
diff --git a/client/js/app.js b/client/js/app.js
index 0539739..6578392 100644
--- a/client/js/app.js
+++ b/client/js/app.js
@@ -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 $("")
- .attr("data-civ-id", civ.id)
- .append($("
").attr("src", `assets/flags/${civ.flag}`))
- .append($("").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 $("")
+ .attr("data-civ-id", civ.id)
+ .append($("
").attr("src", `assets/flags/${civ.flag}`))
+ .append($("").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"));
diff --git a/client/js/devlog.js b/client/js/devlog.js
new file mode 100644
index 0000000..29c2d02
--- /dev/null
+++ b/client/js/devlog.js
@@ -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 = $("");
+ const $header = $("");
+ $header.attr("aria-expanded", "false");
+ $header.append($("").text(formatCommitDate(commit.date)));
+ $header.append($("").text(commit.subject || ""));
+ $header.append($("").text("+"));
+ const $body = $("");
+ if (commit.body) {
+ $body.append($("").text(commit.body));
+ }
+ $body.append($("").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);
+ }
+}
diff --git a/client/js/devlog_data.js b/client/js/devlog_data.js
new file mode 100644
index 0000000..35fd3e5
--- /dev/null
+++ b/client/js/devlog_data.js
@@ -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": ""
+ }
+];
diff --git a/client/js/game_screen.js b/client/js/game_screen.js
index aa4615f..87e3178 100644
--- a/client/js/game_screen.js
+++ b/client/js/game_screen.js
@@ -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" }
);
diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js
index b1c965c..57bb80e 100644
--- a/client/js/game_screen/panels.js
+++ b/client/js/game_screen/panels.js
@@ -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) || {},
diff --git a/client/js/map_view/entities.js b/client/js/map_view/entities.js
index 52f074b..5a9d2ff 100644
--- a/client/js/map_view/entities.js
+++ b/client/js/map_view/entities.js
@@ -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;
},
diff --git a/client/js/map_view/political.js b/client/js/map_view/political.js
index f53698e..662fc61 100644
--- a/client/js/map_view/political.js
+++ b/client/js/map_view/political.js
@@ -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) {
diff --git a/client/js/net.js b/client/js/net.js
index 3071fef..fb55ad4 100644
--- a/client/js/net.js
+++ b/client/js/net.js
@@ -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;
diff --git a/githooks/pre-commit b/githooks/pre-commit
index f3e4653..eea4e98 100755
--- a/githooks/pre-commit
+++ b/githooks/pre-commit
@@ -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
diff --git a/scripts/generate-devlog.js b/scripts/generate-devlog.js
new file mode 100644
index 0000000..89ad5ef
--- /dev/null
+++ b/scripts/generate-devlog.js
@@ -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}`);
+}
diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh
index 5758a4c..fba8c6d 100755
--- a/scripts/install-git-hooks.sh
+++ b/scripts/install-git-hooks.sh
@@ -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"
diff --git a/server/game_server.js b/server/game_server.js
index 42a6093..f14de2a 100644
--- a/server/game_server.js
+++ b/server/game_server.js
@@ -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);
diff --git a/server/server.js b/server/server.js
index d3bfe1c..690a423 100644
--- a/server/server.js
+++ b/server/server.js
@@ -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"),
});
}
diff --git a/server/websocket.js b/server/websocket.js
index 89663b6..9104631 100644
--- a/server/websocket.js
+++ b/server/websocket.js
@@ -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));
diff --git a/tests/app_test.js b/tests/app_test.js
index bba949d..e46ab62 100644
--- a/tests/app_test.js
+++ b/tests/app_test.js
@@ -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");
+ }
}
diff --git a/tests/devlog_test.js b/tests/devlog_test.js
new file mode 100644
index 0000000..0fd3e35
--- /dev/null
+++ b/tests/devlog_test.js
@@ -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");
+ }
+ }
+}
diff --git a/tests/framework/dom.js b/tests/framework/dom.js
index aa0a06b..666317c 100644
--- a/tests/framework/dom.js
+++ b/tests/framework/dom.js
@@ -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,
});
diff --git a/tests/framework/helpers.js b/tests/framework/helpers.js
index d46df36..bc31cee 100644
--- a/tests/framework/helpers.js
+++ b/tests/framework/helpers.js
@@ -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);
diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js
index d569485..40c81a7 100644
--- a/tests/game_screen_test.js
+++ b/tests/game_screen_test.js
@@ -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 {
diff --git a/tests/net_test.js b/tests/net_test.js
index 89b5ad6..f1cca29 100644
--- a/tests/net_test.js
+++ b/tests/net_test.js
@@ -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;
diff --git a/tests/political_map_test.js b/tests/political_map_test.js
index 16548e1..fffff84 100644
--- a/tests/political_map_test.js
+++ b/tests/political_map_test.js
@@ -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();
diff --git a/tests/server_test.js b/tests/server_test.js
index fdb074d..21e501f 100644
--- a/tests/server_test.js
+++ b/tests/server_test.js
@@ -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();
diff --git a/tests/testing_mode_test.js b/tests/testing_mode_test.js
index 89b943a..e77715a 100644
--- a/tests/testing_mode_test.js
+++ b/tests/testing_mode_test.js
@@ -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");
+ }
}