Files
adrien 1c6f0ae1f1 Cut the per-broadcast and per-frame cost on a full-sized map
The server sent the whole army on every snapshot and the client re-cloned
the whole icon layer whenever a unit moved, so a settled world with a
standing army drained the broadcast loop and the frame loop alike.

- Ship only the units and city list that changed, keyed by a per-peer
  fingerprint and a serialised hash, and merge those deltas on the client.
- Memoise the battlefield scan on a battle version bumped when a unit
  joins, leaves or changes side.
- Skip the DOM writes for units whose snapshot object is unchanged, and
  cache the unit-view inner nodes.
- Fold units, cities and tile markers onto the nearest world copy instead
  of mirroring the icon layers, which re-cloned thousands of views every
  frame; the small decoration layers still mirror, and only on a change.

Frame work at full zoom-out with units moving fell from ~190ms to ~6.5ms.
2026-09-24 00:33:51 +02:00

236 lines
7.7 KiB
JavaScript

import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom, FakeWebSocket } from "./framework/dom.js";
import { Network } from "../client/js/net.js";
export class NetTest extends TestCase {
async connected(url = "ws://localhost/ws") {
const env = await setupDom();
FakeWebSocket.reset();
const network = new Network();
const promise = network.connect(url);
const ws = FakeWebSocket.last;
ws.simulateOpen();
await promise;
return { env, network, ws };
}
async test_default_server_url_uses_the_page_origin() {
const env = await setupDom();
try {
this.assertEqual(new Network().defaultServerUrl(), "ws://game.example/ws");
} finally {
teardownDom(env);
}
}
async test_normalize_url_accepts_hosts_and_urls() {
const env = await setupDom();
try {
const network = new Network();
this.assertEqual(network.normalizeUrl("example.com"), "ws://example.com:27015/ws");
this.assertEqual(network.normalizeUrl("example.com", 1234), "ws://example.com:1234/ws");
this.assertEqual(network.normalizeUrl("http://example.com"), "ws://example.com/ws");
this.assertEqual(network.normalizeUrl("https://example.com/ws"), "wss://example.com/ws");
this.assertEqual(network.normalizeUrl("ws://example.com/socket"), "ws://example.com/socket");
} finally {
teardownDom(env);
}
}
async test_request_game_list_sends_and_receives() {
const { env, network, ws } = await this.connected();
try {
let games = null;
network.on("game_list_received", (value) => {
games = value;
});
const promise = network.requestGameList("ws://localhost/ws");
const socket = FakeWebSocket.last;
socket.simulateOpen();
await promise;
this.assertEqual(socket.sent[0], { t: "request_games" });
socket.simulateMessage({ t: "game_list", games: [{ name: "Game" }] });
this.assertEqual(games, [{ name: "Game" }]);
} finally {
teardownDom(env);
}
}
async test_login_success_emits_session_started() {
const env = await setupDom();
try {
FakeWebSocket.reset();
const network = new Network();
let started = null;
network.on("session_started", (message) => {
started = message;
});
const promise = network.joinGame("ws://localhost/ws", "Alice", "pw");
const ws = FakeWebSocket.last;
ws.simulateOpen();
await promise;
this.assertEqual(ws.lastSent(), { t: "login", name: "Alice", password: "pw" });
this.assertFalse(network.isClientConnected());
ws.simulateMessage({ t: "assigned", playerId: 7, admin: true, name: "Alice" });
this.assertEqual(started.playerId, 7);
this.assertEqual(network.localPlayerId, 7);
this.assertTrue(network.isAdmin);
this.assertTrue(network.isClientConnected());
} finally {
teardownDom(env);
}
}
async test_login_rejection_emits_session_failed() {
const { env, network, ws } = await this.connected();
try {
let failed = null;
network.on("session_failed", (message) => {
failed = message;
});
ws.simulateMessage({ t: "login_rejected", reason: "Wrong password for Alice." });
this.assertEqual(failed, "Wrong password for Alice.");
} finally {
teardownDom(env);
}
}
async test_players_and_game_state_events() {
const { env, network, ws } = await this.connected();
try {
let players = null;
let state = null;
network.on("players_updated", (value) => {
players = value;
});
network.on("game_state_received", (value) => {
state = value;
});
ws.simulateMessage({ t: "players", players: { 1: { id: 1, name: "A", admin: true } } });
this.assertEqual(players["1"].name, "A");
this.assertTrue(network.players["1"].admin);
ws.simulateMessage({ t: "game_state", state: { seed: 5, viewer: 0 } });
this.assertEqual(state.seed, 5);
} finally {
teardownDom(env);
}
}
async test_game_state_deltas_merge_into_the_last_full_state() {
const { env, network, ws } = await this.connected();
try {
let state = null;
network.on("game_state_received", (value) => {
state = value;
});
ws.simulateMessage({
t: "game_state",
state: { seed: 5, viewer: 0, territory: [[0, 0, 1]], explored: [[0, 0]], units: [] },
});
this.assertEqual(state.territory.length, 1, "the first state is used whole");
// A delta updates only what it carries and keeps the rest.
ws.simulateMessage({
t: "game_state",
state: { delta: true, unitsUpdated: [{ id: 1 }], exploredAdded: [[1, 0]] },
});
this.assertEqual(state.territory.length, 1, "unchanged territory is kept");
this.assertSize(state.units, 1, "the changed unit is merged in");
this.assertSize(state.explored, 2, "newly explored tiles are appended");
this.assertEqual(state.explored[1], [1, 0]);
this.assertNull(state.exploredAdded, "the temporary tail is dropped");
// A later delta appends new units and drops removed ones.
ws.simulateMessage({
t: "game_state",
state: { delta: true, unitsUpdated: [{ id: 2 }], unitsRemoved: [1] },
});
this.assertSize(state.units, 1, "the removed unit is gone");
this.assertEqual(state.units[0].id, 2, "and the new one remains");
this.assertNull(state.unitsUpdated, "the temporary update list is dropped");
this.assertNull(state.unitsRemoved, "and so is the removal list");
// A full state replaces the base.
ws.simulateMessage({
t: "game_state",
state: { seed: 6, viewer: 0, territory: [], explored: [], units: [] },
});
this.assertEqual(state.seed, 6, "a full state replaces the base");
} finally {
teardownDom(env);
}
}
async test_disconnected_without_failure_reports_it() {
const { env, network, ws } = await this.connected();
try {
let disconnected = false;
network.on("server_disconnected", () => {
disconnected = true;
});
ws.simulateClose();
this.assertTrue(disconnected);
} finally {
teardownDom(env);
}
}
async test_send_order_is_ignored_while_disconnected() {
const env = await setupDom();
try {
FakeWebSocket.reset();
const network = new Network();
network.sendOrder({ type: "move" });
this.assertEmpty(FakeWebSocket.instances, "no socket was opened");
network.close();
} finally {
teardownDom(env);
}
}
async test_join_game_sends_login() {
const env = await setupDom();
try {
FakeWebSocket.reset();
const network = new Network();
const promise = network.joinGame("ws://example.com/ws", "Bob", "pw");
const ws = FakeWebSocket.last;
this.assertEqual(ws.url, "ws://example.com/ws");
ws.simulateOpen();
await promise;
this.assertEqual(ws.lastSent(), { t: "login", name: "Bob", password: "pw" });
} finally {
teardownDom(env);
}
}
async test_unknown_messages_are_ignored() {
const { env, network, ws } = await this.connected();
try {
ws.simulateMessage({ t: "something-unknown" });
this.assertEqual(network.localPlayerId, 0);
} finally {
teardownDom(env);
}
}
async test_connection_error_rejects_the_connect_promise() {
const env = await setupDom();
try {
FakeWebSocket.reset();
const network = new Network();
const promise = network.connect("ws://localhost/ws");
FakeWebSocket.last.simulateError();
let failed = false;
try {
await promise;
} catch {
failed = true;
}
this.assertTrue(failed, "the connection promise rejected");
} finally {
teardownDom(env);
}
}
}