mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-05 18:14:35 +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.
156 lines
4.6 KiB
JavaScript
156 lines
4.6 KiB
JavaScript
import { expect } from '@playwright/test';
|
|
import { continuation } from './fixture-dictionary.js';
|
|
|
|
/**
|
|
* Shared page driving for the end-to-end specs.
|
|
*
|
|
* Everything here waits on a state the server produced — the turn indicator,
|
|
* the syllable, a word appearing in the chain — rather than on a fixed delay.
|
|
* A sleep would pass or fail depending on how loaded the machine is, which is
|
|
* the usual reason a browser suite goes intermittently red.
|
|
*/
|
|
|
|
/** @param {import('@playwright/test').Page} page */
|
|
export function board(page) {
|
|
return {
|
|
syllable: page.getByTestId('current-syllable'),
|
|
turn: page.getByTestId('turn-indicator'),
|
|
input: page.getByRole('textbox', { name: 'Nhập từ của bạn' }),
|
|
submit: page.getByRole('button', { name: 'Gửi', exact: true }),
|
|
chain: page.getByRole('list').first(),
|
|
rejection: page.getByRole('alert')
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Waits until it is this player's turn to move.
|
|
*
|
|
* @param {import('@playwright/test').Page} page
|
|
*/
|
|
export async function waitForMyTurn(page) {
|
|
await expect(board(page).turn).toHaveText('Đến lượt bạn', { timeout: 30_000 });
|
|
}
|
|
|
|
/**
|
|
* Types a word and sends it. The field is uncontrolled on purpose, so this
|
|
* fills and submits exactly as a player would.
|
|
*
|
|
* @param {import('@playwright/test').Page} page
|
|
* @param {string} word
|
|
*/
|
|
export async function submitWord(page, word) {
|
|
const b = board(page);
|
|
await b.input.fill(word);
|
|
await b.submit.click();
|
|
}
|
|
|
|
/**
|
|
* Plays one legal move for whatever syllable is currently required, and returns
|
|
* the word it played.
|
|
*
|
|
* @param {import('@playwright/test').Page} page
|
|
* @param {Set<string>} used
|
|
* @returns {Promise<string>}
|
|
*/
|
|
export async function playLegalMove(page, used) {
|
|
await waitForMyTurn(page);
|
|
|
|
const syllable = (await board(page).syllable.textContent())?.trim() ?? '';
|
|
const word = continuation(syllable, used);
|
|
expect(word, `the fixture has no unused continuation of "${syllable}"`).toBeTruthy();
|
|
|
|
used.add(/** @type {string} */ (word));
|
|
await submitWord(page, /** @type {string} */ (word));
|
|
await expect(page.getByText(/** @type {string} */ (word), { exact: true }).first()).toBeVisible();
|
|
return /** @type {string} */ (word);
|
|
}
|
|
|
|
/**
|
|
* The chat panel's parts, for the tests that drive a conversation.
|
|
*
|
|
* @param {import('@playwright/test').Page} page
|
|
*/
|
|
export function chat(page) {
|
|
return {
|
|
log: page.getByTestId('chat-log'),
|
|
input: page.getByTestId('chat-input'),
|
|
send: page.getByTestId('chat-send'),
|
|
unread: page.getByTestId('chat-unread'),
|
|
error: page.getByTestId('chat-error')
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Types a line and sends it, the way a player does.
|
|
*
|
|
* @param {import('@playwright/test').Page} page
|
|
* @param {string} text
|
|
*/
|
|
export async function say(page, text) {
|
|
const c = chat(page);
|
|
await c.input.fill(text);
|
|
await c.send.click();
|
|
}
|
|
|
|
/**
|
|
* 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, ...guests) {
|
|
for (const guest of guests) {
|
|
await guest.getByTestId('ready').click();
|
|
}
|
|
const start = owner.getByTestId('start-game');
|
|
await expect(start).toBeEnabled();
|
|
await start.click();
|
|
}
|
|
|
|
/**
|
|
* The seat rows in the lobby, one per player who is actually in the room. The
|
|
* free seats are drawn too, so counting rows would count the room's size
|
|
* rather than its occupants.
|
|
*
|
|
* @param {import('@playwright/test').Page} page
|
|
*/
|
|
export function seats(page) {
|
|
return page.locator('.seat:not(.empty)');
|
|
}
|
|
|
|
/**
|
|
* Picks a difficulty by clicking its card.
|
|
*
|
|
* The radio itself is visually hidden so the platform supplies arrow-key
|
|
* behaviour, so the card is what a player actually clicks.
|
|
*
|
|
* @param {import('@playwright/test').Page} page
|
|
* @param {string | RegExp} label
|
|
*/
|
|
export async function chooseDifficulty(page, label) {
|
|
await page.locator('label', { has: page.getByRole('radio', { name: label }) }).click();
|
|
await expect(page.getByRole('radio', { name: label })).toBeChecked();
|
|
}
|
|
|
|
/**
|
|
* Sets the nickname on the home screen.
|
|
*
|
|
* @param {import('@playwright/test').Page} page
|
|
* @param {string} name
|
|
*/
|
|
export async function setNickname(page, name) {
|
|
await page.getByLabel('Tên của bạn').fill(name);
|
|
}
|
|
|
|
/**
|
|
* Reads the words currently in the chain, opening word included.
|
|
*
|
|
* @param {import('@playwright/test').Page} page
|
|
* @returns {Promise<string[]>}
|
|
*/
|
|
export async function chainWords(page) {
|
|
return page.locator('ol li .word').allTextContents();
|
|
}
|