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

+15
View File
@@ -65,6 +65,21 @@ export async function playLegalMove(page, used) {
return /** @type {string} */ (word);
}
/**
* Takes a seated pair from their lobby into a game: the guest readies, the
* owner starts. Nothing begins on its own now, so every online test that is
* about a game goes through here.
*
* @param {import('@playwright/test').Page} owner
* @param {import('@playwright/test').Page} guest
*/
export async function readyAndStart(owner, guest) {
await guest.getByTestId('ready').click();
const start = owner.getByTestId('start-game');
await expect(start).toBeEnabled();
await start.click();
}
/**
* Picks a difficulty by clicking its card.
*
+118 -25
View File
@@ -1,5 +1,12 @@
import { expect, test } from '@playwright/test';
import { board, chainWords, playLegalMove, setNickname, waitForMyTurn } from './helpers.js';
import {
board,
chainWords,
playLegalMove,
readyAndStart,
setNickname,
waitForMyTurn
} from './helpers.js';
/**
* Two browser contexts, so each player has their own storage, nickname and
@@ -36,15 +43,26 @@ async function joinRoom(page, nickname, code) {
await page.getByRole('button', { name: 'Vào phòng' }).click();
}
/** Seats a pair and plays them into a game, which is where most tests start. */
async function playingPair(browser) {
const pair = await twoPlayers(browser);
const code = await createRoom(pair.host, 'Minh');
await joinRoom(pair.guest, 'Lan', code);
await readyAndStart(pair.host, pair.guest);
await waitForMyTurn(pair.host);
return { ...pair, code };
}
test.describe('playing a stranger', () => {
test('two players join by code and alternate turns', async ({ browser }) => {
const { host, guest, close } = await twoPlayers(browser);
const code = await createRoom(host, 'Minh');
expect(code).toMatch(/^[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{6}$/);
await expect(host.getByTestId('waiting-status')).toBeVisible();
await expect(host.getByText('Còn trống')).toBeVisible();
await joinRoom(guest, 'Lan', code);
await readyAndStart(host, guest);
// Both boards come up, and exactly one player is on turn.
await waitForMyTurn(host);
@@ -76,6 +94,10 @@ test.describe('playing a stranger', () => {
// No code typed, no button pressed beyond opening the link.
await guest.goto(`/online?code=${code}`);
// The link seats them; the lobby is still where the game is agreed.
await expect(guest.getByTestId('ready')).toBeVisible();
await readyAndStart(host, guest);
await waitForMyTurn(host);
await expect(board(guest).syllable).toBeVisible();
@@ -83,11 +105,7 @@ test.describe('playing a stranger', () => {
});
test('resigning ends the game on both sides with the right winner', async ({ browser }) => {
const { host, guest, close } = await twoPlayers(browser);
const code = await createRoom(host, 'Minh');
await joinRoom(guest, 'Lan', code);
await waitForMyTurn(host);
const { host, guest, close } = await playingPair(browser);
host.on('dialog', (dialog) => dialog.accept());
await host.getByRole('button', { name: 'Đầu hàng' }).click();
@@ -98,23 +116,17 @@ test.describe('playing a stranger', () => {
await close();
});
test('a rematch restarts in the same room once both agree', async ({ browser }) => {
const { host, guest, close } = await twoPlayers(browser);
const code = await createRoom(host, 'Minh');
await joinRoom(guest, 'Lan', code);
await waitForMyTurn(host);
test('a second game is agreed in the lobby the first one ends in', async ({ browser }) => {
const { host, guest, code, close } = await playingPair(browser);
host.on('dialog', (dialog) => dialog.accept());
await host.getByRole('button', { name: 'Đầu hàng' }).click();
await expect(guest.getByTestId('rematch-prompt')).toBeVisible();
await expect(host.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible();
// One acceptance is not enough: the other player is still being asked.
await host.getByTestId('rematch-accept').click();
await expect(guest.getByTestId('rematch-prompt')).toContainText('Đối thủ muốn chơi lại');
await expect(host.getByTestId('rematch-prompt')).toContainText('Đang chờ đối thủ');
await guest.getByTestId('rematch-accept').click();
// The readiness that started the first game is spent, so the owner
// cannot simply start another.
await expect(host.getByTestId('start-game')).toBeDisabled();
await readyAndStart(host, guest);
// A new game in the same room: the code is unchanged and the board is
// back to a single opening word.
@@ -125,6 +137,90 @@ test.describe('playing a stranger', () => {
await close();
});
test('the guest readies, and cannot leave without taking it back', async ({ browser }) => {
const { host, guest, close } = await twoPlayers(browser);
const code = await createRoom(host, 'Minh');
await joinRoom(guest, 'Lan', code);
// Nothing starts until the owner says so.
await expect(host.getByTestId('start-game')).toBeDisabled();
await guest.getByTestId('ready').click();
await expect(guest.getByTestId('my-ready')).toHaveText('Đã sẵn sàng');
await expect(host.getByTestId('opponent-ready')).toHaveText('Đã sẵn sàng');
await expect(host.getByTestId('start-game')).toBeEnabled();
// Ready is a commitment: the way out is to take it back first.
await expect(guest.getByRole('button', { name: 'Rời phòng' })).toBeDisabled();
await guest.getByTestId('ready').click();
await expect(guest.getByRole('button', { name: 'Rời phòng' })).toBeEnabled();
await guest.getByRole('button', { name: 'Rời phòng' }).click();
// The room survives with its owner in it, one seat free.
await expect(host.getByText('Còn trống')).toBeVisible();
await expect(host.getByTestId('start-game')).toBeDisabled();
// And the player who left is back on the join screen.
await expect(guest.getByRole('button', { name: 'Tạo phòng' })).toBeVisible();
await close();
});
test('the owner can put an unready guest out of the room', async ({ browser }) => {
const { host, guest, close } = await twoPlayers(browser);
const code = await createRoom(host, 'Minh');
await joinRoom(guest, 'Lan', code);
const kick = host.getByRole('button', { name: 'Mời ra khỏi phòng' });
await expect(kick).toBeEnabled();
// A guest who is ready is waiting on the owner, not in the way.
await guest.getByTestId('ready').click();
await expect(kick).toBeDisabled();
await guest.getByTestId('ready').click();
await expect(kick).toBeEnabled();
host.on('dialog', (dialog) => dialog.accept());
await kick.click();
await expect(guest.getByTestId('join-error')).toHaveText('Bạn đã bị mời ra khỏi phòng.');
await expect(host.getByText('Còn trống')).toBeVisible();
await close();
});
test('the room outlives its owner, who hands it to whoever is left', async ({ browser }) => {
const { host, guest, close } = await twoPlayers(browser);
const code = await createRoom(host, 'Minh');
await joinRoom(guest, 'Lan', code);
await expect(guest.locator('.role', { hasText: 'Chủ phòng' })).toBeVisible();
await host.getByRole('button', { name: 'Rời phòng' }).click();
// The guest is the owner now, which is visible in what they are
// offered rather than only in a label.
await expect(guest.getByTestId('start-game')).toBeVisible();
await expect(guest.getByText('Còn trống')).toBeVisible();
// And the promotion is real: the next person in can be played against.
const thirdContext = await browser.newContext();
const third = await thirdContext.newPage();
await joinRoom(third, 'Nam', code);
await readyAndStart(guest, third);
// Both boards come up. Which of them moves first is the engine's
// business, and asserting it here would test seat order instead of the
// promotion.
await expect(board(guest).syllable).toBeVisible();
await expect(board(third).syllable).toBeVisible();
await thirdContext.close();
await close();
});
test('an unknown room code is refused in Vietnamese', async ({ page }) => {
await page.goto('/online');
await setNickname(page, 'Minh');
@@ -146,11 +242,8 @@ test.describe('playing a stranger', () => {
});
test('a full room turns a third player away', async ({ browser }) => {
const { host, guest, close } = await twoPlayers(browser);
const code = await createRoom(host, 'Minh');
await joinRoom(guest, 'Lan', code);
await waitForMyTurn(host);
const { host, code, close } = await playingPair(browser);
void host;
const thirdContext = await browser.newContext();
const third = await thirdContext.newPage();
+9 -1
View File
@@ -1,5 +1,12 @@
import { expect, test } from '@playwright/test';
import { board, chainWords, playLegalMove, setNickname, waitForMyTurn } from './helpers.js';
import {
board,
chainWords,
playLegalMove,
readyAndStart,
setNickname,
waitForMyTurn
} from './helpers.js';
import { cuttableSocket } from './socket-cut.js';
/**
@@ -29,6 +36,7 @@ async function pvpRoom(browser) {
await setNickname(guest, 'Lan');
await guest.getByLabel('Mã phòng').fill(code);
await guest.getByRole('button', { name: 'Vào phòng' }).click();
await readyAndStart(host, guest);
await waitForMyTurn(host);
return {