diff --git a/web/src/app.css b/web/src/app.css
index b245ae0..35f364a 100644
--- a/web/src/app.css
+++ b/web/src/app.css
@@ -171,19 +171,20 @@ input {
border-radius: var(--radius-sm);
background: none;
color: inherit;
- font-size: var(--text-7);
+ font-size: var(--text-4);
line-height: 1;
}
/*
* A small link to the rules, used wherever a screen has room for one but not
* for a button that would compete with the primary action beside it: the
- * landing page, the game header and the lobby all point at the same route
- * through the same class, so it reads as one control rather than three.
+ * landing page and the lobby point at the same route through the same class,
+ * so it reads as one control. The game header, with no room for the words,
+ * uses a "?" icon-button instead.
*/
.rules-link {
color: var(--text-muted);
- font-size: var(--text-4);
+ font-size: var(--text-2);
text-decoration: underline;
}
@@ -224,7 +225,7 @@ input {
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
- font-size: var(--text-5);
+ font-size: var(--text-2);
font-weight: 600;
}
diff --git a/web/src/lib/components/AlertBanner.svelte b/web/src/lib/components/AlertBanner.svelte
new file mode 100644
index 0000000..a45932d
--- /dev/null
+++ b/web/src/lib/components/AlertBanner.svelte
@@ -0,0 +1,52 @@
+
+
+
+ {@render children()}
+ {#if ondismiss}
+ ×
+ {/if}
+
+
+
diff --git a/web/src/lib/components/ArmedButton.svelte b/web/src/lib/components/ArmedButton.svelte
index cc4be46..fd4f207 100644
--- a/web/src/lib/components/ArmedButton.svelte
+++ b/web/src/lib/components/ArmedButton.svelte
@@ -44,19 +44,20 @@
/** @type {ReturnType} */
let timer;
+ function disarm() {
+ clearTimeout(timer);
+ armed = false;
+ }
+
$effect(() => {
- if (disabled) {
- clearTimeout(timer);
- armed = false;
- }
+ if (disabled) disarm();
});
$effect(() => () => clearTimeout(timer));
function press() {
if (armed) {
- clearTimeout(timer);
- armed = false;
+ disarm();
onconfirm();
return;
}
diff --git a/web/src/lib/components/ChatPanel.svelte b/web/src/lib/components/ChatPanel.svelte
index 71ba1ec..7b7b456 100644
--- a/web/src/lib/components/ChatPanel.svelte
+++ b/web/src/lib/components/ChatPanel.svelte
@@ -1,4 +1,5 @@
@@ -134,15 +132,7 @@
{/if}
{#if game.state.error}
-
- {game.state.error}
- game.clearError()}
- aria-label={t.dismiss}>×
-
+ game.clearError()}>{game.state.error}
{/if}
{#if game.state.phase === 'over'}
@@ -213,27 +203,19 @@
class="claim-dead-end"
label={t.claimDeadEnd}
confirmLabel={t.claimDeadEndSure}
- disabled={!canClaimDeadEnd}
+ disabled={!canPlayInsteadOfAWord}
onconfirm={onclaimdeadend}
/>
{#if game.state.claimError}
-
- {game.state.claimError}
- game.clearClaimError()}
- aria-label={t.dismiss}>×
-
+ game.clearClaimError()}>{game.state.claimError}
{/if}
{/if}
@@ -338,26 +320,19 @@
line-height: 1.35;
}
- .error,
+ /* Not an AlertBanner: it is not announced (see the markup), and it
+ carries the one control that can do anything about it. */
.offline {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin: 0;
- padding: var(--space-3) var(--space-3);
+ padding: var(--space-3);
border-radius: var(--radius-sm);
- font-size: var(--text-2);
- }
-
- .error {
- background: var(--danger-soft);
- color: var(--danger);
- }
-
- .offline {
background: var(--surface-alt);
color: var(--warn);
+ font-size: var(--text-2);
}
.offline button {
@@ -404,85 +379,63 @@
gap: var(--space-2);
}
- /* :global(): these are ArmedButton's own , not one this
- component's template renders directly, so Svelte's scoped-style
- attribute never lands on it. */
+ /* :global() under .secondary: these are ArmedButton's own , which
+ Svelte's scoped-style attribute never lands on, so the rules are scoped
+ by the row they sit in instead — a bare :global(.resign) would reach
+ any element with that class anywhere in the app.
- /* Danger coloured because it ends the game, subordinate because it is not
- the way to play it. */
- :global(.resign) {
- /* Below the 44px the rest of the controls keep, deliberately: this is
- the one button here nobody is trying to hit, it takes two presses to
- do anything, and at full size it read as an offer rather than as the
- way out. Still its own outlined block in danger colour, so it is
- plainly findable rather than hidden. */
+ Both are secondary weight: resigning ends the game and claiming skips
+ a turn that cannot be answered, and neither is the way to play one.
+ Below the 44px the rest of the controls keep, deliberately: nobody is
+ trying to hit these, each takes two presses to do anything, and at
+ full size they read as offers rather than as ways out. Still outlined
+ blocks, so they are plainly findable rather than hidden. */
+ .secondary :global(button) {
min-height: 32px;
padding: var(--space-1) var(--space-3);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: transparent;
- color: var(--danger);
font-size: var(--text-2);
transition: background-color 150ms ease-out;
}
- :global(.resign:hover:enabled) {
- background: var(--danger-soft);
- }
-
- /* Off turn: still there, so the way out of the game does not appear and
- disappear under the player's thumb every handover, but plainly not the
- thing to press yet. */
- :global(.resign:disabled) {
+ /* Off turn: still there, so neither appears and disappears under the
+ player's thumb every handover, but plainly not the thing to press yet. */
+ .secondary :global(button:disabled) {
border-color: var(--border);
color: var(--text-muted);
}
- /* Armed, and saying so: the second press is the one that ends the game. */
- :global(.resign.arming) {
- border-color: var(--danger);
- background: var(--danger-soft);
+ .secondary :global(.arming) {
font-weight: 600;
}
- /* Secondary weight, same as resign: a dead-end claim is a shortcut past a
- turn that cannot be answered, not the way to play one. */
- :global(.claim-dead-end) {
- min-height: 32px;
- padding: var(--space-1) var(--space-3);
- border: 1px solid var(--border-strong);
- border-radius: var(--radius-sm);
- background: transparent;
+ .secondary :global(.claim-dead-end) {
color: var(--text);
- font-size: var(--text-2);
- transition: background-color 150ms ease-out;
}
- :global(.claim-dead-end:hover:enabled) {
+ .secondary :global(.claim-dead-end:hover:enabled) {
background: var(--surface-alt);
}
- :global(.claim-dead-end:disabled) {
- border-color: var(--border);
- color: var(--text-muted);
- }
-
- :global(.claim-dead-end.arming) {
+ .secondary :global(.claim-dead-end.arming) {
border-color: var(--accent);
background: var(--accent-soft);
- font-weight: 600;
}
- .claim-error {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--space-2);
- margin: 0;
- padding: var(--space-3) var(--space-3);
- border-radius: var(--radius-sm);
- background: var(--danger-soft);
+ /* Danger coloured because it ends the game. */
+ .secondary :global(.resign) {
color: var(--danger);
- font-size: var(--text-2);
+ }
+
+ .secondary :global(.resign:hover:enabled) {
+ background: var(--danger-soft);
+ }
+
+ /* Armed, and saying so: the second press is the one that ends the game. */
+ .secondary :global(.resign.arming) {
+ border-color: var(--danger);
+ background: var(--danger-soft);
}
diff --git a/web/src/lib/components/Lobby.svelte b/web/src/lib/components/Lobby.svelte
index 13365f6..d746beb 100644
--- a/web/src/lib/components/Lobby.svelte
+++ b/web/src/lib/components/Lobby.svelte
@@ -1,4 +1,5 @@
@@ -165,17 +161,9 @@
player had already scrolled past, which made "Bắt đầu" look broken.
-->
{#if s.error}
-
- {s.error}
- game.clearError()}
- aria-label={t.dismiss}>×
-
+ game.clearError()}>{s.error}
{:else if actionHeld}
- {t.reconnecting}
+ {t.reconnecting}
{/if}
@@ -333,10 +321,11 @@
* touch target is the full 44 all the same, expanded out of the flow by a
* pseudo-element so the row keeps its height.
*
- * :global(): ArmedButton renders its own
, which this component's
- * scoped-style attribute never reaches.
+ * :global() under .seats: ArmedButton renders its own , which this
+ * component's scoped-style attribute never reaches, so the rules are
+ * scoped by the list instead of leaking to every .kick in the app.
*/
- :global(.kick) {
+ .seats :global(.kick) {
position: relative;
width: 36px;
height: 36px;
@@ -350,17 +339,17 @@
line-height: 1;
}
- :global(.kick::after) {
+ .seats :global(.kick::after) {
content: '';
position: absolute;
inset: -4px;
}
- :global(.kick:disabled) {
+ .seats :global(.kick:disabled) {
opacity: 0.35;
}
- :global(.kick.arming) {
+ .seats :global(.kick.arming) {
border-color: var(--danger);
background: var(--danger-soft);
color: var(--danger);
@@ -374,19 +363,6 @@
text-align: center;
}
- .error {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--space-2);
- margin: 0;
- padding: var(--space-3) var(--space-3);
- border-radius: var(--radius-sm);
- background: var(--danger-soft);
- color: var(--danger);
- font-size: var(--text-2);
- }
-
.actions {
display: flex;
gap: var(--space-2);
diff --git a/web/src/lib/components/WordInput.svelte b/web/src/lib/components/WordInput.svelte
index b959d43..ef75bc5 100644
--- a/web/src/lib/components/WordInput.svelte
+++ b/web/src/lib/components/WordInput.svelte
@@ -126,11 +126,18 @@
if (draft && draft.toLowerCase().startsWith(syllable.toLowerCase())) return;
field.value = `${syllable} `;
- // Caret after the seed, so typing continues the word instead of
- // landing in front of it.
- field.setSelectionRange(field.value.length, field.value.length);
+ caretToEnd(field);
});
+ /**
+ * Puts the caret after whatever the field holds, so typing continues the
+ * word instead of landing in front of it.
+ * @param {HTMLInputElement} input
+ */
+ function caretToEnd(input) {
+ input.setSelectionRange(input.value.length, input.value.length);
+ }
+
/** @param {SubmitEvent} event */
function handleSubmit(event) {
event.preventDefault();
@@ -167,7 +174,7 @@
if (!suggestion || !field) return;
field.value = suggestion;
field.focus();
- field.setSelectionRange(field.value.length, field.value.length);
+ caretToEnd(field);
}
function report() {
@@ -283,7 +290,10 @@
color: var(--text-muted);
}
- button {
+ /* Scoped to the row: a bare `button` rule also reached the fix buttons
+ below, and its hover and press states — more specific than
+ `.fix:hover` — turned the report button solid accent under dark text. */
+ .input-row button {
padding: var(--space-4) var(--space-5);
border: 0;
border-radius: var(--radius-sm);
@@ -293,15 +303,15 @@
transition: background-color 150ms ease-out;
}
- button:hover:not(:disabled) {
+ .input-row button:hover:not(:disabled) {
background: var(--accent-hover);
}
- button:active:not(:disabled) {
+ .input-row button:active:not(:disabled) {
background: var(--accent-pressed);
}
- button:disabled {
+ .input-row button:disabled {
background: var(--surface-alt);
color: var(--text-muted);
}
diff --git a/web/src/lib/stores/game-apply.js b/web/src/lib/stores/game-apply.js
index bf9e1bf..2d1739d 100644
--- a/web/src/lib/stores/game-apply.js
+++ b/web/src/lib/stores/game-apply.js
@@ -3,7 +3,9 @@ import { toParts, toSenses, toScore, toSlot } from './game-shape.js';
/**
* @typedef {import('$lib/proto/noitu/v1/game_pb.js').ServerMessage} ServerMessage
+ * @typedef {import('$lib/proto/noitu/v1/game_pb.js').ChatMessage} WireChatMessage
* @typedef {import('./game-shape.js').GameState} GameState
+ * @typedef {import('./game-shape.js').ChatLine} ChatLine
*/
// Ordinal handed to each chat line as it arrives, for list keys. Never reset:
@@ -16,6 +18,44 @@ let chatOrdinal = 0;
*/
export const CHAT_WINDOW = 20;
+/**
+ * Error codes that also end this connection's membership of the room, so the
+ * model has to stop describing one.
+ */
+const LEAVES_ROOM = new Set(['kicked', 'room_idle_closed']);
+
+/**
+ * A false dead-end claim, and a resign or claim that raced the turn moving
+ * on, are both answered next to the button that sent them, not in the top
+ * banner: each is about the move just attempted, not a room-wide condition
+ * every screen has to show.
+ */
+const ANSWERED_BY_THE_BUTTON = new Set(['not_a_dead_end', 'not_your_turn']);
+
+/**
+ * A match the server could not open leaves nobody queued, and the only frame
+ * that says so is one of these refusals.
+ */
+const ENDS_THE_QUEUE = new Set(['server_full', 'room_start_failed', 'server_restarting']);
+
+/**
+ * Reads one chat line off the wire, numbering it for list keys.
+ * @param {WireChatMessage} m
+ * @returns {ChatLine}
+ */
+function toChatLine(m) {
+ return {
+ n: ++chatOrdinal,
+ fromMe: m.fromMe,
+ playerId: m.playerId,
+ author: m.author,
+ text: m.text,
+ // int64 on the wire, which the runtime hands over as a bigint.
+ // Nothing downstream expects one.
+ atMs: Number(m.sentUnixMs)
+ };
+}
+
/**
* Applies one ServerMessage to a GameState in place.
*
@@ -113,6 +153,14 @@ export function applyTo(state, msg, { reset, leave }) {
});
state.expanded = state.expanded.filter((w) => w !== previous);
if (!state.expanded.includes(played.word)) state.expanded.push(played.word);
+ // An accepted move answers the previous rejection — and only
+ // an accepted move does. A wordless update is somebody being
+ // eliminated, which says nothing about the word this player
+ // was just refused, and wiping the reason off their screen is
+ // one player's exit costing another the only explanation they
+ // had.
+ state.rejection = null;
+ state.reportConfirmation = null;
}
state.currentSyllable = value.currentSyllable;
state.myTurn = value.myTurn;
@@ -121,16 +169,6 @@ export function applyTo(state, msg, { reset, leave }) {
state.chainLength = value.chainLength;
state.gamePlayers = value.players.map(toScore);
state.turnPlayerId = value.turnPlayerId;
- // An accepted move answers the previous rejection — and only
- // an accepted move does. A wordless update is somebody being
- // eliminated, which says nothing about the word this player
- // was just refused, and wiping the reason off their screen is
- // one player's exit costing another the only explanation they
- // had.
- if (played) {
- state.rejection = null;
- state.reportConfirmation = null;
- }
// A false dead-end claim is about the position this update
// just moved past, however the turn moved.
state.claimError = null;
@@ -194,16 +232,7 @@ export function applyTo(state, msg, { reset, leave }) {
}
case 'chatMessage':
- state.chat.push({
- n: ++chatOrdinal,
- fromMe: payload.value.fromMe,
- playerId: payload.value.playerId,
- author: payload.value.author,
- text: payload.value.text,
- // int64 on the wire, which the runtime hands over as a
- // bigint. Nothing downstream expects one.
- atMs: Number(payload.value.sentUnixMs)
- });
+ state.chat.push(toChatLine(payload.value));
state.chatCount++;
// Trimmed to the server's window, so a long conversation and a
// replayed one are the same list.
@@ -216,41 +245,21 @@ export function applyTo(state, msg, { reset, leave }) {
// A snapshot replaces; it never merges. It is also what a
// client arriving in a new room is given, so a conversation
// cannot outlive the room it was had in.
- state.chat = payload.value.messages.map((m) => ({
- n: ++chatOrdinal,
- fromMe: m.fromMe,
- playerId: m.playerId,
- author: m.author,
- text: m.text,
- atMs: Number(m.sentUnixMs)
- }));
+ state.chat = payload.value.messages.map(toChatLine);
state.chatCount = state.chat.length;
break;
case 'error': {
- const value = payload.value;
- // 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();
- // A false dead-end claim, and a resign or claim that raced the
- // turn moving on, are both answered next to the button that sent
- // them, not in the top banner: each is about the move just
- // attempted, not a room-wide condition every screen has to show.
- if (value.code === 'not_a_dead_end' || value.code === 'not_your_turn') {
- state.claimError = errorMessage(value.code);
+ const code = payload.value.code;
+ // Before anything else, because leaving clears everything
+ // including the message about to be set.
+ if (LEAVES_ROOM.has(code)) leave();
+ if (ANSWERED_BY_THE_BUTTON.has(code)) {
+ state.claimError = errorMessage(code);
break;
}
- // A match the server could not open leaves nobody queued, and
- // the only frame that says so is this refusal.
- if (
- value.code === 'server_full' ||
- value.code === 'room_start_failed' ||
- value.code === 'server_restarting'
- ) {
- state.queued = false;
- }
- state.error = errorMessage(value.code);
+ if (ENDS_THE_QUEUE.has(code)) state.queued = false;
+ state.error = errorMessage(code);
break;
}
diff --git a/web/src/lib/stores/room-session.svelte.js b/web/src/lib/stores/room-session.svelte.js
index bb8750e..a794658 100644
--- a/web/src/lib/stores/room-session.svelte.js
+++ b/web/src/lib/stores/room-session.svelte.js
@@ -115,7 +115,10 @@ export function createRoomSession() {
return sent;
},
- /** Marks a resume attempt as starting, optionally behind a held join. */
+ /**
+ * Marks a resume attempt as starting. A join the page wants to make
+ * once it is settled is held separately, with `holdPendingJoin()`.
+ */
startResume() {
state.resuming = true;
},
diff --git a/web/src/lib/ws/connection.svelte.js b/web/src/lib/ws/connection.svelte.js
index aae6d2d..31ab6ea 100644
--- a/web/src/lib/ws/connection.svelte.js
+++ b/web/src/lib/ws/connection.svelte.js
@@ -1,4 +1,5 @@
import { Status, createClient, hasStoredSession } from './client.js';
+import { claimDeadEnd, reportWord, resign, submitWord } from './messages.js';
import { game } from '$lib/stores/game.svelte.js';
import { settings } from '$lib/stores/settings.svelte.js';
@@ -88,5 +89,26 @@ export function disconnect() {
state.status = Status.CLOSED;
}
+/**
+ * The moves a board offers, sent as the server expects them. Both game
+ * screens hand these to GameBoard unchanged: what a turn sends does not
+ * depend on whether the other side is a bot or a room.
+ *
+ * Resigning and claiming are armed by the board with a second press rather
+ * than a native confirm(), which would block the countdown's frame loop
+ * while the server's deadline kept running.
+ */
+export const turnActions = {
+ /**
+ * @param {string} word
+ * @returns {boolean} whether the word reached the server
+ */
+ submit: (word) => send(submitWord(word, game.state.turnSeq)),
+ resign: () => send(resign()),
+ claimDeadEnd: () => send(claimDeadEnd()),
+ /** @param {string} word */
+ reportWord: (word) => send(reportWord(word))
+};
+
export const connection = state;
export { Status, hasStoredSession };
diff --git a/web/src/routes/online/+page.svelte b/web/src/routes/online/+page.svelte
index 927286b..83327dd 100644
--- a/web/src/routes/online/+page.svelte
+++ b/web/src/routes/online/+page.svelte
@@ -2,6 +2,7 @@
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
+ import AlertBanner from '$lib/components/AlertBanner.svelte';
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
import GameBoard from '$lib/components/GameBoard.svelte';
import GameOverPanel from '$lib/components/GameOverPanel.svelte';
@@ -17,18 +18,14 @@
import { settings } from '$lib/stores/settings.svelte.js';
import {
cancelQuickMatch,
- claimDeadEnd,
createRoom,
joinRoom,
kickPlayer,
leaveRoom,
quickMatch,
- reportWord,
- resign,
sendChat,
setReady,
- startGame,
- submitWord
+ startGame
} from '$lib/ws/messages.js';
import {
Status,
@@ -37,7 +34,8 @@
disconnect,
forgetSession,
hasStoredSession,
- send
+ send,
+ turnActions
} from '$lib/ws/connection.svelte.js';
/**
@@ -233,11 +231,8 @@
// found and then a later, separate wait never inherits the first one's
// clock.
$effect(() => {
- if (!game.state.queued) {
- session.resetQueued();
- return;
- }
session.resetQueued();
+ if (!game.state.queued) return;
const id = setInterval(() => session.tickQueued(), 1000);
return () => clearInterval(id);
});
@@ -264,13 +259,7 @@
// not a handshake that opened and then went quiet.
$effect(() => {
if (!(session.state.resuming && connection.status === Status.OPEN)) return;
- const timer = setTimeout(() => {
- untrack(() => {
- session.noteResumeFailed(named);
- forgetSession();
- if (!session.state.needName) flush(connection.status === Status.OPEN);
- });
- }, RESUME_TIMEOUT_MS);
+ const timer = setTimeout(abandonResume, RESUME_TIMEOUT_MS);
return () => clearTimeout(timer);
});
@@ -281,16 +270,24 @@
// to answer a stale token with `session_not_resumable` lands here, ahead
// of the time-box above.
$effect(() => {
- const failed = session.state.resuming && !!game.state.error;
+ if (!(session.state.resuming && game.state.error)) return;
untrack(() => {
- if (!failed) return;
game.clearError();
- forgetSession();
- session.noteResumeFailed(named);
- if (!session.state.needName) flush(connection.status === Status.OPEN);
+ abandonResume();
});
});
+ /**
+ * Gives up on a resume, whichever of the two paths above noticed first:
+ * the token is spent, and an invite code held behind it either goes out
+ * now or waits on the name the player has not typed yet.
+ */
+ function abandonResume() {
+ session.noteResumeFailed(named);
+ forgetSession();
+ if (!session.state.needName) flush(connection.status === Status.OPEN);
+ }
+
/** @param {import('$lib/stores/room-session.svelte.js').RoomRequest} req */
function request(req) {
codeError = '';
@@ -312,11 +309,12 @@
}
/**
- * Resends a lobby action the socket refused the first time. Every one of
- * these is safe to resend regardless of what happened in between: the
- * server refuses whichever no longer apply rather than misapplying them.
+ * Sends a lobby action. Every one of these is safe to resend regardless of
+ * what happened in between — the server refuses whichever no longer apply
+ * rather than misapplying them — which is why this is also what a held
+ * action is retried through once the socket reopens.
* @param {import('$lib/stores/room-session.svelte.js').RoomAction} action
- * @returns {boolean}
+ * @returns {boolean} whether it reached the server
*/
function dispatchAction(action) {
switch (action.kind) {
@@ -335,6 +333,19 @@
}
}
+ /**
+ * Sends a lobby action now, or holds it for the socket to carry once it
+ * reopens. Reports which, so the lobby can say a request is waiting
+ * rather than looking like a button that does nothing.
+ * @param {import('$lib/stores/room-session.svelte.js').RoomAction} action
+ * @returns {boolean} whether it reached the server
+ */
+ function act(action) {
+ const sent = dispatchAction(action);
+ if (!sent) session.holdAction(action);
+ return sent;
+ }
+
function join() {
const code = normalizeRoomCode(codeInput);
if (!isRoomCode(code)) {
@@ -358,8 +369,7 @@
/** Withdraws from the pairing queue without leaving the page. */
function cancelQueue() {
- const sent = send(cancelQuickMatch());
- if (!sent) session.holdAction({ kind: 'cancelQueue' });
+ act({ kind: 'cancelQueue' });
session.clearPending();
}
@@ -367,47 +377,34 @@
goto('/');
}
+ /** @param {boolean} value */
+ function ready(value) {
+ return act({ kind: 'setReady', ready: value });
+ }
+
+ function start() {
+ return act({ kind: 'startGame' });
+ }
+
+ /**
+ * Armed by the lobby with a second press of the same button; a native
+ * confirm() would block the frame loop the countdown runs on.
+ * @param {string} playerId
+ */
+ function kick(playerId) {
+ return act({ kind: 'kickPlayer', playerId });
+ }
+
/**
* 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.
+ * to rejoin, and the join form 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.
- *
- * Each of these reports whether the request actually reached the server, so
- * the lobby can say so rather than looking like a button that does nothing.
- * @param {boolean} ready
- * @returns {boolean}
*/
- function ready(ready) {
- const sent = send(setReady(ready));
- if (!sent) session.holdAction({ kind: 'setReady', ready });
- return sent;
- }
-
- /** @returns {boolean} */
- function start() {
- const sent = send(startGame());
- if (!sent) session.holdAction({ kind: 'startGame' });
- return sent;
- }
-
- /**
- * @param {string} playerId
- * @returns {boolean}
- */
- function kick(playerId) {
- // The lobby arms this with a second press of the same button; a native
- // confirm() would block the frame loop the countdown runs on.
- const sent = send(kickPlayer(playerId));
- if (!sent) session.holdAction({ kind: 'kickPlayer', playerId });
- return sent;
- }
-
function leave() {
- const sent = send(leaveRoom());
- if (!sent) session.holdAction({ kind: 'leaveRoom' });
+ act({ kind: 'leaveRoom' });
game.leave();
session.clearPending();
// Matches the page-teardown path: leaving deliberately must not leave
@@ -420,30 +417,6 @@
function say(text) {
send(sendChat(text));
}
-
- /**
- * @param {string} word
- * @returns {boolean}
- */
- function play(word) {
- return send(submitWord(word, game.state.turnSeq));
- }
-
- function giveUp() {
- // Armed by the board with a second press, for the same reason as kick.
- send(resign());
- }
-
- function claim() {
- // Armed by the board the same way giving up is: a second press, so a
- // stray tap cannot spend it.
- send(claimDeadEnd());
- }
-
- /** @param {string} word */
- function report(word) {
- send(reportWord(word));
- }
@@ -469,10 +442,10 @@
{#if playing}
@@ -521,11 +494,11 @@
{#if session.state.needName}
- {t.nicknameNeeded}
+ {t.nicknameNeeded}
{/if}
{#if game.state.error}
- {game.state.error}
+ {game.state.error}
{/if}
{#if session.state.resumeFailed}
@@ -533,13 +506,13 @@
ServerMessage the page decides how to react to in the ordinary
way, and the message is the same whether the server actually said
`session_not_resumable` or simply never answered. -->
-
+
{errorMessage('session_not_resumable')}
-
+
{/if}
{#if session.state.stalled}
- {t.connectStalled}
+ {t.connectStalled}
{/if}
{#if game.state.queued}
@@ -827,24 +800,6 @@
color: var(--danger);
}
- .error,
- .notice {
- margin: 0;
- padding: var(--space-3) var(--space-3);
- border-radius: var(--radius-sm);
- font-size: var(--text-2);
- }
-
- .error {
- background: var(--danger-soft);
- color: var(--danger);
- }
-
- .notice {
- background: var(--surface-alt);
- color: var(--warn);
- }
-
.back {
align-self: flex-start;
color: var(--text-muted);
diff --git a/web/src/routes/play/+page.svelte b/web/src/routes/play/+page.svelte
index fb11334..3f52900 100644
--- a/web/src/routes/play/+page.svelte
+++ b/web/src/routes/play/+page.svelte
@@ -9,8 +9,15 @@
import { createBotSession } from '$lib/stores/bot-session.svelte.js';
import { game } from '$lib/stores/game.svelte.js';
import { settings } from '$lib/stores/settings.svelte.js';
- import { claimDeadEnd, reportWord, resign, startBotGame, submitWord } from '$lib/ws/messages.js';
- import { Status, connect, connection, disconnect, send } from '$lib/ws/connection.svelte.js';
+ import { resign, startBotGame } from '$lib/ws/messages.js';
+ import {
+ Status,
+ connect,
+ connection,
+ disconnect,
+ send,
+ turnActions
+ } from '$lib/ws/connection.svelte.js';
/**
* The difficulty travels in the URL so a reload resumes the same ladder rung
@@ -77,31 +84,6 @@
function goHome() {
goto('/');
}
-
- /**
- * @param {string} word
- * @returns {boolean} whether the word reached the server
- */
- function play(word) {
- return send(submitWord(word, game.state.turnSeq));
- }
-
- function giveUp() {
- // The board arms this with a second press of the same button. A native
- // confirm() blocks the main thread, which stops the countdown's frame
- // loop while the server's deadline keeps running — hesitating over the
- // dialog could cost the turn it was meant to protect.
- send(resign());
- }
-
- function claim() {
- send(claimDeadEnd());
- }
-
- /** @param {string} word */
- function report(word) {
- send(reportWord(word));
- }
@@ -112,10 +94,10 @@
{#snippet gameOver()}