mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-15 12:14:39 +00:00
feat(online): make a room a lobby with an owner and a ready-up
A room used to be a wrapper around one game: joining started it, and the room died with it unless both players accepted a rematch inside thirty seconds. It is now a lobby that outlives its games. Whoever created the room owns it and the other seat is the guest. The guest readies and the owner starts; the owner has no readiness of their own, because starting is the same statement. A finished game returns both to the lobby, where the next one is agreed exactly as the last was — the readiness that started a game is spent with it. A guest takes their readiness back before leaving, which is deliberate friction: a player the owner is waiting on should have to say so before walking away. The owner can free the seat of a guest who is not ready, and not of one who is — readiness is a commitment, not an inconvenience. An owner who leaves hands the room to whoever is left, unreadied, because they are the one who starts now. Something has to bound a room that outlives its games: the last player out closes it, as does ten minutes in a lobby nobody started a game in. A dropped connection is still not a player leaving — the seat is held for the reconnect window in the lobby as well as mid-game, so a refresh no longer costs somebody their room, and a resume lands in the lobby it left. The rematch handshake is retired, and RoomCreated and RoomJoined go with it. All three described part of what RoomState now describes in full, and three messages for one lobby is three ways for a client to hold a view of it the server never had. One snapshot per recipient, broadcast from the one place that knows an input is finished, so no handler can forget to send it.
This commit is contained in:
1 parent
ef638e52fd
commit
d481a093ec
39 files changed
+2352
-1215
No files matched your search
@@ -65,6 +65,21 @@ export async function playLegalMove(page, used) {
|
||||
return /** @type {string} */ (word);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a seated pair from their lobby into a game: the guest readies, the
|
||||
* owner starts. Nothing begins on its own now, so every online test that is
|
||||
* about a game goes through here.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} owner
|
||||
* @param {import('@playwright/test').Page} guest
|
||||
*/
|
||||
export async function readyAndStart(owner, guest) {
|
||||
await guest.getByTestId('ready').click();
|
||||
const start = owner.getByTestId('start-game');
|
||||
await expect(start).toBeEnabled();
|
||||
await start.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a difficulty by clicking its card.
|
||||
*
|
||||
|
||||
+118
-25
@@ -1,5 +1,12 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { board, chainWords, playLegalMove, setNickname, waitForMyTurn } from './helpers.js';
|
||||
import {
|
||||
board,
|
||||
chainWords,
|
||||
playLegalMove,
|
||||
readyAndStart,
|
||||
setNickname,
|
||||
waitForMyTurn
|
||||
} from './helpers.js';
|
||||
|
||||
/**
|
||||
* Two browser contexts, so each player has their own storage, nickname and
|
||||
@@ -36,15 +43,26 @@ async function joinRoom(page, nickname, code) {
|
||||
await page.getByRole('button', { name: 'Vào phòng' }).click();
|
||||
}
|
||||
|
||||
/** Seats a pair and plays them into a game, which is where most tests start. */
|
||||
async function playingPair(browser) {
|
||||
const pair = await twoPlayers(browser);
|
||||
const code = await createRoom(pair.host, 'Minh');
|
||||
await joinRoom(pair.guest, 'Lan', code);
|
||||
await readyAndStart(pair.host, pair.guest);
|
||||
await waitForMyTurn(pair.host);
|
||||
return { ...pair, code };
|
||||
}
|
||||
|
||||
test.describe('playing a stranger', () => {
|
||||
test('two players join by code and alternate turns', async ({ browser }) => {
|
||||
const { host, guest, close } = await twoPlayers(browser);
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
expect(code).toMatch(/^[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{6}$/);
|
||||
await expect(host.getByTestId('waiting-status')).toBeVisible();
|
||||
await expect(host.getByText('Còn trống')).toBeVisible();
|
||||
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
await readyAndStart(host, guest);
|
||||
|
||||
// Both boards come up, and exactly one player is on turn.
|
||||
await waitForMyTurn(host);
|
||||
@@ -76,6 +94,10 @@ test.describe('playing a stranger', () => {
|
||||
// No code typed, no button pressed beyond opening the link.
|
||||
await guest.goto(`/online?code=${code}`);
|
||||
|
||||
// The link seats them; the lobby is still where the game is agreed.
|
||||
await expect(guest.getByTestId('ready')).toBeVisible();
|
||||
await readyAndStart(host, guest);
|
||||
|
||||
await waitForMyTurn(host);
|
||||
await expect(board(guest).syllable).toBeVisible();
|
||||
|
||||
@@ -83,11 +105,7 @@ test.describe('playing a stranger', () => {
|
||||
});
|
||||
|
||||
test('resigning ends the game on both sides with the right winner', async ({ browser }) => {
|
||||
const { host, guest, close } = await twoPlayers(browser);
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
await waitForMyTurn(host);
|
||||
const { host, guest, close } = await playingPair(browser);
|
||||
|
||||
host.on('dialog', (dialog) => dialog.accept());
|
||||
await host.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||
@@ -98,23 +116,17 @@ test.describe('playing a stranger', () => {
|
||||
await close();
|
||||
});
|
||||
|
||||
test('a rematch restarts in the same room once both agree', async ({ browser }) => {
|
||||
const { host, guest, close } = await twoPlayers(browser);
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
await waitForMyTurn(host);
|
||||
test('a second game is agreed in the lobby the first one ends in', async ({ browser }) => {
|
||||
const { host, guest, code, close } = await playingPair(browser);
|
||||
|
||||
host.on('dialog', (dialog) => dialog.accept());
|
||||
await host.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||
await expect(guest.getByTestId('rematch-prompt')).toBeVisible();
|
||||
await expect(host.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible();
|
||||
|
||||
// One acceptance is not enough: the other player is still being asked.
|
||||
await host.getByTestId('rematch-accept').click();
|
||||
await expect(guest.getByTestId('rematch-prompt')).toContainText('Đối thủ muốn chơi lại');
|
||||
await expect(host.getByTestId('rematch-prompt')).toContainText('Đang chờ đối thủ');
|
||||
|
||||
await guest.getByTestId('rematch-accept').click();
|
||||
// The readiness that started the first game is spent, so the owner
|
||||
// cannot simply start another.
|
||||
await expect(host.getByTestId('start-game')).toBeDisabled();
|
||||
await readyAndStart(host, guest);
|
||||
|
||||
// A new game in the same room: the code is unchanged and the board is
|
||||
// back to a single opening word.
|
||||
@@ -125,6 +137,90 @@ test.describe('playing a stranger', () => {
|
||||
await close();
|
||||
});
|
||||
|
||||
test('the guest readies, and cannot leave without taking it back', async ({ browser }) => {
|
||||
const { host, guest, close } = await twoPlayers(browser);
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
|
||||
// Nothing starts until the owner says so.
|
||||
await expect(host.getByTestId('start-game')).toBeDisabled();
|
||||
await guest.getByTestId('ready').click();
|
||||
|
||||
await expect(guest.getByTestId('my-ready')).toHaveText('Đã sẵn sàng');
|
||||
await expect(host.getByTestId('opponent-ready')).toHaveText('Đã sẵn sàng');
|
||||
await expect(host.getByTestId('start-game')).toBeEnabled();
|
||||
|
||||
// Ready is a commitment: the way out is to take it back first.
|
||||
await expect(guest.getByRole('button', { name: 'Rời phòng' })).toBeDisabled();
|
||||
await guest.getByTestId('ready').click();
|
||||
await expect(guest.getByRole('button', { name: 'Rời phòng' })).toBeEnabled();
|
||||
|
||||
await guest.getByRole('button', { name: 'Rời phòng' }).click();
|
||||
|
||||
// The room survives with its owner in it, one seat free.
|
||||
await expect(host.getByText('Còn trống')).toBeVisible();
|
||||
await expect(host.getByTestId('start-game')).toBeDisabled();
|
||||
// And the player who left is back on the join screen.
|
||||
await expect(guest.getByRole('button', { name: 'Tạo phòng' })).toBeVisible();
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('the owner can put an unready guest out of the room', async ({ browser }) => {
|
||||
const { host, guest, close } = await twoPlayers(browser);
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
|
||||
const kick = host.getByRole('button', { name: 'Mời ra khỏi phòng' });
|
||||
await expect(kick).toBeEnabled();
|
||||
|
||||
// A guest who is ready is waiting on the owner, not in the way.
|
||||
await guest.getByTestId('ready').click();
|
||||
await expect(kick).toBeDisabled();
|
||||
|
||||
await guest.getByTestId('ready').click();
|
||||
await expect(kick).toBeEnabled();
|
||||
host.on('dialog', (dialog) => dialog.accept());
|
||||
await kick.click();
|
||||
|
||||
await expect(guest.getByTestId('join-error')).toHaveText('Bạn đã bị mời ra khỏi phòng.');
|
||||
await expect(host.getByText('Còn trống')).toBeVisible();
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('the room outlives its owner, who hands it to whoever is left', async ({ browser }) => {
|
||||
const { host, guest, close } = await twoPlayers(browser);
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
await expect(guest.locator('.role', { hasText: 'Chủ phòng' })).toBeVisible();
|
||||
|
||||
await host.getByRole('button', { name: 'Rời phòng' }).click();
|
||||
|
||||
// The guest is the owner now, which is visible in what they are
|
||||
// offered rather than only in a label.
|
||||
await expect(guest.getByTestId('start-game')).toBeVisible();
|
||||
await expect(guest.getByText('Còn trống')).toBeVisible();
|
||||
|
||||
// And the promotion is real: the next person in can be played against.
|
||||
const thirdContext = await browser.newContext();
|
||||
const third = await thirdContext.newPage();
|
||||
await joinRoom(third, 'Nam', code);
|
||||
await readyAndStart(guest, third);
|
||||
|
||||
// Both boards come up. Which of them moves first is the engine's
|
||||
// business, and asserting it here would test seat order instead of the
|
||||
// promotion.
|
||||
await expect(board(guest).syllable).toBeVisible();
|
||||
await expect(board(third).syllable).toBeVisible();
|
||||
|
||||
await thirdContext.close();
|
||||
await close();
|
||||
});
|
||||
|
||||
test('an unknown room code is refused in Vietnamese', async ({ page }) => {
|
||||
await page.goto('/online');
|
||||
await setNickname(page, 'Minh');
|
||||
@@ -146,11 +242,8 @@ test.describe('playing a stranger', () => {
|
||||
});
|
||||
|
||||
test('a full room turns a third player away', async ({ browser }) => {
|
||||
const { host, guest, close } = await twoPlayers(browser);
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
await waitForMyTurn(host);
|
||||
const { host, code, close } = await playingPair(browser);
|
||||
void host;
|
||||
|
||||
const thirdContext = await browser.newContext();
|
||||
const third = await thirdContext.newPage();
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { board, chainWords, playLegalMove, setNickname, waitForMyTurn } from './helpers.js';
|
||||
import {
|
||||
board,
|
||||
chainWords,
|
||||
playLegalMove,
|
||||
readyAndStart,
|
||||
setNickname,
|
||||
waitForMyTurn
|
||||
} from './helpers.js';
|
||||
import { cuttableSocket } from './socket-cut.js';
|
||||
|
||||
/**
|
||||
@@ -29,6 +36,7 @@ async function pvpRoom(browser) {
|
||||
await setNickname(guest, 'Lan');
|
||||
await guest.getByLabel('Mã phòng').fill(code);
|
||||
await guest.getByRole('button', { name: 'Vào phòng' }).click();
|
||||
await readyAndStart(host, guest);
|
||||
await waitForMyTurn(host);
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
[Status.CONNECTING]: t.connecting,
|
||||
[Status.OPEN]: t.connected,
|
||||
[Status.RECONNECTING]: t.reconnecting,
|
||||
[Status.CLOSED]: t.offline
|
||||
}[connection.status] ?? t.offline
|
||||
[Status.CLOSED]: t.noConnection
|
||||
}[connection.status] ?? t.noConnection
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
/**
|
||||
* onrematch is optional because the two modes differ: a bot always plays
|
||||
* again, so the button starts the next game, while online play has to ask
|
||||
* the other person first and offers it from the rematch prompt instead.
|
||||
* again, so the button starts the next game, while online play agrees the
|
||||
* next one in the lobby this panel appears above.
|
||||
*
|
||||
* @type {{ isRecord: boolean, onrematch?: () => void, onhome: () => void }}
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<script>
|
||||
import RoomCodePanel from '$lib/components/RoomCodePanel.svelte';
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/**
|
||||
* The room between games. Everything it renders comes from the last
|
||||
* RoomState the server sent, and every button is a request the server is
|
||||
* free to refuse — which is why none of them predict the answer by
|
||||
* changing the screen first.
|
||||
*
|
||||
* `compact` drops the room code panel, for the lobby that appears under a
|
||||
* finished game's result rather than as the whole screen.
|
||||
*
|
||||
* @type {{
|
||||
* compact?: boolean,
|
||||
* onready: (ready: boolean) => void,
|
||||
* onstart: () => void,
|
||||
* onkick: () => void,
|
||||
* onleave: () => void
|
||||
* }}
|
||||
*/
|
||||
let { compact = false, onready, onstart, onkick, onleave } = $props();
|
||||
|
||||
const s = $derived(game.state);
|
||||
</script>
|
||||
|
||||
<section class="lobby" aria-label={t.lobbyTitle}>
|
||||
{#if !compact}
|
||||
<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>
|
||||
|
||||
<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}
|
||||
<span class="name muted">{t.emptySeat}</span>
|
||||
{/if}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="hint">
|
||||
{#if s.isOwner}
|
||||
{s.opponentPresent ? t.ownerStartsHint : t.waitingForOpponent}
|
||||
{:else if s.isReady}
|
||||
{t.waitingForStart}
|
||||
{:else}
|
||||
{t.guestReadyHint}
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
{#if s.isOwner}
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
disabled={!s.canStart}
|
||||
data-testid="start-game"
|
||||
onclick={onstart}
|
||||
>
|
||||
{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}
|
||||
data-testid="ready"
|
||||
onclick={() => onready(!s.isReady)}
|
||||
>
|
||||
{s.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}>
|
||||
{t.leaveRoom}
|
||||
</button>
|
||||
{#if s.isReady}
|
||||
<p class="note">{t.unreadyToLeave}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.lobby {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.seats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.seat {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.seat.ready {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.seat.empty {
|
||||
border-style: dashed;
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.name.muted {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.role {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.state {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.state.offline {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions .primary {
|
||||
border-color: transparent;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.actions .primary.on {
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.actions button:disabled {
|
||||
background: var(--surface-alt);
|
||||
border-color: var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.leave {
|
||||
align-self: center;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.leave:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,87 +0,0 @@
|
||||
<script>
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/** @type {{ onaccept: () => void }} */
|
||||
let { onaccept } = $props();
|
||||
|
||||
const offer = $derived(game.state.rematch);
|
||||
|
||||
// Counted down locally from the duration the server sent. The server owns
|
||||
// the real expiry and closes the room when it passes; this only stops the
|
||||
// prompt from claiming time that has already gone.
|
||||
let remainingMs = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
const current = offer;
|
||||
if (!current) {
|
||||
remainingMs = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const endsAt = Date.now() + current.expiresInMs;
|
||||
remainingMs = current.expiresInMs;
|
||||
|
||||
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));
|
||||
const expired = $derived(!!offer && remainingMs === 0);
|
||||
</script>
|
||||
|
||||
{#if offer}
|
||||
<div class="prompt" data-testid="rematch-prompt">
|
||||
{#if expired}
|
||||
<p class="line">{t.rematchExpired}</p>
|
||||
{:else if offer.iAccepted}
|
||||
<p class="line">{fill(t.rematchWaiting, { n: seconds })}</p>
|
||||
{:else}
|
||||
{#if offer.opponentAccepted}
|
||||
<p class="line ready">{fill(t.rematchOpponentReady, { n: seconds })}</p>
|
||||
{:else}
|
||||
<p class="line">{t.rematchAsk}</p>
|
||||
{/if}
|
||||
<button type="button" onclick={onaccept} data-testid="rematch-accept">
|
||||
{t.rematchYes}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.prompt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
.line {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.line.ready {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -1,59 +0,0 @@
|
||||
<script>
|
||||
import RoomCodePanel from '$lib/components/RoomCodePanel.svelte';
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
|
||||
/** @type {{ code: string, onleave: () => void }} */
|
||||
let { code, onleave } = $props();
|
||||
</script>
|
||||
|
||||
<section class="waiting">
|
||||
<RoomCodePanel {code} />
|
||||
|
||||
<p class="status" role="status" data-testid="waiting-status">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
{t.waitingForOpponent}
|
||||
</p>
|
||||
|
||||
<button type="button" class="leave" onclick={onleave}>{t.leaveRoom}</button>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.waiting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.leave {
|
||||
padding: 10px 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
+30
-8
@@ -41,7 +41,7 @@ export const t = {
|
||||
connecting: 'Đang kết nối…',
|
||||
connected: 'Đã kết nối',
|
||||
reconnecting: 'Mất kết nối, đang thử lại…',
|
||||
offline: 'Không có kết nối',
|
||||
noConnection: 'Không có kết nối',
|
||||
|
||||
won: 'Bạn thắng!',
|
||||
lost: 'Bạn thua.',
|
||||
@@ -76,16 +76,27 @@ export const t = {
|
||||
leaveRoom: 'Rời phòng',
|
||||
shareInvite: 'Vào chơi nối từ với tôi',
|
||||
|
||||
lobbyTitle: 'Phòng chờ',
|
||||
owner: 'Chủ phòng',
|
||||
guest: 'Khách',
|
||||
ready: 'Sẵn sàng',
|
||||
unready: 'Chưa sẵn sàng',
|
||||
isReady: 'Đã sẵn sàng',
|
||||
notReady: 'Chưa sẵn sàng',
|
||||
startGame: 'Bắt đầu',
|
||||
kickPlayer: 'Mời ra khỏi phòng',
|
||||
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.',
|
||||
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.',
|
||||
|
||||
rematchAsk: 'Chơi lại ván nữa?',
|
||||
rematchYes: 'Chơi lại',
|
||||
rematchWaiting: 'Đang chờ đối thủ đồng ý… ({n}s)',
|
||||
rematchOpponentReady: 'Đối thủ muốn chơi lại. ({n}s)',
|
||||
rematchExpired: 'Không còn ván nào nữa.',
|
||||
|
||||
attributionIntro: 'Từ điển dựa trên',
|
||||
attributionSource: 'minhqnd/dictionary',
|
||||
attributionLicense: 'giấy phép CC BY-SA 4.0',
|
||||
@@ -149,14 +160,25 @@ export const errorMessages = {
|
||||
busy: 'Bạn đang ở trong một ván đấu khác.',
|
||||
cannot_join_own_room: 'Bạn không thể vào phòng của chính mình.',
|
||||
game_already_over: 'Ván đấu đã kết thúc.',
|
||||
game_in_progress: 'Ván đấu đang diễn ra.',
|
||||
game_not_started: 'Ván đấu chưa bắt đầu.',
|
||||
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.',
|
||||
no_rematch_offered: 'Chưa thể chơi lại lúc này.',
|
||||
kicked: 'Bạn đã bị mời 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.',
|
||||
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.',
|
||||
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.',
|
||||
room_full: 'Phòng đã đủ người.',
|
||||
room_idle_closed: 'Phòng đã đóng vì không có ván nào được bắt đầu.',
|
||||
room_not_found: 'Không tìm thấy phòng với mã này.',
|
||||
room_start_failed: 'Không thể tạo phòng. Hãy thử lại.',
|
||||
server_restarting: 'Máy chủ đang khởi động lại. Hãy thử lại sau giây lát.',
|
||||
|
||||
+145
-87
@@ -136,22 +136,73 @@ export declare type Resign = Message<"noitu.v1.Resign"> & {
|
||||
export declare const ResignSchema: GenMessage<Resign>;
|
||||
|
||||
/**
|
||||
* RequestRematch asks to play again in the same room after a game ends.
|
||||
* SetReady is the guest declaring themselves ready, or taking it back.
|
||||
*
|
||||
* There is no matching decline: leaving the room is the decline, and the
|
||||
* server already learns about that from the socket closing. One message and
|
||||
* one timeout cover every way a rematch does not happen.
|
||||
* Only the guest has 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.
|
||||
*
|
||||
* @generated from message noitu.v1.RequestRematch
|
||||
* @generated from message noitu.v1.SetReady
|
||||
*/
|
||||
export declare type RequestRematch = Message<"noitu.v1.RequestRematch"> & {
|
||||
export declare type SetReady = Message<"noitu.v1.SetReady"> & {
|
||||
/**
|
||||
* @generated from field: bool ready = 1;
|
||||
*/
|
||||
ready: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.RequestRematch.
|
||||
* Use `create(RequestRematchSchema)` to create a new message.
|
||||
* Describes the message noitu.v1.SetReady.
|
||||
* Use `create(SetReadySchema)` to create a new message.
|
||||
*/
|
||||
export declare const RequestRematchSchema: GenMessage<RequestRematch>;
|
||||
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.
|
||||
*
|
||||
* @generated from message noitu.v1.StartGame
|
||||
*/
|
||||
export declare type StartGame = Message<"noitu.v1.StartGame"> & {
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.StartGame.
|
||||
* Use `create(StartGameSchema)` to create a new message.
|
||||
*/
|
||||
export declare const StartGameSchema: GenMessage<StartGame>;
|
||||
|
||||
/**
|
||||
* KickPlayer is the owner freeing the guest's seat. Refused while the guest 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"> & {
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.KickPlayer.
|
||||
* Use `create(KickPlayerSchema)` to create a new message.
|
||||
*/
|
||||
export declare const KickPlayerSchema: GenMessage<KickPlayer>;
|
||||
|
||||
/**
|
||||
* LeaveRoom gives up a seat without dropping the connection, which is what
|
||||
* makes a room outlive one game rather than one visit. Refused while the
|
||||
* sender is ready — unreadying first is the deliberate friction.
|
||||
*
|
||||
* @generated from message noitu.v1.LeaveRoom
|
||||
*/
|
||||
export declare type LeaveRoom = Message<"noitu.v1.LeaveRoom"> & {
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.LeaveRoom.
|
||||
* Use `create(LeaveRoomSchema)` to create a new message.
|
||||
*/
|
||||
export declare const LeaveRoomSchema: GenMessage<LeaveRoom>;
|
||||
|
||||
/**
|
||||
* Ping echoes the client clock so Pong can expose the offset between the two.
|
||||
@@ -222,10 +273,28 @@ export declare type ClientMessage = Message<"noitu.v1.ClientMessage"> & {
|
||||
case: "ping";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: noitu.v1.RequestRematch request_rematch = 8;
|
||||
* @generated from field: noitu.v1.SetReady set_ready = 9;
|
||||
*/
|
||||
value: RequestRematch;
|
||||
case: "requestRematch";
|
||||
value: SetReady;
|
||||
case: "setReady";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: noitu.v1.StartGame start_game = 10;
|
||||
*/
|
||||
value: StartGame;
|
||||
case: "startGame";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: noitu.v1.KickPlayer kick_player = 11;
|
||||
*/
|
||||
value: KickPlayer;
|
||||
case: "kickPlayer";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: noitu.v1.LeaveRoom leave_room = 12;
|
||||
*/
|
||||
value: LeaveRoom;
|
||||
case: "leaveRoom";
|
||||
} | { case: undefined; value?: undefined };
|
||||
};
|
||||
|
||||
@@ -269,46 +338,6 @@ export declare type Welcome = Message<"noitu.v1.Welcome"> & {
|
||||
*/
|
||||
export declare const WelcomeSchema: GenMessage<Welcome>;
|
||||
|
||||
/**
|
||||
* @generated from message noitu.v1.RoomCreated
|
||||
*/
|
||||
export declare type RoomCreated = Message<"noitu.v1.RoomCreated"> & {
|
||||
/**
|
||||
* @generated from field: string room_code = 1;
|
||||
*/
|
||||
roomCode: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.RoomCreated.
|
||||
* Use `create(RoomCreatedSchema)` to create a new message.
|
||||
*/
|
||||
export declare const RoomCreatedSchema: GenMessage<RoomCreated>;
|
||||
|
||||
/**
|
||||
* @generated from message noitu.v1.RoomJoined
|
||||
*/
|
||||
export declare type RoomJoined = Message<"noitu.v1.RoomJoined"> & {
|
||||
/**
|
||||
* @generated from field: string room_code = 1;
|
||||
*/
|
||||
roomCode: string;
|
||||
|
||||
/**
|
||||
* Always server-sanitized. A client must never render another player's raw
|
||||
* input.
|
||||
*
|
||||
* @generated from field: string opponent_name = 2;
|
||||
*/
|
||||
opponentName: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.RoomJoined.
|
||||
* Use `create(RoomJoinedSchema)` to create a new message.
|
||||
*/
|
||||
export declare const RoomJoinedSchema: GenMessage<RoomJoined>;
|
||||
|
||||
/**
|
||||
* PlayedWord is one accepted move. word is the canonical spelling, which can
|
||||
* differ from what the player typed; typed preserves the raw input so the UI
|
||||
@@ -587,38 +616,79 @@ export declare type Pong = Message<"noitu.v1.Pong"> & {
|
||||
export declare const PongSchema: GenMessage<Pong>;
|
||||
|
||||
/**
|
||||
* RematchState is sent to both players whenever either one asks for a rematch,
|
||||
* and once when the offer opens. Each recipient is told about their own side
|
||||
* and their opponent's, so neither client has to work out which acceptance is
|
||||
* whose.
|
||||
* RoomState is the whole lobby, 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.
|
||||
*
|
||||
* @generated from message noitu.v1.RematchState
|
||||
* 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.
|
||||
*
|
||||
* @generated from message noitu.v1.RoomState
|
||||
*/
|
||||
export declare type RematchState = Message<"noitu.v1.RematchState"> & {
|
||||
export declare type RoomState = Message<"noitu.v1.RoomState"> & {
|
||||
/**
|
||||
* @generated from field: bool i_accepted = 1;
|
||||
* @generated from field: string room_code = 1;
|
||||
*/
|
||||
iAccepted: boolean;
|
||||
roomCode: string;
|
||||
|
||||
/**
|
||||
* @generated from field: bool opponent_accepted = 2;
|
||||
*/
|
||||
opponentAccepted: boolean;
|
||||
|
||||
/**
|
||||
* How long is left to accept. The room closes when this runs out, which is
|
||||
* also what a player who simply leaves ends up doing.
|
||||
* True for the player who may start the game and kick the other.
|
||||
*
|
||||
* @generated from field: uint32 expires_in_ms = 3;
|
||||
* @generated from field: bool i_am_owner = 2;
|
||||
*/
|
||||
expiresInMs: number;
|
||||
iAmOwner: boolean;
|
||||
|
||||
/**
|
||||
* Whether StartGame would be accepted right now. The server decides this
|
||||
* because it owns every condition that feeds it.
|
||||
*
|
||||
* @generated from field: bool can_start = 3;
|
||||
*/
|
||||
canStart: boolean;
|
||||
|
||||
/**
|
||||
* The recipient's own readiness. Always false for the owner, whose readiness
|
||||
* is StartGame itself.
|
||||
*
|
||||
* @generated from field: bool i_am_ready = 4;
|
||||
*/
|
||||
iAmReady: boolean;
|
||||
|
||||
/**
|
||||
* False when the other seat is empty; the fields below are then meaningless.
|
||||
*
|
||||
* @generated from field: bool opponent_present = 5;
|
||||
*/
|
||||
opponentPresent: boolean;
|
||||
|
||||
/**
|
||||
* Always server-sanitized, as everywhere else another player's name appears.
|
||||
*
|
||||
* @generated from field: string opponent_name = 6;
|
||||
*/
|
||||
opponentName: string;
|
||||
|
||||
/**
|
||||
* @generated from field: bool opponent_ready = 7;
|
||||
*/
|
||||
opponentReady: boolean;
|
||||
|
||||
/**
|
||||
* False while the other player is inside their reconnect window.
|
||||
*
|
||||
* @generated from field: bool opponent_connected = 8;
|
||||
*/
|
||||
opponentConnected: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.RematchState.
|
||||
* Use `create(RematchStateSchema)` to create a new message.
|
||||
* Describes the message noitu.v1.RoomState.
|
||||
* Use `create(RoomStateSchema)` to create a new message.
|
||||
*/
|
||||
export declare const RematchStateSchema: GenMessage<RematchState>;
|
||||
export declare const RoomStateSchema: GenMessage<RoomState>;
|
||||
|
||||
/**
|
||||
* @generated from message noitu.v1.ServerMessage
|
||||
@@ -633,18 +703,6 @@ export declare type ServerMessage = Message<"noitu.v1.ServerMessage"> & {
|
||||
*/
|
||||
value: Welcome;
|
||||
case: "welcome";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: noitu.v1.RoomCreated room_created = 2;
|
||||
*/
|
||||
value: RoomCreated;
|
||||
case: "roomCreated";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: noitu.v1.RoomJoined room_joined = 3;
|
||||
*/
|
||||
value: RoomJoined;
|
||||
case: "roomJoined";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: noitu.v1.GameStarted game_started = 4;
|
||||
@@ -689,10 +747,10 @@ export declare type ServerMessage = Message<"noitu.v1.ServerMessage"> & {
|
||||
case: "pong";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: noitu.v1.RematchState rematch_state = 11;
|
||||
* @generated from field: noitu.v1.RoomState room_state = 12;
|
||||
*/
|
||||
value: RematchState;
|
||||
case: "rematchState";
|
||||
value: RoomState;
|
||||
case: "roomState";
|
||||
} | { case: undefined; value?: undefined };
|
||||
};
|
||||
|
||||
|
||||
Generated
+41
-34
@@ -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("ChNub2l0dS92MS9nYW1lLnByb3RvEghub2l0dS52MSJJCgVIZWxsbxIYChBwcm90b2NvbF92ZXJzaW9uGAEgASgNEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIQCghuaWNrbmFtZRgDIAEoCSI4CgxTdGFydEJvdEdhbWUSKAoKZGlmZmljdWx0eRgBIAEoDjIULm5vaXR1LnYxLkRpZmZpY3VsdHkiDAoKQ3JlYXRlUm9vbSIdCghKb2luUm9vbRIRCglyb29tX2NvZGUYASABKAkiLAoKU3VibWl0V29yZBIMCgR3b3JkGAEgASgJEhAKCHR1cm5fc2VxGAIgASgNIggKBlJlc2lnbiIQCg5SZXF1ZXN0UmVtYXRjaCIeCgRQaW5nEhYKDmNsaWVudF90aW1lX21zGAEgASgDIuoCCg1DbGllbnRNZXNzYWdlEiAKBWhlbGxvGAEgASgLMg8ubm9pdHUudjEuSGVsbG9IABIwCg5zdGFydF9ib3RfZ2FtZRgCIAEoCzIWLm5vaXR1LnYxLlN0YXJ0Qm90R2FtZUgAEisKC2NyZWF0ZV9yb29tGAMgASgLMhQubm9pdHUudjEuQ3JlYXRlUm9vbUgAEicKCWpvaW5fcm9vbRgEIAEoCzISLm5vaXR1LnYxLkpvaW5Sb29tSAASKwoLc3VibWl0X3dvcmQYBSABKAsyFC5ub2l0dS52MS5TdWJtaXRXb3JkSAASIgoGcmVzaWduGAYgASgLMhAubm9pdHUudjEuUmVzaWduSAASHgoEcGluZxgHIAEoCzIOLm5vaXR1LnYxLlBpbmdIABIzCg9yZXF1ZXN0X3JlbWF0Y2gYCCABKAsyGC5ub2l0dS52MS5SZXF1ZXN0UmVtYXRjaEgAQgkKB3BheWxvYWQiaAoHV2VsY29tZRISCgpzZXNzaW9uX2lkGAEgASgJEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIYChBwcm90b2NvbF92ZXJzaW9uGAMgASgNEhkKEWFjY2VwdGVkX25pY2tuYW1lGAQgASgJIiAKC1Jvb21DcmVhdGVkEhEKCXJvb21fY29kZRgBIAEoCSI2CgpSb29tSm9pbmVkEhEKCXJvb21fY29kZRgBIAEoCRIVCg1vcHBvbmVudF9uYW1lGAIgASgJIlsKClBsYXllZFdvcmQSDAoEd29yZBgBIAEoCRINCgVieV9tZRgCIAEoCBIOCgZwb2ludHMYAyABKA0SEQoJc3lsbGFibGVzGAQgASgNEg0KBXR5cGVkGAUgASgJIpEBCgtHYW1lU3RhcnRlZBIUCgxvcGVuaW5nX3dvcmQYASABKAkSGAoQY3VycmVudF9zeWxsYWJsZRgCIAEoCRIPCgdteV90dXJuGAMgASgIEhgKEGRlYWRsaW5lX3VuaXhfbXMYBCABKAMSEAoIdHVybl9zZXEYBSABKA0SFQoNdHVybl9saW1pdF9tcxgGIAEoDSLJAQoKVHVyblVwZGF0ZRIkCgZwbGF5ZWQYASABKAsyFC5ub2l0dS52MS5QbGF5ZWRXb3JkEhgKEGN1cnJlbnRfc3lsbGFibGUYAiABKAkSDwoHbXlfdHVybhgDIAEoCBIYChBkZWFkbGluZV91bml4X21zGAQgASgDEhAKCHR1cm5fc2VxGAUgASgNEhAKCG15X3Njb3JlGAYgASgNEhYKDm9wcG9uZW50X3Njb3JlGAcgASgNEhQKDGNoYWluX2xlbmd0aBgIIAEoDSJWCgxNb3ZlUmVqZWN0ZWQSJgoGcmVhc29uGAEgASgOMhYubm9pdHUudjEuUmVqZWN0UmVhc29uEgwKBHdvcmQYAiABKAkSEAoIdHVybl9zZXEYAyABKA0ifwoIR2FtZU92ZXISDQoFaV93b24YASABKAgSJwoGcmVhc29uGAIgASgOMhcubm9pdHUudjEuR2FtZUVuZFJlYXNvbhIQCghteV9zY29yZRgDIAEoDRIUCgxjaGFpbl9sZW5ndGgYBCABKA0SEwoLc3VnZ2VzdGlvbnMYBSADKAkiNwoMT3Bwb25lbnRMZWZ0EhUKDWNhbl9yZWNvbm5lY3QYASABKAgSEAoIZ3JhY2VfbXMYAiABKA0iLAoLU2VydmVyRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIjYKBFBvbmcSFgoOY2xpZW50X3RpbWVfbXMYASABKAMSFgoOc2VydmVyX3RpbWVfbXMYAiABKAMiVAoMUmVtYXRjaFN0YXRlEhIKCmlfYWNjZXB0ZWQYASABKAgSGQoRb3Bwb25lbnRfYWNjZXB0ZWQYAiABKAgSFQoNZXhwaXJlc19pbl9tcxgDIAEoDSL8AwoNU2VydmVyTWVzc2FnZRIkCgd3ZWxjb21lGAEgASgLMhEubm9pdHUudjEuV2VsY29tZUgAEi0KDHJvb21fY3JlYXRlZBgCIAEoCzIVLm5vaXR1LnYxLlJvb21DcmVhdGVkSAASKwoLcm9vbV9qb2luZWQYAyABKAsyFC5ub2l0dS52MS5Sb29tSm9pbmVkSAASLQoMZ2FtZV9zdGFydGVkGAQgASgLMhUubm9pdHUudjEuR2FtZVN0YXJ0ZWRIABIrCgt0dXJuX3VwZGF0ZRgFIAEoCzIULm5vaXR1LnYxLlR1cm5VcGRhdGVIABIvCg1tb3ZlX3JlamVjdGVkGAYgASgLMhYubm9pdHUudjEuTW92ZVJlamVjdGVkSAASJwoJZ2FtZV9vdmVyGAcgASgLMhIubm9pdHUudjEuR2FtZU92ZXJIABIvCg1vcHBvbmVudF9sZWZ0GAggASgLMhYubm9pdHUudjEuT3Bwb25lbnRMZWZ0SAASJgoFZXJyb3IYCSABKAsyFS5ub2l0dS52MS5TZXJ2ZXJFcnJvckgAEh4KBHBvbmcYCiABKAsyDi5ub2l0dS52MS5Qb25nSAASLwoNcmVtYXRjaF9zdGF0ZRgLIAEoCzIWLm5vaXR1LnYxLlJlbWF0Y2hTdGF0ZUgAQgkKB3BheWxvYWQqaQoKRGlmZmljdWx0eRIaChZESUZGSUNVTFRZX1VOU1BFQ0lGSUVEEAASEwoPRElGRklDVUxUWV9FQVNZEAESFQoRRElGRklDVUxUWV9NRURJVU0QAhITCg9ESUZGSUNVTFRZX0hBUkQQAyqOAgoMUmVqZWN0UmVhc29uEh0KGVJFSkVDVF9SRUFTT05fVU5TUEVDSUZJRUQQABIjCh9SRUpFQ1RfUkVBU09OX1RPT19GRVdfU1lMTEFCTEVTEAESHAoYUkVKRUNUX1JFQVNPTl9XUk9OR19MSU5LEAISIwofUkVKRUNUX1JFQVNPTl9OT1RfSU5fRElDVElPTkFSWRADEh4KGlJFSkVDVF9SRUFTT05fQUxSRUFEWV9VU0VEEAQSHwobUkVKRUNUX1JFQVNPTl9OT1RfWU9VUl9UVVJOEAUSGQoVUkVKRUNUX1JFQVNPTl9USU1FT1VUEAYSGwoXUkVKRUNUX1JFQVNPTl9HQU1FX09WRVIQByqxAQoNR2FtZUVuZFJlYXNvbhIfChtHQU1FX0VORF9SRUFTT05fVU5TUEVDSUZJRUQQABIbChdHQU1FX0VORF9SRUFTT05fVElNRU9VVBABEiEKHUdBTUVfRU5EX1JFQVNPTl9OT19MRUdBTF9NT1ZFEAISIQodR0FNRV9FTkRfUkVBU09OX09QUE9ORU5UX0xFRlQQAxIcChhHQU1FX0VORF9SRUFTT05fUkVTSUdORUQQBEI6WjhnaXRodWIuY29tL3RpZW5ubTk5ZGV2L25vaXR1L3NlcnZlci9nZW4vbm9pdHUvdjE7bm9pdHV2MWIGcHJvdG8z");
|
||||
fileDesc("ChNub2l0dS92MS9nYW1lLnByb3RvEghub2l0dS52MSJJCgVIZWxsbxIYChBwcm90b2NvbF92ZXJzaW9uGAEgASgNEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIQCghuaWNrbmFtZRgDIAEoCSI4CgxTdGFydEJvdEdhbWUSKAoKZGlmZmljdWx0eRgBIAEoDjIULm5vaXR1LnYxLkRpZmZpY3VsdHkiDAoKQ3JlYXRlUm9vbSIdCghKb2luUm9vbRIRCglyb29tX2NvZGUYASABKAkiLAoKU3VibWl0V29yZBIMCgR3b3JkGAEgASgJEhAKCHR1cm5fc2VxGAIgASgNIggKBlJlc2lnbiIZCghTZXRSZWFkeRINCgVyZWFkeRgBIAEoCCILCglTdGFydEdhbWUiDAoKS2lja1BsYXllciILCglMZWF2ZVJvb20iHgoEUGluZxIWCg5jbGllbnRfdGltZV9tcxgBIAEoAyLnAwoNQ2xpZW50TWVzc2FnZRIgCgVoZWxsbxgBIAEoCzIPLm5vaXR1LnYxLkhlbGxvSAASMAoOc3RhcnRfYm90X2dhbWUYAiABKAsyFi5ub2l0dS52MS5TdGFydEJvdEdhbWVIABIrCgtjcmVhdGVfcm9vbRgDIAEoCzIULm5vaXR1LnYxLkNyZWF0ZVJvb21IABInCglqb2luX3Jvb20YBCABKAsyEi5ub2l0dS52MS5Kb2luUm9vbUgAEisKC3N1Ym1pdF93b3JkGAUgASgLMhQubm9pdHUudjEuU3VibWl0V29yZEgAEiIKBnJlc2lnbhgGIAEoCzIQLm5vaXR1LnYxLlJlc2lnbkgAEh4KBHBpbmcYByABKAsyDi5ub2l0dS52MS5QaW5nSAASJwoJc2V0X3JlYWR5GAkgASgLMhIubm9pdHUudjEuU2V0UmVhZHlIABIpCgpzdGFydF9nYW1lGAogASgLMhMubm9pdHUudjEuU3RhcnRHYW1lSAASKwoLa2lja19wbGF5ZXIYCyABKAsyFC5ub2l0dS52MS5LaWNrUGxheWVySAASKQoKbGVhdmVfcm9vbRgMIAEoCzITLm5vaXR1LnYxLkxlYXZlUm9vbUgAQgkKB3BheWxvYWRKBAgIEAkiaAoHV2VsY29tZRISCgpzZXNzaW9uX2lkGAEgASgJEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIYChBwcm90b2NvbF92ZXJzaW9uGAMgASgNEhkKEWFjY2VwdGVkX25pY2tuYW1lGAQgASgJIlsKClBsYXllZFdvcmQSDAoEd29yZBgBIAEoCRINCgVieV9tZRgCIAEoCBIOCgZwb2ludHMYAyABKA0SEQoJc3lsbGFibGVzGAQgASgNEg0KBXR5cGVkGAUgASgJIpEBCgtHYW1lU3RhcnRlZBIUCgxvcGVuaW5nX3dvcmQYASABKAkSGAoQY3VycmVudF9zeWxsYWJsZRgCIAEoCRIPCgdteV90dXJuGAMgASgIEhgKEGRlYWRsaW5lX3VuaXhfbXMYBCABKAMSEAoIdHVybl9zZXEYBSABKA0SFQoNdHVybl9saW1pdF9tcxgGIAEoDSLJAQoKVHVyblVwZGF0ZRIkCgZwbGF5ZWQYASABKAsyFC5ub2l0dS52MS5QbGF5ZWRXb3JkEhgKEGN1cnJlbnRfc3lsbGFibGUYAiABKAkSDwoHbXlfdHVybhgDIAEoCBIYChBkZWFkbGluZV91bml4X21zGAQgASgDEhAKCHR1cm5fc2VxGAUgASgNEhAKCG15X3Njb3JlGAYgASgNEhYKDm9wcG9uZW50X3Njb3JlGAcgASgNEhQKDGNoYWluX2xlbmd0aBgIIAEoDSJWCgxNb3ZlUmVqZWN0ZWQSJgoGcmVhc29uGAEgASgOMhYubm9pdHUudjEuUmVqZWN0UmVhc29uEgwKBHdvcmQYAiABKAkSEAoIdHVybl9zZXEYAyABKA0ifwoIR2FtZU92ZXISDQoFaV93b24YASABKAgSJwoGcmVhc29uGAIgASgOMhcubm9pdHUudjEuR2FtZUVuZFJlYXNvbhIQCghteV9zY29yZRgDIAEoDRIUCgxjaGFpbl9sZW5ndGgYBCABKA0SEwoLc3VnZ2VzdGlvbnMYBSADKAkiNwoMT3Bwb25lbnRMZWZ0EhUKDWNhbl9yZWNvbm5lY3QYASABKAgSEAoIZ3JhY2VfbXMYAiABKA0iLAoLU2VydmVyRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIjYKBFBvbmcSFgoOY2xpZW50X3RpbWVfbXMYASABKAMSFgoOc2VydmVyX3RpbWVfbXMYAiABKAMivgEKCVJvb21TdGF0ZRIRCglyb29tX2NvZGUYASABKAkSEgoKaV9hbV9vd25lchgCIAEoCBIRCgljYW5fc3RhcnQYAyABKAgSEgoKaV9hbV9yZWFkeRgEIAEoCBIYChBvcHBvbmVudF9wcmVzZW50GAUgASgIEhUKDW9wcG9uZW50X25hbWUYBiABKAkSFgoOb3Bwb25lbnRfcmVhZHkYByABKAgSGgoSb3Bwb25lbnRfY29ubmVjdGVkGAggASgIIqwDCg1TZXJ2ZXJNZXNzYWdlEiQKB3dlbGNvbWUYASABKAsyES5ub2l0dS52MS5XZWxjb21lSAASLQoMZ2FtZV9zdGFydGVkGAQgASgLMhUubm9pdHUudjEuR2FtZVN0YXJ0ZWRIABIrCgt0dXJuX3VwZGF0ZRgFIAEoCzIULm5vaXR1LnYxLlR1cm5VcGRhdGVIABIvCg1tb3ZlX3JlamVjdGVkGAYgASgLMhYubm9pdHUudjEuTW92ZVJlamVjdGVkSAASJwoJZ2FtZV9vdmVyGAcgASgLMhIubm9pdHUudjEuR2FtZU92ZXJIABIvCg1vcHBvbmVudF9sZWZ0GAggASgLMhYubm9pdHUudjEuT3Bwb25lbnRMZWZ0SAASJgoFZXJyb3IYCSABKAsyFS5ub2l0dS52MS5TZXJ2ZXJFcnJvckgAEh4KBHBvbmcYCiABKAsyDi5ub2l0dS52MS5Qb25nSAASKQoKcm9vbV9zdGF0ZRgMIAEoCzITLm5vaXR1LnYxLlJvb21TdGF0ZUgAQgkKB3BheWxvYWRKBAgCEANKBAgDEARKBAgLEAwqaQoKRGlmZmljdWx0eRIaChZESUZGSUNVTFRZX1VOU1BFQ0lGSUVEEAASEwoPRElGRklDVUxUWV9FQVNZEAESFQoRRElGRklDVUxUWV9NRURJVU0QAhITCg9ESUZGSUNVTFRZX0hBUkQQAyqOAgoMUmVqZWN0UmVhc29uEh0KGVJFSkVDVF9SRUFTT05fVU5TUEVDSUZJRUQQABIjCh9SRUpFQ1RfUkVBU09OX1RPT19GRVdfU1lMTEFCTEVTEAESHAoYUkVKRUNUX1JFQVNPTl9XUk9OR19MSU5LEAISIwofUkVKRUNUX1JFQVNPTl9OT1RfSU5fRElDVElPTkFSWRADEh4KGlJFSkVDVF9SRUFTT05fQUxSRUFEWV9VU0VEEAQSHwobUkVKRUNUX1JFQVNPTl9OT1RfWU9VUl9UVVJOEAUSGQoVUkVKRUNUX1JFQVNPTl9USU1FT1VUEAYSGwoXUkVKRUNUX1JFQVNPTl9HQU1FX09WRVIQByqxAQoNR2FtZUVuZFJlYXNvbhIfChtHQU1FX0VORF9SRUFTT05fVU5TUEVDSUZJRUQQABIbChdHQU1FX0VORF9SRUFTT05fVElNRU9VVBABEiEKHUdBTUVfRU5EX1JFQVNPTl9OT19MRUdBTF9NT1ZFEAISIQodR0FNRV9FTkRfUkVBU09OX09QUE9ORU5UX0xFRlQQAxIcChhHQU1FX0VORF9SRUFTT05fUkVTSUdORUQQBEI6WjhnaXRodWIuY29tL3RpZW5ubTk5ZGV2L25vaXR1L3NlcnZlci9nZW4vbm9pdHUvdjE7bm9pdHV2MWIGcHJvdG8z");
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.Hello.
|
||||
@@ -66,116 +66,123 @@ export const ResignSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 5);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.RequestRematch.
|
||||
* Use `create(RequestRematchSchema)` to create a new message.
|
||||
* Describes the message noitu.v1.SetReady.
|
||||
* Use `create(SetReadySchema)` to create a new message.
|
||||
*/
|
||||
export const RequestRematchSchema = /*@__PURE__*/
|
||||
export const SetReadySchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 6);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.StartGame.
|
||||
* Use `create(StartGameSchema)` to create a new message.
|
||||
*/
|
||||
export const StartGameSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 7);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.KickPlayer.
|
||||
* Use `create(KickPlayerSchema)` to create a new message.
|
||||
*/
|
||||
export const KickPlayerSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 8);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.LeaveRoom.
|
||||
* Use `create(LeaveRoomSchema)` to create a new message.
|
||||
*/
|
||||
export const LeaveRoomSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 9);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.Ping.
|
||||
* Use `create(PingSchema)` to create a new message.
|
||||
*/
|
||||
export const PingSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 7);
|
||||
messageDesc(file_noitu_v1_game, 10);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.ClientMessage.
|
||||
* Use `create(ClientMessageSchema)` to create a new message.
|
||||
*/
|
||||
export const ClientMessageSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 8);
|
||||
messageDesc(file_noitu_v1_game, 11);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.Welcome.
|
||||
* Use `create(WelcomeSchema)` to create a new message.
|
||||
*/
|
||||
export const WelcomeSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 9);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.RoomCreated.
|
||||
* Use `create(RoomCreatedSchema)` to create a new message.
|
||||
*/
|
||||
export const RoomCreatedSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 10);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.RoomJoined.
|
||||
* Use `create(RoomJoinedSchema)` to create a new message.
|
||||
*/
|
||||
export const RoomJoinedSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 11);
|
||||
messageDesc(file_noitu_v1_game, 12);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.PlayedWord.
|
||||
* Use `create(PlayedWordSchema)` to create a new message.
|
||||
*/
|
||||
export const PlayedWordSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 12);
|
||||
messageDesc(file_noitu_v1_game, 13);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.GameStarted.
|
||||
* Use `create(GameStartedSchema)` to create a new message.
|
||||
*/
|
||||
export const GameStartedSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 13);
|
||||
messageDesc(file_noitu_v1_game, 14);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.TurnUpdate.
|
||||
* Use `create(TurnUpdateSchema)` to create a new message.
|
||||
*/
|
||||
export const TurnUpdateSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 14);
|
||||
messageDesc(file_noitu_v1_game, 15);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.MoveRejected.
|
||||
* Use `create(MoveRejectedSchema)` to create a new message.
|
||||
*/
|
||||
export const MoveRejectedSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 15);
|
||||
messageDesc(file_noitu_v1_game, 16);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.GameOver.
|
||||
* Use `create(GameOverSchema)` to create a new message.
|
||||
*/
|
||||
export const GameOverSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 16);
|
||||
messageDesc(file_noitu_v1_game, 17);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.OpponentLeft.
|
||||
* Use `create(OpponentLeftSchema)` to create a new message.
|
||||
*/
|
||||
export const OpponentLeftSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 17);
|
||||
messageDesc(file_noitu_v1_game, 18);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.ServerError.
|
||||
* Use `create(ServerErrorSchema)` to create a new message.
|
||||
*/
|
||||
export const ServerErrorSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 18);
|
||||
messageDesc(file_noitu_v1_game, 19);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.Pong.
|
||||
* Use `create(PongSchema)` to create a new message.
|
||||
*/
|
||||
export const PongSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 19);
|
||||
messageDesc(file_noitu_v1_game, 20);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.RematchState.
|
||||
* Use `create(RematchStateSchema)` to create a new message.
|
||||
* Describes the message noitu.v1.RoomState.
|
||||
* Use `create(RoomStateSchema)` to create a new message.
|
||||
*/
|
||||
export const RematchStateSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 20);
|
||||
export const RoomStateSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 21);
|
||||
|
||||
/**
|
||||
* Describes the message noitu.v1.ServerMessage.
|
||||
* Use `create(ServerMessageSchema)` to create a new message.
|
||||
*/
|
||||
export const ServerMessageSchema = /*@__PURE__*/
|
||||
messageDesc(file_noitu_v1_game, 21);
|
||||
messageDesc(file_noitu_v1_game, 22);
|
||||
|
||||
/**
|
||||
* Describes the enum noitu.v1.Difficulty.
|
||||
|
||||
@@ -19,10 +19,14 @@ import { rejectMessage, errorMessage } from '$lib/i18n/vi.js';
|
||||
function initialState() {
|
||||
return {
|
||||
/**
|
||||
* Where the screen is. `waiting` is online-only: the room exists and
|
||||
* its code can be shared, but there is nobody to play yet.
|
||||
* Where the screen is. `lobby` is online-only: the room exists and its
|
||||
* code can be shared, and it is where every game is agreed before it
|
||||
* starts and returned to after it ends.
|
||||
*
|
||||
* @type {'idle' | 'waiting' | 'playing' | 'over'}
|
||||
* RoomState deliberately does not move this. A game running is what
|
||||
* the phase is about, and only GameStarted and GameOver know that.
|
||||
*
|
||||
* @type {'idle' | 'lobby' | 'playing' | 'over'}
|
||||
*/
|
||||
phase: 'idle',
|
||||
/** @type {ChainEntry[]} */
|
||||
@@ -40,6 +44,18 @@ function initialState() {
|
||||
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.
|
||||
*/
|
||||
isOwner: false,
|
||||
isReady: false,
|
||||
canStart: false,
|
||||
opponentPresent: false,
|
||||
opponentReady: false,
|
||||
opponentConnected: false,
|
||||
|
||||
/** @type {{ word: string, message: string } | null} */
|
||||
rejection: null,
|
||||
/**
|
||||
@@ -53,14 +69,6 @@ function initialState() {
|
||||
result: null,
|
||||
/** @type {{ canReconnect: boolean, graceMs: number } | null} */
|
||||
opponentLeft: null,
|
||||
/**
|
||||
* The offer that follows a finished online game, or null when none is
|
||||
* open. Both sides of the answer come from the server, so neither
|
||||
* client has to work out which acceptance is whose.
|
||||
*
|
||||
* @type {{ iAccepted: boolean, opponentAccepted: boolean, expiresInMs: number } | null}
|
||||
*/
|
||||
rematch: null,
|
||||
/** @type {string | null} */
|
||||
error: null
|
||||
};
|
||||
@@ -73,11 +81,38 @@ function initialState() {
|
||||
export function createGameStore() {
|
||||
const state = $state(initialState());
|
||||
|
||||
/** Returns the model to its pre-game shape, keeping the identity fields. */
|
||||
/**
|
||||
* 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
|
||||
* 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',
|
||||
'canStart',
|
||||
'opponentPresent',
|
||||
'opponentReady',
|
||||
'opponentConnected'
|
||||
]);
|
||||
|
||||
function reset() {
|
||||
const fresh = initialState();
|
||||
for (const key of Object.keys(fresh)) {
|
||||
if (key === 'nickname' || key === 'roomCode' || key === 'opponentName') continue;
|
||||
if (kept.has(key)) continue;
|
||||
state[key] = fresh[key];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets the room entirely: this connection is not in one any more, which
|
||||
* is what leaving and being kicked have in common.
|
||||
*/
|
||||
function leave() {
|
||||
const fresh = initialState();
|
||||
for (const key of Object.keys(fresh)) {
|
||||
state[key] = fresh[key];
|
||||
}
|
||||
}
|
||||
@@ -99,24 +134,29 @@ export function createGameStore() {
|
||||
state.nickname = value.acceptedNickname;
|
||||
break;
|
||||
|
||||
case 'roomCreated':
|
||||
state.roomCode = value.roomCode;
|
||||
// The room exists but has one seat filled. A resumed session
|
||||
// that was waiting lands here too, which is why the phase is
|
||||
// set rather than assumed.
|
||||
if (state.phase !== 'playing') state.phase = 'waiting';
|
||||
break;
|
||||
|
||||
case 'roomJoined':
|
||||
case 'roomState':
|
||||
// One snapshot, applied wholesale. Merging fields selectively
|
||||
// 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;
|
||||
// The opponent is in the room, which is also how a reconnect is
|
||||
// announced. Either way there is nobody to be waiting for.
|
||||
state.opponentLeft = null;
|
||||
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;
|
||||
// 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';
|
||||
break;
|
||||
|
||||
case 'gameStarted':
|
||||
// reset() already clears the rematch offer that led here.
|
||||
// reset() clears the readiness that led here, along with the
|
||||
// last game's board.
|
||||
reset();
|
||||
state.phase = 'playing';
|
||||
state.chain = [
|
||||
@@ -181,25 +221,20 @@ export function createGameStore() {
|
||||
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
|
||||
};
|
||||
// An opponent who cannot come back also ends any rematch offer.
|
||||
// Leaving the prompt up would show a countdown with nobody left
|
||||
// to answer it.
|
||||
if (!value.canReconnect) state.rematch = null;
|
||||
break;
|
||||
|
||||
case 'rematchState':
|
||||
state.rematch = {
|
||||
iAccepted: value.iAccepted,
|
||||
opponentAccepted: value.opponentAccepted,
|
||||
expiresInMs: value.expiresInMs
|
||||
};
|
||||
state.opponentConnected = false;
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
// Two of them also end this player's membership of the room, so
|
||||
// the model has to stop describing one. Set after, because
|
||||
// leaving clears everything including the message.
|
||||
if (value.code === 'kicked' || value.code === 'room_idle_closed') leave();
|
||||
state.error = errorMessage(value.code);
|
||||
break;
|
||||
|
||||
@@ -213,6 +248,7 @@ export function createGameStore() {
|
||||
state,
|
||||
apply,
|
||||
reset,
|
||||
leave,
|
||||
clearRejection() {
|
||||
state.rejection = null;
|
||||
},
|
||||
@@ -221,9 +257,6 @@ export function createGameStore() {
|
||||
},
|
||||
clearOpponentLeft() {
|
||||
state.opponentLeft = null;
|
||||
},
|
||||
clearRematch() {
|
||||
state.rematch = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
CreateRoomSchema,
|
||||
HelloSchema,
|
||||
JoinRoomSchema,
|
||||
KickPlayerSchema,
|
||||
LeaveRoomSchema,
|
||||
PingSchema,
|
||||
RequestRematchSchema,
|
||||
ResignSchema,
|
||||
SetReadySchema,
|
||||
StartGameSchema,
|
||||
StartBotGameSchema,
|
||||
SubmitWordSchema
|
||||
} from '$lib/proto/noitu/v1/game_pb.js';
|
||||
@@ -76,12 +79,35 @@ export function submitWord(word, turnSeq) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks to play the same room again. There is no matching decline: leaving is
|
||||
* the decline, and the server learns about that from the socket closing.
|
||||
* 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.
|
||||
*
|
||||
* @param {boolean} ready
|
||||
*/
|
||||
export function requestRematch() {
|
||||
export function setReady(ready) {
|
||||
return create(ClientMessageSchema, {
|
||||
payload: { case: 'requestRematch', value: create(RequestRematchSchema, {}) }
|
||||
payload: { case: 'setReady', value: create(SetReadySchema, { ready }) }
|
||||
});
|
||||
}
|
||||
|
||||
/** Begins the game the lobby has agreed on. Refused unless the 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() {
|
||||
return create(ClientMessageSchema, {
|
||||
payload: { case: 'kickPlayer', value: create(KickPlayerSchema, {}) }
|
||||
});
|
||||
}
|
||||
|
||||
/** Gives up a seat without dropping the connection. Refused while ready. */
|
||||
export function leaveRoom() {
|
||||
return create(ClientMessageSchema, {
|
||||
payload: { case: 'leaveRoom', value: create(LeaveRoomSchema, {}) }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,22 @@
|
||||
import { page } from '$app/state';
|
||||
import GameBoard from '$lib/components/GameBoard.svelte';
|
||||
import GameOverPanel from '$lib/components/GameOverPanel.svelte';
|
||||
import Lobby from '$lib/components/Lobby.svelte';
|
||||
import NicknameInput from '$lib/components/NicknameInput.svelte';
|
||||
import OpponentStatus from '$lib/components/OpponentStatus.svelte';
|
||||
import RematchPrompt from '$lib/components/RematchPrompt.svelte';
|
||||
import WaitingRoom from '$lib/components/WaitingRoom.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';
|
||||
import { createRoom, joinRoom, requestRematch, resign, submitWord } from '$lib/ws/messages.js';
|
||||
import {
|
||||
createRoom,
|
||||
joinRoom,
|
||||
kickPlayer,
|
||||
leaveRoom,
|
||||
resign,
|
||||
setReady,
|
||||
startGame,
|
||||
submitWord
|
||||
} from '$lib/ws/messages.js';
|
||||
import {
|
||||
Status,
|
||||
connect,
|
||||
@@ -42,7 +50,7 @@
|
||||
|
||||
// The resume worked, so nothing that happens from here is its fault.
|
||||
$effect(() => {
|
||||
if (playing || game.state.phase === 'waiting') untrack(() => (resuming = false));
|
||||
if (playing || game.state.phase === 'lobby') untrack(() => (resuming = false));
|
||||
});
|
||||
|
||||
// Owns the socket while this screen is on, exactly as the bot screen does.
|
||||
@@ -124,10 +132,38 @@
|
||||
request({ kind: 'join', code });
|
||||
}
|
||||
|
||||
function leave() {
|
||||
function goHome() {
|
||||
goto('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives up the seat without leaving the page: the room may still be there
|
||||
* to rejoin, and the lobby list is the natural place to land.
|
||||
*
|
||||
* The local state goes with it. The server sends nothing back to somebody
|
||||
* who is no longer in the room to be told about, and the button is only
|
||||
* enabled when this client already knows the rule allows it.
|
||||
*
|
||||
* @param {boolean} ready
|
||||
*/
|
||||
function ready(ready) {
|
||||
send(setReady(ready));
|
||||
}
|
||||
|
||||
function start() {
|
||||
send(startGame());
|
||||
}
|
||||
|
||||
function kick() {
|
||||
if (confirm(t.kickConfirm)) send(kickPlayer());
|
||||
}
|
||||
|
||||
function leave() {
|
||||
send(leaveRoom());
|
||||
game.leave();
|
||||
pending = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} word
|
||||
* @returns {boolean}
|
||||
@@ -153,15 +189,21 @@
|
||||
<OpponentStatus />
|
||||
{/snippet}
|
||||
{#snippet gameOver()}
|
||||
<!-- No rematch button on the panel: two people have to agree, and
|
||||
an offer is not always open. The prompt below appears only
|
||||
while one is, so it is the only thing that can ask. -->
|
||||
<GameOverPanel isRecord={false} onhome={leave} />
|
||||
<RematchPrompt onaccept={() => send(requestRematch())} />
|
||||
<!-- No rematch button on the panel: the room is still here, and
|
||||
the next game is agreed in the lobby below exactly as the
|
||||
last one was. -->
|
||||
<GameOverPanel isRecord={false} onhome={goHome} />
|
||||
<Lobby
|
||||
compact
|
||||
onready={ready}
|
||||
onstart={start}
|
||||
onkick={kick}
|
||||
onleave={leave}
|
||||
/>
|
||||
{/snippet}
|
||||
</GameBoard>
|
||||
{:else if game.state.phase === 'waiting'}
|
||||
<WaitingRoom code={game.state.roomCode} onleave={leave} />
|
||||
{:else if game.state.phase === 'lobby'}
|
||||
<Lobby onready={ready} onstart={start} onkick={kick} onleave={leave} />
|
||||
{:else}
|
||||
<h1>{t.onlineTitle}</h1>
|
||||
<p class="intro">{t.onlineIntro}</p>
|
||||
|
||||
@@ -15,14 +15,18 @@ import { errorMessages } from '../src/lib/i18n/vi.js';
|
||||
|
||||
const wsapiDir = fileURLToPath(new URL('../../server/internal/wsapi', import.meta.url));
|
||||
|
||||
/** Every `errorMsg("…")` literal in the transport, excluding its own tests. */
|
||||
/**
|
||||
* Every error-code literal in the transport, excluding its own tests. Both the
|
||||
* one-recipient and the broadcast call sites count: a code that only ever goes
|
||||
* to both players is no less a code the client has to know.
|
||||
*/
|
||||
function serverErrorCodes() {
|
||||
const codes = new Set();
|
||||
const files = readdirSync(wsapiDir).filter((f) => f.endsWith('.go') && !f.endsWith('_test.go'));
|
||||
|
||||
for (const file of files) {
|
||||
const source = readFileSync(join(wsapiDir, file), 'utf8');
|
||||
for (const [, code] of source.matchAll(/errorMsg\("([a-z_]+)"\)/g)) {
|
||||
for (const [, code] of source.matchAll(/(?:errorMsg|broadcastError)\("([a-z_]+)"\)/g)) {
|
||||
codes.add(code);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,25 +222,40 @@ describe('opponentLeft', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** One lobby snapshot, with the fields a test does not care about defaulted. */
|
||||
function lobby(fields = {}) {
|
||||
return msg('roomState', {
|
||||
roomCode: 'ABCD',
|
||||
iAmOwner: false,
|
||||
canStart: false,
|
||||
iAmReady: false,
|
||||
opponentPresent: false,
|
||||
opponentName: '',
|
||||
opponentReady: false,
|
||||
opponentConnected: false,
|
||||
...fields
|
||||
});
|
||||
}
|
||||
|
||||
describe('room messages', () => {
|
||||
it('keeps the room code and the sanitized opponent name', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(msg('roomCreated', { roomCode: 'ABCD' }));
|
||||
expect(store.state.roomCode).toBe('ABCD');
|
||||
store.apply(lobby({ opponentPresent: true, opponentName: 'Lan', opponentConnected: true }));
|
||||
|
||||
store.apply(msg('roomJoined', { roomCode: 'ABCD', opponentName: 'Lan' }));
|
||||
expect(store.state.roomCode).toBe('ABCD');
|
||||
expect(store.state.opponentName).toBe('Lan');
|
||||
});
|
||||
|
||||
it('survives a reset, because identity outlives one game', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(msg('welcome', { acceptedNickname: 'Minh', sessionId: 's', resumeToken: 't' }));
|
||||
store.apply(msg('roomJoined', { roomCode: 'ABCD', opponentName: 'Lan' }));
|
||||
store.apply(lobby({ opponentPresent: true, opponentName: 'Lan', opponentConnected: true }));
|
||||
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.chain).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -255,70 +270,88 @@ describe('pong', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('online rooms', () => {
|
||||
it('waits once the room exists but has one seat filled', () => {
|
||||
describe('the lobby', () => {
|
||||
it('opens the lobby when the room appears', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(msg('roomCreated', { roomCode: 'K7M2QP' }));
|
||||
store.apply(lobby({ roomCode: 'K7M2QP', iAmOwner: true }));
|
||||
|
||||
expect(store.state.phase).toBe('waiting');
|
||||
expect(store.state.phase).toBe('lobby');
|
||||
expect(store.state.roomCode).toBe('K7M2QP');
|
||||
expect(store.state.isOwner).toBe(true);
|
||||
});
|
||||
|
||||
it('does not drop a live game back into waiting', () => {
|
||||
// A resumed session is told its room again. Treating that as "waiting"
|
||||
// would replace the board with the invite screen mid-game.
|
||||
it('takes every field from the server rather than deriving any', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(msg('roomCreated', { roomCode: 'K7M2QP' }));
|
||||
store.apply(
|
||||
lobby({
|
||||
iAmOwner: false,
|
||||
iAmReady: true,
|
||||
canStart: true,
|
||||
opponentPresent: true,
|
||||
opponentName: 'Lan',
|
||||
opponentReady: false,
|
||||
opponentConnected: true
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.state.phase).toBe('playing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rematch offers', () => {
|
||||
function finished(store) {
|
||||
store.apply(started());
|
||||
store.apply(msg('gameOver', { iWon: false, reason: GameEndReason.RESIGNED }));
|
||||
}
|
||||
|
||||
it('records both sides of the answer as the server rendered them', () => {
|
||||
const store = createGameStore();
|
||||
finished(store);
|
||||
store.apply(msg('rematchState', { iAccepted: true, opponentAccepted: false, expiresInMs: 27500 }));
|
||||
|
||||
expect(store.state.rematch).toEqual({
|
||||
iAccepted: true,
|
||||
opponentAccepted: false,
|
||||
expiresInMs: 27500
|
||||
expect(store.state).toMatchObject({
|
||||
isOwner: false,
|
||||
isReady: true,
|
||||
canStart: true,
|
||||
opponentPresent: true,
|
||||
opponentName: 'Lan',
|
||||
opponentReady: false,
|
||||
opponentConnected: true
|
||||
});
|
||||
});
|
||||
|
||||
it('ends the offer when the opponent cannot come back', () => {
|
||||
// The countdown would otherwise keep running with nobody to answer it.
|
||||
it('does not drop a live game back into the lobby', () => {
|
||||
// A room state arriving mid-game is a presence change, not a phase.
|
||||
// Acting on it would replace the board with the lobby mid-turn.
|
||||
const store = createGameStore();
|
||||
finished(store);
|
||||
store.apply(msg('rematchState', { iAccepted: true, opponentAccepted: false, expiresInMs: 27500 }));
|
||||
store.apply(msg('opponentLeft', { canReconnect: false, graceMs: 0 }));
|
||||
store.apply(started());
|
||||
store.apply(lobby({ opponentPresent: true, opponentConnected: true }));
|
||||
|
||||
expect(store.state.rematch).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the offer while the opponent is only disconnected', () => {
|
||||
const store = createGameStore();
|
||||
finished(store);
|
||||
store.apply(msg('rematchState', { iAccepted: false, opponentAccepted: true, expiresInMs: 20000 }));
|
||||
store.apply(msg('opponentLeft', { canReconnect: true, graceMs: 30000 }));
|
||||
|
||||
expect(store.state.rematch).not.toBeNull();
|
||||
});
|
||||
|
||||
it('clears the offer once the next game starts', () => {
|
||||
const store = createGameStore();
|
||||
finished(store);
|
||||
store.apply(msg('rematchState', { iAccepted: true, opponentAccepted: true, expiresInMs: 15000 }));
|
||||
store.apply(started({ openingWord: 'thí sinh' }));
|
||||
|
||||
expect(store.state.rematch).toBeNull();
|
||||
expect(store.state.phase).toBe('playing');
|
||||
});
|
||||
|
||||
it('keeps the result on screen when a finished game returns to the lobby', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(msg('gameOver', { iWon: false, reason: GameEndReason.RESIGNED }));
|
||||
store.apply(lobby({ opponentPresent: true, opponentConnected: true }));
|
||||
|
||||
expect(store.state.phase).toBe('over');
|
||||
expect(store.state.result).not.toBeNull();
|
||||
});
|
||||
|
||||
it('stops waiting for an opponent 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 }));
|
||||
|
||||
expect(store.state.opponentLeft).toBeNull();
|
||||
expect(store.state.opponentConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('forgets the room when this player is kicked out of it', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(lobby({ opponentPresent: true, opponentConnected: true }));
|
||||
store.apply(msg('error', { code: 'kicked', message: '' }));
|
||||
|
||||
expect(store.state.phase).toBe('idle');
|
||||
expect(store.state.roomCode).toBe('');
|
||||
expect(store.state.error).toBe('Bạn đã bị mời ra khỏi phòng.');
|
||||
});
|
||||
|
||||
it('forgets a room that closed for sitting idle', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(lobby({ iAmOwner: true }));
|
||||
store.apply(msg('error', { code: 'room_idle_closed', message: '' }));
|
||||
|
||||
expect(store.state.phase).toBe('idle');
|
||||
expect(store.state.roomCode).toBe('');
|
||||
expect(store.state.error).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -323,10 +323,10 @@ describe('frames', () => {
|
||||
const h = setup();
|
||||
h.client.connect();
|
||||
h.last().open();
|
||||
h.last().deliver(serverMsg('roomCreated', { roomCode: 'ABCD' }));
|
||||
h.last().deliver(serverMsg('roomState', { roomCode: 'ABCD' }));
|
||||
|
||||
expect(h.received).toHaveLength(1);
|
||||
expect(h.received[0].payload.case).toBe('roomCreated');
|
||||
expect(h.received[0].payload.case).toBe('roomState');
|
||||
});
|
||||
|
||||
it('drops an undecodable frame instead of ending the session', () => {
|
||||
|
||||
Reference in new issue
Block a user