mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-13 00:15:01 +00:00
feat(online)!: seat two to four players in a room
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.
This commit is contained in:
1 parent
0b507c3537
commit
4e2e9e1433
54 files changed
+3692
-1129
No files matched your search
+15
-2
@@ -100,13 +100,26 @@ export async function say(page, text) {
|
||||
* @param {import('@playwright/test').Page} owner
|
||||
* @param {import('@playwright/test').Page} guest
|
||||
*/
|
||||
export async function readyAndStart(owner, guest) {
|
||||
await guest.getByTestId('ready').click();
|
||||
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.
|
||||
*
|
||||
|
||||
+100
-9
@@ -6,6 +6,7 @@ import {
|
||||
playLegalMove,
|
||||
readyAndStart,
|
||||
say,
|
||||
seats,
|
||||
setNickname,
|
||||
waitForMyTurn
|
||||
} from './helpers.js';
|
||||
@@ -61,14 +62,16 @@ test.describe('playing a stranger', () => {
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
expect(code).toMatch(/^[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{6}$/);
|
||||
await expect(host.getByText('Còn trống')).toBeVisible();
|
||||
await expect(host.getByTestId('player-count')).toHaveText('1/4 người chơi');
|
||||
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
await readyAndStart(host, guest);
|
||||
|
||||
// Both boards come up, and exactly one player is on turn.
|
||||
await waitForMyTurn(host);
|
||||
await expect(board(guest).turn).toHaveText('Đối thủ đang suy nghĩ…');
|
||||
// Whose turn it is, by name: with four seats "the opponent" would stop
|
||||
// naming anybody.
|
||||
await expect(board(guest).turn).toHaveText('Đến lượt Minh…');
|
||||
|
||||
// Each side is shown the other's server-sanitized name.
|
||||
await expect(host.locator('.who', { hasText: 'Lan' })).toBeVisible();
|
||||
@@ -150,7 +153,7 @@ test.describe('playing a stranger', () => {
|
||||
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('ready-p2')).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.
|
||||
@@ -160,8 +163,8 @@ test.describe('playing a stranger', () => {
|
||||
|
||||
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();
|
||||
// The room survives with its owner in it, and the rest going spare.
|
||||
await expect(host.getByTestId('player-count')).toHaveText('1/4 người chơi');
|
||||
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();
|
||||
@@ -188,7 +191,7 @@ test.describe('playing a stranger', () => {
|
||||
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 expect(host.getByTestId('player-count')).toHaveText('1/4 người chơi');
|
||||
|
||||
await close();
|
||||
});
|
||||
@@ -205,7 +208,7 @@ test.describe('playing a stranger', () => {
|
||||
// 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();
|
||||
await expect(guest.getByTestId('player-count')).toHaveText('1/4 người chơi');
|
||||
|
||||
// And the promotion is real: the next person in can be played against.
|
||||
const thirdContext = await browser.newContext();
|
||||
@@ -371,7 +374,9 @@ test.describe('playing a stranger', () => {
|
||||
await expect(page.getByTestId('join-error')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a full room turns a third player away', async ({ browser }) => {
|
||||
test('a game in progress turns a latecomer away', async ({ browser }) => {
|
||||
// The room has seats going spare; arriving in the middle of a game is
|
||||
// what is refused, not the room being full.
|
||||
const { host, code, close } = await playingPair(browser);
|
||||
void host;
|
||||
|
||||
@@ -379,7 +384,93 @@ test.describe('playing a stranger', () => {
|
||||
const third = await thirdContext.newPage();
|
||||
await joinRoom(third, 'Nam', code);
|
||||
|
||||
await expect(third.getByTestId('join-error')).toHaveText('Phòng đã đủ người.');
|
||||
await expect(third.getByTestId('join-error')).toHaveText('Ván đấu đang diễn ra.');
|
||||
|
||||
await thirdContext.close();
|
||||
await close();
|
||||
});
|
||||
|
||||
test('four players fill the room, and a fifth is turned away', async ({ browser }) => {
|
||||
const { host, guest, code, close } = await twoPlayers(browser).then(async (pair) => ({
|
||||
...pair,
|
||||
code: await createRoom(pair.host, 'Minh')
|
||||
}));
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
|
||||
/** @type {import('@playwright/test').BrowserContext[]} */
|
||||
const extra = [];
|
||||
/** @type {import('@playwright/test').Page[]} */
|
||||
const players = [];
|
||||
for (const name of ['Nam', 'Hà']) {
|
||||
const context = await browser.newContext();
|
||||
extra.push(context);
|
||||
const page = await context.newPage();
|
||||
players.push(page);
|
||||
await joinRoom(page, name, code);
|
||||
}
|
||||
|
||||
await expect(host.getByTestId('player-count')).toHaveText('4/4 người chơi');
|
||||
await expect(seats(host)).toHaveCount(4);
|
||||
|
||||
const fifthContext = await browser.newContext();
|
||||
const fifth = await fifthContext.newPage();
|
||||
await joinRoom(fifth, 'Bình', code);
|
||||
await expect(fifth.getByTestId('join-error')).toHaveText('Phòng đã đủ người.');
|
||||
|
||||
// Every guest has to say yes, not just the first one.
|
||||
await guest.getByTestId('ready').click();
|
||||
await expect(host.getByTestId('start-game')).toBeDisabled();
|
||||
await readyAndStart(host, ...players);
|
||||
|
||||
// Four boards, and exactly one player on turn.
|
||||
await waitForMyTurn(host);
|
||||
for (const page of [guest, ...players]) {
|
||||
await expect(board(page).turn).toHaveText('Đến lượt Minh…');
|
||||
}
|
||||
|
||||
await fifthContext.close();
|
||||
for (const context of extra) await context.close();
|
||||
await close();
|
||||
});
|
||||
|
||||
test('a player who goes out is a spectator, and the rest play on', async ({ browser }) => {
|
||||
// Three players, so the game outlives the first knockout. Resignation
|
||||
// rather than the clock: the turn limit on the test server is two
|
||||
// minutes, and both go through the same elimination.
|
||||
const { host, guest, code, close } = await twoPlayers(browser).then(async (pair) => ({
|
||||
...pair,
|
||||
code: await createRoom(pair.host, 'Minh')
|
||||
}));
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
|
||||
const thirdContext = await browser.newContext();
|
||||
const third = await thirdContext.newPage();
|
||||
await joinRoom(third, 'Nam', code);
|
||||
|
||||
await readyAndStart(host, guest, third);
|
||||
await waitForMyTurn(host);
|
||||
|
||||
host.on('dialog', (dialog) => dialog.accept());
|
||||
await host.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||
|
||||
// Out, but still in the room: no input, no result screen, and the game
|
||||
// carrying on in front of them.
|
||||
await expect(host.getByTestId('eliminated')).toBeVisible();
|
||||
await expect(host.getByRole('textbox', { name: 'Nhập từ của bạn' })).toHaveCount(0);
|
||||
await expect(host.getByRole('heading', { name: 'Bạn thua.' })).toHaveCount(0);
|
||||
await expect(host.getByTestId('turn-indicator')).toHaveText('Đến lượt Lan…');
|
||||
|
||||
// The others are told who went out, and one of them is now on turn.
|
||||
await expect(guest.getByTestId('player-out')).toContainText('Minh');
|
||||
await waitForMyTurn(guest);
|
||||
|
||||
// The last two settle it, and everybody sees the same table.
|
||||
guest.on('dialog', (dialog) => dialog.accept());
|
||||
await guest.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||
|
||||
await expect(third.getByRole('heading', { name: 'Bạn thắng!' })).toBeVisible();
|
||||
await expect(host.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible();
|
||||
await expect(host.getByTestId('standings').locator('li')).toHaveCount(3);
|
||||
|
||||
await thirdContext.close();
|
||||
await close();
|
||||
|
||||
@@ -67,7 +67,7 @@ test.describe('losing the connection', () => {
|
||||
// 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', {
|
||||
await expect(host.getByTestId('away-p2')).toContainText('Lan mất kết nối', {
|
||||
timeout: 20_000
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ test.describe('losing the connection', () => {
|
||||
|
||||
// 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(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');
|
||||
|
||||
@@ -94,7 +94,7 @@ test.describe('losing the connection', () => {
|
||||
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 expect(host.getByText('Có người đã rời trận.')).toBeVisible();
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
{#each entries as entry, index}
|
||||
<li class:mine={entry.byMe} class:opening={entry.opening} class:latest={index === 0}>
|
||||
<span class="word">{entry.word}</span>
|
||||
<!-- Who played it, not merely whether it was mine: a chain
|
||||
four people built is unreadable without the names. -->
|
||||
{#if !entry.opening && !entry.byMe && game.nameOf(entry.playerId)}
|
||||
<span class="by">{game.nameOf(entry.playerId)}</span>
|
||||
{/if}
|
||||
<span class="meta">
|
||||
{#if entry.syllables > 2}
|
||||
<span class="badge">{entry.syllables} {t.syllableUnit}</span>
|
||||
@@ -116,6 +121,11 @@
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.by {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import CountdownRing from '$lib/components/CountdownRing.svelte';
|
||||
import ScoreBoard from '$lib/components/ScoreBoard.svelte';
|
||||
import WordInput from '$lib/components/WordInput.svelte';
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/**
|
||||
@@ -13,7 +13,6 @@
|
||||
* that arrives as a snippet rather than as a branch in here.
|
||||
*
|
||||
* @type {{
|
||||
* opponentLabel: string,
|
||||
* modeLabel?: string,
|
||||
* onsubmit: (word: string) => boolean,
|
||||
* onresign: () => void,
|
||||
@@ -22,7 +21,15 @@
|
||||
* chat?: import('svelte').Snippet
|
||||
* }}
|
||||
*/
|
||||
let { opponentLabel, modeLabel = '', onsubmit, onresign, gameOver, banner, chat } = $props();
|
||||
let { modeLabel = '', onsubmit, onresign, gameOver, banner, chat } = $props();
|
||||
|
||||
// Whose turn it is, said by name. With four people at the table "the
|
||||
// opponent is thinking" stops naming anybody.
|
||||
const turnLabel = $derived.by(() => {
|
||||
if (game.state.myTurn) return t.yourTurn;
|
||||
const name = game.nameOf(game.state.turnPlayerId);
|
||||
return name ? fill(t.playerTurn, { name }) : t.opponentTurn;
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="board" data-phase={game.state.phase}>
|
||||
@@ -31,7 +38,7 @@
|
||||
{#if modeLabel}<span class="mode">{modeLabel}</span>{/if}
|
||||
</div>
|
||||
|
||||
<ScoreBoard {opponentLabel} />
|
||||
<ScoreBoard />
|
||||
|
||||
{#if banner}{@render banner()}{/if}
|
||||
|
||||
@@ -48,9 +55,7 @@
|
||||
<div class="turn">
|
||||
<CountdownRing />
|
||||
<div class="prompt">
|
||||
<p class="who" data-testid="turn-indicator">
|
||||
{game.state.myTurn ? t.yourTurn : t.opponentTurn}
|
||||
</p>
|
||||
<p class="who" data-testid="turn-indicator">{turnLabel}</p>
|
||||
<p class="syllable">
|
||||
<span class="label">{t.currentSyllable}</span>
|
||||
<strong data-testid="current-syllable">{game.state.currentSyllable || '…'}</strong>
|
||||
@@ -58,7 +63,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WordInput {onsubmit} />
|
||||
<!-- A player who has been knocked out watches the rest of it: the chain,
|
||||
the clock and the chat all keep working, and only the one thing
|
||||
they can no longer do goes away. -->
|
||||
{#if game.iAmOut}
|
||||
<p class="spectating">{t.spectating}</p>
|
||||
{:else}
|
||||
<WordInput {onsubmit} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<ChainHistory />
|
||||
@@ -68,7 +80,7 @@
|
||||
a condition somebody has to remember to check. -->
|
||||
{#if chat}{@render chat()}{/if}
|
||||
|
||||
{#if game.state.phase === 'playing'}
|
||||
{#if game.state.phase === 'playing' && !game.iAmOut}
|
||||
<button type="button" class="resign" onclick={onresign}>{t.resign}</button>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -147,6 +159,15 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.spectating {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.resign {
|
||||
align-self: center;
|
||||
padding: 8px 16px;
|
||||
|
||||
@@ -12,8 +12,13 @@
|
||||
*/
|
||||
let { isRecord, onrematch, onhome } = $props();
|
||||
|
||||
/** @type {{ iWon: boolean, reason: number, myScore: number, chainLength: number, suggestions: string[] } | null} */
|
||||
const result = $derived(game.state.result);
|
||||
const standings = $derived(game.state.standings);
|
||||
// What the position still had when this player lost it. It arrives with
|
||||
// their knockout rather than with the result, because by the time a game
|
||||
// with four people in it ends, the position that beat them is nobody
|
||||
// else's position.
|
||||
const elimination = $derived(game.state.elimination);
|
||||
|
||||
/** Hands the finished chain to the player as a text file to keep. */
|
||||
function exportHistory() {
|
||||
@@ -21,7 +26,7 @@
|
||||
const text = chainToText({
|
||||
chain: game.state.chain,
|
||||
result,
|
||||
opponentLabel: game.state.opponentName || t.opponent,
|
||||
nameOf: (id) => game.nameOf(id),
|
||||
at
|
||||
});
|
||||
downloadText(historyFilename(at), text);
|
||||
@@ -36,6 +41,23 @@
|
||||
<p class="reason">{endReasonMessages[result.reason]}</p>
|
||||
{/if}
|
||||
|
||||
{#if standings.length > 0}
|
||||
<!-- Ranked by who outlasted whom, which is what the game is decided
|
||||
on. The score sits beside the place rather than setting it. -->
|
||||
<ol class="standings" aria-label={t.standingsTitle} data-testid="standings">
|
||||
{#each standings as player (player.playerId)}
|
||||
<li class:me={player.isMe} class:winner={player.rank === 1}>
|
||||
<span class="rank">{player.rank}</span>
|
||||
<span class="name">
|
||||
{player.isMe ? game.state.nickname || t.you : player.name || t.someone}
|
||||
</span>
|
||||
<span class="points">{player.score} {t.pointsUnit}</span>
|
||||
{#if player.rank === 1}<span class="trophy" aria-label={t.winnerBadge}>🏆</span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
<dl class="stats">
|
||||
<div>
|
||||
<dt>{t.finalScore}</dt>
|
||||
@@ -47,16 +69,16 @@
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{#if !result.iWon}
|
||||
<!-- Only the loser is shown this, and only they were sent it. Losing
|
||||
without ever learning what the position wanted is the part that
|
||||
stings; an empty list says the position had nothing, which is
|
||||
worth hearing too. -->
|
||||
{#if result.suggestions.length > 0}
|
||||
{#if elimination}
|
||||
<!-- Only the player who was stuck is sent this, and only they were
|
||||
stuck. Losing without ever learning what the position wanted is
|
||||
the part that stings; an empty list says the position had
|
||||
nothing, which is worth hearing too. -->
|
||||
{#if elimination.suggestions.length > 0}
|
||||
<div class="suggestions">
|
||||
<h3>{t.suggestionsTitle}</h3>
|
||||
<ul>
|
||||
{#each result.suggestions as word}
|
||||
{#each elimination.suggestions as word}
|
||||
<li>{word}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -111,6 +133,53 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.standings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.standings li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
.standings li.me {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.standings li.winner {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.rank {
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.standings .name {
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.points {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import RoomCodePanel from '$lib/components/RoomCodePanel.svelte';
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/**
|
||||
@@ -16,13 +16,17 @@
|
||||
* compact?: boolean,
|
||||
* onready: (ready: boolean) => void,
|
||||
* onstart: () => void,
|
||||
* onkick: () => void,
|
||||
* onkick: (playerId: string) => void,
|
||||
* onleave: () => void
|
||||
* }}
|
||||
*/
|
||||
let { compact = false, onready, onstart, onkick, onleave } = $props();
|
||||
|
||||
const s = $derived(game.state);
|
||||
// The seats nobody is in yet, drawn so a room that is waiting on people
|
||||
// looks like one rather than like a room that is simply small.
|
||||
const empties = $derived(Array.from({ length: game.freeSeats }, (_, i) => i));
|
||||
const shortHanded = $derived(s.roomPlayers.length < s.minPlayers);
|
||||
</script>
|
||||
|
||||
<section class="lobby" aria-label={t.lobbyTitle}>
|
||||
@@ -30,37 +34,57 @@
|
||||
<RoomCodePanel code={s.roomCode} />
|
||||
{/if}
|
||||
|
||||
<ul class="seats">
|
||||
<li class="seat" class:ready={s.isOwner || s.isReady}>
|
||||
<span class="name">{s.nickname || t.you}</span>
|
||||
<span class="role">{s.isOwner ? t.owner : t.guest}</span>
|
||||
<!-- The owner has no readiness to show: starting is the statement. -->
|
||||
{#if !s.isOwner}
|
||||
<span class="state" data-testid="my-ready">{s.isReady ? t.isReady : t.notReady}</span>
|
||||
{/if}
|
||||
</li>
|
||||
<p class="count" data-testid="player-count">
|
||||
{fill(t.playerCount, { n: s.roomPlayers.length, max: s.maxPlayers })}
|
||||
</p>
|
||||
|
||||
<li class="seat" class:empty={!s.opponentPresent} class:ready={s.opponentReady}>
|
||||
{#if s.opponentPresent}
|
||||
<span class="name">{s.opponentName || t.opponent}</span>
|
||||
<span class="role">{s.isOwner ? t.guest : t.owner}</span>
|
||||
{#if !s.opponentConnected}
|
||||
<span class="state offline">{t.offline}</span>
|
||||
{:else if s.isOwner}
|
||||
<span class="state" data-testid="opponent-ready">
|
||||
{s.opponentReady ? t.isReady : t.notReady}
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<ul class="seats">
|
||||
{#each s.roomPlayers as player (player.playerId)}
|
||||
<li class="seat" class:ready={player.isOwner || player.ready} class:me={player.isMe}>
|
||||
<span class="name">{player.isMe ? s.nickname || t.you : player.name}</span>
|
||||
<span class="role">{player.isOwner ? t.owner : t.guest}</span>
|
||||
|
||||
<span class="right">
|
||||
{#if !player.connected}
|
||||
<span class="state offline">{t.offline}</span>
|
||||
{:else if !player.isOwner}
|
||||
<!-- The owner has no readiness to show: starting is the statement. -->
|
||||
<span class="state" data-testid={player.isMe ? 'my-ready' : `ready-${player.playerId}`}>
|
||||
{player.ready ? t.isReady : t.notReady}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Only the owner sees these, and never on their own row: leaving
|
||||
is what an owner who wants out does, and it hands the room on. -->
|
||||
{#if game.isOwner && !player.isMe}
|
||||
<button
|
||||
type="button"
|
||||
class="kick"
|
||||
disabled={player.ready}
|
||||
aria-label={t.kickPlayer}
|
||||
data-testid={`kick-${player.playerId}`}
|
||||
onclick={() => onkick(player.playerId)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
{#each empties as index (index)}
|
||||
<li class="seat empty">
|
||||
<span class="name muted">{t.emptySeat}</span>
|
||||
{/if}
|
||||
</li>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<p class="hint">
|
||||
{#if s.isOwner}
|
||||
{s.opponentPresent ? t.ownerStartsHint : t.waitingForOpponent}
|
||||
{:else if s.isReady}
|
||||
{#if game.isOwner && shortHanded}
|
||||
{fill(t.ownerNeedsMore, { n: s.minPlayers })}
|
||||
{:else if game.isOwner}
|
||||
{t.ownerStartsHint}
|
||||
{:else if game.isReady}
|
||||
{t.waitingForStart}
|
||||
{:else}
|
||||
{t.guestReadyHint}
|
||||
@@ -68,7 +92,7 @@
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
{#if s.isOwner}
|
||||
{#if game.isOwner}
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
@@ -78,30 +102,25 @@
|
||||
>
|
||||
{t.startGame}
|
||||
</button>
|
||||
{#if s.opponentPresent}
|
||||
<button type="button" disabled={s.opponentReady} onclick={onkick}>
|
||||
{t.kickPlayer}
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
class:on={s.isReady}
|
||||
class:on={game.isReady}
|
||||
data-testid="ready"
|
||||
onclick={() => onready(!s.isReady)}
|
||||
onclick={() => onready(!game.isReady)}
|
||||
>
|
||||
{s.isReady ? t.unready : t.ready}
|
||||
{game.isReady ? t.unready : t.ready}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Disabled rather than hidden while ready: the rule is worth seeing, and
|
||||
a button that vanishes reads as a bug. -->
|
||||
<button type="button" class="leave" disabled={s.isReady} onclick={onleave}>
|
||||
<button type="button" class="leave" disabled={game.isReady} onclick={onleave}>
|
||||
{t.leaveRoom}
|
||||
</button>
|
||||
{#if s.isReady}
|
||||
{#if game.isReady}
|
||||
<p class="note">{t.unreadyToLeave}</p>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -114,6 +133,13 @@
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.count {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.seats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -161,8 +187,16 @@
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.state {
|
||||
/* One right-hand group, so a row keeps its shape whether or not it has a
|
||||
readiness to show and whether or not the reader may kick it. */
|
||||
.right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.state {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@@ -171,6 +205,22 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.kick {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.kick:disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<script>
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/**
|
||||
* The banner shown while an opponent is inside their reconnect window.
|
||||
*
|
||||
* The countdown is local because the server sends a duration, not a
|
||||
* deadline: it is a rough "how much longer" for the player watching, and
|
||||
* the server still decides when the seat is actually forfeit.
|
||||
*/
|
||||
let remainingMs = $state(0);
|
||||
|
||||
const away = $derived(game.state.opponentLeft);
|
||||
|
||||
$effect(() => {
|
||||
const left = away;
|
||||
if (!left?.canReconnect) {
|
||||
remainingMs = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const endsAt = Date.now() + left.graceMs;
|
||||
remainingMs = left.graceMs;
|
||||
|
||||
const tick = setInterval(() => {
|
||||
remainingMs = Math.max(0, endsAt - Date.now());
|
||||
if (remainingMs === 0) clearInterval(tick);
|
||||
}, 250);
|
||||
return () => clearInterval(tick);
|
||||
});
|
||||
|
||||
const seconds = $derived(Math.ceil(remainingMs / 1000));
|
||||
</script>
|
||||
|
||||
{#if away}
|
||||
<p class="banner" class:gone={!away.canReconnect} role="status" data-testid="opponent-status">
|
||||
{#if !away.canReconnect}
|
||||
{t.opponentGone}
|
||||
{:else if seconds > 0}
|
||||
{fill(t.opponentDisconnectedIn, { n: seconds })}
|
||||
{:else}
|
||||
{t.opponentDisconnected}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.banner {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-alt);
|
||||
color: var(--warn);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.banner.gone {
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script>
|
||||
import { untrack } from 'svelte';
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/**
|
||||
* What is happening to the other people in the room: who has dropped and
|
||||
* how much longer their seat is held, and who has been knocked out.
|
||||
*
|
||||
* The countdown is local because the server sends a duration, not a
|
||||
* deadline: it is a rough "how much longer" for the players watching, and
|
||||
* the server still decides when a seat is actually forfeit.
|
||||
*
|
||||
* @type {Record<string, number>}
|
||||
*/
|
||||
let endsAt = $state({});
|
||||
let now = $state(Date.now());
|
||||
|
||||
const away = $derived(game.awayPlayers);
|
||||
|
||||
// One deadline per player, opened when they drop and forgotten when they
|
||||
// come back. Deliberately not keyed off the room state as a whole: it is
|
||||
// broadcast on every change a player can see, and rebuilding the deadlines
|
||||
// from it would restart every countdown each time somebody said they were
|
||||
// ready.
|
||||
$effect(() => {
|
||||
const present = away.map((p) => p.playerId);
|
||||
untrack(() => {
|
||||
/** @type {Record<string, number>} */
|
||||
const next = {};
|
||||
for (const id of present) next[id] = endsAt[id] ?? Date.now() + game.state.graceMs;
|
||||
|
||||
const before = Object.keys(endsAt);
|
||||
if (before.length !== present.length || before.some((id) => !(id in next))) {
|
||||
endsAt = next;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (away.length === 0) return;
|
||||
const tick = setInterval(() => (now = Date.now()), 250);
|
||||
return () => clearInterval(tick);
|
||||
});
|
||||
|
||||
/** @param {string} playerId */
|
||||
function secondsLeft(playerId) {
|
||||
return Math.max(0, Math.ceil(((endsAt[playerId] ?? 0) - now) / 1000));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if game.iAmOut}
|
||||
<!-- The game carries on without this player, and saying so is the whole
|
||||
difference between being knocked out and being disconnected. -->
|
||||
<p class="banner gone" role="status" data-testid="eliminated">{t.youAreOut}</p>
|
||||
{:else if game.state.lastOut && !game.state.lastOut.isMe && game.state.phase === 'playing'}
|
||||
<p class="banner" role="status" data-testid="player-out">
|
||||
{fill(t.playerOut, { name: game.state.lastOut.name || t.someone })}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#each away as player (player.playerId)}
|
||||
{@const seconds = secondsLeft(player.playerId)}
|
||||
<p class="banner" role="status" data-testid={`away-${player.playerId}`}>
|
||||
{#if seconds > 0}
|
||||
{fill(t.playerDisconnectedIn, { name: player.name || t.someone, n: seconds })}
|
||||
{:else}
|
||||
{fill(t.playerDisconnected, { name: player.name || t.someone })}
|
||||
{/if}
|
||||
</p>
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
.banner {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-alt);
|
||||
color: var(--warn);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.banner.gone {
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
</style>
|
||||
@@ -2,41 +2,63 @@
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/** @type {{ opponentLabel?: string }} */
|
||||
let { opponentLabel = t.opponent } = $props();
|
||||
|
||||
const me = $derived(game.state.nickname || t.you);
|
||||
/**
|
||||
* The table of the game on screen: one row per player, in turn order.
|
||||
*
|
||||
* It doubles as the turn indicator — the row that is lit is the player to
|
||||
* act — which is why there is no second one anywhere on the board.
|
||||
*/
|
||||
const s = $derived(game.state);
|
||||
// Standings once the game is over, so the board settles into the result
|
||||
// rather than freezing on the last position.
|
||||
const players = $derived(s.phase === 'over' && s.standings.length ? s.standings : s.gamePlayers);
|
||||
</script>
|
||||
|
||||
<div class="board">
|
||||
<div class="side" class:active={game.state.myTurn}>
|
||||
<span class="who">{me}</span>
|
||||
<span class="score">{game.state.myScore}</span>
|
||||
</div>
|
||||
<span class="sep" aria-hidden="true">–</span>
|
||||
<div class="side" class:active={game.state.phase === 'playing' && !game.state.myTurn}>
|
||||
<span class="who">{opponentLabel}</span>
|
||||
<span class="score">{game.state.opponentScore}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="board" data-testid="scoreboard">
|
||||
{#each players as player (player.playerId)}
|
||||
<li
|
||||
class="side"
|
||||
class:active={s.phase === 'playing' && player.playerId === s.turnPlayerId}
|
||||
class:out={player.eliminated}
|
||||
class:me={player.isMe}
|
||||
>
|
||||
<span class="who">
|
||||
{player.isMe ? s.nickname || t.you : player.name || t.someone}
|
||||
{#if !player.connected && !player.eliminated}
|
||||
<span class="away" title={t.offline}>⚠</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="score">{player.score}</span>
|
||||
{#if player.rank === 1}
|
||||
<span class="badge win">{t.winnerBadge}</span>
|
||||
{:else if player.eliminated}
|
||||
<span class="badge">{t.eliminated}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.board {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 6px 12px;
|
||||
flex: 1 1 0;
|
||||
min-width: 72px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* The active side is whose turn it is, so the board doubles as the turn
|
||||
@@ -46,7 +68,14 @@
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.side.out {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.who {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
@@ -55,13 +84,36 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.side.me .who {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.away {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.score {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sep {
|
||||
.side.out .score {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.badge.win {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -38,14 +38,18 @@ export function historyFilename(at = new Date()) {
|
||||
* the newest-first ordering is a reading aid for the live board, not the
|
||||
* shape of the game.
|
||||
*
|
||||
* `nameOf` resolves a seat id to the name that seat was playing under. It is
|
||||
* passed in rather than read from the store so the transcript can be built and
|
||||
* tested without one.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {import('$lib/stores/game.svelte.js').ChainEntry[]} args.chain
|
||||
* @param {{ iWon: boolean, myScore: number, chainLength: number } | null} [args.result]
|
||||
* @param {string} [args.opponentLabel]
|
||||
* @param {(playerId: string) => string} [args.nameOf]
|
||||
* @param {Date} [args.at]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function chainToText({ chain, result = null, opponentLabel = t.opponent, at = new Date() }) {
|
||||
export function chainToText({ chain, result = null, nameOf = () => '', at = new Date() }) {
|
||||
const lines = [`${t.appName} — ${stamp(at)}`];
|
||||
|
||||
if (result) {
|
||||
@@ -63,7 +67,9 @@ export function chainToText({ chain, result = null, opponentLabel = t.opponent,
|
||||
lines.push(`${number} (${t.exportOpening})`);
|
||||
return;
|
||||
}
|
||||
const who = entry.byMe ? t.you : opponentLabel;
|
||||
// A four-way chain has to say which of the others played a word, not
|
||||
// merely that it was not this player's.
|
||||
const who = entry.byMe ? t.you : nameOf(entry.playerId) || t.opponent;
|
||||
const points = entry.points > 0 ? ` +${entry.points}` : '';
|
||||
lines.push(`${number} — ${who}${points}`);
|
||||
});
|
||||
|
||||
+20
-9
@@ -25,6 +25,7 @@ export const t = {
|
||||
|
||||
yourTurn: 'Đến lượt bạn',
|
||||
opponentTurn: 'Đối thủ đang suy nghĩ…',
|
||||
playerTurn: 'Đến lượt {name}…',
|
||||
currentSyllable: 'Nối tiếp tiếng',
|
||||
wordInputPlaceholder: 'Nhập từ của bạn',
|
||||
submit: 'Gửi',
|
||||
@@ -32,6 +33,7 @@ export const t = {
|
||||
chainEmpty: 'Chưa có từ nào.',
|
||||
you: 'Bạn',
|
||||
opponent: 'Đối thủ',
|
||||
someone: 'Người chơi',
|
||||
scoreLabel: 'Điểm',
|
||||
syllableUnit: 'tiếng',
|
||||
correctedFrom: 'Bạn gõ “{typed}”, từ đúng là “{word}”.',
|
||||
@@ -47,6 +49,9 @@ export const t = {
|
||||
lost: 'Bạn thua.',
|
||||
finalScore: 'Điểm cuối cùng',
|
||||
chainLength: 'Số từ trong chuỗi',
|
||||
standingsTitle: 'Kết quả',
|
||||
winnerBadge: 'Vô địch',
|
||||
pointsUnit: 'điểm',
|
||||
newRecord: 'Kỷ lục mới!',
|
||||
suggestionsTitle: 'Bạn có thể nối',
|
||||
noSuggestions: 'Không còn từ nào bắt đầu bằng tiếng “{syllable}”. Ai gặp thế này cũng chịu thôi!',
|
||||
@@ -72,7 +77,7 @@ export const t = {
|
||||
copyLink: 'Sao chép liên kết mời',
|
||||
shareLink: 'Chia sẻ',
|
||||
copied: 'Đã sao chép',
|
||||
waitingForOpponent: 'Đang chờ đối thủ vào phòng…',
|
||||
waitingForOpponent: 'Đang chờ người chơi vào phòng…',
|
||||
leaveRoom: 'Rời phòng',
|
||||
shareInvite: 'Vào chơi nối từ với tôi',
|
||||
|
||||
@@ -85,6 +90,7 @@ export const t = {
|
||||
lobbyTitle: 'Phòng chờ',
|
||||
owner: 'Chủ phòng',
|
||||
guest: 'Khách',
|
||||
playerCount: '{n}/{max} người chơi',
|
||||
ready: 'Sẵn sàng',
|
||||
unready: 'Chưa sẵn sàng',
|
||||
isReady: 'Đã sẵn sàng',
|
||||
@@ -94,14 +100,18 @@ export const t = {
|
||||
kickConfirm: 'Mời người chơi này ra khỏi phòng?',
|
||||
emptySeat: 'Còn trống',
|
||||
offline: 'Mất kết nối',
|
||||
ownerStartsHint: 'Bạn là chủ phòng. Bắt đầu khi đối thủ đã sẵn sàng.',
|
||||
ownerStartsHint: 'Bạn là chủ phòng. Bắt đầu khi mọi người đã sẵn sàng.',
|
||||
ownerNeedsMore: 'Cần ít nhất {n} người mới bắt đầu được. Gửi mã phòng cho bạn bè!',
|
||||
guestReadyHint: 'Bấm sẵn sàng rồi chờ chủ phòng bắt đầu.',
|
||||
waitingForStart: 'Đang chờ chủ phòng bắt đầu…',
|
||||
unreadyToLeave: 'Bỏ sẵn sàng trước khi rời phòng.',
|
||||
|
||||
opponentDisconnected: 'Đối thủ mất kết nối…',
|
||||
opponentDisconnectedIn: 'Đối thủ mất kết nối… ({n}s)',
|
||||
opponentGone: 'Đối thủ đã rời trận.',
|
||||
playerDisconnected: '{name} mất kết nối…',
|
||||
playerDisconnectedIn: '{name} mất kết nối… ({n}s)',
|
||||
youAreOut: 'Bạn đã bị loại. Ván đấu vẫn đang tiếp tục.',
|
||||
spectating: 'Bạn đang xem ván đấu.',
|
||||
playerOut: '{name} đã bị loại.',
|
||||
eliminated: 'Đã bị loại',
|
||||
|
||||
attributionIntro: 'Từ điển dựa trên',
|
||||
attributionSource: 'minhqnd/dictionary',
|
||||
@@ -149,7 +159,7 @@ export const endReasonMessages = {
|
||||
[GameEndReason.UNSPECIFIED]: '',
|
||||
[GameEndReason.TIMEOUT]: 'Hết thời gian suy nghĩ.',
|
||||
[GameEndReason.NO_LEGAL_MOVE]: 'Không còn từ nào nối được.',
|
||||
[GameEndReason.OPPONENT_LEFT]: 'Đối thủ đã rời trận.',
|
||||
[GameEndReason.OPPONENT_LEFT]: 'Có người đã rời trận.',
|
||||
[GameEndReason.RESIGNED]: 'Có người đầu hàng.'
|
||||
};
|
||||
|
||||
@@ -171,15 +181,16 @@ export const errorMessages = {
|
||||
game_start_failed: 'Không thể bắt đầu ván đấu. Hãy thử lại.',
|
||||
handshake_required: 'Phiên chơi chưa sẵn sàng. Hãy tải lại trang.',
|
||||
kicked: 'Bạn đã bị mời ra khỏi phòng.',
|
||||
cannot_kick_self: 'Bạn không thể tự mời mình ra khỏi phòng.',
|
||||
must_unready_first: 'Hãy bỏ sẵn sàng trước khi rời phòng.',
|
||||
need_two_players: 'Cần hai người chơi mới bắt đầu được.',
|
||||
not_everyone_ready: 'Đối thủ chưa sẵn sàng.',
|
||||
need_more_players: 'Cần ít nhất hai người chơi mới bắt đầu được.',
|
||||
not_everyone_ready: 'Vẫn còn người chưa sẵn sàng.',
|
||||
not_in_a_game: 'Bạn không ở trong ván đấu nào.',
|
||||
not_in_a_room: 'Bạn không ở trong phòng nào.',
|
||||
not_the_owner: 'Chỉ chủ phòng làm được việc này.',
|
||||
not_your_seat: 'Bạn không phải người chơi trong ván này.',
|
||||
no_one_to_kick: 'Chưa có ai trong phòng để mời ra.',
|
||||
opponent_offline: 'Đối thủ đang mất kết nối.',
|
||||
player_offline: 'Vẫn còn người đang mất kết nối.',
|
||||
owner_needs_no_ready: 'Chủ phòng không cần bấm sẵn sàng.',
|
||||
player_is_ready: 'Không thể mời một người đã sẵn sàng ra khỏi phòng.',
|
||||
protocol_version_mismatch: 'Phiên bản đã cũ. Hãy tải lại trang.',
|
||||
|
||||
+243
-78
@@ -136,9 +136,9 @@ export declare type Resign = Message<"noitu.v1.Resign"> & {
|
||||
export declare const ResignSchema: GenMessage<Resign>;
|
||||
|
||||
/**
|
||||
* SetReady is the guest declaring themselves ready, or taking it back.
|
||||
* SetReady is a guest declaring themselves ready, or taking it back.
|
||||
*
|
||||
* Only the guest has a readiness to set. The owner's is implied by StartGame:
|
||||
* Only guests have a readiness to set. The owner's is implied by StartGame:
|
||||
* asking for the game to begin is the same statement, and a second flag they
|
||||
* would always have to set first buys nothing.
|
||||
*
|
||||
@@ -159,7 +159,8 @@ export declare const SetReadySchema: GenMessage<SetReady>;
|
||||
|
||||
/**
|
||||
* StartGame is the owner beginning the game the lobby has agreed on. It is
|
||||
* refused unless the guest is seated, connected and ready.
|
||||
* refused unless at least one guest is seated and every seated guest is
|
||||
* connected and ready.
|
||||
*
|
||||
* @generated from message noitu.v1.StartGame
|
||||
*/
|
||||
@@ -173,13 +174,20 @@ export declare type StartGame = Message<"noitu.v1.StartGame"> & {
|
||||
export declare const StartGameSchema: GenMessage<StartGame>;
|
||||
|
||||
/**
|
||||
* KickPlayer is the owner freeing the guest's seat. Refused while the guest is
|
||||
* KickPlayer is the owner freeing one seat. Refused while that player is
|
||||
* ready: readiness is a commitment, and a player who has made it is not
|
||||
* something the owner gets to overrule.
|
||||
*
|
||||
* @generated from message noitu.v1.KickPlayer
|
||||
*/
|
||||
export declare type KickPlayer = Message<"noitu.v1.KickPlayer"> & {
|
||||
/**
|
||||
* Which seat, from RoomState.players. A room holds up to four people, so
|
||||
* "the other one" stopped being an answer.
|
||||
*
|
||||
* @generated from field: string player_id = 1;
|
||||
*/
|
||||
playerId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -394,6 +402,15 @@ export declare type PlayedWord = Message<"noitu.v1.PlayedWord"> & {
|
||||
* @generated from field: string typed = 5;
|
||||
*/
|
||||
typed: string;
|
||||
|
||||
/**
|
||||
* Which seat played it. by_me answers "was this mine"; with four people at
|
||||
* the table the chain also has to say whose the other words were, and a seat
|
||||
* id says that without the client matching display names.
|
||||
*
|
||||
* @generated from field: string player_id = 6;
|
||||
*/
|
||||
playerId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -403,8 +420,125 @@ export declare type PlayedWord = Message<"noitu.v1.PlayedWord"> & {
|
||||
export declare const PlayedWordSchema: GenMessage<PlayedWord>;
|
||||
|
||||
/**
|
||||
* GameStarted is rendered per recipient: my_turn is true for exactly one of
|
||||
* the two players.
|
||||
* PlayerSlot is one seat in the room, rendered for one recipient.
|
||||
*
|
||||
* The recipient's own row is in the list like everybody else's, marked by
|
||||
* is_me. That is deliberately the only way to find yourself: a separate
|
||||
* i_am_owner alongside an is_owner in the list would be two encodings of one
|
||||
* fact, and two ways for a client to disagree with the server.
|
||||
*
|
||||
* @generated from message noitu.v1.PlayerSlot
|
||||
*/
|
||||
export declare type PlayerSlot = Message<"noitu.v1.PlayerSlot"> & {
|
||||
/**
|
||||
* Stable for as long as this player holds the seat. Not stable across a
|
||||
* seat being vacated and refilled, which is exactly when a name stops
|
||||
* meaning the same person too.
|
||||
*
|
||||
* @generated from field: string player_id = 1;
|
||||
*/
|
||||
playerId: string;
|
||||
|
||||
/**
|
||||
* Always server-sanitized, as everywhere else another player's name appears.
|
||||
*
|
||||
* @generated from field: string name = 2;
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* @generated from field: bool is_me = 3;
|
||||
*/
|
||||
isMe: boolean;
|
||||
|
||||
/**
|
||||
* @generated from field: bool is_owner = 4;
|
||||
*/
|
||||
isOwner: boolean;
|
||||
|
||||
/**
|
||||
* Always false for the owner, whose readiness is StartGame itself.
|
||||
*
|
||||
* @generated from field: bool ready = 5;
|
||||
*/
|
||||
ready: boolean;
|
||||
|
||||
/**
|
||||
* False while this player is inside their reconnect window.
|
||||
*
|
||||
* @generated from field: bool connected = 6;
|
||||
*/
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.PlayerSlot.
|
||||
* Use `create(PlayerSlotSchema)` to create a new message.
|
||||
*/
|
||||
export declare const PlayerSlotSchema: GenMessage<PlayerSlot>;
|
||||
|
||||
/**
|
||||
* PlayerScore is one player in a running or finished game.
|
||||
*
|
||||
* Separate from PlayerSlot because they answer different questions: a slot is
|
||||
* about the room, a score is about the game being played in it. A player who
|
||||
* has been eliminated still has both — being out of the game is not being out
|
||||
* of the room.
|
||||
*
|
||||
* @generated from message noitu.v1.PlayerScore
|
||||
*/
|
||||
export declare type PlayerScore = Message<"noitu.v1.PlayerScore"> & {
|
||||
/**
|
||||
* @generated from field: string player_id = 1;
|
||||
*/
|
||||
playerId: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string name = 2;
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* @generated from field: bool is_me = 3;
|
||||
*/
|
||||
isMe: boolean;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 score = 4;
|
||||
*/
|
||||
score: number;
|
||||
|
||||
/**
|
||||
* True once this player has been knocked out. They keep their seat, their
|
||||
* score and their words; they simply no longer get a turn.
|
||||
*
|
||||
* @generated from field: bool eliminated = 5;
|
||||
*/
|
||||
eliminated: boolean;
|
||||
|
||||
/**
|
||||
* @generated from field: bool connected = 6;
|
||||
*/
|
||||
connected: boolean;
|
||||
|
||||
/**
|
||||
* Final placing, 1 for the winner. Zero while the game is still running,
|
||||
* which is what tells the two apart without a second field.
|
||||
*
|
||||
* @generated from field: uint32 rank = 7;
|
||||
*/
|
||||
rank: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.PlayerScore.
|
||||
* Use `create(PlayerScoreSchema)` to create a new message.
|
||||
*/
|
||||
export declare const PlayerScoreSchema: GenMessage<PlayerScore>;
|
||||
|
||||
/**
|
||||
* GameStarted is rendered per recipient: my_turn is true for exactly one
|
||||
* player.
|
||||
*
|
||||
* @generated from message noitu.v1.GameStarted
|
||||
*/
|
||||
@@ -441,6 +575,21 @@ export declare type GameStarted = Message<"noitu.v1.GameStarted"> & {
|
||||
* @generated from field: uint32 turn_limit_ms = 6;
|
||||
*/
|
||||
turnLimitMs: number;
|
||||
|
||||
/**
|
||||
* Everyone playing, in turn order.
|
||||
*
|
||||
* @generated from field: repeated noitu.v1.PlayerScore players = 7;
|
||||
*/
|
||||
players: PlayerScore[];
|
||||
|
||||
/**
|
||||
* Whose turn it is. my_turn above says whether it is yours; this says whose
|
||||
* it is when it is not, which a two-player game never had to.
|
||||
*
|
||||
* @generated from field: string turn_player_id = 8;
|
||||
*/
|
||||
turnPlayerId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -450,9 +599,13 @@ export declare type GameStarted = Message<"noitu.v1.GameStarted"> & {
|
||||
export declare const GameStartedSchema: GenMessage<GameStarted>;
|
||||
|
||||
/**
|
||||
* TurnUpdate follows every accepted move and goes to both players, serialized
|
||||
* TurnUpdate follows every turn change and goes to every player, serialized
|
||||
* once per recipient so by_me and my_turn are correct for each.
|
||||
*
|
||||
* played is absent when the turn moved without a word being played, which is
|
||||
* what an elimination does: the syllable and the used set survive the player
|
||||
* who could not answer them.
|
||||
*
|
||||
* @generated from message noitu.v1.TurnUpdate
|
||||
*/
|
||||
export declare type TurnUpdate = Message<"noitu.v1.TurnUpdate"> & {
|
||||
@@ -481,20 +634,22 @@ export declare type TurnUpdate = Message<"noitu.v1.TurnUpdate"> & {
|
||||
*/
|
||||
turnSeq: number;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 my_score = 6;
|
||||
*/
|
||||
myScore: number;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 opponent_score = 7;
|
||||
*/
|
||||
opponentScore: number;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 chain_length = 8;
|
||||
*/
|
||||
chainLength: number;
|
||||
|
||||
/**
|
||||
* Everyone playing, in turn order, with scores as they stand.
|
||||
*
|
||||
* @generated from field: repeated noitu.v1.PlayerScore players = 9;
|
||||
*/
|
||||
players: PlayerScore[];
|
||||
|
||||
/**
|
||||
* @generated from field: string turn_player_id = 10;
|
||||
*/
|
||||
turnPlayerId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -530,8 +685,8 @@ export declare type MoveRejected = Message<"noitu.v1.MoveRejected"> & {
|
||||
export declare const MoveRejectedSchema: GenMessage<MoveRejected>;
|
||||
|
||||
/**
|
||||
* GameOver is rendered per recipient: i_won is true for exactly one of the two
|
||||
* players.
|
||||
* GameOver is rendered per recipient: i_won is true for exactly one player,
|
||||
* the one still standing when everybody else had been eliminated.
|
||||
*
|
||||
* @generated from message noitu.v1.GameOver
|
||||
*/
|
||||
@@ -546,25 +701,20 @@ export declare type GameOver = Message<"noitu.v1.GameOver"> & {
|
||||
*/
|
||||
reason: GameEndReason;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 my_score = 3;
|
||||
*/
|
||||
myScore: number;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 chain_length = 4;
|
||||
*/
|
||||
chainLength: number;
|
||||
|
||||
/**
|
||||
* A few words that could still have been played from the position the game
|
||||
* ended on, filled only for the player who lost — the winner is not the one
|
||||
* who needed them. An empty list on a loss is itself the answer: the
|
||||
* position was a dead end and nobody could have answered it.
|
||||
* The final table, best first: the player left standing, then the others in
|
||||
* reverse order of elimination. Outlasting somebody is what beats them, so
|
||||
* the ranking is finishing order and each score is reported beside it rather
|
||||
* than deciding it.
|
||||
*
|
||||
* @generated from field: repeated string suggestions = 5;
|
||||
* @generated from field: repeated noitu.v1.PlayerScore standings = 6;
|
||||
*/
|
||||
suggestions: string[];
|
||||
standings: PlayerScore[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -574,25 +724,49 @@ export declare type GameOver = Message<"noitu.v1.GameOver"> & {
|
||||
export declare const GameOverSchema: GenMessage<GameOver>;
|
||||
|
||||
/**
|
||||
* @generated from message noitu.v1.OpponentLeft
|
||||
* PlayerEliminated is one player knocked out of a game that is still running.
|
||||
*
|
||||
* Rendered per recipient like everything else in a room, and the only message
|
||||
* whose contents differ by more than a flag: suggestions are filled in solely
|
||||
* for the player who went out, because they are the one who was stuck.
|
||||
*
|
||||
* @generated from message noitu.v1.PlayerEliminated
|
||||
*/
|
||||
export declare type OpponentLeft = Message<"noitu.v1.OpponentLeft"> & {
|
||||
export declare type PlayerEliminated = Message<"noitu.v1.PlayerEliminated"> & {
|
||||
/**
|
||||
* @generated from field: bool can_reconnect = 1;
|
||||
* @generated from field: string player_id = 1;
|
||||
*/
|
||||
canReconnect: boolean;
|
||||
playerId: string;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 grace_ms = 2;
|
||||
* @generated from field: string name = 2;
|
||||
*/
|
||||
graceMs: number;
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* @generated from field: bool is_me = 3;
|
||||
*/
|
||||
isMe: boolean;
|
||||
|
||||
/**
|
||||
* @generated from field: noitu.v1.GameEndReason reason = 4;
|
||||
*/
|
||||
reason: GameEndReason;
|
||||
|
||||
/**
|
||||
* A few words the position still had, for the player who just lost it. An
|
||||
* empty list is itself the answer: nobody could have answered that syllable.
|
||||
*
|
||||
* @generated from field: repeated string suggestions = 5;
|
||||
*/
|
||||
suggestions: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.OpponentLeft.
|
||||
* Use `create(OpponentLeftSchema)` to create a new message.
|
||||
* Describes the message noitu.v1.PlayerEliminated.
|
||||
* Use `create(PlayerEliminatedSchema)` to create a new message.
|
||||
*/
|
||||
export declare const OpponentLeftSchema: GenMessage<OpponentLeft>;
|
||||
export declare const PlayerEliminatedSchema: GenMessage<PlayerEliminated>;
|
||||
|
||||
/**
|
||||
* ServerError.message is a UI key such as "room_not_found", never prose: all
|
||||
@@ -640,15 +814,16 @@ export declare type Pong = Message<"noitu.v1.Pong"> & {
|
||||
export declare const PongSchema: GenMessage<Pong>;
|
||||
|
||||
/**
|
||||
* RoomState is the whole lobby, rendered for one recipient, and it is the only
|
||||
* RoomState is the whole room, rendered for one recipient, and it is the only
|
||||
* thing the lobby screen is built from. Sent on every change a player could
|
||||
* see — a seat filled or freed, a readiness set, an owner promoted — and again
|
||||
* on resume, so a client that missed a frame recovers by being told the state
|
||||
* rather than by replaying the events that led to it.
|
||||
* see — a seat filled or freed, a readiness set, an owner promoted, somebody
|
||||
* dropping or coming back — and again on resume, so a client that missed a
|
||||
* frame recovers by being told the state rather than by replaying the events
|
||||
* that led to it.
|
||||
*
|
||||
* The seat that is absent is reported as an unoccupied opponent rather than by
|
||||
* omitting the field, so "alone in the room" and "opponent still loading" are
|
||||
* never the same frame.
|
||||
* It describes the room, not the game, so it is meaningful during one too:
|
||||
* while a game runs this is what carries presence, which is why there is no
|
||||
* separate message for a player disconnecting.
|
||||
*
|
||||
* @generated from message noitu.v1.RoomState
|
||||
*/
|
||||
@@ -658,13 +833,6 @@ export declare type RoomState = Message<"noitu.v1.RoomState"> & {
|
||||
*/
|
||||
roomCode: string;
|
||||
|
||||
/**
|
||||
* True for the player who may start the game and kick the other.
|
||||
*
|
||||
* @generated from field: bool i_am_owner = 2;
|
||||
*/
|
||||
iAmOwner: boolean;
|
||||
|
||||
/**
|
||||
* Whether StartGame would be accepted right now. The server decides this
|
||||
* because it owns every condition that feeds it.
|
||||
@@ -674,38 +842,35 @@ export declare type RoomState = Message<"noitu.v1.RoomState"> & {
|
||||
canStart: boolean;
|
||||
|
||||
/**
|
||||
* The recipient's own readiness. Always false for the owner, whose readiness
|
||||
* is StartGame itself.
|
||||
* Everyone seated, in seat order, which is also the turn order a game will
|
||||
* use. Always includes the recipient, marked is_me.
|
||||
*
|
||||
* @generated from field: bool i_am_ready = 4;
|
||||
* @generated from field: repeated noitu.v1.PlayerSlot players = 9;
|
||||
*/
|
||||
iAmReady: boolean;
|
||||
players: PlayerSlot[];
|
||||
|
||||
/**
|
||||
* False when the other seat is empty; the fields below are then meaningless.
|
||||
* How many seats the room has and how many players a game needs. Sent
|
||||
* rather than compiled in, so the lobby draws whatever the server allows and
|
||||
* raising the limit does not need a client deploy.
|
||||
*
|
||||
* @generated from field: bool opponent_present = 5;
|
||||
* @generated from field: uint32 max_players = 10;
|
||||
*/
|
||||
opponentPresent: boolean;
|
||||
maxPlayers: number;
|
||||
|
||||
/**
|
||||
* Always server-sanitized, as everywhere else another player's name appears.
|
||||
*
|
||||
* @generated from field: string opponent_name = 6;
|
||||
* @generated from field: uint32 min_players = 11;
|
||||
*/
|
||||
opponentName: string;
|
||||
minPlayers: number;
|
||||
|
||||
/**
|
||||
* @generated from field: bool opponent_ready = 7;
|
||||
*/
|
||||
opponentReady: boolean;
|
||||
|
||||
/**
|
||||
* False while the other player is inside their reconnect window.
|
||||
* How long a seat is held for a player who has dropped. The client counts
|
||||
* down against it for anybody whose connected is false; the server still
|
||||
* decides when the seat is actually forfeit.
|
||||
*
|
||||
* @generated from field: bool opponent_connected = 8;
|
||||
* @generated from field: uint32 grace_ms = 12;
|
||||
*/
|
||||
opponentConnected: boolean;
|
||||
graceMs: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -816,12 +981,6 @@ export declare type ServerMessage = Message<"noitu.v1.ServerMessage"> & {
|
||||
*/
|
||||
value: GameOver;
|
||||
case: "gameOver";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: noitu.v1.OpponentLeft opponent_left = 8;
|
||||
*/
|
||||
value: OpponentLeft;
|
||||
case: "opponentLeft";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: noitu.v1.ServerError error = 9;
|
||||
@@ -852,6 +1011,12 @@ export declare type ServerMessage = Message<"noitu.v1.ServerMessage"> & {
|
||||
*/
|
||||
value: ChatHistory;
|
||||
case: "chatHistory";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: noitu.v1.PlayerEliminated player_eliminated = 15;
|
||||
*/
|
||||
value: PlayerEliminated;
|
||||
case: "playerEliminated";
|
||||
} | { case: undefined; value?: undefined };
|
||||
};
|
||||
|
||||
|
||||
Generated
+29
-15
@@ -21,7 +21,7 @@ import { enumDesc, fileDesc, messageDesc, tsEnum } from "@bufbuild/protobuf/code
|
||||
* Describes the file noitu/v1/game.proto.
|
||||
*/
|
||||
export const file_noitu_v1_game = /*@__PURE__*/
|
||||
fileDesc("ChNub2l0dS92MS9nYW1lLnByb3RvEghub2l0dS52MSJJCgVIZWxsbxIYChBwcm90b2NvbF92ZXJzaW9uGAEgASgNEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIQCghuaWNrbmFtZRgDIAEoCSI4CgxTdGFydEJvdEdhbWUSKAoKZGlmZmljdWx0eRgBIAEoDjIULm5vaXR1LnYxLkRpZmZpY3VsdHkiDAoKQ3JlYXRlUm9vbSIdCghKb2luUm9vbRIRCglyb29tX2NvZGUYASABKAkiLAoKU3VibWl0V29yZBIMCgR3b3JkGAEgASgJEhAKCHR1cm5fc2VxGAIgASgNIggKBlJlc2lnbiIZCghTZXRSZWFkeRINCgVyZWFkeRgBIAEoCCILCglTdGFydEdhbWUiDAoKS2lja1BsYXllciILCglMZWF2ZVJvb20iGAoIU2VuZENoYXQSDAoEdGV4dBgBIAEoCSIeCgRQaW5nEhYKDmNsaWVudF90aW1lX21zGAEgASgDIpAECg1DbGllbnRNZXNzYWdlEiAKBWhlbGxvGAEgASgLMg8ubm9pdHUudjEuSGVsbG9IABIwCg5zdGFydF9ib3RfZ2FtZRgCIAEoCzIWLm5vaXR1LnYxLlN0YXJ0Qm90R2FtZUgAEisKC2NyZWF0ZV9yb29tGAMgASgLMhQubm9pdHUudjEuQ3JlYXRlUm9vbUgAEicKCWpvaW5fcm9vbRgEIAEoCzISLm5vaXR1LnYxLkpvaW5Sb29tSAASKwoLc3VibWl0X3dvcmQYBSABKAsyFC5ub2l0dS52MS5TdWJtaXRXb3JkSAASIgoGcmVzaWduGAYgASgLMhAubm9pdHUudjEuUmVzaWduSAASHgoEcGluZxgHIAEoCzIOLm5vaXR1LnYxLlBpbmdIABInCglzZXRfcmVhZHkYCSABKAsyEi5ub2l0dS52MS5TZXRSZWFkeUgAEikKCnN0YXJ0X2dhbWUYCiABKAsyEy5ub2l0dS52MS5TdGFydEdhbWVIABIrCgtraWNrX3BsYXllchgLIAEoCzIULm5vaXR1LnYxLktpY2tQbGF5ZXJIABIpCgpsZWF2ZV9yb29tGAwgASgLMhMubm9pdHUudjEuTGVhdmVSb29tSAASJwoJc2VuZF9jaGF0GA0gASgLMhIubm9pdHUudjEuU2VuZENoYXRIAEIJCgdwYXlsb2FkSgQICBAJImgKB1dlbGNvbWUSEgoKc2Vzc2lvbl9pZBgBIAEoCRIUCgxyZXN1bWVfdG9rZW4YAiABKAkSGAoQcHJvdG9jb2xfdmVyc2lvbhgDIAEoDRIZChFhY2NlcHRlZF9uaWNrbmFtZRgEIAEoCSJbCgpQbGF5ZWRXb3JkEgwKBHdvcmQYASABKAkSDQoFYnlfbWUYAiABKAgSDgoGcG9pbnRzGAMgASgNEhEKCXN5bGxhYmxlcxgEIAEoDRINCgV0eXBlZBgFIAEoCSKRAQoLR2FtZVN0YXJ0ZWQSFAoMb3BlbmluZ193b3JkGAEgASgJEhgKEGN1cnJlbnRfc3lsbGFibGUYAiABKAkSDwoHbXlfdHVybhgDIAEoCBIYChBkZWFkbGluZV91bml4X21zGAQgASgDEhAKCHR1cm5fc2VxGAUgASgNEhUKDXR1cm5fbGltaXRfbXMYBiABKA0iyQEKClR1cm5VcGRhdGUSJAoGcGxheWVkGAEgASgLMhQubm9pdHUudjEuUGxheWVkV29yZBIYChBjdXJyZW50X3N5bGxhYmxlGAIgASgJEg8KB215X3R1cm4YAyABKAgSGAoQZGVhZGxpbmVfdW5peF9tcxgEIAEoAxIQCgh0dXJuX3NlcRgFIAEoDRIQCghteV9zY29yZRgGIAEoDRIWCg5vcHBvbmVudF9zY29yZRgHIAEoDRIUCgxjaGFpbl9sZW5ndGgYCCABKA0iVgoMTW92ZVJlamVjdGVkEiYKBnJlYXNvbhgBIAEoDjIWLm5vaXR1LnYxLlJlamVjdFJlYXNvbhIMCgR3b3JkGAIgASgJEhAKCHR1cm5fc2VxGAMgASgNIn8KCEdhbWVPdmVyEg0KBWlfd29uGAEgASgIEicKBnJlYXNvbhgCIAEoDjIXLm5vaXR1LnYxLkdhbWVFbmRSZWFzb24SEAoIbXlfc2NvcmUYAyABKA0SFAoMY2hhaW5fbGVuZ3RoGAQgASgNEhMKC3N1Z2dlc3Rpb25zGAUgAygJIjcKDE9wcG9uZW50TGVmdBIVCg1jYW5fcmVjb25uZWN0GAEgASgIEhAKCGdyYWNlX21zGAIgASgNIiwKC1NlcnZlckVycm9yEgwKBGNvZGUYASABKAkSDwoHbWVzc2FnZRgCIAEoCSI2CgRQb25nEhYKDmNsaWVudF90aW1lX21zGAEgASgDEhYKDnNlcnZlcl90aW1lX21zGAIgASgDIr4BCglSb29tU3RhdGUSEQoJcm9vbV9jb2RlGAEgASgJEhIKCmlfYW1fb3duZXIYAiABKAgSEQoJY2FuX3N0YXJ0GAMgASgIEhIKCmlfYW1fcmVhZHkYBCABKAgSGAoQb3Bwb25lbnRfcHJlc2VudBgFIAEoCBIVCg1vcHBvbmVudF9uYW1lGAYgASgJEhYKDm9wcG9uZW50X3JlYWR5GAcgASgIEhoKEm9wcG9uZW50X2Nvbm5lY3RlZBgIIAEoCCJSCgtDaGF0TWVzc2FnZRIPCgdmcm9tX21lGAEgASgIEg4KBmF1dGhvchgCIAEoCRIMCgR0ZXh0GAMgASgJEhQKDHNlbnRfdW5peF9tcxgEIAEoAyI2CgtDaGF0SGlzdG9yeRInCghtZXNzYWdlcxgBIAMoCzIVLm5vaXR1LnYxLkNoYXRNZXNzYWdlIooECg1TZXJ2ZXJNZXNzYWdlEiQKB3dlbGNvbWUYASABKAsyES5ub2l0dS52MS5XZWxjb21lSAASLQoMZ2FtZV9zdGFydGVkGAQgASgLMhUubm9pdHUudjEuR2FtZVN0YXJ0ZWRIABIrCgt0dXJuX3VwZGF0ZRgFIAEoCzIULm5vaXR1LnYxLlR1cm5VcGRhdGVIABIvCg1tb3ZlX3JlamVjdGVkGAYgASgLMhYubm9pdHUudjEuTW92ZVJlamVjdGVkSAASJwoJZ2FtZV9vdmVyGAcgASgLMhIubm9pdHUudjEuR2FtZU92ZXJIABIvCg1vcHBvbmVudF9sZWZ0GAggASgLMhYubm9pdHUudjEuT3Bwb25lbnRMZWZ0SAASJgoFZXJyb3IYCSABKAsyFS5ub2l0dS52MS5TZXJ2ZXJFcnJvckgAEh4KBHBvbmcYCiABKAsyDi5ub2l0dS52MS5Qb25nSAASKQoKcm9vbV9zdGF0ZRgMIAEoCzITLm5vaXR1LnYxLlJvb21TdGF0ZUgAEi0KDGNoYXRfbWVzc2FnZRgNIAEoCzIVLm5vaXR1LnYxLkNoYXRNZXNzYWdlSAASLQoMY2hhdF9oaXN0b3J5GA4gASgLMhUubm9pdHUudjEuQ2hhdEhpc3RvcnlIAEIJCgdwYXlsb2FkSgQIAhADSgQIAxAESgQICxAMKmkKCkRpZmZpY3VsdHkSGgoWRElGRklDVUxUWV9VTlNQRUNJRklFRBAAEhMKD0RJRkZJQ1VMVFlfRUFTWRABEhUKEURJRkZJQ1VMVFlfTUVESVVNEAISEwoPRElGRklDVUxUWV9IQVJEEAMqjgIKDFJlamVjdFJlYXNvbhIdChlSRUpFQ1RfUkVBU09OX1VOU1BFQ0lGSUVEEAASIwofUkVKRUNUX1JFQVNPTl9UT09fRkVXX1NZTExBQkxFUxABEhwKGFJFSkVDVF9SRUFTT05fV1JPTkdfTElOSxACEiMKH1JFSkVDVF9SRUFTT05fTk9UX0lOX0RJQ1RJT05BUlkQAxIeChpSRUpFQ1RfUkVBU09OX0FMUkVBRFlfVVNFRBAEEh8KG1JFSkVDVF9SRUFTT05fTk9UX1lPVVJfVFVSThAFEhkKFVJFSkVDVF9SRUFTT05fVElNRU9VVBAGEhsKF1JFSkVDVF9SRUFTT05fR0FNRV9PVkVSEAcqsQEKDUdhbWVFbmRSZWFzb24SHwobR0FNRV9FTkRfUkVBU09OX1VOU1BFQ0lGSUVEEAASGwoXR0FNRV9FTkRfUkVBU09OX1RJTUVPVVQQARIhCh1HQU1FX0VORF9SRUFTT05fTk9fTEVHQUxfTU9WRRACEiEKHUdBTUVfRU5EX1JFQVNPTl9PUFBPTkVOVF9MRUZUEAMSHAoYR0FNRV9FTkRfUkVBU09OX1JFU0lHTkVEEARCOlo4Z2l0aHViLmNvbS90aWVubm05OWRldi9ub2l0dS9zZXJ2ZXIvZ2VuL25vaXR1L3YxO25vaXR1djFiBnByb3RvMw");
|
||||
fileDesc("ChNub2l0dS92MS9nYW1lLnByb3RvEghub2l0dS52MSJJCgVIZWxsbxIYChBwcm90b2NvbF92ZXJzaW9uGAEgASgNEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIQCghuaWNrbmFtZRgDIAEoCSI4CgxTdGFydEJvdEdhbWUSKAoKZGlmZmljdWx0eRgBIAEoDjIULm5vaXR1LnYxLkRpZmZpY3VsdHkiDAoKQ3JlYXRlUm9vbSIdCghKb2luUm9vbRIRCglyb29tX2NvZGUYASABKAkiLAoKU3VibWl0V29yZBIMCgR3b3JkGAEgASgJEhAKCHR1cm5fc2VxGAIgASgNIggKBlJlc2lnbiIZCghTZXRSZWFkeRINCgVyZWFkeRgBIAEoCCILCglTdGFydEdhbWUiHwoKS2lja1BsYXllchIRCglwbGF5ZXJfaWQYASABKAkiCwoJTGVhdmVSb29tIhgKCFNlbmRDaGF0EgwKBHRleHQYASABKAkiHgoEUGluZxIWCg5jbGllbnRfdGltZV9tcxgBIAEoAyKQBAoNQ2xpZW50TWVzc2FnZRIgCgVoZWxsbxgBIAEoCzIPLm5vaXR1LnYxLkhlbGxvSAASMAoOc3RhcnRfYm90X2dhbWUYAiABKAsyFi5ub2l0dS52MS5TdGFydEJvdEdhbWVIABIrCgtjcmVhdGVfcm9vbRgDIAEoCzIULm5vaXR1LnYxLkNyZWF0ZVJvb21IABInCglqb2luX3Jvb20YBCABKAsyEi5ub2l0dS52MS5Kb2luUm9vbUgAEisKC3N1Ym1pdF93b3JkGAUgASgLMhQubm9pdHUudjEuU3VibWl0V29yZEgAEiIKBnJlc2lnbhgGIAEoCzIQLm5vaXR1LnYxLlJlc2lnbkgAEh4KBHBpbmcYByABKAsyDi5ub2l0dS52MS5QaW5nSAASJwoJc2V0X3JlYWR5GAkgASgLMhIubm9pdHUudjEuU2V0UmVhZHlIABIpCgpzdGFydF9nYW1lGAogASgLMhMubm9pdHUudjEuU3RhcnRHYW1lSAASKwoLa2lja19wbGF5ZXIYCyABKAsyFC5ub2l0dS52MS5LaWNrUGxheWVySAASKQoKbGVhdmVfcm9vbRgMIAEoCzITLm5vaXR1LnYxLkxlYXZlUm9vbUgAEicKCXNlbmRfY2hhdBgNIAEoCzISLm5vaXR1LnYxLlNlbmRDaGF0SABCCQoHcGF5bG9hZEoECAgQCSJoCgdXZWxjb21lEhIKCnNlc3Npb25faWQYASABKAkSFAoMcmVzdW1lX3Rva2VuGAIgASgJEhgKEHByb3RvY29sX3ZlcnNpb24YAyABKA0SGQoRYWNjZXB0ZWRfbmlja25hbWUYBCABKAkibgoKUGxheWVkV29yZBIMCgR3b3JkGAEgASgJEg0KBWJ5X21lGAIgASgIEg4KBnBvaW50cxgDIAEoDRIRCglzeWxsYWJsZXMYBCABKA0SDQoFdHlwZWQYBSABKAkSEQoJcGxheWVyX2lkGAYgASgJInAKClBsYXllclNsb3QSEQoJcGxheWVyX2lkGAEgASgJEgwKBG5hbWUYAiABKAkSDQoFaXNfbWUYAyABKAgSEAoIaXNfb3duZXIYBCABKAgSDQoFcmVhZHkYBSABKAgSEQoJY29ubmVjdGVkGAYgASgIIoEBCgtQbGF5ZXJTY29yZRIRCglwbGF5ZXJfaWQYASABKAkSDAoEbmFtZRgCIAEoCRINCgVpc19tZRgDIAEoCBINCgVzY29yZRgEIAEoDRISCgplbGltaW5hdGVkGAUgASgIEhEKCWNvbm5lY3RlZBgGIAEoCBIMCgRyYW5rGAcgASgNItEBCgtHYW1lU3RhcnRlZBIUCgxvcGVuaW5nX3dvcmQYASABKAkSGAoQY3VycmVudF9zeWxsYWJsZRgCIAEoCRIPCgdteV90dXJuGAMgASgIEhgKEGRlYWRsaW5lX3VuaXhfbXMYBCABKAMSEAoIdHVybl9zZXEYBSABKA0SFQoNdHVybl9saW1pdF9tcxgGIAEoDRImCgdwbGF5ZXJzGAcgAygLMhUubm9pdHUudjEuUGxheWVyU2NvcmUSFgoOdHVybl9wbGF5ZXJfaWQYCCABKAki6wEKClR1cm5VcGRhdGUSJAoGcGxheWVkGAEgASgLMhQubm9pdHUudjEuUGxheWVkV29yZBIYChBjdXJyZW50X3N5bGxhYmxlGAIgASgJEg8KB215X3R1cm4YAyABKAgSGAoQZGVhZGxpbmVfdW5peF9tcxgEIAEoAxIQCgh0dXJuX3NlcRgFIAEoDRIUCgxjaGFpbl9sZW5ndGgYCCABKA0SJgoHcGxheWVycxgJIAMoCzIVLm5vaXR1LnYxLlBsYXllclNjb3JlEhYKDnR1cm5fcGxheWVyX2lkGAogASgJSgQIBhAHSgQIBxAIIlYKDE1vdmVSZWplY3RlZBImCgZyZWFzb24YASABKA4yFi5ub2l0dS52MS5SZWplY3RSZWFzb24SDAoEd29yZBgCIAEoCRIQCgh0dXJuX3NlcRgDIAEoDSKOAQoIR2FtZU92ZXISDQoFaV93b24YASABKAgSJwoGcmVhc29uGAIgASgOMhcubm9pdHUudjEuR2FtZUVuZFJlYXNvbhIUCgxjaGFpbl9sZW5ndGgYBCABKA0SKAoJc3RhbmRpbmdzGAYgAygLMhUubm9pdHUudjEuUGxheWVyU2NvcmVKBAgDEARKBAgFEAYigAEKEFBsYXllckVsaW1pbmF0ZWQSEQoJcGxheWVyX2lkGAEgASgJEgwKBG5hbWUYAiABKAkSDQoFaXNfbWUYAyABKAgSJwoGcmVhc29uGAQgASgOMhcubm9pdHUudjEuR2FtZUVuZFJlYXNvbhITCgtzdWdnZXN0aW9ucxgFIAMoCSIsCgtTZXJ2ZXJFcnJvchIMCgRjb2RlGAEgASgJEg8KB21lc3NhZ2UYAiABKAkiNgoEUG9uZxIWCg5jbGllbnRfdGltZV9tcxgBIAEoAxIWCg5zZXJ2ZXJfdGltZV9tcxgCIAEoAyK4AQoJUm9vbVN0YXRlEhEKCXJvb21fY29kZRgBIAEoCRIRCgljYW5fc3RhcnQYAyABKAgSJQoHcGxheWVycxgJIAMoCzIULm5vaXR1LnYxLlBsYXllclNsb3QSEwoLbWF4X3BsYXllcnMYCiABKA0SEwoLbWluX3BsYXllcnMYCyABKA0SEAoIZ3JhY2VfbXMYDCABKA1KBAgCEANKBAgEEAVKBAgFEAZKBAgGEAdKBAgHEAhKBAgIEAkiUgoLQ2hhdE1lc3NhZ2USDwoHZnJvbV9tZRgBIAEoCBIOCgZhdXRob3IYAiABKAkSDAoEdGV4dBgDIAEoCRIUCgxzZW50X3VuaXhfbXMYBCABKAMiNgoLQ2hhdEhpc3RvcnkSJwoIbWVzc2FnZXMYASADKAsyFS5ub2l0dS52MS5DaGF0TWVzc2FnZSKYBAoNU2VydmVyTWVzc2FnZRIkCgd3ZWxjb21lGAEgASgLMhEubm9pdHUudjEuV2VsY29tZUgAEi0KDGdhbWVfc3RhcnRlZBgEIAEoCzIVLm5vaXR1LnYxLkdhbWVTdGFydGVkSAASKwoLdHVybl91cGRhdGUYBSABKAsyFC5ub2l0dS52MS5UdXJuVXBkYXRlSAASLwoNbW92ZV9yZWplY3RlZBgGIAEoCzIWLm5vaXR1LnYxLk1vdmVSZWplY3RlZEgAEicKCWdhbWVfb3ZlchgHIAEoCzISLm5vaXR1LnYxLkdhbWVPdmVySAASJgoFZXJyb3IYCSABKAsyFS5ub2l0dS52MS5TZXJ2ZXJFcnJvckgAEh4KBHBvbmcYCiABKAsyDi5ub2l0dS52MS5Qb25nSAASKQoKcm9vbV9zdGF0ZRgMIAEoCzITLm5vaXR1LnYxLlJvb21TdGF0ZUgAEi0KDGNoYXRfbWVzc2FnZRgNIAEoCzIVLm5vaXR1LnYxLkNoYXRNZXNzYWdlSAASLQoMY2hhdF9oaXN0b3J5GA4gASgLMhUubm9pdHUudjEuQ2hhdEhpc3RvcnlIABI3ChFwbGF5ZXJfZWxpbWluYXRlZBgPIAEoCzIaLm5vaXR1LnYxLlBsYXllckVsaW1pbmF0ZWRIAEIJCgdwYXlsb2FkSgQIAhADSgQIAxAESgQICBAJSgQICxAMKmkKCkRpZmZpY3VsdHkSGgoWRElGRklDVUxUWV9VTlNQRUNJRklFRBAAEhMKD0RJRkZJQ1VMVFlfRUFTWRABEhUKEURJRkZJQ1VMVFlfTUVESVVNEAISEwoPRElGRklDVUxUWV9IQVJEEAMqjgIKDFJlamVjdFJlYXNvbhIdChlSRUpFQ1RfUkVBU09OX1VOU1BFQ0lGSUVEEAASIwofUkVKRUNUX1JFQVNPTl9UT09fRkVXX1NZTExBQkxFUxABEhwKGFJFSkVDVF9SRUFTT05fV1JPTkdfTElOSxACEiMKH1JFSkVDVF9SRUFTT05fTk9UX0lOX0RJQ1RJT05BUlkQAxIeChpSRUpFQ1RfUkVBU09OX0FMUkVBRFlfVVNFRBAEEh8KG1JFSkVDVF9SRUFTT05fTk9UX1lPVVJfVFVSThAFEhkKFVJFSkVDVF9SRUFTT05fVElNRU9VVBAGEhsKF1JFSkVDVF9SRUFTT05fR0FNRV9PVkVSEAcqsQEKDUdhbWVFbmRSZWFzb24SHwobR0FNRV9FTkRfUkVBU09OX1VOU1BFQ0lGSUVEEAASGwoXR0FNRV9FTkRfUkVBU09OX1RJTUVPVVQQARIhCh1HQU1FX0VORF9SRUFTT05fTk9fTEVHQUxfTU9WRRACEiEKHUdBTUVfRU5EX1JFQVNPTl9PUFBPTkVOVF9MRUZUEAMSHAoYR0FNRV9FTkRfUkVBU09OX1JFU0lLine truncated
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.Hello.
|
||||
@@ -128,82 +128,96 @@ export const WelcomeSchema = /*@__PURE__*/
|
||||
export const PlayedWordSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 14);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.PlayerSlot.
|
||||
* Use `create(PlayerSlotSchema)` to create a new message.
|
||||
*/
|
||||
export const PlayerSlotSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 15);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.PlayerScore.
|
||||
* Use `create(PlayerScoreSchema)` to create a new message.
|
||||
*/
|
||||
export const PlayerScoreSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 16);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.GameStarted.
|
||||
* Use `create(GameStartedSchema)` to create a new message.
|
||||
*/
|
||||
export const GameStartedSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 15);
|
||||
messageDesc(file_noitu_v1_game, 17);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.TurnUpdate.
|
||||
* Use `create(TurnUpdateSchema)` to create a new message.
|
||||
*/
|
||||
export const TurnUpdateSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 16);
|
||||
messageDesc(file_noitu_v1_game, 18);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.MoveRejected.
|
||||
* Use `create(MoveRejectedSchema)` to create a new message.
|
||||
*/
|
||||
export const MoveRejectedSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 17);
|
||||
messageDesc(file_noitu_v1_game, 19);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.GameOver.
|
||||
* Use `create(GameOverSchema)` to create a new message.
|
||||
*/
|
||||
export const GameOverSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 18);
|
||||
messageDesc(file_noitu_v1_game, 20);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.OpponentLeft.
|
||||
* Use `create(OpponentLeftSchema)` to create a new message.
|
||||
* Describes the message noitu.v1.PlayerEliminated.
|
||||
* Use `create(PlayerEliminatedSchema)` to create a new message.
|
||||
*/
|
||||
export const OpponentLeftSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 19);
|
||||
export const PlayerEliminatedSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 21);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.ServerError.
|
||||
* Use `create(ServerErrorSchema)` to create a new message.
|
||||
*/
|
||||
export const ServerErrorSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 20);
|
||||
messageDesc(file_noitu_v1_game, 22);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.Pong.
|
||||
* Use `create(PongSchema)` to create a new message.
|
||||
*/
|
||||
export const PongSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 21);
|
||||
messageDesc(file_noitu_v1_game, 23);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.RoomState.
|
||||
* Use `create(RoomStateSchema)` to create a new message.
|
||||
*/
|
||||
export const RoomStateSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 22);
|
||||
messageDesc(file_noitu_v1_game, 24);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.ChatMessage.
|
||||
* Use `create(ChatMessageSchema)` to create a new message.
|
||||
*/
|
||||
export const ChatMessageSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 23);
|
||||
messageDesc(file_noitu_v1_game, 25);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.ChatHistory.
|
||||
* Use `create(ChatHistorySchema)` to create a new message.
|
||||
*/
|
||||
export const ChatHistorySchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 24);
|
||||
messageDesc(file_noitu_v1_game, 26);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.ServerMessage.
|
||||
* Use `create(ServerMessageSchema)` to create a new message.
|
||||
*/
|
||||
export const ServerMessageSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 25);
|
||||
messageDesc(file_noitu_v1_game, 27);
|
||||
|
||||
/**
|
||||
* Describes the enum noitu.v1.Difficulty.
|
||||
|
||||
@@ -16,9 +16,27 @@ export const CHAT_WINDOW = 20;
|
||||
* @property {string} word - the canonical spelling
|
||||
* @property {string} typed - what the player actually typed, when it differed
|
||||
* @property {boolean} byMe
|
||||
* @property {string} playerId - the seat that played it, empty for the opening
|
||||
* @property {number} points
|
||||
* @property {number} syllables
|
||||
* @property {boolean} opening - the seed word, played by neither side
|
||||
*
|
||||
* @typedef {object} PlayerSlot
|
||||
* @property {string} playerId
|
||||
* @property {string} name
|
||||
* @property {boolean} isMe
|
||||
* @property {boolean} isOwner
|
||||
* @property {boolean} ready
|
||||
* @property {boolean} connected
|
||||
*
|
||||
* @typedef {object} PlayerScore
|
||||
* @property {string} playerId
|
||||
* @property {string} name
|
||||
* @property {boolean} isMe
|
||||
* @property {number} score
|
||||
* @property {boolean} eliminated
|
||||
* @property {boolean} connected
|
||||
* @property {number} rank - final placing, 1 for the winner; 0 while in play
|
||||
*/
|
||||
|
||||
/** @returns {any} */
|
||||
@@ -42,25 +60,64 @@ function initialState() {
|
||||
deadlineMs: 0,
|
||||
turnSeq: 0,
|
||||
turnLimitMs: 0,
|
||||
myScore: 0,
|
||||
opponentScore: 0,
|
||||
chainLength: 0,
|
||||
|
||||
nickname: '',
|
||||
opponentName: '',
|
||||
roomCode: '',
|
||||
|
||||
/**
|
||||
* The lobby, exactly as the server last described it. Every field is
|
||||
* server-owned: the client never decides who owns the room, who is
|
||||
* ready, or whether a game may start.
|
||||
* The room, exactly as the server last described it. Every field is
|
||||
* server-owned: the client never decides who is seated, who owns the
|
||||
* room, who is ready, or whether a game may start.
|
||||
*
|
||||
* The recipient's own row is in `roomPlayers` like everybody else's,
|
||||
* marked `isMe`, which is what the derived accessors below read.
|
||||
*
|
||||
* @type {PlayerSlot[]}
|
||||
*/
|
||||
isOwner: false,
|
||||
isReady: false,
|
||||
roomPlayers: [],
|
||||
canStart: false,
|
||||
opponentPresent: false,
|
||||
opponentReady: false,
|
||||
opponentConnected: false,
|
||||
/**
|
||||
* How many seats the room has and how many a game needs. Sent by the
|
||||
* server rather than compiled in here, so widening a room is a server
|
||||
* change alone.
|
||||
*/
|
||||
maxPlayers: 0,
|
||||
minPlayers: 0,
|
||||
/** How long a seat is held for somebody who dropped. */
|
||||
graceMs: 0,
|
||||
|
||||
/**
|
||||
* The table of a running game, in turn order, and who is on turn.
|
||||
*
|
||||
* @type {PlayerScore[]}
|
||||
*/
|
||||
gamePlayers: [],
|
||||
turnPlayerId: '',
|
||||
/**
|
||||
* The final table, best first: the player left standing, then the rest
|
||||
* in reverse order of elimination.
|
||||
*
|
||||
* @type {PlayerScore[]}
|
||||
*/
|
||||
standings: [],
|
||||
|
||||
/**
|
||||
* This player's own knockout, and nobody else's. `suggestions` is what
|
||||
* the position still had when they lost it; empty means it was a dead
|
||||
* end, which is a different thing to say than "here is what you
|
||||
* missed".
|
||||
*
|
||||
* @type {{ playerId: string, name: string, reason: number, suggestions: string[] } | null}
|
||||
*/
|
||||
elimination: null,
|
||||
/**
|
||||
* The last player to go out, whoever they were. It is what a spectator
|
||||
* is shown; the client's own knockout is `elimination` above.
|
||||
*
|
||||
* @type {{ playerId: string, name: string, isMe: boolean, reason: number } | null}
|
||||
*/
|
||||
lastOut: null,
|
||||
|
||||
/**
|
||||
* The room's conversation, oldest first, capped at CHAT_WINDOW. Chat
|
||||
@@ -84,21 +141,35 @@ function initialState() {
|
||||
/** @type {{ word: string, message: string } | null} */
|
||||
rejection: null,
|
||||
/**
|
||||
* The finished game. `suggestions` is what the position still had to
|
||||
* offer and arrives only for the player who lost; empty on a loss
|
||||
* means the position was a dead end, which is a different thing to
|
||||
* say than "here is what you missed".
|
||||
* The finished game, from this player's side. The table it came with
|
||||
* is `standings`; this is the part about them.
|
||||
*
|
||||
* @type {{ iWon: boolean, reason: number, myScore: number, chainLength: number, suggestions: string[] } | null}
|
||||
* @type {{ iWon: boolean, reason: number, myScore: number, chainLength: number } | null}
|
||||
*/
|
||||
result: null,
|
||||
/** @type {{ canReconnect: boolean, graceMs: number } | null} */
|
||||
opponentLeft: null,
|
||||
/** @type {string | null} */
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one PlayerScore off the wire.
|
||||
*
|
||||
* @param {any} p
|
||||
* @returns {PlayerScore}
|
||||
*/
|
||||
function toScore(p) {
|
||||
return {
|
||||
playerId: p.playerId,
|
||||
name: p.name,
|
||||
isMe: p.isMe,
|
||||
score: p.score,
|
||||
eliminated: p.eliminated,
|
||||
connected: p.connected,
|
||||
rank: p.rank
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a store instance. Tests construct their own rather than sharing the
|
||||
* module singleton, so one test's game cannot leak into the next.
|
||||
@@ -108,19 +179,17 @@ export function createGameStore() {
|
||||
|
||||
/**
|
||||
* Returns the model to its pre-game shape, keeping the identity fields and
|
||||
* the lobby. A game ending, or a new one starting, does not change which
|
||||
* the room. A game ending, or a new one starting, does not change which
|
||||
* room this is or who is in it — the server says so with its own message.
|
||||
*/
|
||||
const kept = new Set([
|
||||
'nickname',
|
||||
'roomCode',
|
||||
'opponentName',
|
||||
'isOwner',
|
||||
'isReady',
|
||||
'roomPlayers',
|
||||
'canStart',
|
||||
'opponentPresent',
|
||||
'opponentReady',
|
||||
'opponentConnected',
|
||||
'maxPlayers',
|
||||
'minPlayers',
|
||||
'graceMs',
|
||||
'chat',
|
||||
'chatCount'
|
||||
]);
|
||||
@@ -166,16 +235,18 @@ export function createGameStore() {
|
||||
// is how a client ends up believing a mixture of two states
|
||||
// the server was never in.
|
||||
state.roomCode = value.roomCode;
|
||||
state.isOwner = value.iAmOwner;
|
||||
state.isReady = value.iAmReady;
|
||||
state.canStart = value.canStart;
|
||||
state.opponentPresent = value.opponentPresent;
|
||||
state.opponentName = value.opponentName;
|
||||
state.opponentReady = value.opponentReady;
|
||||
state.opponentConnected = value.opponentConnected;
|
||||
// A room state arriving mid-game is a presence change, and an
|
||||
// opponent who is connected again is not one to wait for.
|
||||
if (value.opponentPresent && value.opponentConnected) state.opponentLeft = null;
|
||||
state.maxPlayers = value.maxPlayers;
|
||||
state.minPlayers = value.minPlayers;
|
||||
state.graceMs = value.graceMs;
|
||||
state.roomPlayers = value.players.map((/** @type {any} */ p) => ({
|
||||
playerId: p.playerId,
|
||||
name: p.name,
|
||||
isMe: p.isMe,
|
||||
isOwner: p.isOwner,
|
||||
ready: p.ready,
|
||||
connected: p.connected
|
||||
}));
|
||||
// The lobby is where a room sits when no game is on. `over`
|
||||
// keeps its result panel, which the lobby appears beneath.
|
||||
if (state.phase === 'idle') state.phase = 'lobby';
|
||||
@@ -183,7 +254,7 @@ export function createGameStore() {
|
||||
|
||||
case 'gameStarted':
|
||||
// reset() clears the readiness that led here, along with the
|
||||
// last game's board.
|
||||
// last game's board and its knockouts.
|
||||
reset();
|
||||
state.phase = 'playing';
|
||||
state.chain = [
|
||||
@@ -191,6 +262,7 @@ export function createGameStore() {
|
||||
word: value.openingWord,
|
||||
typed: '',
|
||||
byMe: false,
|
||||
playerId: '',
|
||||
points: 0,
|
||||
syllables: 0,
|
||||
opening: true
|
||||
@@ -202,15 +274,21 @@ export function createGameStore() {
|
||||
state.turnSeq = value.turnSeq;
|
||||
state.turnLimitMs = value.turnLimitMs;
|
||||
state.chainLength = 1;
|
||||
state.gamePlayers = value.players.map(toScore);
|
||||
state.turnPlayerId = value.turnPlayerId;
|
||||
break;
|
||||
|
||||
case 'turnUpdate': {
|
||||
const played = value.played;
|
||||
// A turn update with no word is an elimination moving the turn
|
||||
// on: the syllable and the chain survive the player who could
|
||||
// not answer them, so there is nothing to append.
|
||||
if (played) {
|
||||
state.chain.push({
|
||||
word: played.word,
|
||||
typed: played.typed,
|
||||
byMe: played.byMe,
|
||||
playerId: played.playerId,
|
||||
points: played.points,
|
||||
syllables: played.syllables,
|
||||
opening: false
|
||||
@@ -220,9 +298,9 @@ export function createGameStore() {
|
||||
state.myTurn = value.myTurn;
|
||||
state.deadlineMs = Number(value.deadlineUnixMs);
|
||||
state.turnSeq = value.turnSeq;
|
||||
state.myScore = value.myScore;
|
||||
state.opponentScore = value.opponentScore;
|
||||
state.chainLength = value.chainLength;
|
||||
state.gamePlayers = value.players.map(toScore);
|
||||
state.turnPlayerId = value.turnPlayerId;
|
||||
// An accepted move answers the previous rejection.
|
||||
state.rejection = null;
|
||||
break;
|
||||
@@ -235,27 +313,40 @@ export function createGameStore() {
|
||||
};
|
||||
break;
|
||||
|
||||
case 'gameOver':
|
||||
case 'playerEliminated':
|
||||
state.lastOut = {
|
||||
playerId: value.playerId,
|
||||
name: value.name,
|
||||
isMe: value.isMe,
|
||||
reason: value.reason
|
||||
};
|
||||
// Only the player who went out is sent suggestions, and only
|
||||
// they have a use for them: they describe the position that
|
||||
// beat them, which is nobody else's position.
|
||||
if (value.isMe) {
|
||||
state.myTurn = false;
|
||||
state.elimination = {
|
||||
playerId: value.playerId,
|
||||
name: value.name,
|
||||
reason: value.reason,
|
||||
suggestions: value.suggestions ?? []
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'gameOver': {
|
||||
state.phase = 'over';
|
||||
state.myTurn = false;
|
||||
state.standings = value.standings.map(toScore);
|
||||
const mine = state.standings.find((/** @type {PlayerScore} */ p) => p.isMe);
|
||||
state.result = {
|
||||
iWon: value.iWon,
|
||||
reason: value.reason,
|
||||
myScore: value.myScore,
|
||||
chainLength: value.chainLength,
|
||||
suggestions: value.suggestions ?? []
|
||||
myScore: mine?.score ?? 0,
|
||||
chainLength: value.chainLength
|
||||
};
|
||||
break;
|
||||
|
||||
case 'opponentLeft':
|
||||
// Only sent while a game is running: in a lobby the same fact
|
||||
// arrives as part of the room's own state.
|
||||
state.opponentLeft = {
|
||||
canReconnect: value.canReconnect,
|
||||
graceMs: value.graceMs
|
||||
};
|
||||
state.opponentConnected = false;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'chatMessage':
|
||||
state.chat.push({
|
||||
@@ -313,15 +404,68 @@ export function createGameStore() {
|
||||
apply,
|
||||
reset,
|
||||
leave,
|
||||
|
||||
/**
|
||||
* The recipient's own row in the room. Their role and their readiness
|
||||
* live there and nowhere else: a second copy alongside the list is a
|
||||
* second thing to keep in step with the server.
|
||||
*
|
||||
* @returns {PlayerSlot | null}
|
||||
*/
|
||||
get me() {
|
||||
return state.roomPlayers.find((/** @type {PlayerSlot} */ p) => p.isMe) ?? null;
|
||||
},
|
||||
get isOwner() {
|
||||
return this.me?.isOwner ?? false;
|
||||
},
|
||||
get isReady() {
|
||||
return this.me?.ready ?? false;
|
||||
},
|
||||
/** How many seats are still free, for a lobby that draws the empty ones. */
|
||||
get freeSeats() {
|
||||
return Math.max(0, state.maxPlayers - state.roomPlayers.length);
|
||||
},
|
||||
/** Whether this player has been knocked out of the game still running. */
|
||||
get iAmOut() {
|
||||
return state.phase === 'playing' && state.elimination !== null;
|
||||
},
|
||||
/** This player's score in the game on screen, finished or not. */
|
||||
get myScore() {
|
||||
const table = state.phase === 'over' ? state.standings : state.gamePlayers;
|
||||
return table.find((/** @type {PlayerScore} */ p) => p.isMe)?.score ?? 0;
|
||||
},
|
||||
/**
|
||||
* Everybody whose reconnect window is currently running. The lobby and
|
||||
* the board both wait on the same list.
|
||||
*
|
||||
* @returns {PlayerSlot[]}
|
||||
*/
|
||||
get awayPlayers() {
|
||||
return state.roomPlayers.filter((/** @type {PlayerSlot} */ p) => !p.isMe && !p.connected);
|
||||
},
|
||||
/**
|
||||
* The name behind a seat id, for the chain and the board. Falls back to
|
||||
* the id's absence rather than inventing a label: an empty string is
|
||||
* something a caller can substitute its own copy for.
|
||||
*
|
||||
* @param {string} playerId
|
||||
* @returns {string}
|
||||
*/
|
||||
nameOf(playerId) {
|
||||
const from = state.gamePlayers.length ? state.gamePlayers : state.standings;
|
||||
return (
|
||||
from.find((/** @type {PlayerScore} */ p) => p.playerId === playerId)?.name ??
|
||||
state.roomPlayers.find((/** @type {PlayerSlot} */ p) => p.playerId === playerId)?.name ??
|
||||
''
|
||||
);
|
||||
},
|
||||
|
||||
clearRejection() {
|
||||
state.rejection = null;
|
||||
},
|
||||
clearError() {
|
||||
state.error = null;
|
||||
},
|
||||
clearOpponentLeft() {
|
||||
state.opponentLeft = null;
|
||||
},
|
||||
/**
|
||||
* Forgets the conversation without forgetting the room. The screen
|
||||
* calls this when it is entered and left: chat survives reset() so a
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
* with `protocol_version_mismatch` rather than failing to decode, so this
|
||||
* constant is the client half of that contract.
|
||||
*/
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
export const PROTOCOL_VERSION = 2;
|
||||
|
||||
/**
|
||||
* Thin builders, one per client message. They exist so no other module has to
|
||||
@@ -80,8 +80,8 @@ export function submitWord(word, turnSeq) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares the guest ready for the next game, or takes it back. Only the guest
|
||||
* has a readiness to declare: the owner's is Start itself.
|
||||
* Declares this player ready for the next game, or takes it back. Only guests
|
||||
* have a readiness to declare: the owner's is Start itself.
|
||||
*
|
||||
* @param {boolean} ready
|
||||
*/
|
||||
@@ -91,17 +91,22 @@ export function setReady(ready) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Begins the game the lobby has agreed on. Refused unless the guest is ready. */
|
||||
/** Begins the game the lobby has agreed on. Refused unless every guest is ready. */
|
||||
export function startGame() {
|
||||
return create(ClientMessageSchema, {
|
||||
payload: { case: 'startGame', value: create(StartGameSchema, {}) }
|
||||
});
|
||||
}
|
||||
|
||||
/** Frees the guest's seat. Refused while they are ready. */
|
||||
export function kickPlayer() {
|
||||
/**
|
||||
* Frees one named seat. Refused while that player is ready, and refused on the
|
||||
* owner's own seat — leaving is what an owner who wants out does.
|
||||
*
|
||||
* @param {string} playerId
|
||||
*/
|
||||
export function kickPlayer(playerId) {
|
||||
return create(ClientMessageSchema, {
|
||||
payload: { case: 'kickPlayer', value: create(KickPlayerSchema, {}) }
|
||||
payload: { case: 'kickPlayer', value: create(KickPlayerSchema, { playerId }) }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,7 +118,7 @@ export function leaveRoom() {
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of chat to the other player. The server sanitizes and caps it, so
|
||||
* One line of chat to the rest of the room. The server sanitizes and caps it, so
|
||||
* this sends what was typed and lets the copy that comes back be the truth.
|
||||
*
|
||||
* @param {string} text
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import ChatPanel from '$lib/components/ChatPanel.svelte';
|
||||
import Lobby from '$lib/components/Lobby.svelte';
|
||||
import NicknameInput from '$lib/components/NicknameInput.svelte';
|
||||
import OpponentStatus from '$lib/components/OpponentStatus.svelte';
|
||||
import PlayerStatus from '$lib/components/PlayerStatus.svelte';
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { isRoomCode, normalizeRoomCode, ROOM_CODE_LENGTH } from '$lib/room-code.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
@@ -161,8 +161,9 @@
|
||||
send(startGame());
|
||||
}
|
||||
|
||||
function kick() {
|
||||
if (confirm(t.kickConfirm)) send(kickPlayer());
|
||||
/** @param {string} playerId */
|
||||
function kick(playerId) {
|
||||
if (confirm(t.kickConfirm)) send(kickPlayer(playerId));
|
||||
}
|
||||
|
||||
function leave() {
|
||||
@@ -191,14 +192,9 @@
|
||||
|
||||
<section class="online">
|
||||
{#if playing}
|
||||
<GameBoard
|
||||
opponentLabel={game.state.opponentName || t.opponent}
|
||||
modeLabel={game.state.roomCode}
|
||||
onsubmit={play}
|
||||
onresign={giveUp}
|
||||
>
|
||||
<GameBoard modeLabel={game.state.roomCode} onsubmit={play} onresign={giveUp}>
|
||||
{#snippet banner()}
|
||||
<OpponentStatus />
|
||||
<PlayerStatus />
|
||||
{/snippet}
|
||||
{#snippet chat()}
|
||||
<ChatPanel collapsible onsend={say} />
|
||||
|
||||
@@ -91,12 +91,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<GameBoard
|
||||
opponentLabel={t.opponent}
|
||||
modeLabel={difficultyLabels[difficulty]}
|
||||
onsubmit={play}
|
||||
onresign={giveUp}
|
||||
>
|
||||
<GameBoard modeLabel={difficultyLabels[difficulty]} onsubmit={play} onresign={giveUp}>
|
||||
{#snippet gameOver()}
|
||||
<!-- A bot always plays again, so there is nothing to negotiate: the
|
||||
button starts the next game rather than offering one. -->
|
||||
|
||||
+198
-65
@@ -18,6 +18,14 @@ function msg(kind, value) {
|
||||
return create(ServerMessageSchema, { payload: { case: kind, value } });
|
||||
}
|
||||
|
||||
/** The two players every in-game fixture below is scored for. */
|
||||
function table(mine = 0, theirs = 0) {
|
||||
return [
|
||||
{ playerId: 'p1', name: 'Minh', isMe: true, score: mine, connected: true },
|
||||
{ playerId: 'p2', name: 'Lan', isMe: false, score: theirs, connected: true }
|
||||
];
|
||||
}
|
||||
|
||||
function started(overrides = {}) {
|
||||
return msg('gameStarted', {
|
||||
openingWord: 'học sinh',
|
||||
@@ -26,6 +34,8 @@ function started(overrides = {}) {
|
||||
deadlineUnixMs: 1_700_000_020_000n,
|
||||
turnSeq: 1,
|
||||
turnLimitMs: 20_000,
|
||||
players: table(),
|
||||
turnPlayerId: 'p1',
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
@@ -82,14 +92,21 @@ describe('turnUpdate', () => {
|
||||
store.apply(started());
|
||||
store.apply(
|
||||
msg('turnUpdate', {
|
||||
played: { word: 'sinh viên', byMe: true, points: 12, syllables: 2, typed: 'sinh vien' },
|
||||
played: {
|
||||
word: 'sinh viên',
|
||||
byMe: true,
|
||||
points: 12,
|
||||
syllables: 2,
|
||||
typed: 'sinh vien',
|
||||
playerId: 'p1'
|
||||
},
|
||||
currentSyllable: 'viên',
|
||||
myTurn: false,
|
||||
deadlineUnixMs: 1_700_000_040_000n,
|
||||
turnSeq: 2,
|
||||
myScore: 12,
|
||||
opponentScore: 0,
|
||||
chainLength: 2
|
||||
chainLength: 2,
|
||||
players: table(12, 0),
|
||||
turnPlayerId: 'p2'
|
||||
})
|
||||
);
|
||||
|
||||
@@ -103,11 +120,39 @@ describe('turnUpdate', () => {
|
||||
});
|
||||
expect(store.state.currentSyllable).toBe('viên');
|
||||
expect(store.state.myTurn).toBe(false);
|
||||
expect(store.state.myScore).toBe(12);
|
||||
expect(store.myScore).toBe(12);
|
||||
expect(store.state.turnPlayerId).toBe('p2');
|
||||
expect(store.state.chainLength).toBe(2);
|
||||
expect(store.state.turnSeq).toBe(2);
|
||||
});
|
||||
|
||||
it('moves the turn on without a word when an elimination did it', () => {
|
||||
// The syllable and the chain survive the player who could not answer
|
||||
// them, so there is nothing to append — but the deadline and the player
|
||||
// to act are both new.
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(
|
||||
msg('turnUpdate', {
|
||||
currentSyllable: 'sinh',
|
||||
myTurn: true,
|
||||
deadlineUnixMs: 1_700_000_050_000n,
|
||||
turnSeq: 3,
|
||||
chainLength: 1,
|
||||
players: [
|
||||
{ playerId: 'p1', name: 'Minh', isMe: true, score: 0, connected: true },
|
||||
{ playerId: 'p2', name: 'Lan', score: 0, eliminated: true, connected: true }
|
||||
],
|
||||
turnPlayerId: 'p1'
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.state.chain).toHaveLength(1);
|
||||
expect(store.state.turnSeq).toBe(3);
|
||||
expect(store.state.deadlineMs).toBe(1_700_000_050_000);
|
||||
expect(store.state.gamePlayers[1].eliminated).toBe(true);
|
||||
});
|
||||
|
||||
it('clears the standing rejection, because an accepted move answers it', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
@@ -156,8 +201,11 @@ describe('gameOver', () => {
|
||||
msg('gameOver', {
|
||||
iWon: true,
|
||||
reason: GameEndReason.NO_LEGAL_MOVE,
|
||||
myScore: 42,
|
||||
chainLength: 7
|
||||
chainLength: 7,
|
||||
standings: [
|
||||
{ playerId: 'p1', name: 'Minh', isMe: true, score: 42, connected: true, rank: 1 },
|
||||
{ playerId: 'p2', name: 'Lan', score: 8, eliminated: true, connected: true, rank: 2 }
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
@@ -167,35 +215,81 @@ describe('gameOver', () => {
|
||||
iWon: true,
|
||||
reason: GameEndReason.NO_LEGAL_MOVE,
|
||||
myScore: 42,
|
||||
chainLength: 7,
|
||||
suggestions: []
|
||||
chainLength: 7
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the words the losing side could have played', () => {
|
||||
it('keeps the final table in the order the server ranked it', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(
|
||||
msg('gameOver', {
|
||||
iWon: false,
|
||||
reason: GameEndReason.TIMEOUT,
|
||||
myScore: 12,
|
||||
chainLength: 3,
|
||||
chainLength: 9,
|
||||
standings: [
|
||||
{ playerId: 'p3', name: 'Hà', score: 60, connected: true, rank: 1 },
|
||||
{ playerId: 'p1', name: 'Minh', isMe: true, score: 40, eliminated: true, rank: 2 },
|
||||
{ playerId: 'p2', name: 'Lan', score: 55, eliminated: true, rank: 3 }
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.state.standings.map((p) => p.playerId)).toEqual(['p3', 'p1', 'p2']);
|
||||
// Rank is finishing order, not score order: Lan outscored Minh and
|
||||
// still placed below him, because he outlasted her.
|
||||
expect(store.state.standings[2].score).toBeGreaterThan(store.state.standings[1].score);
|
||||
expect(store.state.result?.myScore).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playerEliminated', () => {
|
||||
it('keeps the words this player could have played', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(
|
||||
msg('playerEliminated', {
|
||||
playerId: 'p1',
|
||||
name: 'Minh',
|
||||
isMe: true,
|
||||
reason: GameEndReason.TIMEOUT,
|
||||
suggestions: ['sinh viên', 'sinh sôi']
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.state.result?.suggestions).toEqual(['sinh viên', 'sinh sôi']);
|
||||
expect(store.state.elimination?.suggestions).toEqual(['sinh viên', 'sinh sôi']);
|
||||
expect(store.iAmOut).toBe(true);
|
||||
expect(store.state.myTurn).toBe(false);
|
||||
});
|
||||
|
||||
it('reads an absent list as a position that had nothing left', () => {
|
||||
// A dead end and an older server that never sends the field arrive the
|
||||
// same way, and both mean "no words to offer" rather than undefined.
|
||||
// A dead end arrives as an empty list, which means "no words to offer"
|
||||
// rather than undefined.
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(msg('gameOver', { iWon: false, reason: GameEndReason.NO_LEGAL_MOVE }));
|
||||
store.apply(
|
||||
msg('playerEliminated', { playerId: 'p1', isMe: true, reason: GameEndReason.NO_LEGAL_MOVE })
|
||||
);
|
||||
|
||||
expect(store.state.result?.suggestions).toEqual([]);
|
||||
expect(store.state.elimination?.suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
it('records somebody else going out without claiming this player did', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(
|
||||
msg('playerEliminated', {
|
||||
playerId: 'p2',
|
||||
name: 'Lan',
|
||||
isMe: false,
|
||||
reason: GameEndReason.RESIGNED,
|
||||
suggestions: []
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.state.lastOut?.name).toBe('Lan');
|
||||
expect(store.state.elimination).toBeNull();
|
||||
expect(store.iAmOut).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,50 +308,70 @@ describe('error', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('opponentLeft', () => {
|
||||
it('exposes the grace window so the UI can say how long to wait', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(msg('opponentLeft', { canReconnect: true, graceMs: 30_000 }));
|
||||
expect(store.state.opponentLeft).toEqual({ canReconnect: true, graceMs: 30_000 });
|
||||
});
|
||||
});
|
||||
/** One seat, with the fields a test does not care about defaulted. */
|
||||
function seat(fields = {}) {
|
||||
return {
|
||||
playerId: 'p1',
|
||||
name: 'Minh',
|
||||
isMe: false,
|
||||
isOwner: false,
|
||||
ready: false,
|
||||
connected: true,
|
||||
...fields
|
||||
};
|
||||
}
|
||||
|
||||
/** One lobby snapshot, with the fields a test does not care about defaulted. */
|
||||
/** One lobby snapshot. `players` is the whole room, the recipient included. */
|
||||
function lobby(fields = {}) {
|
||||
return msg('roomState', {
|
||||
roomCode: 'ABCD',
|
||||
iAmOwner: false,
|
||||
canStart: false,
|
||||
iAmReady: false,
|
||||
opponentPresent: false,
|
||||
opponentName: '',
|
||||
opponentReady: false,
|
||||
opponentConnected: false,
|
||||
maxPlayers: 4,
|
||||
minPlayers: 2,
|
||||
graceMs: 30_000,
|
||||
players: [seat({ isMe: true })],
|
||||
...fields
|
||||
});
|
||||
}
|
||||
|
||||
/** A two-player room, seen by the guest. */
|
||||
function pair(fields = {}) {
|
||||
return lobby({
|
||||
players: [
|
||||
seat({ playerId: 'p1', name: 'Chủ', isOwner: true }),
|
||||
seat({ playerId: 'p2', name: 'Lan', isMe: true })
|
||||
],
|
||||
...fields
|
||||
});
|
||||
}
|
||||
|
||||
describe('room messages', () => {
|
||||
it('keeps the room code and the sanitized opponent name', () => {
|
||||
it('keeps the room code and the sanitized names', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(lobby({ opponentPresent: true, opponentName: 'Lan', opponentConnected: true }));
|
||||
store.apply(pair());
|
||||
|
||||
expect(store.state.roomCode).toBe('ABCD');
|
||||
expect(store.state.opponentName).toBe('Lan');
|
||||
expect(store.state.roomPlayers.map((p) => p.name)).toEqual(['Chủ', 'Lan']);
|
||||
expect(store.me?.playerId).toBe('p2');
|
||||
});
|
||||
|
||||
it('survives a reset, because identity outlives one game', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(msg('welcome', { acceptedNickname: 'Minh', sessionId: 's', resumeToken: 't' }));
|
||||
store.apply(lobby({ opponentPresent: true, opponentName: 'Lan', opponentConnected: true }));
|
||||
store.apply(pair());
|
||||
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.roomPlayers).toHaveLength(2);
|
||||
expect(store.state.chain).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts the seats nobody is in yet', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(pair());
|
||||
expect(store.freeSeats).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pong', () => {
|
||||
@@ -379,36 +493,48 @@ describe('chat', () => {
|
||||
describe('the lobby', () => {
|
||||
it('opens the lobby when the room appears', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(lobby({ roomCode: 'K7M2QP', iAmOwner: true }));
|
||||
store.apply(lobby({ roomCode: 'K7M2QP', players: [seat({ isMe: true, isOwner: true })] }));
|
||||
|
||||
expect(store.state.phase).toBe('lobby');
|
||||
expect(store.state.roomCode).toBe('K7M2QP');
|
||||
expect(store.state.isOwner).toBe(true);
|
||||
expect(store.isOwner).toBe(true);
|
||||
});
|
||||
|
||||
it('takes every field from the server rather than deriving any', () => {
|
||||
it("reads this player’s role and readiness off their own row", () => {
|
||||
// There is one encoding of each, so a client cannot end up believing a
|
||||
// role the list it is rendering disagrees with.
|
||||
const store = createGameStore();
|
||||
store.apply(
|
||||
lobby({
|
||||
iAmOwner: false,
|
||||
iAmReady: true,
|
||||
pair({
|
||||
canStart: true,
|
||||
opponentPresent: true,
|
||||
opponentName: 'Lan',
|
||||
opponentReady: false,
|
||||
opponentConnected: true
|
||||
players: [
|
||||
seat({ playerId: 'p1', name: 'Chủ', isOwner: true }),
|
||||
seat({ playerId: 'p2', name: 'Lan', isMe: true, ready: true })
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.state).toMatchObject({
|
||||
isOwner: false,
|
||||
isReady: true,
|
||||
canStart: true,
|
||||
opponentPresent: true,
|
||||
opponentName: 'Lan',
|
||||
opponentReady: false,
|
||||
opponentConnected: true
|
||||
});
|
||||
expect(store.isOwner).toBe(false);
|
||||
expect(store.isReady).toBe(true);
|
||||
expect(store.state.canStart).toBe(true);
|
||||
expect(store.state.maxPlayers).toBe(4);
|
||||
expect(store.state.minPlayers).toBe(2);
|
||||
});
|
||||
|
||||
it('lists everybody whose reconnect window is running, and nobody else', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(
|
||||
lobby({
|
||||
players: [
|
||||
seat({ playerId: 'p1', name: 'Chủ', isOwner: true }),
|
||||
seat({ playerId: 'p2', name: 'Lan', isMe: true }),
|
||||
seat({ playerId: 'p3', name: 'Hà', connected: false })
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.awayPlayers.map((p) => p.name)).toEqual(['Hà']);
|
||||
expect(store.state.graceMs).toBe(30_000);
|
||||
});
|
||||
|
||||
it('does not drop a live game back into the lobby', () => {
|
||||
@@ -416,7 +542,7 @@ describe('the lobby', () => {
|
||||
// Acting on it would replace the board with the lobby mid-turn.
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(lobby({ opponentPresent: true, opponentConnected: true }));
|
||||
store.apply(pair());
|
||||
|
||||
expect(store.state.phase).toBe('playing');
|
||||
});
|
||||
@@ -425,25 +551,32 @@ describe('the lobby', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(msg('gameOver', { iWon: false, reason: GameEndReason.RESIGNED }));
|
||||
store.apply(lobby({ opponentPresent: true, opponentConnected: true }));
|
||||
store.apply(pair());
|
||||
|
||||
expect(store.state.phase).toBe('over');
|
||||
expect(store.state.result).not.toBeNull();
|
||||
});
|
||||
|
||||
it('stops waiting for an opponent who is connected again', () => {
|
||||
it('stops waiting for a player 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 }));
|
||||
store.apply(
|
||||
pair({
|
||||
players: [
|
||||
seat({ playerId: 'p1', name: 'Chủ', isOwner: true, connected: false }),
|
||||
seat({ playerId: 'p2', name: 'Lan', isMe: true })
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(store.awayPlayers).toHaveLength(1);
|
||||
|
||||
expect(store.state.opponentLeft).toBeNull();
|
||||
expect(store.state.opponentConnected).toBe(true);
|
||||
store.apply(pair());
|
||||
expect(store.awayPlayers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('forgets the room when this player is kicked out of it', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(lobby({ opponentPresent: true, opponentConnected: true }));
|
||||
store.apply(pair());
|
||||
store.apply(msg('error', { code: 'kicked', message: '' }));
|
||||
|
||||
expect(store.state.phase).toBe('idle');
|
||||
@@ -453,7 +586,7 @@ describe('the lobby', () => {
|
||||
|
||||
it('forgets a room that closed for sitting idle', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(lobby({ iAmOwner: true }));
|
||||
store.apply(lobby());
|
||||
store.apply(msg('error', { code: 'room_idle_closed', message: '' }));
|
||||
|
||||
expect(store.state.phase).toBe('idle');
|
||||
|
||||
@@ -11,6 +11,7 @@ import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PROTOCOL_VERSION } from '../src/lib/ws/messages.js';
|
||||
import { fromBinary } from '@bufbuild/protobuf';
|
||||
import {
|
||||
ClientMessageSchema,
|
||||
@@ -48,7 +49,7 @@ describe('generated wire types', () => {
|
||||
const msg = decode('server_welcome');
|
||||
expect(msg.payload.case).toBe('welcome');
|
||||
expect(msg.payload.value.acceptedNickname).toBe('Người chơi ẩn danh');
|
||||
expect(msg.payload.value.protocolVersion).toBe(1);
|
||||
expect(msg.payload.value.protocolVersion).toBe(PROTOCOL_VERSION);
|
||||
});
|
||||
|
||||
// int64 is a bigint in this runtime. Reading it as a Number would silently
|
||||
@@ -71,9 +72,13 @@ describe('generated wire types', () => {
|
||||
expect(over.payload.case).toBe('gameOver');
|
||||
expect(over.payload.value.reason).toBe(GameEndReason.NO_LEGAL_MOVE);
|
||||
expect(over.payload.value.iWon).toBe(false);
|
||||
// The only repeated field in the contract, and the one the losing
|
||||
// The final table, in the order the server ranked it.
|
||||
expect(over.payload.value.standings.map((/** @type {any} */ p) => p.rank)).toEqual([1, 2, 3]);
|
||||
|
||||
const out = decode('server_player_eliminated');
|
||||
// The only repeated string in the contract, and the one the losing
|
||||
// player's screen is built from.
|
||||
expect(over.payload.value.suggestions).toEqual(['sinh viên', 'sinh sôi']);
|
||||
expect(out.payload.value.suggestions).toEqual(['sinh viên', 'sinh sôi']);
|
||||
|
||||
const chat = decode('server_chat_message');
|
||||
expect(chat.payload.case).toBe('chatMessage');
|
||||
|
||||
@@ -9,15 +9,27 @@ import { chainToText, historyFilename } from '../src/lib/history-export.js';
|
||||
* @param {Partial<import('../src/lib/stores/game.svelte.js').ChainEntry>} entry
|
||||
*/
|
||||
function link(entry) {
|
||||
return { word: '', typed: '', byMe: false, points: 0, syllables: 2, opening: false, ...entry };
|
||||
return {
|
||||
word: '',
|
||||
typed: '',
|
||||
byMe: false,
|
||||
playerId: '',
|
||||
points: 0,
|
||||
syllables: 2,
|
||||
opening: false,
|
||||
...entry
|
||||
};
|
||||
}
|
||||
|
||||
const chain = [
|
||||
link({ word: 'học sinh', opening: true, syllables: 0 }),
|
||||
link({ word: 'sinh viên', byMe: true, points: 2 }),
|
||||
link({ word: 'viên chức', points: 2 })
|
||||
link({ word: 'sinh viên', byMe: true, playerId: 'p1', points: 2 }),
|
||||
link({ word: 'viên chức', playerId: 'p2', points: 2 })
|
||||
];
|
||||
|
||||
/** Resolves a seat to the name that seat was playing under. */
|
||||
const nameOf = (/** @type {string} */ id) => (id === 'p2' ? 'Minh' : '');
|
||||
|
||||
const at = new Date(2026, 8, 7, 15, 25);
|
||||
|
||||
describe('chainToText', () => {
|
||||
@@ -39,12 +51,21 @@ describe('chainToText', () => {
|
||||
});
|
||||
|
||||
it('names who played each word and what it scored', () => {
|
||||
const text = chainToText({ chain, at, opponentLabel: 'Minh' });
|
||||
const text = chainToText({ chain, at, nameOf });
|
||||
|
||||
expect(text).toContain('2. sinh viên — Bạn +2');
|
||||
expect(text).toContain('3. viên chức — Minh +2');
|
||||
});
|
||||
|
||||
it('falls back to a generic label for a seat whose name is gone', () => {
|
||||
// A player who left takes their name with them, and the transcript is
|
||||
// written long after. The line still has to say it was not this
|
||||
// player's word.
|
||||
const text = chainToText({ chain, at });
|
||||
|
||||
expect(text).toContain('3. viên chức — Đối thủ +2');
|
||||
});
|
||||
|
||||
it('heads the file with the result when there is one', () => {
|
||||
const text = chainToText({
|
||||
chain,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { create, toBinary } from '@bufbuild/protobuf';
|
||||
import { PROTOCOL_VERSION } from '../src/lib/ws/messages.js';
|
||||
import {
|
||||
ClientMessageSchema,
|
||||
ServerMessageSchema
|
||||
@@ -171,7 +172,7 @@ describe('handshake', () => {
|
||||
|
||||
const [first] = await sentMessages(h.last());
|
||||
expect(first.payload.case).toBe('hello');
|
||||
expect(first.payload.value.protocolVersion).toBe(1);
|
||||
expect(first.payload.value.protocolVersion).toBe(PROTOCOL_VERSION);
|
||||
expect(first.payload.value.nickname).toBe('Minh');
|
||||
expect(first.payload.value.resumeToken).toBe('');
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user