feat(online): make a room a lobby with an owner and a ready-up

A room used to be a wrapper around one game: joining started it, and the
room died with it unless both players accepted a rematch inside thirty
seconds. It is now a lobby that outlives its games.

Whoever created the room owns it and the other seat is the guest. The guest
readies and the owner starts; the owner has no readiness of their own,
because starting is the same statement. A finished game returns both to the
lobby, where the next one is agreed exactly as the last was — the readiness
that started a game is spent with it.

A guest takes their readiness back before leaving, which is deliberate
friction: a player the owner is waiting on should have to say so before
walking away. The owner can free the seat of a guest who is not ready, and
not of one who is — readiness is a commitment, not an inconvenience. An
owner who leaves hands the room to whoever is left, unreadied, because they
are the one who starts now.

Something has to bound a room that outlives its games: the last player out
closes it, as does ten minutes in a lobby nobody started a game in. A
dropped connection is still not a player leaving — the seat is held for the
reconnect window in the lobby as well as mid-game, so a refresh no longer
costs somebody their room, and a resume lands in the lobby it left.

The rematch handshake is retired, and RoomCreated and RoomJoined go with it.
All three described part of what RoomState now describes in full, and three
messages for one lobby is three ways for a client to hold a view of it the
server never had. One snapshot per recipient, broadcast from the one place
that knows an input is finished, so no handler can forget to send it.
This commit is contained in:
tiennm99 committed 2026-09-07 16:32:14 +07:00
1 parent ef638e52fd
commit d481a093ec
39 files changed
+2352 -1215

No files matched your search

+6 -2
View File
@@ -15,14 +15,18 @@ import { errorMessages } from '../src/lib/i18n/vi.js';
const wsapiDir = fileURLToPath(new URL('../../server/internal/wsapi', import.meta.url));
/** Every `errorMsg("…")` literal in the transport, excluding its own tests. */
/**
* 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.
*/
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\("([a-z_]+)"\)/g)) {
for (const [, code] of source.matchAll(/(?:errorMsg|broadcastError)\("([a-z_]+)"\)/g)) {
codes.add(code);
}
}
+89 -56
View File
@@ -222,25 +222,40 @@ describe('opponentLeft', () => {
});
});
/** One lobby snapshot, with the fields a test does not care about defaulted. */
function lobby(fields = {}) {
return msg('roomState', {
roomCode: 'ABCD',
iAmOwner: false,
canStart: false,
iAmReady: false,
opponentPresent: false,
opponentName: '',
opponentReady: false,
opponentConnected: false,
...fields
});
}
describe('room messages', () => {
it('keeps the room code and the sanitized opponent name', () => {
const store = createGameStore();
store.apply(msg('roomCreated', { roomCode: 'ABCD' }));
expect(store.state.roomCode).toBe('ABCD');
store.apply(lobby({ opponentPresent: true, opponentName: 'Lan', opponentConnected: true }));
store.apply(msg('roomJoined', { roomCode: 'ABCD', opponentName: 'Lan' }));
expect(store.state.roomCode).toBe('ABCD');
expect(store.state.opponentName).toBe('Lan');
});
it('survives a reset, because identity outlives one game', () => {
const store = createGameStore();
store.apply(msg('welcome', { acceptedNickname: 'Minh', sessionId: 's', resumeToken: 't' }));
store.apply(msg('roomJoined', { roomCode: 'ABCD', opponentName: 'Lan' }));
store.apply(lobby({ opponentPresent: true, opponentName: 'Lan', opponentConnected: true }));
store.reset();
expect(store.state.nickname).toBe('Minh');
expect(store.state.roomCode).toBe('ABCD');
expect(store.state.opponentName).toBe('Lan');
expect(store.state.opponentPresent).toBe(true);
expect(store.state.chain).toEqual([]);
});
});
@@ -255,70 +270,88 @@ describe('pong', () => {
});
});
describe('online rooms', () => {
it('waits once the room exists but has one seat filled', () => {
describe('the lobby', () => {
it('opens the lobby when the room appears', () => {
const store = createGameStore();
store.apply(msg('roomCreated', { roomCode: 'K7M2QP' }));
store.apply(lobby({ roomCode: 'K7M2QP', iAmOwner: true }));
expect(store.state.phase).toBe('waiting');
expect(store.state.phase).toBe('lobby');
expect(store.state.roomCode).toBe('K7M2QP');
expect(store.state.isOwner).toBe(true);
});
it('does not drop a live game back into waiting', () => {
// A resumed session is told its room again. Treating that as "waiting"
// would replace the board with the invite screen mid-game.
it('takes every field from the server rather than deriving any', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('roomCreated', { roomCode: 'K7M2QP' }));
store.apply(
lobby({
iAmOwner: false,
iAmReady: true,
canStart: true,
opponentPresent: true,
opponentName: 'Lan',
opponentReady: false,
opponentConnected: true
})
);
expect(store.state.phase).toBe('playing');
});
});
describe('rematch offers', () => {
function finished(store) {
store.apply(started());
store.apply(msg('gameOver', { iWon: false, reason: GameEndReason.RESIGNED }));
}
it('records both sides of the answer as the server rendered them', () => {
const store = createGameStore();
finished(store);
store.apply(msg('rematchState', { iAccepted: true, opponentAccepted: false, expiresInMs: 27500 }));
expect(store.state.rematch).toEqual({
iAccepted: true,
opponentAccepted: false,
expiresInMs: 27500
expect(store.state).toMatchObject({
isOwner: false,
isReady: true,
canStart: true,
opponentPresent: true,
opponentName: 'Lan',
opponentReady: false,
opponentConnected: true
});
});
it('ends the offer when the opponent cannot come back', () => {
// The countdown would otherwise keep running with nobody to answer it.
it('does not drop a live game back into the lobby', () => {
// A room state arriving mid-game is a presence change, not a phase.
// Acting on it would replace the board with the lobby mid-turn.
const store = createGameStore();
finished(store);
store.apply(msg('rematchState', { iAccepted: true, opponentAccepted: false, expiresInMs: 27500 }));
store.apply(msg('opponentLeft', { canReconnect: false, graceMs: 0 }));
store.apply(started());
store.apply(lobby({ opponentPresent: true, opponentConnected: true }));
expect(store.state.rematch).toBeNull();
});
it('keeps the offer while the opponent is only disconnected', () => {
const store = createGameStore();
finished(store);
store.apply(msg('rematchState', { iAccepted: false, opponentAccepted: true, expiresInMs: 20000 }));
store.apply(msg('opponentLeft', { canReconnect: true, graceMs: 30000 }));
expect(store.state.rematch).not.toBeNull();
});
it('clears the offer once the next game starts', () => {
const store = createGameStore();
finished(store);
store.apply(msg('rematchState', { iAccepted: true, opponentAccepted: true, expiresInMs: 15000 }));
store.apply(started({ openingWord: 'thí sinh' }));
expect(store.state.rematch).toBeNull();
expect(store.state.phase).toBe('playing');
});
it('keeps the result on screen when a finished game returns to the lobby', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('gameOver', { iWon: false, reason: GameEndReason.RESIGNED }));
store.apply(lobby({ opponentPresent: true, opponentConnected: true }));
expect(store.state.phase).toBe('over');
expect(store.state.result).not.toBeNull();
});
it('stops waiting for an opponent who is connected again', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('opponentLeft', { canReconnect: true, graceMs: 30_000 }));
store.apply(lobby({ opponentPresent: true, opponentConnected: true }));
expect(store.state.opponentLeft).toBeNull();
expect(store.state.opponentConnected).toBe(true);
});
it('forgets the room when this player is kicked out of it', () => {
const store = createGameStore();
store.apply(lobby({ opponentPresent: true, opponentConnected: true }));
store.apply(msg('error', { code: 'kicked', message: '' }));
expect(store.state.phase).toBe('idle');
expect(store.state.roomCode).toBe('');
expect(store.state.error).toBe('Bạn đã bị mời ra khỏi phòng.');
});
it('forgets a room that closed for sitting idle', () => {
const store = createGameStore();
store.apply(lobby({ iAmOwner: true }));
store.apply(msg('error', { code: 'room_idle_closed', message: '' }));
expect(store.state.phase).toBe('idle');
expect(store.state.roomCode).toBe('');
expect(store.state.error).not.toBeNull();
});
});
+2 -2
View File
@@ -323,10 +323,10 @@ describe('frames', () => {
const h = setup();
h.client.connect();
h.last().open();
h.last().deliver(serverMsg('roomCreated', { roomCode: 'ABCD' }));
h.last().deliver(serverMsg('roomState', { roomCode: 'ABCD' }));
expect(h.received).toHaveLength(1);
expect(h.received[0].payload.case).toBe('roomCreated');
expect(h.received[0].payload.case).toBe('roomState');
});
it('drops an undecodable frame instead of ending the session', () => {