Files
Battle-for-Tismo/client/js/net.js
T
adrien f3e94a3d53 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.
2026-09-21 23:08:05 +02:00

205 lines
5.8 KiB
JavaScript

// Browser network role: talks to the authoritative Node server over a WebSocket
// and exposes high-level events so the screens do not care about the transport.
export const DEFAULT_PORT = 27015;
export const DEFAULT_PATH = "/ws";
export class Network {
constructor() {
this.socket = null;
this.isAdmin = false;
this.localPlayerId = 0;
this.players = {};
this._handlers = {};
// The last full game state, with deltas merged into it. GameScreen always
// sees a complete snapshot even though the wire only carries what changed.
this._gameState = null;
this._loginFailure = "";
this._connectResolve = null;
this._connectReject = null;
}
on(event, handler) {
if (!this._handlers[event]) this._handlers[event] = [];
this._handlers[event].push(handler);
}
_emit(event, ...args) {
for (const handler of this._handlers[event] || []) handler(...args);
}
isClientConnected() {
return this.localPlayerId !== 0;
}
defaultServerUrl() {
const location = window.location;
if (location.protocol === "http:" || location.protocol === "https:") {
const scheme = location.protocol === "https:" ? "wss" : "ws";
return `${scheme}://${location.host}${DEFAULT_PATH}`;
}
return `ws://127.0.0.1:${DEFAULT_PORT}${DEFAULT_PATH}`;
}
// Accepts a bare hostname or a full ws(s):// or http(s):// URL.
normalizeUrl(host, port = DEFAULT_PORT) {
let url = String(host || "").trim().replace(/\/+$/, "");
if (url.startsWith("https://")) url = "wss://" + url.slice("https://".length);
else if (url.startsWith("http://")) url = "ws://" + url.slice("http://".length);
else if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
url = `ws://${url}:${port}`;
}
const afterScheme = url.slice(url.indexOf("://") + 3);
if (!afterScheme.includes("/")) url += DEFAULT_PATH;
return url;
}
connect(url) {
this.close();
this._loginFailure = "";
this.localPlayerId = 0;
this.isAdmin = false;
this._gameState = null;
return new Promise((resolve, reject) => {
let socket;
try {
socket = new WebSocket(url);
} catch (error) {
reject(error);
return;
}
this.socket = socket;
this._connectResolve = resolve;
this._connectReject = reject;
socket.onopen = () => {
if (this._connectResolve) {
this._connectResolve();
this._connectResolve = null;
this._connectReject = null;
}
};
socket.onerror = () => {
if (this._connectReject) {
this._connectReject(new Error("connection failed"));
this._connectResolve = null;
this._connectReject = null;
}
};
socket.onclose = () => {
const failure = this._loginFailure;
this._loginFailure = "";
this.localPlayerId = 0;
this.isAdmin = false;
if (this._connectReject) {
this._connectReject(new Error("connection closed"));
this._connectResolve = null;
this._connectReject = null;
return;
}
if (failure) this._emit("session_failed", failure);
else this._emit("server_disconnected");
};
socket.onmessage = (event) => {
let message;
try {
message = JSON.parse(event.data);
} catch {
return;
}
this._handleMessage(message);
};
});
}
requestGameList(url) {
return this.connect(url).then(() => {
this._send({ t: "request_games" });
});
}
joinGame(url, name, password) {
return this.connect(url).then(() => {
this._send({ t: "login", name, password });
});
}
claimCiv(index) {
this._send({ t: "claim_civ", civ: index });
}
sendOrder(order) {
this._send({ t: "order", order });
}
close() {
if (!this.socket) return;
const socket = this.socket;
this.socket = null;
socket.onopen = null;
socket.onerror = null;
socket.onclose = null;
socket.onmessage = null;
try {
socket.close();
} catch {
// ignore
}
}
_send(message) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
}
}
// Folds a delta into the last full state. A message without `delta` replaces
// it. `exploredAdded` carries only the tiles discovered since the last one, so
// the explored list (which only grows) is never re-sent in full.
_mergeGameState(state) {
if (!state || !state.delta || !this._gameState) {
this._gameState = state;
return;
}
const merged = { ...this._gameState, ...state };
if (state.exploredAdded) {
merged.explored = (this._gameState.explored || []).concat(state.exploredAdded);
delete merged.exploredAdded;
}
this._gameState = merged;
}
_handleMessage(message) {
switch (message.t) {
case "assigned":
this.localPlayerId = message.playerId;
this.isAdmin = !!message.admin;
this._emit("session_started", message);
break;
case "players":
this.players = message.players || {};
this._emit("players_updated", this.players);
break;
case "game_list":
this._emit("game_list_received", message.games || []);
break;
case "login_rejected":
this._loginFailure = message.reason || "Login failed.";
this._emit("session_failed", this._loginFailure);
this.close();
break;
case "choose_civ":
this._emit("choose_civ_received", message.civs || []);
break;
case "civ_assigned":
this._emit("civ_assigned", message.civ);
break;
case "game_state":
this._mergeGameState(message.state);
this._emit("game_state_received", this._gameState);
break;
default:
break;
}
}
}