0.2.6-dev: WebSocket permessage-deflate for frames from 1 KB (no server context takeover, 2 KB server window, memLevel 7; a client offering a narrower window connects uncompressed) — host bandwidth layer 0.2; golden: unchanged

This commit is contained in:
sganggs committed 2026-10-12 15:57:33 +08:00
1 parent 69011d2b54
commit 8edda589f3
6 files changed
+135 -8

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@
|---|---|
| 服务器 CPU | 战斗在各玩家浏览器里模拟(DESIGN §14),服务器只负责回合、经济和校验:**每个房间每个作战回合约 1 ms CPU**。AI 队友 / 掉线玩家的战场由服务器模拟:作战开始时 3 个 AI 战场在开发机上约 0.2–0.5 s CPU,小主机上可能要几秒(分成 8 ms 小片执行,不会卡住其他房间)。`SP_VERIFY=all` 会复算每个真人战场,CPU 明显增加,小主机建议保持 `off` 或 `sample`。 |
| 服务器内存 | 空闲约 100 MB,每个进行中的对局再增加几 MB。 |
| 网络 | 4 人对局中服务器每回合下行约 0.25 MB(DESIGN §14 实测)。首次进入游戏时浏览器要从主机下载所需的图片 / Spine 模型 / 音频(按需加载,之后走浏览器缓存),公网隧道带宽小时第一次会慢一些。 |
| 网络 | 4 人对局中服务器每回合下行约 0.25 MB(DESIGN §14 实测;0.2.6 起 1 KB 以上的 WebSocket 消息用 permessage-deflate 压缩,实测一局单人对局的服务器消息从 169 KB 降到 63 KB,约为原来的四成)。首次进入游戏时浏览器要从主机下载所需的图片 / Spine 模型 / 音频(按需加载,之后走浏览器缓存),公网隧道带宽小时第一次会慢一些。 |
| 磁盘 | 素材约 550 MB(`public/assets`,含中文约 65 MB、日文约 85 MB 两套干员语音)+ 依赖约 125 MB(`node_modules`;整合包只带运行依赖,约 65 MB);可选的本地提取约 40 MB(`.venv-extract`)+ 70 MB 贴图(见第 6 节)。完整包解压后约 710 MB。 |
| 玩家设备 | 支持 WebGL 的现代浏览器(Chrome / Edge / Firefox / Safari 最新版),电脑或手机平板(横屏)。老旧设备可在设置里调低画质或访问 `/?board=2d`。 |
+1 -1
View File
@@ -4,7 +4,7 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
## 8. Network protocol (shared/protocol.js is normative)
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound; rate limit 40 msgs/s per connection (excess dropped with `error RATE`).
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound (a compressed frame counts its inflated size); rate limit 40 msgs/s per connection (excess dropped with `error RATE`). Frames from 1 KB up are compressed with permessage-deflate (RFC 7692; `server/http/websocket.js WS_DEFLATE`: no server context takeover, a 2 KB server window, zlib memLevel 7 — about 72 KB of zlib state per connection); smaller frames go out plain, and a client that offers no compression, or caps the server window below 2 KB, gets plain frames. A measured solo session (rounds 3–5): 169 KB of server frames → 63 KB on the wire.
### 8.1 Session & lobby
C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.setAiPicksLast {on}` (host, co-op, before the match — `ROOM_STARTED` during it, `BAD_TARGET` in a solo room; un-readies the other humans like a difficulty change; the match takes it as `opts.aiPicksLast`: humans draft before AI seats, off by default, no extra random draw) · `room.addBot` / `room.removeBot {seat}` (host) · `room.kick {seat, playerId}` (host, before the match: another human leaves the room and gets `room.closed {reason:'kicked'}`; may join again; `playerId` = the player the host confirmed, a seat that changed hands meanwhile → `BAD_TARGET`) · `room.start` (host) · `room.loadout {entries, ops?}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`; `ops` — 0.2.2 — the per-operator 潜能 / 练度 `{ [charId]: { potential?: 1–6, cultivate?: 0–3 } }` of the 干员调配 roster and the 自选 owned pool, ≤ 256, checked strictly with the entries, `m.private.ops`; absent = none set: every operator 潜能 6, 精英2 Lv.60) · `room.ownership {notOwned}` (0.2.0 补位, the 干员持有 tab: the base chess ids marked as not owned — ≤ 160 ids, kept leniently: anything but a droppable NORMAL chess is dropped; stored on the session and the seat, a match takes the seat's list when it starts and never changes it — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes it as `m.private.standIns`) · `room.diy {picks}` (0.2.0 自选编队: `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the four DIY slots — ≤ 8 entries, kept leniently: shared/protocol.js `checkDiyPicks` drops an illegal pick (an operator without a kit, another tier's prototype, a prototype off its locked skill, a module of another game mode (集成战略 / 生息演算), an owned operator's second slot, the same operator twice in a tier, an unknown slot), only malformed picks are `BAD_MSG`; stored on the session and the seat like the not-owned list, a match takes the seat's picks when it starts — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes them as `m.private.diy`) · `ping {c}`
+2 -1
View File
@@ -42,7 +42,8 @@ server/
index.js process entry: startServer() wires server/http/ (plain node:http + ws); boots when run directly
http/
config.js ROOT, served dirs, env (PORT, HOST, TRUST_PROXY, DEBUG), options handed to net.js / lobby.js
websocket.js session wiring (SessionRegistry → Lobby → Network), WebSocket upgrade at /ws (64 KB frames)
websocket.js session wiring (SessionRegistry → Lobby → Network), WebSocket upgrade at /ws (64 KB frames,
permessage-deflate from 1 KB)
static.js mounts / → public, /data/, /shared/, /sim/ (.js only), the /data.js browser stand-in, path guards
media.js /media/… extension-less audio → public/assets/audio
files.js one file → response: MIME, gzip (.skel/.atlas/.json/.js/.css …), ETag / 304, cache policy, ranges
+35 -3
View File
@@ -2,8 +2,8 @@
//
// * session wiring: SessionRegistry (reconnect tokens) → Lobby (rooms, server/lobby.js) → Network (the socket
// protocol, server/net.js), built from the startServer() options (config.js decides which go where);
// * WebSocket (ws) at /ws, maxPayload 64 KB, no per-message deflate → Network.handleConnection. Refused at the
// upgrade: any other path 404; per-network socket limit for internet clients (maxConnectionsPerAddr, see net.js
// * WebSocket (ws) at /ws, maxPayload 64 KB, permessage-deflate (WS_DEFLATE) → Network.handleConnection. Refused at
// the upgrade: any other path 404; per-network socket limit for internet clients (maxConnectionsPerAddr, see net.js
// clientAddress; local/LAN peers are exempt) 429; server full (maxConnections) or shutting down 503.
import { WebSocketServer } from 'ws';
@@ -15,6 +15,37 @@ import { netOptionsFrom, lobbyOptionsFrom } from './config.js';
/** Inbound WebSocket frame limit (DESIGN §8). */
export const WS_MAX_PAYLOAD = 64 * 1024;
/**
* permessage-deflate (RFC 7692) for the JSON text frames. A message from 1 KB up (m.public, m.private, b.start, welcome
* …) is compressed on its own — no context is kept from one message to the next (`serverNoContextTakeover`) — with a
* 2 KB window and zlib memLevel 7, so a connection holds about 72 KB of zlib state; smaller frames (pong, ok) go out
* as they are. Measured on a solo session (rounds 3–5, 216 server frames): 169 KB of payload → 63 KB on the wire; a
* larger window gains nothing on these messages. Browsers negotiate it by themselves; a client that offers none gets
* plain frames. The inflated size of a client frame still counts against WS_MAX_PAYLOAD.
*/
export const WS_DEFLATE = Object.freeze({
threshold: 1024,
serverNoContextTakeover: true,
serverMaxWindowBits: 11,
zlibDeflateOptions: Object.freeze({ memLevel: 7 }),
});
/**
* Does every permessage-deflate offer of this `Sec-WebSocket-Extensions` header cap the server window below
* WS_DEFLATE.serverMaxWindowBits? ws cannot accept such an offer and refuses the whole handshake (400), so the upgrade
* drops the header instead and the connection goes uncompressed. No browser sends `server_max_window_bits`.
* @param {string | string[] | undefined} header
*/
export function narrowDeflateOffer(header) {
if (typeof header !== 'string') return false;
const offers = header.split(',').map((s) => s.trim()).filter((s) => /^permessage-deflate\b/i.test(s));
if (!offers.length) return false;
return offers.every((offer) => {
const m = /(?:^|;)\s*server_max_window_bits\s*=\s*"?(\d+)"?/i.exec(offer);
return !!m && Number(m[1]) < WS_DEFLATE.serverMaxWindowBits;
});
}
/**
* The session stack of one server.
* @param {{ MatchClass?: Function, seedFn?: () => number, [option: string]: any }} opts startServer() options
@@ -37,7 +68,7 @@ export function createSessionStack(opts, { data, log }) {
* @returns {WebSocketServer}
*/
export function attachWebSocket(server, { network, log }) {
const wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD, perMessageDeflate: false, clientTracking: false });
const wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD, perMessageDeflate: { ...WS_DEFLATE }, clientTracking: false });
wss.on('connection', (ws, req) => network.handleConnection(ws, req));
wss.on('error', (e) => log.error('[ws] server error', e));
@@ -51,6 +82,7 @@ export function attachWebSocket(server, { network, log }) {
const refused = network.admission(req);
if (refused === 'per-address') { reject(429, 'Too Many Requests'); return; }
if (refused) { reject(503, 'Service Unavailable'); return; }
if (narrowDeflateOffer(req.headers['sec-websocket-extensions'])) delete req.headers['sec-websocket-extensions'];
try {
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
} catch (e) {
+2 -2
View File
@@ -3,8 +3,8 @@
//
// http/config.js ROOT, the served directories, the environment (PORT 3000, HOST '::' dual-stack, TRUST_PROXY auto, DEBUG),
// which startServer() options go to net.js / lobby.js, the console logger
// http/websocket.js session wiring (SessionRegistry → Lobby → Network) and the WebSocket at /ws (maxPayload 64 KB;
// refused at upgrade with 404 / 429 per network / 503)
// http/websocket.js session wiring (SessionRegistry → Lobby → Network) and the WebSocket at /ws (maxPayload 64 KB,
// permessage-deflate from 1 KB; refused at upgrade with 404 / 429 per network / 503)
// http/static.js the static mounts (/ → public/, /data/, /shared/, /sim/ `.js` only), the /data.js browser stand-in,
// the content packs (/packs/index.json, /packs/<id>/<file> — the registry is packs.js)
// http/media.js /media/bgm/act1 → public/assets/audio/bgm/act1.mp3 (audio addressed without its extension)
+94
View File
@@ -0,0 +1,94 @@
// WebSocket permessage-deflate (server/http/websocket.js WS_DEFLATE, host bandwidth layer 0): a browser-like client
// negotiates it with no server context takeover and a 2 KB server window, a large JSON frame goes out compressed, a
// frame under the 1 KB threshold and a client without the extension get plain frames, and an offer that caps the server
// window below ours is answered without compression instead of a refused handshake.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import WebSocket from 'ws';
import { attachWebSocket, narrowDeflateOffer, WS_DEFLATE } from '../server/http/websocket.js';
import { startServer } from '../server/index.js';
const BIG = JSON.stringify({ t: 'm.public', players: Array.from({ length: 200 }, (_, i) => ({ playerId: `p_${i % 4}`, name: '阿米娅', lp: 30, phase: 'PREP' })) });
const SMALL = JSON.stringify({ t: 'pong', at: 12345 });
/** A bare http server whose /ws sends BIG then SMALL to every socket (a stand-in for net.js Network). */
async function fakeServer() {
const server = http.createServer((req, res) => { res.writeHead(404); res.end(); });
const network = {
admission: () => null,
handleConnection: (ws) => { ws.send(BIG); ws.send(SMALL); },
};
const wss = attachWebSocket(server, { network, log: { error() {} } });
await new Promise((r) => server.listen(0, '127.0.0.1', r));
return { url: `ws://127.0.0.1:${server.address().port}/ws`, close: () => new Promise((r) => { wss.close(); server.close(() => r()); }) };
}
/** Connect, collect two messages, report the server's Sec-WebSocket-Extensions answer and the bytes the socket read. */
function receive(url, opts) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, opts);
const msgs = [];
let negotiated = '';
ws.on('upgrade', (res) => { negotiated = res.headers['sec-websocket-extensions'] || ''; });
ws.on('error', reject);
ws.on('message', (data) => {
msgs.push(String(data));
if (msgs.length === 2) {
const read = ws._socket.bytesRead;
ws.close();
resolve({ msgs, read, extensions: negotiated });
}
});
});
}
describe('WebSocket permessage-deflate', () => {
let srv;
before(async () => { srv = await fakeServer(); });
after(async () => { await srv.close(); });
test('negotiated with no server context takeover and a small server window; a large frame is compressed', async () => {
const r = await receive(srv.url);
assert.match(r.extensions, /permessage-deflate/);
assert.match(r.extensions, /server_no_context_takeover/);
assert.match(r.extensions, new RegExp(`server_max_window_bits=${WS_DEFLATE.serverMaxWindowBits}`));
assert.deepEqual(r.msgs, [BIG, SMALL], 'the payloads arrive intact');
assert.ok(BIG.length > 10000);
assert.ok(r.read < BIG.length / 4, `compressed on the wire: ${r.read} bytes read for ${BIG.length} of payload`);
assert.equal(WS_DEFLATE.threshold, 1024);
});
test('a client without the extension gets the same messages uncompressed', async () => {
const r = await receive(srv.url, { perMessageDeflate: false });
assert.equal(r.extensions, '');
assert.deepEqual(r.msgs, [BIG, SMALL]);
assert.ok(r.read > BIG.length + SMALL.length, `plain frames: ${r.read}`);
});
test('an offer that caps the server window below ours connects uncompressed instead of a refused handshake', async () => {
const r = await receive(srv.url, { perMessageDeflate: { serverMaxWindowBits: 9 } });
assert.equal(r.extensions, '');
assert.deepEqual(r.msgs, [BIG, SMALL]);
assert.equal(narrowDeflateOffer('permessage-deflate; server_max_window_bits=9'), true);
assert.equal(narrowDeflateOffer('permessage-deflate; server_max_window_bits=12; client_max_window_bits'), false);
assert.equal(narrowDeflateOffer('permessage-deflate; server_max_window_bits=9, permessage-deflate'), false, 'another offer is acceptable');
assert.equal(narrowDeflateOffer('permessage-deflate; client_max_window_bits'), false);
assert.equal(narrowDeflateOffer(undefined), false);
});
});
test('the real server negotiates compression at /ws and keeps the 64 KB inflated frame limit', async () => {
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
try {
const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/ws`);
await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); });
assert.match(ws.extensions, /permessage-deflate/);
// a compressed client frame whose inflated size is over the limit closes the socket (1009), like a plain one
const closed = new Promise((resolve) => ws.once('close', (code) => resolve(code)));
ws.send(JSON.stringify({ t: 'hello', name: 'x'.repeat(70 * 1024) }));
assert.equal(await closed, 1009);
} finally {
await srv.close();
}
});