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:
tiennm99 committed 2026-09-07 16:32:14 +07:00
1 parent ef638e52fd
commit d481a093ec
39 files changed
+2352 -1215

No files matched your search

@@ -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>
+2 -2
View File
@@ -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 }}
*/
+232
View File
@@ -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>
-59
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
};
+41 -34
View File
@@ -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.
+74 -41
View File
@@ -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;
}
};
}
+31 -5
View File
@@ -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, {}) }
});
}