mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
A room holds up to four people and needs two to start. Both numbers are server constants sent to the client in RoomState, so the lobby draws whatever the server allows and widening a room is a server change alone. Failing a turn eliminates that player rather than ending the game. The syllable and the used words survive them, the turn passes to whoever is next, and the last player standing wins. Two seats is that same rule seen from close up, which is why there is one implementation of it and not two. A dead end still costs the first player to face it their own clock, as before: they get their turn, and lose it. Everyone behind them has already seen that board, so they go out together rather than each sitting out a turn limit they cannot use — which leaves the player who closed the position standing, the same outcome two players get. A player who is knocked out keeps their seat. They watch the rest of the game, chat included, with only the word input gone, and everybody lands back in the same lobby when it ends. The result screen is the whole table, ranked by who outlasted whom, with each score reported beside the place rather than deciding it. The turn clock is deliberately not paused for a seat that has dropped, so a player who loses their connection on their own turn loses it the way anybody else would. Their reconnect window decides only whether they are still in the game afterwards. Any number of windows can be open at once, settled by one timer armed for the nearest of them. Starting waits for every guest, not merely the first: a room of four that began on one yes would have dealt three people a turn they never agreed to. Kicking names a seat and is still refused on a player who is ready, and on the owner's own — leaving is what an owner who wants out does, and it hands the room on. Joining stays a lobby thing: a room with a game running turns a latecomer away even with seats going spare, because there is no way to hand somebody a game already in progress. BREAKING CHANGE: RoomState, TurnUpdate and GameOver lose the fields that could only ever describe a second player, OpponentLeft is retired in favour of presence on RoomState, and suggestions move to the new PlayerEliminated — they describe the position that beat a player, which by the end of a longer game is nobody else's position. ProtocolVersion goes to 2, so a client built against 1 is refused with a readable error rather than decoding a frame that now means something else.
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('away-p2')).toContainText('Lan 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('away-p2')).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('Có người đã 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();
|
|
});
|
|
});
|