mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
feat(web): slim the board header and stop the claim/resign row from churning
At 360px the header used to open on two rows of chrome: the connection badge said "Đã kết nối" in full while it was true, "Luật chơi" was a full-width text link, and the room code sat crowded in between. The badge now shows only its dot once open, the rules link is a 44px icon with an aria-label, and the room code moves under the turn prompt it used to sit beside. "Bí từ" used to mount and unmount every handover, shifting the input under a player's thumb each turn; it now sits in one persistent row with "Đầu hàng", both built on ArmedButton and disabled off-turn rather than unmounted. A resign or claim raced by the turn moving on now answers beside the button that sent it instead of the top banner, matching how a false dead-end claim was already handled. Knockout stops saying "you're out" twice — once in a banner above the scoreboard, once in the spectating box below it — and the elimination suggestions a spectator used to wait for the game-over screen to see now show up the moment they're eliminated, keyed to the syllable they were actually stuck on rather than whatever the game has moved on to since.
This commit is contained in:
1 parent
81d99bb0cf
commit
4fe7ef4f11
8 files changed
+267
-99
No files matched your search
@@ -2,6 +2,15 @@
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { Status, connection } from '$lib/ws/connection.svelte.js';
|
||||
|
||||
/**
|
||||
* `compact` hides the label's text while the connection is open, keeping
|
||||
* only the dot — that is the state a board's header has no room to spell
|
||||
* out every time, and the one that needs no announcement anyway. Any other
|
||||
* status still shows its text: that is exactly when it matters.
|
||||
* @type {{ compact?: boolean }}
|
||||
*/
|
||||
let { compact = false } = $props();
|
||||
|
||||
const label = $derived(
|
||||
{
|
||||
[Status.CONNECTING]: t.connecting,
|
||||
@@ -18,7 +27,7 @@
|
||||
-->
|
||||
<p class="badge" data-status={connection.status} aria-live="polite">
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{label}
|
||||
<span class:sr-only={compact && connection.status === Status.OPEN}>{label}</span>
|
||||
</p>
|
||||
|
||||
<style>
|
||||
@@ -27,11 +36,11 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 4px 10px;
|
||||
padding: 4px var(--space-3);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-4);
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
.dot {
|
||||
|
||||
@@ -65,12 +65,22 @@
|
||||
|
||||
<section class="board" data-phase={game.state.phase}>
|
||||
<div class="top">
|
||||
<ConnectionBadge />
|
||||
<!-- Its text label is what crowded this row: "Đã kết nối" said nothing
|
||||
a player needed while it stayed true, and the moment it stops
|
||||
being true is exactly when the label earns its width back. -->
|
||||
<ConnectionBadge compact />
|
||||
<div class="meta">
|
||||
{#if modeLabel}<span class="mode">{modeLabel}</span>{/if}
|
||||
<!-- A new tab: this screen resigns or leaves the room when it unmounts,
|
||||
so an in-page navigation to the rules would forfeit the game. -->
|
||||
<a class="rules-link" href="/rules" target="_blank" rel="noopener">{t.rulesLink}</a>
|
||||
so an in-page navigation to the rules would forfeit the game. A
|
||||
44px glyph rather than the underlined text link used elsewhere:
|
||||
at 360px "Luật chơi" was a third of the row on its own. -->
|
||||
<a
|
||||
class="icon-button"
|
||||
href="/rules"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label={t.rulesLink}>?</a
|
||||
>
|
||||
{#if onchatopen}
|
||||
<!-- Above the chain rather than below it, which is where this used
|
||||
to live: the chain grows a row per turn, and a badge under it
|
||||
@@ -90,9 +100,10 @@
|
||||
: t.chatOpen}
|
||||
data-testid="chat-pill"
|
||||
>
|
||||
{t.chatTitle}
|
||||
<span aria-hidden="true">💬</span>
|
||||
<span class="sr-only">{t.chatTitle}</span>
|
||||
{#if chatUnread > 0}
|
||||
<span class="pill-badge">{fill(t.chatUnread, { n: chatUnread })}</span>
|
||||
<span class="pill-badge">{chatUnread}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -160,54 +171,70 @@
|
||||
<span class="label">{t.currentSyllable}</span>
|
||||
<strong data-testid="current-syllable">{game.state.currentSyllable || '…'}</strong>
|
||||
</p>
|
||||
<!-- The room code (or, in a bot game, the difficulty): a room fact
|
||||
rather than a turn fact, so it sits under the prompt it used to
|
||||
crowd in the header rather than beside the connection badge. -->
|
||||
{#if modeLabel}<p class="mode">{modeLabel}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- A player who has been knocked out watches the rest of it: the chain,
|
||||
the clock and the chat all keep working, and only the one thing
|
||||
they can no longer do goes away. -->
|
||||
they can no longer do goes away. The board said so twice before —
|
||||
this box and a banner above the scoreboard — so the elimination
|
||||
suggestions move in here rather than waiting for the game-over
|
||||
screen, which a four-seat spectator can be minutes away from. -->
|
||||
{#if game.iAmOut}
|
||||
<p class="spectating">{t.spectating}</p>
|
||||
<div class="spectating" role="status" data-testid="eliminated">
|
||||
<p>{t.youAreOut}</p>
|
||||
{#if game.state.elimination?.suggestions.length}
|
||||
<p class="could">
|
||||
{t.suggestionsTitle}: {game.state.elimination.suggestions.join(' · ')}
|
||||
</p>
|
||||
{:else if game.state.elimination}
|
||||
<p class="could">
|
||||
{fill(t.noSuggestions, { syllable: game.state.elimination.syllable })}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<WordInput {onsubmit} {onreportword} />
|
||||
{#if game.state.myTurn}
|
||||
<!-- Next to the input, not down by resign: a dead end is read off
|
||||
the current syllable, which only means something on this
|
||||
player's own turn — unlike resign, there is no "not yet" state
|
||||
worth showing for it off turn. -->
|
||||
<ArmedButton
|
||||
class="claim-dead-end"
|
||||
label={t.claimDeadEnd}
|
||||
confirmLabel={t.claimDeadEndSure}
|
||||
disabled={!canClaimDeadEnd}
|
||||
onconfirm={onclaimdeadend}
|
||||
/>
|
||||
{/if}
|
||||
{#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>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Above the chain, not below it: the chain is the one part of the board
|
||||
that grows, and a button under it walks off the bottom of the screen
|
||||
exactly as the game gets long enough to want to give up on. -->
|
||||
<!-- One row, both controls always mounted: the "Bí từ" button used to
|
||||
mount and unmount with every handover, shifting the input under a
|
||||
player's thumb each turn — the same churn `.resign` was already
|
||||
built to avoid. Disabled off-turn instead, which keeps the row's
|
||||
height constant. -->
|
||||
{#if game.state.phase === 'playing' && !game.iAmOut}
|
||||
<ArmedButton
|
||||
class="resign"
|
||||
label={t.resign}
|
||||
confirmLabel={t.resignSure}
|
||||
disabled={!canResign}
|
||||
onconfirm={onresign}
|
||||
/>
|
||||
<div class="secondary">
|
||||
<ArmedButton
|
||||
class="claim-dead-end"
|
||||
label={t.claimDeadEnd}
|
||||
confirmLabel={t.claimDeadEndSure}
|
||||
disabled={!canClaimDeadEnd}
|
||||
onconfirm={onclaimdeadend}
|
||||
/>
|
||||
<ArmedButton
|
||||
class="resign"
|
||||
label={t.resign}
|
||||
confirmLabel={t.resignSure}
|
||||
disabled={!canResign}
|
||||
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>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<ChainHistory />
|
||||
@@ -239,20 +266,23 @@
|
||||
}
|
||||
|
||||
.mode {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-4);
|
||||
font-size: var(--text-1);
|
||||
}
|
||||
|
||||
.chat-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: var(--space-1);
|
||||
min-height: 32px;
|
||||
min-width: 44px;
|
||||
justify-content: center;
|
||||
padding: 4px var(--space-3);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-alt);
|
||||
font-size: var(--text-3);
|
||||
font-size: var(--text-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -278,14 +308,14 @@
|
||||
.who {
|
||||
margin: 0 0 var(--space-1);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-4);
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
/* The player's own turn, said loudly enough to catch the eye that is in the
|
||||
chat column beside the board. */
|
||||
.who.mine {
|
||||
color: var(--text);
|
||||
font-size: var(--text-6);
|
||||
font-size: var(--text-3);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -298,13 +328,13 @@
|
||||
.syllable .label {
|
||||
margin-bottom: 2px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-2);
|
||||
font-size: var(--text-1);
|
||||
}
|
||||
|
||||
/* The one glyph read every single turn, so it gets the headroom: a stacked
|
||||
Vietnamese tone mark on ệ or ộ rides into the label above it at 1.2. */
|
||||
.syllable strong {
|
||||
font-size: 1.6rem;
|
||||
font-size: var(--text-7);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@@ -315,9 +345,9 @@
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: 10px var(--space-3);
|
||||
padding: var(--space-3) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-5);
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
.error {
|
||||
@@ -339,11 +369,14 @@
|
||||
margin: -8px 0;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: var(--text-4);
|
||||
font-size: var(--text-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.spectating {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: var(--space-3);
|
||||
border: 1px dashed var(--border);
|
||||
@@ -352,15 +385,32 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spectating p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* The one thing worth reading in this box once the news itself has sunk
|
||||
in: what would have gotten this player out of the position that beat
|
||||
them. */
|
||||
.spectating .could {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Both controls on one row now, so alignment comes from the row rather
|
||||
than from each button placing itself at an end of the column. */
|
||||
.secondary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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. */
|
||||
|
||||
/* Right of the board and away from the input: giving up is the one thing
|
||||
here nobody should hit by accident while typing. Danger coloured because
|
||||
it ends the game, subordinate because it is not the way to play it. */
|
||||
/* Danger coloured because it ends the game, subordinate because it is not
|
||||
the way to play it. */
|
||||
:global(.resign) {
|
||||
align-self: flex-end;
|
||||
/* 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
|
||||
@@ -372,7 +422,7 @@
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
font-size: var(--text-3);
|
||||
font-size: var(--text-2);
|
||||
transition: background-color 150ms ease-out;
|
||||
}
|
||||
|
||||
@@ -395,19 +445,16 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Beside the input rather than down with resign: a dead-end claim is about
|
||||
the syllable on screen right now, so it reads as part of answering it
|
||||
rather than as a way out of the game. Secondary weight either way — it
|
||||
is not the way to play a turn, just a shortcut past an empty one. */
|
||||
/* 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) {
|
||||
align-self: flex-start;
|
||||
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(--text);
|
||||
font-size: var(--text-3);
|
||||
font-size: var(--text-2);
|
||||
transition: background-color 150ms ease-out;
|
||||
}
|
||||
|
||||
@@ -432,10 +479,10 @@
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: 10px var(--space-3);
|
||||
padding: var(--space-3) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
font-size: var(--text-5);
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
</style>
|
||||
@@ -106,8 +106,12 @@
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- The syllable this player was actually stuck on, not
|
||||
whatever the game has moved on to since: by the time a
|
||||
four-seat game ends, currentSyllable belongs to whoever
|
||||
is playing now. -->
|
||||
<p class="dead-end">
|
||||
{fill(t.noSuggestions, { syllable: game.state.currentSyllable })}
|
||||
{fill(t.noSuggestions, { syllable: elimination.syllable })}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -146,7 +150,7 @@
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: var(--text-7);
|
||||
font-size: var(--text-4);
|
||||
}
|
||||
|
||||
h2.won {
|
||||
@@ -171,9 +175,9 @@
|
||||
.standings li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--text-5);
|
||||
font-size: var(--text-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-alt);
|
||||
@@ -202,7 +206,7 @@
|
||||
.points {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-4);
|
||||
font-size: var(--text-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -226,12 +230,12 @@
|
||||
|
||||
dt {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-4);
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: var(--text-5);
|
||||
font-size: var(--text-2);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -243,13 +247,13 @@
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 6px var(--space-2);
|
||||
gap: var(--space-2) var(--space-2);
|
||||
}
|
||||
|
||||
.suggestions h3 {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-3);
|
||||
font-size: var(--text-2);
|
||||
font-weight: 600;
|
||||
/* Uppercase Vietnamese stacks a tone mark above a capital. */
|
||||
line-height: 1.6;
|
||||
@@ -261,7 +265,7 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
@@ -282,7 +286,7 @@
|
||||
|
||||
.record {
|
||||
margin: 0;
|
||||
padding: 6px var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
@@ -312,12 +316,21 @@
|
||||
border-color: transparent;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
transition: background-color 150ms ease-out;
|
||||
}
|
||||
|
||||
.actions .primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.actions .primary:active {
|
||||
background: var(--accent-pressed);
|
||||
}
|
||||
|
||||
/* Keeping the chain is worth offering and not worth pressing first. */
|
||||
.actions .export {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-5);
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
</style>
|
||||
@@ -59,11 +59,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if game.iAmOut}
|
||||
<!-- The game carries on without this player, and saying so is the whole
|
||||
difference between being knocked out and being disconnected. -->
|
||||
<p class="banner gone" role="status" data-testid="eliminated">{t.youAreOut}</p>
|
||||
{:else if game.state.lastOut && !game.state.lastOut.isMe && game.state.phase === 'playing'}
|
||||
{#if game.state.lastOut && !game.state.lastOut.isMe && game.state.phase === 'playing'}
|
||||
<p class="banner" role="status" data-testid="player-out">{lastOutLabel}</p>
|
||||
{/if}
|
||||
|
||||
@@ -81,16 +77,11 @@
|
||||
<style>
|
||||
.banner {
|
||||
margin: 0;
|
||||
padding: 10px var(--space-3);
|
||||
padding: var(--space-3) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-alt);
|
||||
color: var(--warn);
|
||||
font-size: var(--text-5);
|
||||
font-size: var(--text-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.banner.gone {
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
</style>
|
||||
@@ -169,7 +169,10 @@ export function applyTo(state, msg, { reset, leave }) {
|
||||
playerId: value.playerId,
|
||||
name: value.name,
|
||||
reason: value.reason,
|
||||
suggestions: value.suggestions ?? []
|
||||
suggestions: value.suggestions ?? [],
|
||||
// Captured now: the game keeps running without this
|
||||
// player, and currentSyllable moves on with it.
|
||||
syllable: state.currentSyllable
|
||||
};
|
||||
}
|
||||
break;
|
||||
@@ -230,10 +233,11 @@ export function applyTo(state, msg, { reset, leave }) {
|
||||
// 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 is answered next to the input, not in
|
||||
// the top banner: it is about the move just attempted, not a
|
||||
// room-wide condition every screen has to show.
|
||||
if (value.code === 'not_a_dead_end') {
|
||||
// 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);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
* @property {number} rank - final placing, 1 for the winner; 0 while in play
|
||||
*/
|
||||
|
||||
/** @typedef {{ playerId: string, name: string, reason: number, suggestions: string[] }} Elimination */
|
||||
/** @typedef {{ playerId: string, name: string, reason: number, suggestions: string[], syllable: string }} Elimination */
|
||||
/** @typedef {{ playerId: string, name: string, isMe: boolean, reason: number }} LastOut */
|
||||
/** @typedef {{ n: number, fromMe: boolean, playerId: string, author: string, text: string, atMs: number }} ChatLine */
|
||||
/** @typedef {{ word: string, message: string, reason: number, suggestion: string }} Rejection */
|
||||
@@ -110,7 +110,10 @@
|
||||
* player left standing, then the rest in reverse order of elimination.
|
||||
* @property {Elimination | null} elimination - This player's own knockout,
|
||||
* and nobody else's. Empty `suggestions` means it was a dead end, which is
|
||||
* a different thing to say than "here is what you missed".
|
||||
* a different thing to say than "here is what you missed". `syllable` is
|
||||
* the one this player was stuck on, captured at the moment of knockout —
|
||||
* `currentSyllable` moves on with the game that keeps running without
|
||||
* them, so reading it later would name somebody else's syllable.
|
||||
* @property {LastOut | null} lastOut - The last player to go out, whoever
|
||||
* they were: what a spectator is shown. The client's own knockout is
|
||||
* `elimination` above.
|
||||
|
||||
@@ -101,3 +101,56 @@ describe('the chat pill', () => {
|
||||
unmount(component);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the persistent claim/resign row', () => {
|
||||
// "Bí từ" used to mount only on the player's own turn, shifting everything
|
||||
// under the input each handover. Both controls now stay mounted for the
|
||||
// whole game and are disabled off-turn instead, so the row's height never
|
||||
// changes turn to turn.
|
||||
it('keeps both buttons mounted, enabled, on the player\'s own turn', () => {
|
||||
const { target, component } = renderGameBoard();
|
||||
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
const claim = target.querySelector('.claim-dead-end');
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
const resign = target.querySelector('.resign');
|
||||
|
||||
expect(claim).not.toBeNull();
|
||||
expect(resign).not.toBeNull();
|
||||
expect(claim?.disabled).toBe(false);
|
||||
expect(resign?.disabled).toBe(false);
|
||||
unmount(component);
|
||||
});
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
flushSync();
|
||||
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
const claim = target.querySelector('.claim-dead-end');
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
const resign = target.querySelector('.resign');
|
||||
|
||||
expect(claim).not.toBeNull();
|
||||
expect(resign).not.toBeNull();
|
||||
expect(claim?.disabled).toBe(true);
|
||||
expect(resign?.disabled).toBe(true);
|
||||
unmount(component);
|
||||
});
|
||||
});
|
||||
@@ -404,6 +404,38 @@ describe('playerEliminated', () => {
|
||||
expect(store.state.myTurn).toBe(false);
|
||||
});
|
||||
|
||||
it('captures the syllable this player was stuck on, not whatever the game moves on to', () => {
|
||||
// currentSyllable keeps changing for the players still in the game;
|
||||
// the elimination has to freeze its own copy at the moment it happens
|
||||
// or a later reader (the game-over screen, in a four-seat room) would
|
||||
// name somebody else's syllable.
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(
|
||||
msg('playerEliminated', {
|
||||
playerId: 'p1',
|
||||
isMe: true,
|
||||
reason: GameEndReason.NO_LEGAL_MOVE
|
||||
})
|
||||
);
|
||||
expect(store.state.elimination?.syllable).toBe(store.state.currentSyllable);
|
||||
|
||||
const stuckOn = store.state.currentSyllable;
|
||||
store.apply(
|
||||
msg('turnUpdate', {
|
||||
currentSyllable: 'khác',
|
||||
myTurn: false,
|
||||
turnSeq: 2,
|
||||
chainLength: 1,
|
||||
players: table(),
|
||||
turnPlayerId: 'p2'
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.state.currentSyllable).toBe('khác');
|
||||
expect(store.state.elimination?.syllable).toBe(stuckOn);
|
||||
});
|
||||
|
||||
it('reads an absent list as a position that had nothing left', () => {
|
||||
// A dead end arrives as an empty list, which means "no words to offer"
|
||||
// rather than undefined.
|
||||
@@ -933,3 +965,19 @@ describe('dead-end claim', () => {
|
||||
expect(store.state.claimError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a resign or claim that raced the turn moving on', () => {
|
||||
// The server can only answer resign or claim-dead-end with not_your_turn
|
||||
// when the turn already left before the request arrived — both are
|
||||
// gated on the player's own turn client-side. That is the same kind of
|
||||
// news as a false dead-end claim: about the move just attempted, not the
|
||||
// room, so it answers beside the button rather than in the top banner.
|
||||
it('answers inline rather than in the general error banner', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(msg('error', { code: 'not_your_turn', message: '' }));
|
||||
|
||||
expect(store.state.claimError).not.toBeNull();
|
||||
expect(store.state.error).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user