mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
refactor(web): share alert banner and turn actions, fix restyle leaks
Add AlertBanner and turnActions to replace duplicated markup and wrappers, and share component-test setup. Fixes: oversized dismiss, help, rules and skip-link text after the type ramp collapse; report button hover styled as submit; chat draft lost when the panel folds; resign/claim/kick styles leaking app-wide.
This commit is contained in:
1 parent
55abdb36c1
commit
9be86a3cad
18 files changed
+481
-470
No files matched your search
+6
-5
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script>
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
|
||||
/**
|
||||
* One line of news the player has to see, announced as it appears: a
|
||||
* refusal from the server, a request that has stalled, a name still
|
||||
* missing. The board, the lobby and the join form all draw it the same
|
||||
* way, so a refusal reads as a refusal wherever it lands.
|
||||
*
|
||||
* `tone` is `error` for something that went wrong and `notice` for
|
||||
* something the player has to do or know. `ondismiss`, when given, adds
|
||||
* the close button — for news that stays true until somebody acts on it,
|
||||
* the caller omits it.
|
||||
* @type {{
|
||||
* tone?: 'error' | 'notice',
|
||||
* testid?: string,
|
||||
* ondismiss?: () => void,
|
||||
* children: import('svelte').Snippet
|
||||
* }}
|
||||
*/
|
||||
let { tone = 'error', testid, ondismiss, children } = $props();
|
||||
</script>
|
||||
|
||||
<p class="alert {tone}" role="alert" data-testid={testid}>
|
||||
{@render children()}
|
||||
{#if ondismiss}
|
||||
<button type="button" class="icon-button" onclick={ondismiss} aria-label={t.dismiss}>×</button>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<style>
|
||||
.alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: 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);
|
||||
}
|
||||
</style>
|
||||
@@ -44,19 +44,20 @@
|
||||
/** @type {ReturnType<typeof setTimeout>} */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { untrack } from 'svelte';
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { scrollBehavior } from '$lib/motion.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
@@ -8,7 +9,7 @@
|
||||
* client never appends its own copy of a message, so there is one ordering
|
||||
* rather than a guessed one.
|
||||
*
|
||||
* Three facts about where it is, because each is a different question and
|
||||
* Two facts about where it is, because each is a different question and
|
||||
* the caller is the only one that can answer them:
|
||||
*
|
||||
* - `collapsible` folds the panel behind an unread count. That is for the
|
||||
@@ -16,9 +17,10 @@
|
||||
* and an open log would crowd the board off it.
|
||||
* - `column` says the panel has a column of its own, so the log grows into
|
||||
* the height it is given instead of stopping at a phone's worth.
|
||||
*
|
||||
* The room's refusals are not shown here. They belong beside the button
|
||||
* that produced them, which is where the lobby now draws them: down here
|
||||
* they were below the fold on a phone, and "Bắt đầu" looked broken.
|
||||
* that produced them, which is where the lobby draws them: down here they
|
||||
* were below the fold on a phone, and "Bắt đầu" looked broken.
|
||||
*
|
||||
* `folded` and `unread` are bindable so a control drawn outside this panel
|
||||
* — the pill hoisted into the board's top row — can unfold it and read its
|
||||
@@ -69,7 +71,6 @@
|
||||
// the invisible characters that survive a trim stripped out. Matching the
|
||||
// server's own emptiness test is what keeps a blank message from being
|
||||
// sent and silently dropped with nothing on screen to explain it.
|
||||
//
|
||||
/** @param {string} text */
|
||||
function hasContent(text) {
|
||||
return text.replace(/[\p{Cf}\p{Cc}]/gu, '').trim().length > 0;
|
||||
@@ -77,6 +78,16 @@
|
||||
|
||||
const sendable = $derived(hasContent(draft));
|
||||
|
||||
// The field unmounts while the panel is folded and comes back empty, so
|
||||
// the draft is written back into it on the way in: what was typed survives
|
||||
// a fold, and the send button — which reads the draft — never offers to
|
||||
// send text the field no longer holds. Untracked, so this runs once per
|
||||
// mount and never writes into a composition in progress.
|
||||
$effect(() => {
|
||||
const el = field;
|
||||
if (el) untrack(() => (el.value = draft));
|
||||
});
|
||||
|
||||
// Everything is read while the panel is open. A count that went backwards
|
||||
// is a resynchronised panel — a reconnect, or an opponent leaving — and
|
||||
// whatever it now holds is all there is to have missed.
|
||||
@@ -208,7 +219,7 @@
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -216,7 +227,7 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -255,7 +266,7 @@
|
||||
ol {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: var(--space-1);
|
||||
/* Stacked under the game, so bounded: the field it scrolls above has to
|
||||
stay on screen. */
|
||||
max-height: 180px;
|
||||
@@ -302,13 +313,13 @@
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: var(--space-3) 12px;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
@@ -317,7 +328,7 @@
|
||||
}
|
||||
|
||||
.row button {
|
||||
padding: var(--space-3) 16px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import AlertBanner from '$lib/components/AlertBanner.svelte';
|
||||
import ArmedButton from '$lib/components/ArmedButton.svelte';
|
||||
import ChainHistory from '$lib/components/ChainHistory.svelte';
|
||||
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
|
||||
@@ -53,14 +54,11 @@
|
||||
|
||||
const offline = $derived(connection.status !== Status.OPEN);
|
||||
|
||||
// Giving up is a move: it is what a player plays instead of a word, so it
|
||||
// is offered on their turn and no other. Out of turn the way out of a game
|
||||
// is to leave the room, which the lobby's own button does.
|
||||
const canResign = $derived(game.state.myTurn && !offline);
|
||||
|
||||
// A claim is the same offer resign is: made only on the player's own turn,
|
||||
// on the same reasoning canResign already states.
|
||||
const canClaimDeadEnd = $derived(game.state.myTurn && !offline);
|
||||
// Giving up and claiming a dead end are both moves: each is what a player
|
||||
// plays instead of a word, so they are offered on their turn and no other.
|
||||
// Out of turn the way out of a game is to leave the room, which the
|
||||
// lobby's own button does.
|
||||
const canPlayInsteadOfAWord = $derived(game.state.myTurn && !offline);
|
||||
</script>
|
||||
|
||||
<section class="board" data-phase={game.state.phase}>
|
||||
@@ -134,15 +132,7 @@
|
||||
{/if}
|
||||
|
||||
{#if game.state.error}
|
||||
<p class="error" role="alert">
|
||||
{game.state.error}
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button"
|
||||
onclick={() => game.clearError()}
|
||||
aria-label={t.dismiss}>×</button
|
||||
>
|
||||
</p>
|
||||
<AlertBanner ondismiss={() => game.clearError()}>{game.state.error}</AlertBanner>
|
||||
{/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}
|
||||
/>
|
||||
<ArmedButton
|
||||
class="resign"
|
||||
label={t.resign}
|
||||
confirmLabel={t.resignSure}
|
||||
disabled={!canResign}
|
||||
disabled={!canPlayInsteadOfAWord}
|
||||
onconfirm={onresign}
|
||||
/>
|
||||
</div>
|
||||
{#if game.state.claimError}
|
||||
<p class="claim-error" role="alert">
|
||||
{game.state.claimError}
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button"
|
||||
onclick={() => game.clearClaimError()}
|
||||
aria-label={t.dismiss}>×</button
|
||||
>
|
||||
</p>
|
||||
<AlertBanner ondismiss={() => game.clearClaimError()}>{game.state.claimError}</AlertBanner>
|
||||
{/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 <button>, 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 <button>, 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);
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import AlertBanner from '$lib/components/AlertBanner.svelte';
|
||||
import ArmedButton from '$lib/components/ArmedButton.svelte';
|
||||
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
|
||||
import PlayerStatus from '$lib/components/PlayerStatus.svelte';
|
||||
@@ -47,12 +48,7 @@
|
||||
const offline = $derived(connection.status !== Status.OPEN);
|
||||
// The owner is who everybody else is waiting on, so the hint has to stop
|
||||
// telling a ready guest to keep waiting once the owner has dropped.
|
||||
const ownerAway = $derived(
|
||||
s.roomPlayers.some((/** @type {{ isOwner: boolean, connected: boolean }} */ p) => {
|
||||
return p.isOwner && !p.connected;
|
||||
})
|
||||
);
|
||||
|
||||
const ownerAway = $derived(s.roomPlayers.some((p) => p.isOwner && !p.connected));
|
||||
</script>
|
||||
|
||||
<section class="lobby" class:compact aria-label={t.lobbyTitle}>
|
||||
@@ -165,17 +161,9 @@
|
||||
player had already scrolled past, which made "Bắt đầu" look broken.
|
||||
-->
|
||||
{#if s.error}
|
||||
<p class="error" role="alert" data-testid="lobby-error">
|
||||
{s.error}
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button"
|
||||
onclick={() => game.clearError()}
|
||||
aria-label={t.dismiss}>×</button
|
||||
>
|
||||
</p>
|
||||
<AlertBanner testid="lobby-error" ondismiss={() => game.clearError()}>{s.error}</AlertBanner>
|
||||
{:else if actionHeld}
|
||||
<p class="error" role="alert" data-testid="lobby-unsent">{t.reconnecting}</p>
|
||||
<AlertBanner testid="lobby-unsent">{t.reconnecting}</AlertBanner>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
@@ -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 <button>, which this component's
|
||||
* scoped-style attribute never reaches.
|
||||
* :global() under .seats: ArmedButton renders its own <button>, 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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
@@ -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));
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -469,10 +442,10 @@
|
||||
{#if playing}
|
||||
<GameBoard
|
||||
modeLabel={game.state.roomCode}
|
||||
onsubmit={play}
|
||||
onresign={giveUp}
|
||||
onclaimdeadend={claim}
|
||||
onreportword={report}
|
||||
onsubmit={turnActions.submit}
|
||||
onresign={turnActions.resign}
|
||||
onclaimdeadend={turnActions.claimDeadEnd}
|
||||
onreportword={turnActions.reportWord}
|
||||
chatUnread={wide ? 0 : chatUnread}
|
||||
onchatopen={wide ? undefined : openChat}
|
||||
>
|
||||
@@ -521,11 +494,11 @@
|
||||
<NicknameInput />
|
||||
|
||||
{#if session.state.needName}
|
||||
<p class="notice" role="alert" data-testid="name-needed">{t.nicknameNeeded}</p>
|
||||
<AlertBanner tone="notice" testid="name-needed">{t.nicknameNeeded}</AlertBanner>
|
||||
{/if}
|
||||
|
||||
{#if game.state.error}
|
||||
<p class="error" role="alert" data-testid="join-error">{game.state.error}</p>
|
||||
<AlertBanner testid="join-error">{game.state.error}</AlertBanner>
|
||||
{/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. -->
|
||||
<p class="notice" role="alert" data-testid="resume-failed">
|
||||
<AlertBanner tone="notice" testid="resume-failed">
|
||||
{errorMessage('session_not_resumable')}
|
||||
</p>
|
||||
</AlertBanner>
|
||||
{/if}
|
||||
|
||||
{#if session.state.stalled}
|
||||
<p class="error" role="alert" data-testid="connect-stalled">{t.connectStalled}</p>
|
||||
<AlertBanner testid="connect-stalled">{t.connectStalled}</AlertBanner>
|
||||
{/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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -112,10 +94,10 @@
|
||||
|
||||
<GameBoard
|
||||
modeLabel={difficultyLabels[difficulty]}
|
||||
onsubmit={play}
|
||||
onresign={giveUp}
|
||||
onclaimdeadend={claim}
|
||||
onreportword={report}
|
||||
onsubmit={turnActions.submit}
|
||||
onresign={turnActions.resign}
|
||||
onclaimdeadend={turnActions.claimDeadEnd}
|
||||
onreportword={turnActions.reportWord}
|
||||
>
|
||||
{#snippet gameOver()}
|
||||
<!-- A bot always plays again, so there is nothing to negotiate: the
|
||||
|
||||
@@ -1,37 +1,23 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
// ChatPanel's unread accounting is what broke CI once already (the review
|
||||
// that asked for this file cites it by name), and until now nothing mounted
|
||||
// the component to prove it. jsdom is already a devDependency; Svelte 5
|
||||
// components compiled by the vite plugin mount directly under it with no
|
||||
// extra library.
|
||||
// ChatPanel's unread accounting has broken CI once already, and nothing
|
||||
// else mounts the component to prove it. Svelte 5 components compiled by the
|
||||
// vite plugin mount directly under jsdom with no extra library.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mount, unmount, flushSync } from 'svelte';
|
||||
import { create } from '@bufbuild/protobuf';
|
||||
import { unmount, flushSync } from 'svelte';
|
||||
import ChatPanel from '../src/lib/components/ChatPanel.svelte';
|
||||
import { ServerMessageSchema } from '../src/lib/proto/noitu/v1/game_pb.js';
|
||||
import { game } from '../src/lib/stores/game.svelte.js';
|
||||
import { receive, render } from './component-support.js';
|
||||
|
||||
/** @param {{ author?: string, text?: string, fromMe?: boolean }} [fields] */
|
||||
function receiveLine({ author = 'Lan', text = 'chào', fromMe = false } = {}) {
|
||||
game.apply(
|
||||
create(ServerMessageSchema, {
|
||||
payload: {
|
||||
case: 'chatMessage',
|
||||
value: { fromMe, author, text, playerId: 'p2', sentUnixMs: 1n }
|
||||
}
|
||||
})
|
||||
);
|
||||
receive('chatMessage', { fromMe, author, text, playerId: 'p2', sentUnixMs: 1n });
|
||||
}
|
||||
|
||||
/** @param {ConstructorParameters<typeof ChatPanel>[0]['props']} props */
|
||||
function renderChatPanel(props) {
|
||||
const target = document.createElement('div');
|
||||
document.body.appendChild(target);
|
||||
const component = mount(ChatPanel, { target, props });
|
||||
flushSync();
|
||||
return { target, component };
|
||||
return render(ChatPanel, props);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -168,3 +154,36 @@ describe('sending', () => {
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a draft across a fold', () => {
|
||||
it('comes back in the field, and still sendable, once the panel reopens', () => {
|
||||
/** @type {string[]} */
|
||||
const sent = [];
|
||||
const { target, component } = renderChatPanel({
|
||||
collapsible: true,
|
||||
folded: false,
|
||||
onsend: (text) => sent.push(text)
|
||||
});
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
const toggle = target.querySelector('[data-testid="chat-toggle"]');
|
||||
/** @type {HTMLInputElement | null} */
|
||||
let input = target.querySelector('[data-testid="chat-input"]');
|
||||
if (input) {
|
||||
input.value = 'nửa câu';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
toggle?.click(); // folds: the field unmounts
|
||||
flushSync();
|
||||
toggle?.click(); // unfolds: a new field mounts
|
||||
flushSync();
|
||||
|
||||
input = target.querySelector('[data-testid="chat-input"]');
|
||||
expect(input?.value).toBe('nửa câu');
|
||||
target.querySelector('form')?.requestSubmit();
|
||||
flushSync();
|
||||
|
||||
expect(sent).toEqual(['nửa câu']);
|
||||
unmount(component);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// Shared set-up for the suites that mount a component under jsdom. Not a
|
||||
// test file itself: Vitest only collects `*.test.js`.
|
||||
|
||||
import { flushSync, mount } from 'svelte';
|
||||
import { create } from '@bufbuild/protobuf';
|
||||
import { ServerMessageSchema } from '../src/lib/proto/noitu/v1/game_pb.js';
|
||||
import { game } from '../src/lib/stores/game.svelte.js';
|
||||
|
||||
/**
|
||||
* Feeds the shared store one decoded server message, as the socket would.
|
||||
* @param {string} kind - the payload oneof case
|
||||
* @param {object} value
|
||||
*/
|
||||
export function receive(kind, value) {
|
||||
game.apply(
|
||||
create(ServerMessageSchema, { payload: { case: kind, value } })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a one-seat game with this player on turn.
|
||||
* @param {{ turnSeq?: number, currentSyllable?: string }} [fields]
|
||||
*/
|
||||
export function startGame({ turnSeq = 1, currentSyllable = 'yên' } = {}) {
|
||||
receive('gameStarted', {
|
||||
openingWord: 'bình yên',
|
||||
currentSyllable,
|
||||
myTurn: true,
|
||||
deadlineUnixMs: 1_700_000_020_000n,
|
||||
turnSeq,
|
||||
turnLimitMs: 20_000,
|
||||
players: [{ playerId: 'p1', name: 'Minh', isMe: true, connected: true }],
|
||||
turnPlayerId: 'p1'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts a component into a fresh element on the page and settles it.
|
||||
* @template {Record<string, unknown>} Props
|
||||
* @param {import('svelte').Component<Props>} component
|
||||
* @param {Props} props
|
||||
*/
|
||||
export function render(component, props) {
|
||||
const target = document.createElement('div');
|
||||
document.body.appendChild(target);
|
||||
const instance = mount(component, { target, props });
|
||||
flushSync();
|
||||
return { target, component: instance };
|
||||
}
|
||||
@@ -6,49 +6,21 @@
|
||||
// something only the online route happens to exercise.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mount, unmount, flushSync } from 'svelte';
|
||||
import { create } from '@bufbuild/protobuf';
|
||||
import { unmount, flushSync } from 'svelte';
|
||||
import GameBoard from '../src/lib/components/GameBoard.svelte';
|
||||
import { ServerMessageSchema } from '../src/lib/proto/noitu/v1/game_pb.js';
|
||||
import { game } from '../src/lib/stores/game.svelte.js';
|
||||
import { Status, connection } from '../src/lib/ws/connection.svelte.js';
|
||||
|
||||
function startGame() {
|
||||
game.apply(
|
||||
create(ServerMessageSchema, {
|
||||
payload: {
|
||||
case: 'gameStarted',
|
||||
value: {
|
||||
openingWord: 'bình yên',
|
||||
currentSyllable: 'yên',
|
||||
myTurn: true,
|
||||
deadlineUnixMs: 1_700_000_020_000n,
|
||||
turnSeq: 1,
|
||||
turnLimitMs: 20_000,
|
||||
players: [{ playerId: 'p1', name: 'Minh', isMe: true, connected: true }],
|
||||
turnPlayerId: 'p1'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
import { receive, render, startGame } from './component-support.js';
|
||||
|
||||
/** @param {Partial<ConstructorParameters<typeof GameBoard>[0]['props']>} [extra] */
|
||||
function renderGameBoard(extra = {}) {
|
||||
const target = document.createElement('div');
|
||||
document.body.appendChild(target);
|
||||
const component = mount(GameBoard, {
|
||||
target,
|
||||
props: {
|
||||
onsubmit: () => true,
|
||||
onresign: () => {},
|
||||
onclaimdeadend: () => {},
|
||||
onreportword: () => {},
|
||||
...extra
|
||||
}
|
||||
return render(GameBoard, {
|
||||
onsubmit: () => true,
|
||||
onresign: () => {},
|
||||
onclaimdeadend: () => {},
|
||||
onreportword: () => {},
|
||||
...extra
|
||||
});
|
||||
flushSync();
|
||||
return { target, component };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -125,21 +97,14 @@ describe('the persistent claim/resign row', () => {
|
||||
it('disables rather than unmounts both once the turn moves on', () => {
|
||||
const { target, component } = renderGameBoard();
|
||||
|
||||
game.apply(
|
||||
create(ServerMessageSchema, {
|
||||
payload: {
|
||||
case: 'turnUpdate',
|
||||
value: {
|
||||
currentSyllable: 'yên',
|
||||
myTurn: false,
|
||||
turnSeq: 2,
|
||||
chainLength: 1,
|
||||
players: [{ playerId: 'p1', name: 'Minh', isMe: true, connected: true }],
|
||||
turnPlayerId: 'p2'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
receive('turnUpdate', {
|
||||
currentSyllable: 'yên',
|
||||
myTurn: false,
|
||||
turnSeq: 2,
|
||||
chainLength: 1,
|
||||
players: [{ playerId: 'p1', name: 'Minh', isMe: true, connected: true }],
|
||||
turnPlayerId: 'p2'
|
||||
});
|
||||
flushSync();
|
||||
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
@@ -154,3 +119,37 @@ describe('the persistent claim/resign row', () => {
|
||||
unmount(component);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refusals', () => {
|
||||
it('shows a room-wide error with a button that dismisses it', () => {
|
||||
const { target, component } = renderGameBoard();
|
||||
|
||||
receive('error', { code: 'too_fast' });
|
||||
flushSync();
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
const dismiss = target.querySelector('[role="alert"] button');
|
||||
expect(target.querySelector('[role="alert"]')?.textContent).toContain('Thao tác quá nhanh');
|
||||
dismiss?.click();
|
||||
flushSync();
|
||||
|
||||
expect(game.state.error).toBeNull();
|
||||
expect(target.querySelector('[role="alert"]')).toBeNull();
|
||||
unmount(component);
|
||||
});
|
||||
|
||||
it('answers a refused claim beside the button rather than in the top banner', () => {
|
||||
const { target, component } = renderGameBoard();
|
||||
|
||||
receive('error', { code: 'not_a_dead_end' });
|
||||
flushSync();
|
||||
|
||||
expect(game.state.error).toBeNull();
|
||||
const alert = target.querySelector('[role="alert"]');
|
||||
expect(alert?.previousElementSibling?.classList.contains('secondary')).toBe(true);
|
||||
alert?.querySelector('button')?.click();
|
||||
flushSync();
|
||||
|
||||
expect(game.state.claimError).toBeNull();
|
||||
unmount(component);
|
||||
});
|
||||
});
|
||||
@@ -74,7 +74,7 @@ describe('normalizeRoomCode', () => {
|
||||
|
||||
it('handles nothing at all', () => {
|
||||
expect(normalizeRoomCode('')).toBe('');
|
||||
expect(normalizeRoomCode(/** @type {any} */ (undefined))).toBe('');
|
||||
expect(normalizeRoomCode(/** @type {string} */ (/** @type {unknown} */ (undefined)))).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -116,8 +116,8 @@ describe('resuming a stored session', () => {
|
||||
});
|
||||
|
||||
it('times out to the join form instead of staying stuck forever', () => {
|
||||
// The scenario C1 in the review describes: an older server answers a
|
||||
// stale token with silence, never with an error.
|
||||
// An older server answers a stale token with silence, never with an
|
||||
// error.
|
||||
const session = createRoomSession();
|
||||
|
||||
session.startResume();
|
||||
|
||||
@@ -4,63 +4,32 @@
|
||||
// and the submit-clear — are the exceptions to an otherwise fully
|
||||
// uncontrolled field, so each is worth pinning down: the seed fires once per
|
||||
// turn and never during the player's own composition, the suggestion lands
|
||||
// on click, and the seed itself is gated on the connection (C6) as well as
|
||||
// the turn.
|
||||
// on click, and the seed itself is gated on the connection as well as the
|
||||
// turn.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mount, unmount, flushSync } from 'svelte';
|
||||
import { create } from '@bufbuild/protobuf';
|
||||
import { unmount, flushSync } from 'svelte';
|
||||
import WordInput from '../src/lib/components/WordInput.svelte';
|
||||
import { RejectReason, ServerMessageSchema } from '../src/lib/proto/noitu/v1/game_pb.js';
|
||||
import { RejectReason } from '../src/lib/proto/noitu/v1/game_pb.js';
|
||||
import { game } from '../src/lib/stores/game.svelte.js';
|
||||
import { Status, connection } from '../src/lib/ws/connection.svelte.js';
|
||||
|
||||
/** @param {{ turnSeq?: number, currentSyllable?: string }} [fields] */
|
||||
function startTurn({ turnSeq = 1, currentSyllable = 'an' } = {}) {
|
||||
game.apply(
|
||||
create(ServerMessageSchema, {
|
||||
payload: {
|
||||
case: 'gameStarted',
|
||||
value: {
|
||||
openingWord: 'bình yên',
|
||||
currentSyllable,
|
||||
myTurn: true,
|
||||
deadlineUnixMs: 1_700_000_020_000n,
|
||||
turnSeq,
|
||||
turnLimitMs: 20_000,
|
||||
players: [{ playerId: 'p1', name: 'Minh', isMe: true, connected: true }],
|
||||
turnPlayerId: 'p1'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
import { receive, render, startGame as startTurn } from './component-support.js';
|
||||
|
||||
/** @param {string} suggestion */
|
||||
function rejectWith(suggestion) {
|
||||
game.apply(
|
||||
create(ServerMessageSchema, {
|
||||
payload: {
|
||||
case: 'moveRejected',
|
||||
value: {
|
||||
reason: RejectReason.NOT_IN_DICTIONARY,
|
||||
word: 'binh yen',
|
||||
turnSeq: 1,
|
||||
suggestion
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
receive('moveRejected', {
|
||||
reason: RejectReason.NOT_IN_DICTIONARY,
|
||||
word: 'binh yen',
|
||||
turnSeq: 1,
|
||||
suggestion
|
||||
});
|
||||
}
|
||||
|
||||
function renderWordInput() {
|
||||
const target = document.createElement('div');
|
||||
document.body.appendChild(target);
|
||||
const component = mount(WordInput, {
|
||||
target,
|
||||
props: { onsubmit: () => true, onreportword: () => {} }
|
||||
const { target, component } = render(WordInput, {
|
||||
onsubmit: () => true,
|
||||
onreportword: () => {}
|
||||
});
|
||||
flushSync();
|
||||
/** @type {HTMLInputElement} */
|
||||
const input = target.querySelector('input');
|
||||
return { target, component, input };
|
||||
@@ -86,8 +55,8 @@ describe('seeding the turn', () => {
|
||||
});
|
||||
|
||||
it('does not seed, or focus, while the connection is down', () => {
|
||||
// C6: seeding during a reconnect wrote into a field the player could
|
||||
// not submit from, and the first composition event then undid it.
|
||||
// Seeding during a reconnect wrote into a field the player could not
|
||||
// submit from, and the first composition event then undid it.
|
||||
connection.status = Status.CLOSED;
|
||||
startTurn({ currentSyllable: 'an' });
|
||||
const { input, component } = renderWordInput();
|
||||
|
||||
Reference in new issue
Block a user