refactor(wsapi): dedupe routing and seating, fix ordering races

Route room inputs through one toRoom helper, iterate live seats with
connected(), and share seating and reconnect-window code. Declare every
error code once in errcodes.go, which the web vocabulary test now reads.

Fixes:
- a kicked player is released before being told, so their next action
  is answered not_in_a_room
- a rejected word is counted before move_rejected is sent
- room code draw and registration share one lock hold
- two concurrent resumes with one token can no longer both take a seat
- multiplayer tests ready guests one at a time instead of racing

Behaviour changes:
- opening or joining another room while a game is running is refused
  with already_in_a_game
- resigning in the lobby answers game_not_started
This commit is contained in:
tiennm99 committed 2026-09-28 15:19:59 +07:00
1 parent 5848751c6b
commit 55abdb36c1
26 files changed
+643 -584

No files matched your search

+30 -19
View File
@@ -1,7 +1,7 @@
// ServerError.code is a UI key, so the server decides the vocabulary and this
// file has to speak all of it. The proto schema cannot help here — the codes
// are string literals in the Go transport, not an enum — so the guard reads
// them out of the source that emits them.
// are strings in the Go transport, not an enum — so the guard reads them out
// of the one file that declares them, server/internal/wsapi/errcodes.go.
//
// Without this, adding a code on the server degrades silently to the generic
// fallback: the player is told "something went wrong" for a situation the
@@ -14,35 +14,46 @@ import { describe, expect, it } from 'vitest';
import { errorMessages } from '../src/lib/i18n/vi.js';
const wsapiDir = fileURLToPath(new URL('../../server/internal/wsapi', import.meta.url));
const registry = 'errcodes.go';
/** Every code declared in the registry, as its wire string. */
function serverErrorCodes() {
const source = readFileSync(join(wsapiDir, registry), 'utf8');
return new Set([...source.matchAll(/errCode = "([a-z_]+)"/g)].map(([, code]) => code));
}
/**
* Every error-code literal in the transport, excluding its own tests. Both the
* one-recipient and the broadcast call sites count: a code that only ever goes
* to both players is no less a code the client has to know.
* Call sites that hand an emitter a string literal instead of a registry
* constant. Go converts an untyped literal to errCode silently, so this is
* what keeps a new code from bypassing the list the rest of this file checks.
*/
function serverErrorCodes() {
const codes = new Set();
const files = readdirSync(wsapiDir).filter((f) => f.endsWith('.go') && !f.endsWith('_test.go'));
for (const file of files) {
const source = readFileSync(join(wsapiDir, file), 'utf8');
for (const [, code] of source.matchAll(/(?:errorMsg|broadcastError)\("([a-z_]+)"\)/g)) {
codes.add(code);
}
}
return codes;
function literalCallSites() {
const files = readdirSync(wsapiDir).filter(
(f) => f.endsWith('.go') && !f.endsWith('_test.go') && f !== registry
);
return files.flatMap((file) =>
[
...readFileSync(join(wsapiDir, file), 'utf8').matchAll(
/\b(?:errorMsg|broadcastError|toRoom)\([^)]*"/g
)
].map(([call]) => `${file}: ${call}`)
);
}
describe('server error codes', () => {
const codes = serverErrorCodes();
it('finds the call sites at all, so an empty match cannot pass as agreement', () => {
// A rename of errorMsg would otherwise turn this whole file green by
// finding nothing to check.
it('finds the codes at all, so an empty match cannot pass as agreement', () => {
// A renamed type or moved registry would otherwise turn this whole file
// green by finding nothing to check.
expect(codes.size).toBeGreaterThan(10);
expect(codes.has('room_not_found')).toBe(true);
});
it('declares every code in the registry, never inline at a call site', () => {
expect(literalCallSites()).toEqual([]);
});
it('has a Vietnamese message for every code the server can send', () => {
const missing = [...codes].filter((code) => !(code in errorMessages)).sort();
expect(missing).toEqual([]);