From f3e94a3d53d0083d7225275eb446fefb199d3159 Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Mon, 21 Sep 2026 23:08:05 +0200 Subject: [PATCH] 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. --- AGENTS.md | 18 +-- client/css/style.css | 41 ++++++- client/index.html | 49 +++----- client/js/app.js | 207 ++++++++++++++------------------ client/js/devlog.js | 48 ++++++++ client/js/devlog_data.js | 64 ++++++++++ client/js/game_screen.js | 15 +-- client/js/game_screen/panels.js | 7 +- client/js/map_view/entities.js | 9 ++ client/js/map_view/political.js | 6 + client/js/net.js | 13 -- githooks/pre-commit | 19 ++- scripts/generate-devlog.js | 74 ++++++++++++ scripts/install-git-hooks.sh | 6 +- server/game_server.js | 34 ++++-- server/server.js | 57 ++++++--- server/websocket.js | 5 +- tests/app_test.js | 148 ++++++++--------------- tests/devlog_test.js | 72 +++++++++++ tests/framework/dom.js | 5 +- tests/framework/helpers.js | 14 ++- tests/game_screen_test.js | 89 ++++++++++++-- tests/net_test.js | 4 +- tests/political_map_test.js | 28 +++++ tests/server_test.js | 86 +++++++------ tests/testing_mode_test.js | 35 +++++- 26 files changed, 772 insertions(+), 381 deletions(-) create mode 100644 client/js/devlog.js create mode 100644 client/js/devlog_data.js create mode 100644 scripts/generate-devlog.js create mode 100644 tests/devlog_test.js 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 @@ - - -
-
@@ -91,7 +67,6 @@
-

@@ -180,9 +155,13 @@ - + + + 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"); + } }