mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 12:28:54 +00:00
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.
127 lines
4.0 KiB
JavaScript
127 lines
4.0 KiB
JavaScript
import { expect, test } from '@playwright/test';
|
|
import {
|
|
board,
|
|
chainWords,
|
|
playLegalMove,
|
|
readyAndStart,
|
|
setNickname,
|
|
waitForMyTurn
|
|
} from './helpers.js';
|
|
import { cuttableSocket } from './socket-cut.js';
|
|
|
|
/**
|
|
* The reconnect path, driven by cutting one player's socket.
|
|
*
|
|
* The cut is abrupt and unannounced, which is the case the grace window exists
|
|
* for: a player who closes the tab politely is a different, easier story.
|
|
*/
|
|
|
|
/**
|
|
* Seats two players in a room and returns them with the room code.
|
|
*
|
|
* @param {import('@playwright/test').Browser} browser
|
|
*/
|
|
async function pvpRoom(browser) {
|
|
const hostContext = await browser.newContext();
|
|
const guestContext = await browser.newContext();
|
|
const host = await hostContext.newPage();
|
|
const guest = await guestContext.newPage();
|
|
|
|
await host.goto('/online');
|
|
await setNickname(host, 'Minh');
|
|
await host.getByRole('button', { name: 'Tạo phòng' }).click();
|
|
const code = ((await host.getByTestId('room-code').textContent()) ?? '').replace(/\s+/g, '');
|
|
|
|
await guest.goto('/online');
|
|
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 {
|
|
host,
|
|
guest,
|
|
code,
|
|
async close() {
|
|
await hostContext.close();
|
|
await guestContext.close();
|
|
}
|
|
};
|
|
}
|
|
|
|
test.describe('losing the connection', () => {
|
|
test('the opponent is told, and a return inside the window resumes the game', async ({
|
|
browser
|
|
}) => {
|
|
const { host, guest, close } = await pvpRoom(browser);
|
|
|
|
const used = new Set(await chainWords(host));
|
|
await playLegalMove(host, used);
|
|
await waitForMyTurn(guest);
|
|
|
|
const hostSyllable = (await board(host).syllable.textContent())?.trim() ?? '';
|
|
|
|
// Navigating away takes the socket with it, which is what the server
|
|
// sees when a player loses their connection. The resume token lives in
|
|
// this tab's session storage, so coming back can reclaim the seat.
|
|
await guest.goto('about:blank');
|
|
|
|
await expect(host.getByTestId('opponent-status')).toContainText('Đối thủ mất kết nối', {
|
|
timeout: 20_000
|
|
});
|
|
|
|
// Back inside the grace window.
|
|
await guest.goto('/online');
|
|
|
|
// The seat is restored: the host stops waiting, and the returning player
|
|
// is looking at the same position rather than the lobby.
|
|
await expect(host.getByTestId('opponent-status')).toHaveCount(0, { timeout: 20_000 });
|
|
await expect(board(guest).syllable).toHaveText(hostSyllable, { timeout: 20_000 });
|
|
await expect(board(guest).turn).toHaveText('Đến lượt bạn');
|
|
|
|
await close();
|
|
});
|
|
|
|
test('an opponent who never comes back forfeits the game', async ({ browser }) => {
|
|
const { host, guest, close } = await pvpRoom(browser);
|
|
|
|
// Closing the tab: the socket goes and nothing reconnects, so the grace
|
|
// window has to expire on its own.
|
|
await guest.close();
|
|
|
|
// NOITU_GRACE is 25s on the test server, so this waits it out.
|
|
await expect(host.getByRole('heading', { name: 'Bạn thắng!' })).toBeVisible({
|
|
timeout: 45_000
|
|
});
|
|
await expect(host.getByText('Đối thủ đã rời trận.')).toBeVisible();
|
|
|
|
await close();
|
|
});
|
|
|
|
test('a word cannot be typed into a connection that is down', async ({ browser }) => {
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
const socket = await cuttableSocket(page);
|
|
|
|
await page.goto('/play?difficulty=1');
|
|
await waitForMyTurn(page);
|
|
|
|
await socket.cut({ sustained: true });
|
|
|
|
await expect(page.getByText('Mất kết nối, đang thử lại…')).toBeVisible({ timeout: 20_000 });
|
|
|
|
// Disabled rather than accepting a word that cannot go anywhere and
|
|
// leaving the player to watch their turn expire.
|
|
await expect(board(page).input).toBeDisabled();
|
|
await expect(board(page).submit).toBeDisabled();
|
|
|
|
// And it comes back on its own once the connection does.
|
|
socket.restore();
|
|
await expect(page.getByText('Đã kết nối')).toBeVisible({ timeout: 20_000 });
|
|
await expect(board(page).input).toBeEnabled();
|
|
|
|
await context.close();
|
|
});
|
|
});
|