Stage 7 polish: game rework + board zoom + tests (Parts D/E/F/I)
- Board: fixed-viewport transform-scale zoom (animated) with counter-scaled cqw labels, corner letters, bonus-label modes (boardlabels), contrasting grid lines
- Game: Screen shell + game tab-bar (Draw/Skip/Hint/Shuffle) via HoldConfirm popovers; MakeMove 🏁 + compact popup; rack collapses used slots; hint places tiles on board (placementFromHint) + no_hint_available toast; Scores:N replaces Hints; history slide-down (swipe/click, scroll-locked); check-word alphabet/length limit + in-memory cache + 5s throttle
- backend: no_hint_available result code split + test
- vitest: banner rotator + linkify, resultBadge, boardlabels, placementFromHint (29 tests); Playwright smoke updated; prod bundle ~74 KB gzip
This commit is contained in:
@@ -51,6 +51,8 @@ func TestStatusForError(t *testing.T) {
|
|||||||
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
|
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
|
||||||
"session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"},
|
"session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"},
|
||||||
"chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"},
|
"chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"},
|
||||||
|
"no hint move": {game.ErrNoHintAvailable, http.StatusConflict, "no_hint_available"},
|
||||||
|
"no hints left": {game.ErrNoHintsLeft, http.StatusConflict, "hint_unavailable"},
|
||||||
"unknown -> 500": {context_deadline, http.StatusInternalServerError, "internal"},
|
"unknown -> 500": {context_deadline, http.StatusInternalServerError, "internal"},
|
||||||
}
|
}
|
||||||
for name, tc := range cases {
|
for name, tc := range cases {
|
||||||
|
|||||||
@@ -121,7 +121,11 @@ func statusForError(err error) (int, string) {
|
|||||||
return http.StatusConflict, "already_queued"
|
return http.StatusConflict, "already_queued"
|
||||||
case errors.Is(err, game.ErrInvalidConfig):
|
case errors.Is(err, game.ErrInvalidConfig):
|
||||||
return http.StatusBadRequest, "invalid_config"
|
return http.StatusBadRequest, "invalid_config"
|
||||||
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft), errors.Is(err, game.ErrNoHintAvailable):
|
case errors.Is(err, game.ErrNoHintAvailable):
|
||||||
|
// No legal move for the rack — distinct from a budget/disabled hint so the UI
|
||||||
|
// can say "no options" (and the service spends nothing in this case).
|
||||||
|
return http.StatusConflict, "no_hint_available"
|
||||||
|
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft):
|
||||||
return http.StatusConflict, "hint_unavailable"
|
return http.StatusConflict, "hint_unavailable"
|
||||||
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
|
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
|
||||||
return http.StatusUnprocessableEntity, "illegal_play"
|
return http.StatusUnprocessableEntity, "illegal_play"
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ test('guest reaches a board and previews a placement', async ({ page }) => {
|
|||||||
await rackTile.click();
|
await rackTile.click();
|
||||||
await page.locator('[data-cell]:not(.filled)').nth(30).click();
|
await page.locator('[data-cell]:not(.filled)').nth(30).click();
|
||||||
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
|
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
|
||||||
await expect(page.locator('.preview')).toContainText(/\d/);
|
// The score preview appears where the hints count used to be.
|
||||||
|
await expect(page.locator('.scores')).toContainText(/\d/);
|
||||||
|
|
||||||
// The contextual MakeMove control appears once a tile is pending.
|
// The contextual MakeMove control (🏁) appears once a tile is pending.
|
||||||
await expect(page.getByRole('button', { name: /make move/i })).toBeVisible();
|
await expect(page.locator('.make')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-2
@@ -23,7 +23,7 @@
|
|||||||
/* board + tiles (all drawn with CSS primitives) */
|
/* board + tiles (all drawn with CSS primitives) */
|
||||||
--board-bg: #cdd6cf;
|
--board-bg: #cdd6cf;
|
||||||
--cell-bg: #e7ece8;
|
--cell-bg: #e7ece8;
|
||||||
--cell-line: #b6c0b8;
|
--cell-line: #7f8d83;
|
||||||
--tile-bg: #f4e2b8;
|
--tile-bg: #f4e2b8;
|
||||||
--tile-edge: #d8c190;
|
--tile-edge: #d8c190;
|
||||||
--tile-text: #2a2113;
|
--tile-text: #2a2113;
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
|
|
||||||
--board-bg: #2a3330;
|
--board-bg: #2a3330;
|
||||||
--cell-bg: #222a27;
|
--cell-bg: #222a27;
|
||||||
--cell-line: #38433d;
|
--cell-line: #56655c;
|
||||||
--tile-bg: #d9c79a;
|
--tile-bg: #d9c79a;
|
||||||
--tile-edge: #b6a473;
|
--tile-edge: #b6a473;
|
||||||
--tile-text: #20190d;
|
--tile-text: #20190d;
|
||||||
@@ -136,6 +136,8 @@ button {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reduce-motion * {
|
.reduce-motion * {
|
||||||
|
|||||||
+98
-42
@@ -3,6 +3,8 @@
|
|||||||
import type { Premium } from '../lib/premiums';
|
import type { Premium } from '../lib/premiums';
|
||||||
import { tileValue } from '../lib/premiums';
|
import { tileValue } from '../lib/premiums';
|
||||||
import type { Variant } from '../lib/model';
|
import type { Variant } from '../lib/model';
|
||||||
|
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
|
||||||
|
import type { Locale } from '../lib/i18n/catalog';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
board,
|
board,
|
||||||
@@ -12,6 +14,9 @@
|
|||||||
centre,
|
centre,
|
||||||
zoomed,
|
zoomed,
|
||||||
variant,
|
variant,
|
||||||
|
labelMode,
|
||||||
|
locale,
|
||||||
|
focus,
|
||||||
oncell,
|
oncell,
|
||||||
ontogglezoom,
|
ontogglezoom,
|
||||||
}: {
|
}: {
|
||||||
@@ -22,18 +27,30 @@
|
|||||||
centre: { row: number; col: number };
|
centre: { row: number; col: number };
|
||||||
zoomed: boolean;
|
zoomed: boolean;
|
||||||
variant: Variant;
|
variant: Variant;
|
||||||
|
labelMode: BoardLabelMode;
|
||||||
|
locale: Locale;
|
||||||
|
focus: { row: number; col: number } | null;
|
||||||
oncell: (row: number, col: number) => void;
|
oncell: (row: number, col: number) => void;
|
||||||
ontogglezoom: () => void;
|
ontogglezoom: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const premClass: Record<Premium, string> = {
|
const Z = 1.85;
|
||||||
'': '',
|
const z = $derived(zoomed ? Z : 1);
|
||||||
TW: 'tw',
|
const premClass: Record<Premium, string> = { '': '', TW: 'tw', DW: 'dw', TL: 'tl', DL: 'dl' };
|
||||||
DW: 'dw',
|
|
||||||
TL: 'tl',
|
let viewport = $state<HTMLElement>();
|
||||||
DL: 'dl',
|
|
||||||
};
|
// When zoomed in (typically on a placement), centre the focus cell.
|
||||||
const premLabel: Record<Premium, string> = { '': '', TW: '3W', DW: '2W', TL: '3L', DL: '2L' };
|
$effect(() => {
|
||||||
|
const vp = viewport;
|
||||||
|
if (!vp || !zoomed || !focus) return;
|
||||||
|
const cell = vp.clientWidth / 15;
|
||||||
|
vp.scrollTo({
|
||||||
|
left: (focus.col + 0.5) * cell * Z - vp.clientWidth / 2,
|
||||||
|
top: (focus.row + 0.5) * cell * Z - vp.clientHeight / 2,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Double-tap toggles zoom.
|
// Double-tap toggles zoom.
|
||||||
let lastTap = 0;
|
let lastTap = 0;
|
||||||
@@ -48,13 +65,12 @@
|
|||||||
oncell(row, col);
|
oncell(row, col);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimal pinch: two pointers spreading apart zoom in, pinching together zoom out.
|
// Minimal pinch: spread zooms in, pinch zooms out (two-state).
|
||||||
const pts = new Map<number, { x: number; y: number }>();
|
const pts = new Map<number, { x: number; y: number }>();
|
||||||
let startDist = 0;
|
let startDist = 0;
|
||||||
function dist(): number {
|
function dist(): number {
|
||||||
const p = [...pts.values()];
|
const p = [...pts.values()];
|
||||||
if (p.length < 2) return 0;
|
return p.length < 2 ? 0 : Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
|
||||||
return Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
|
|
||||||
}
|
}
|
||||||
function onPointerDown(e: PointerEvent) {
|
function onPointerDown(e: PointerEvent) {
|
||||||
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
@@ -79,26 +95,27 @@
|
|||||||
if (pts.size < 2) startDist = 0;
|
if (pts.size < 2) startDist = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function key(r: number, c: number): string {
|
const key = (r: number, c: number) => `${r},${c}`;
|
||||||
return `${r},${c}`;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="viewport"
|
class="viewport"
|
||||||
class:zoomed
|
class:zoomed
|
||||||
|
bind:this={viewport}
|
||||||
onpointerdown={onPointerDown}
|
onpointerdown={onPointerDown}
|
||||||
onpointermove={onPointerMove}
|
onpointermove={onPointerMove}
|
||||||
onpointerup={onPointerUp}
|
onpointerup={onPointerUp}
|
||||||
onpointercancel={onPointerUp}
|
onpointercancel={onPointerUp}
|
||||||
>
|
>
|
||||||
<div class="grid" class:zoomed>
|
<div class="scaler" style="transform: scale({z}); --inv: {1 / z};">
|
||||||
|
<div class="grid">
|
||||||
{#each board as rowCells, r (r)}
|
{#each board as rowCells, r (r)}
|
||||||
{#each rowCells as cell, c (c)}
|
{#each rowCells as cell, c (c)}
|
||||||
{@const p = pending.get(key(r, c))}
|
{@const p = pending.get(key(r, c))}
|
||||||
{@const letter = cell?.letter ?? p?.letter ?? ''}
|
{@const letter = cell?.letter ?? p?.letter ?? ''}
|
||||||
{@const blank = cell?.blank ?? p?.blank ?? false}
|
{@const blank = cell?.blank ?? p?.blank ?? false}
|
||||||
|
{@const bl = letter ? null : bonusLabel(labelMode, premium[r][c], locale)}
|
||||||
<button
|
<button
|
||||||
class="cell {premClass[premium[r][c]]}"
|
class="cell {premClass[premium[r][c]]}"
|
||||||
class:filled={!!cell}
|
class:filled={!!cell}
|
||||||
@@ -109,53 +126,57 @@
|
|||||||
data-col={c}
|
data-col={c}
|
||||||
onclick={() => onTap(r, c)}
|
onclick={() => onTap(r, c)}
|
||||||
>
|
>
|
||||||
|
<span class="ct">
|
||||||
{#if letter}
|
{#if letter}
|
||||||
<span class="letter">{letter}</span>
|
<span class="letter">{letter}</span>
|
||||||
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
|
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
|
||||||
{:else if r === centre.row && c === centre.col}
|
{:else if r === centre.row && c === centre.col}
|
||||||
<span class="star">★</span>
|
<span class="star">★</span>
|
||||||
{:else if premLabel[premium[r][c]]}
|
{:else if bl?.kind === 'single'}
|
||||||
<span class="plabel">{premLabel[premium[r][c]]}</span>
|
<span class="b1">{bl.text}</span>
|
||||||
|
{:else if bl?.kind === 'split'}
|
||||||
|
<span class="bsplit"><span class="bt">{bl.top}</span><span class="bb">{bl.bottom}</span></span>
|
||||||
{/if}
|
{/if}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.viewport {
|
.viewport {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
background: var(--board-bg);
|
background: var(--board-bg);
|
||||||
padding: 4px;
|
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
|
container-type: inline-size;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
.viewport.zoomed {
|
.viewport.zoomed {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
max-height: 70vh;
|
}
|
||||||
|
.scaler {
|
||||||
|
width: 100%;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
transition: transform 0.25s ease;
|
||||||
}
|
}
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(15, 1fr);
|
grid-template-columns: repeat(15, 1fr);
|
||||||
gap: 2px;
|
gap: 1px;
|
||||||
width: 100%;
|
background: var(--cell-line);
|
||||||
}
|
padding: 1px;
|
||||||
.grid.zoomed {
|
|
||||||
grid-template-columns: repeat(15, 2.6rem);
|
|
||||||
width: max-content;
|
|
||||||
}
|
}
|
||||||
.cell {
|
.cell {
|
||||||
position: relative;
|
position: relative;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 2px;
|
border-radius: 1px;
|
||||||
background: var(--cell-bg);
|
background: var(--cell-bg);
|
||||||
color: var(--prem-text);
|
color: var(--prem-text);
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 0.62rem;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -187,27 +208,62 @@
|
|||||||
inset 0 -2px 0 var(--tile-edge),
|
inset 0 -2px 0 var(--tile-edge),
|
||||||
0 0 0 2px var(--warn);
|
0 0 0 2px var(--warn);
|
||||||
}
|
}
|
||||||
|
/* Counter-scaled text layer: stays constant size as the board scales. */
|
||||||
|
.ct {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
transform: scale(var(--inv));
|
||||||
|
transform-origin: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.letter {
|
.letter {
|
||||||
font-size: 1.05em;
|
position: absolute;
|
||||||
|
top: 6%;
|
||||||
|
left: 9%;
|
||||||
|
font-size: 4.3cqw;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.grid:not(.zoomed) .letter {
|
|
||||||
font-size: 2.6vw;
|
|
||||||
}
|
|
||||||
.val {
|
.val {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 1px;
|
right: 5%;
|
||||||
bottom: 0;
|
bottom: 2%;
|
||||||
font-size: 0.55em;
|
font-size: 2.5cqw;
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.plabel {
|
|
||||||
opacity: 0.85;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.star {
|
.star {
|
||||||
font-size: 1.1em;
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 4cqw;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
.b1 {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 3cqw;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.bsplit {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
.bt {
|
||||||
|
font-size: 2.4cqw;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.bb {
|
||||||
|
font-size: 3.1cqw;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+259
-152
@@ -1,14 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import Header from '../components/Header.svelte';
|
import Screen from '../components/Screen.svelte';
|
||||||
|
import Menu from '../components/Menu.svelte';
|
||||||
|
import TabBar from '../components/TabBar.svelte';
|
||||||
|
import HoldConfirm from '../components/HoldConfirm.svelte';
|
||||||
import Modal from '../components/Modal.svelte';
|
import Modal from '../components/Modal.svelte';
|
||||||
import Board from './Board.svelte';
|
import Board from './Board.svelte';
|
||||||
import Rack from './Rack.svelte';
|
import Rack from './Rack.svelte';
|
||||||
import MakeMove from './MakeMove.svelte';
|
|
||||||
import Controls from './Controls.svelte';
|
|
||||||
import Chat from './Chat.svelte';
|
import Chat from './Chat.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
import { app, handleError, showToast } from '../lib/app.svelte';
|
||||||
|
import { GatewayError } from '../lib/client';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
|
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
|
||||||
import { lastPlayTiles, replay } from '../lib/board';
|
import { lastPlayTiles, replay } from '../lib/board';
|
||||||
@@ -18,6 +20,7 @@
|
|||||||
direction,
|
direction,
|
||||||
newPlacement,
|
newPlacement,
|
||||||
place,
|
place,
|
||||||
|
placementFromHint,
|
||||||
rackView,
|
rackView,
|
||||||
recallAt,
|
recallAt,
|
||||||
reset,
|
reset,
|
||||||
@@ -35,8 +38,9 @@
|
|||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let zoomed = $state(false);
|
let zoomed = $state(false);
|
||||||
let selected = $state<number | null>(null);
|
let selected = $state<number | null>(null);
|
||||||
let panel = $state<'none' | 'chat' | 'history'>('none');
|
let focus = $state<{ row: number; col: number } | null>(null);
|
||||||
let menuOpen = $state(false);
|
let panel = $state<'none' | 'chat'>('none');
|
||||||
|
let historyOpen = $state(false);
|
||||||
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
|
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
|
||||||
let exchangeOpen = $state(false);
|
let exchangeOpen = $state(false);
|
||||||
let exchangeSel = $state<number[]>([]);
|
let exchangeSel = $state<number[]>([]);
|
||||||
@@ -47,6 +51,9 @@
|
|||||||
let messages = $state<ChatMessage[]>([]);
|
let messages = $state<ChatMessage[]>([]);
|
||||||
let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null);
|
let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
const checkedWords = new Map<string, boolean>();
|
||||||
|
let lastCheckAt = 0;
|
||||||
|
|
||||||
const variant = $derived(view?.game.variant ?? 'english');
|
const variant = $derived(view?.game.variant ?? 'english');
|
||||||
const board = $derived(replay(moves));
|
const board = $derived(replay(moves));
|
||||||
const premium = $derived(premiumGrid(variant));
|
const premium = $derived(premiumGrid(variant));
|
||||||
@@ -56,12 +63,11 @@
|
|||||||
);
|
);
|
||||||
const recent = $derived(new Set(lastPlayTiles(moves).map((tt) => `${tt.row},${tt.col}`)));
|
const recent = $derived(new Set(lastPlayTiles(moves).map((tt) => `${tt.row},${tt.col}`)));
|
||||||
const slots = $derived(rackView(placement));
|
const slots = $derived(rackView(placement));
|
||||||
const isMyTurn = $derived(
|
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
|
||||||
!!view && view.game.status === 'active' && view.game.toMove === view.seat,
|
|
||||||
);
|
|
||||||
const gameOver = $derived(!!view && view.game.status !== 'active');
|
const gameOver = $derived(!!view && view.game.status !== 'active');
|
||||||
const dir = $derived(dirOverride ?? direction(placement) ?? 'H');
|
const dir = $derived(dirOverride ?? direction(placement) ?? 'H');
|
||||||
const ambiguous = $derived(placement.pending.length === 1);
|
const ambiguous = $derived(placement.pending.length === 1);
|
||||||
|
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
@@ -76,7 +82,6 @@
|
|||||||
handleError(e);
|
handleError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadChat() {
|
async function loadChat() {
|
||||||
try {
|
try {
|
||||||
messages = await gateway.chatList(id);
|
messages = await gateway.chatList(id);
|
||||||
@@ -84,7 +89,6 @@
|
|||||||
handleError(e);
|
handleError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -99,7 +103,7 @@
|
|||||||
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
|
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- tile placement: pointer drag + tap, both feeding the placement model ---
|
// --- tile placement: pointer drag + tap ---
|
||||||
let downInfo: { index: number; x0: number; y0: number } | null = null;
|
let downInfo: { index: number; x0: number; y0: number } | null = null;
|
||||||
let dragMoved = false;
|
let dragMoved = false;
|
||||||
let swallowClick = false;
|
let swallowClick = false;
|
||||||
@@ -117,7 +121,7 @@
|
|||||||
dragMoved = true;
|
dragMoved = true;
|
||||||
const slot = placement.rack[downInfo.index];
|
const slot = placement.rack[downInfo.index];
|
||||||
drag = { letter: slot, blank: slot === BLANK, x: e.clientX, y: e.clientY };
|
drag = { letter: slot, blank: slot === BLANK, x: e.clientX, y: e.clientY };
|
||||||
if (isCoarse() && !zoomed) zoomed = true; // auto zoom-in on touch placement
|
if (isCoarse() && !zoomed) zoomed = true;
|
||||||
}
|
}
|
||||||
if (drag) drag = { ...drag, x: e.clientX, y: e.clientY };
|
if (drag) drag = { ...drag, x: e.clientX, y: e.clientY };
|
||||||
}
|
}
|
||||||
@@ -158,10 +162,11 @@
|
|||||||
selected = null;
|
selected = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function attemptPlace(index: number, row: number, col: number) {
|
function attemptPlace(index: number, row: number, col: number) {
|
||||||
if (board[row]?.[col]) return;
|
if (board[row]?.[col]) return;
|
||||||
if (pendingMap.has(`${row},${col}`)) return;
|
if (pendingMap.has(`${row},${col}`)) return;
|
||||||
|
focus = { row, col };
|
||||||
|
if (isCoarse() && !zoomed) zoomed = true;
|
||||||
if (placement.rack[index] === BLANK) {
|
if (placement.rack[index] === BLANK) {
|
||||||
blankPrompt = { rackIndex: index, row, col };
|
blankPrompt = { rackIndex: index, row, col };
|
||||||
return;
|
return;
|
||||||
@@ -169,7 +174,6 @@
|
|||||||
placement = place(placement, index, row, col);
|
placement = place(placement, index, row, col);
|
||||||
recompute();
|
recompute();
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseBlank(letter: string) {
|
function chooseBlank(letter: string) {
|
||||||
if (!blankPrompt) return;
|
if (!blankPrompt) return;
|
||||||
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
|
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
|
||||||
@@ -187,7 +191,7 @@
|
|||||||
try {
|
try {
|
||||||
preview = await gateway.evaluate(id, sub.dir, sub.tiles);
|
preview = await gateway.evaluate(id, sub.dir, sub.tiles);
|
||||||
} catch {
|
} catch {
|
||||||
/* preview is best-effort */
|
/* best-effort */
|
||||||
}
|
}
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
@@ -198,6 +202,7 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
await gateway.submitPlay(id, sub.dir, sub.tiles);
|
await gateway.submitPlay(id, sub.dir, sub.tiles);
|
||||||
|
zoomed = false;
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
@@ -205,7 +210,6 @@
|
|||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetPlacement() {
|
function resetPlacement() {
|
||||||
placement = reset(placement);
|
placement = reset(placement);
|
||||||
preview = null;
|
preview = null;
|
||||||
@@ -224,7 +228,6 @@
|
|||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doResign() {
|
async function doResign() {
|
||||||
resignOpen = false;
|
resignOpen = false;
|
||||||
busy = true;
|
busy = true;
|
||||||
@@ -237,18 +240,24 @@
|
|||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doHint() {
|
async function doHint() {
|
||||||
try {
|
try {
|
||||||
const h = await gateway.hint(id);
|
const h = await gateway.hint(id);
|
||||||
const word = h.move.words[0] ?? h.move.tiles.map((x) => x.letter).join('');
|
if (h.move.tiles.length && view) {
|
||||||
showToast(t('game.hintShown', { word, n: h.move.score }));
|
placement = placementFromHint(h.move.tiles, view.rack);
|
||||||
if (view) view = { ...view, hintsRemaining: h.hintsRemaining };
|
if (isCoarse()) zoomed = true;
|
||||||
|
view = { ...view, hintsRemaining: h.hintsRemaining };
|
||||||
|
recompute();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// The backend does not spend a hint when there is no move.
|
||||||
|
if (e instanceof GatewayError && e.code === 'no_hint_available') {
|
||||||
|
showToast(t('game.noHintOptions'), 'info');
|
||||||
|
} else {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
function shuffle() {
|
function shuffle() {
|
||||||
if (placement.pending.length > 0) return;
|
if (placement.pending.length > 0) return;
|
||||||
const r = [...placement.rack];
|
const r = [...placement.rack];
|
||||||
@@ -258,14 +267,12 @@
|
|||||||
}
|
}
|
||||||
placement = newPlacement(r);
|
placement = newPlacement(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDir() {
|
function toggleDir() {
|
||||||
dirOverride = dir === 'H' ? 'V' : 'H';
|
dirOverride = dir === 'H' ? 'V' : 'H';
|
||||||
recompute();
|
recompute();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openExchange() {
|
function openExchange() {
|
||||||
menuOpen = false;
|
|
||||||
resetPlacement();
|
resetPlacement();
|
||||||
exchangeSel = [];
|
exchangeSel = [];
|
||||||
exchangeOpen = true;
|
exchangeOpen = true;
|
||||||
@@ -289,16 +296,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCheck() {
|
function openCheck() {
|
||||||
menuOpen = false;
|
|
||||||
checkWord = '';
|
checkWord = '';
|
||||||
checkResult = null;
|
checkResult = null;
|
||||||
checkOpen = true;
|
checkOpen = true;
|
||||||
}
|
}
|
||||||
|
function onCheckInput(e: Event) {
|
||||||
|
const allowed = new Set(alphabet(variant));
|
||||||
|
const raw = (e.target as HTMLInputElement).value.toUpperCase();
|
||||||
|
checkWord = Array.from(raw).filter((ch) => allowed.has(ch)).slice(0, 15).join('');
|
||||||
|
}
|
||||||
async function runCheck() {
|
async function runCheck() {
|
||||||
const w = checkWord.trim();
|
const w = checkWord.trim().toUpperCase();
|
||||||
if (!w) return;
|
if (!w) return;
|
||||||
|
if (checkedWords.has(w)) {
|
||||||
|
checkResult = { word: w, legal: checkedWords.get(w)! };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() - lastCheckAt < 5000) {
|
||||||
|
showToast(t('game.checkWait'), 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastCheckAt = Date.now();
|
||||||
try {
|
try {
|
||||||
checkResult = await gateway.checkWord(id, w);
|
const r = await gateway.checkWord(id, w);
|
||||||
|
checkedWords.set(r.word.toUpperCase(), r.legal);
|
||||||
|
checkResult = r;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
}
|
}
|
||||||
@@ -308,59 +330,65 @@
|
|||||||
try {
|
try {
|
||||||
await gateway.complaint(id, checkResult.word, '');
|
await gateway.complaint(id, checkResult.word, '');
|
||||||
showToast(t('game.complaintSent'));
|
showToast(t('game.complaintSent'));
|
||||||
|
checkOpen = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openChat() {
|
function openChat() {
|
||||||
menuOpen = false;
|
|
||||||
panel = 'chat';
|
panel = 'chat';
|
||||||
void loadChat();
|
void loadChat();
|
||||||
}
|
}
|
||||||
async function sendChat(text: string) {
|
async function sendChat(text: string) {
|
||||||
try {
|
try {
|
||||||
const m = await gateway.chatPost(id, text);
|
messages = [...messages, await gateway.chatPost(id, text)];
|
||||||
messages = [...messages, m];
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function nudge() {
|
async function nudge() {
|
||||||
try {
|
try {
|
||||||
const m = await gateway.nudge(id);
|
messages = [...messages, await gateway.nudge(id)];
|
||||||
messages = [...messages, m];
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// History slide-down: swipe down on the board to open, up / tap to close.
|
||||||
|
let swipeY: number | null = null;
|
||||||
|
function boardSwipeStart(e: PointerEvent) {
|
||||||
|
swipeY = e.clientY;
|
||||||
|
}
|
||||||
|
function boardSwipeEnd(e: PointerEvent) {
|
||||||
|
if (swipeY == null) return;
|
||||||
|
const dy = e.clientY - swipeY;
|
||||||
|
swipeY = null;
|
||||||
|
if (dy > 40) historyOpen = true;
|
||||||
|
else if (dy < -40) historyOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
function resultText(): string {
|
function resultText(): string {
|
||||||
if (!view) return '';
|
if (!view) return '';
|
||||||
const me = view.game.seats[view.seat];
|
const me = view.game.seats[view.seat];
|
||||||
if (me?.isWinner) return t('game.won');
|
if (me?.isWinner) return t('game.won');
|
||||||
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
|
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const menuItems = $derived([
|
||||||
|
{ label: t('game.history'), onclick: () => (historyOpen = true) },
|
||||||
|
{ label: t('game.chat'), onclick: openChat },
|
||||||
|
{ label: t('game.checkWord'), onclick: openCheck },
|
||||||
|
{ label: t('game.dropGame'), onclick: () => (resignOpen = true) },
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Header title={t('app.title')} back="/">
|
<Screen title={t('app.title')} back="/" scroll={!historyOpen}>
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
<button class="icon" onclick={() => (menuOpen = !menuOpen)} aria-label="Menu">≡</button>
|
<Menu items={menuItems} />
|
||||||
{#if menuOpen}
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div class="backdrop" onclick={() => (menuOpen = false)}></div>
|
|
||||||
<div class="dropdown">
|
|
||||||
<button onclick={() => { menuOpen = false; panel = 'history'; }}>{t('game.history')}</button>
|
|
||||||
<button onclick={openChat}>{t('game.chat')}</button>
|
|
||||||
<button onclick={openCheck}>{t('game.checkWord')}</button>
|
|
||||||
<button onclick={() => { menuOpen = false; resignOpen = true; }}>{t('game.dropGame')}</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Header>
|
|
||||||
|
|
||||||
{#if view}
|
{#if view}
|
||||||
<div class="scoreboard">
|
<div class="scoreboard">
|
||||||
{#each view.game.seats as s (s.seat)}
|
{#each view.game.seats as s (s.seat)}
|
||||||
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
||||||
@@ -370,7 +398,31 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="boardwrap">
|
<div class="stage">
|
||||||
|
{#if historyOpen}
|
||||||
|
<div class="history">
|
||||||
|
<ol>
|
||||||
|
{#each moves as m, i (i)}
|
||||||
|
<li>
|
||||||
|
<span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
|
||||||
|
<span class="ha">{m.action === 'play' ? m.words.join(', ') : m.action}</span>
|
||||||
|
<span class="hs">{m.score}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{#if moves.length === 0}<li class="hempty">—</li>{/if}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div
|
||||||
|
class="boardwrap"
|
||||||
|
class:slid={historyOpen}
|
||||||
|
onpointerdown={boardSwipeStart}
|
||||||
|
onpointerup={boardSwipeEnd}
|
||||||
|
onclick={() => historyOpen && (historyOpen = false)}
|
||||||
|
>
|
||||||
<Board
|
<Board
|
||||||
{board}
|
{board}
|
||||||
{premium}
|
{premium}
|
||||||
@@ -379,19 +431,25 @@
|
|||||||
centre={ctr}
|
centre={ctr}
|
||||||
{zoomed}
|
{zoomed}
|
||||||
{variant}
|
{variant}
|
||||||
|
labelMode={app.boardLabels}
|
||||||
|
locale={app.locale}
|
||||||
|
{focus}
|
||||||
oncell={onCell}
|
oncell={onCell}
|
||||||
ontogglezoom={() => (zoomed = !zoomed)}
|
ontogglezoom={() => (zoomed = !zoomed)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="status">
|
<div class="status">
|
||||||
<span>{t('game.bag', { n: view.bagLen })}</span>
|
<span>{t('game.bag', { n: view.bagLen })}</span>
|
||||||
{#if gameOver}
|
{#if gameOver}
|
||||||
<strong class="over">{t('game.over')} — {resultText()}</strong>
|
<strong class="over">{t('game.over')} — {resultText()}</strong>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : t('game.waiting', { name: view.game.seats[view.game.toMove]?.displayName ?? '' })}</span>
|
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span>{t('game.hints', { n: view.hintsRemaining })}</span>
|
<span class="scores">
|
||||||
|
{#if preview}{preview.legal ? t('game.scores', { n: preview.score }) : t('game.previewIllegal')}{/if}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !gameOver}
|
{#if !gameOver}
|
||||||
@@ -400,31 +458,45 @@
|
|||||||
<Rack {slots} {variant} {selected} ondown={onRackDown} />
|
<Rack {slots} {variant} {selected} ondown={onRackDown} />
|
||||||
</div>
|
</div>
|
||||||
{#if placement.pending.length > 0}
|
{#if placement.pending.length > 0}
|
||||||
<MakeMove
|
<HoldConfirm triggerClass="make" onhold={commit}>
|
||||||
label={t('game.makeMove')}
|
{#snippet trigger()}<span class="flag">🏁</span>{/snippet}
|
||||||
resetLabel={t('game.reset')}
|
{#snippet popover(close)}
|
||||||
onmake={commit}
|
<button class="pop go" onclick={() => { close(); commit(); }}>{t('game.makeMove')} ✅</button>
|
||||||
onreset={resetPlacement}
|
<button class="pop rs" onclick={() => { close(); resetPlacement(); }}>{t('game.reset')} ❌</button>
|
||||||
/>
|
{/snippet}
|
||||||
|
</HoldConfirm>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Controls
|
|
||||||
{preview}
|
|
||||||
hints={view.hintsRemaining}
|
|
||||||
busy={busy || !isMyTurn}
|
|
||||||
{ambiguous}
|
|
||||||
{dir}
|
|
||||||
ondraw={openExchange}
|
|
||||||
onskip={doPass}
|
|
||||||
onshuffle={shuffle}
|
|
||||||
onhint={doHint}
|
|
||||||
ondir={toggleDir}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<p class="loading">{t('common.loading')}</p>
|
<p class="loading">{t('common.loading')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#snippet tabbar()}
|
||||||
|
{#if view && !gameOver}
|
||||||
|
<TabBar>
|
||||||
|
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || bagEmpty} onhold={openExchange}>
|
||||||
|
{#snippet trigger()}<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>{/snippet}
|
||||||
|
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); openExchange(); }}>{t('game.confirm')} ✅</button>{/snippet}
|
||||||
|
</HoldConfirm>
|
||||||
|
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doPass}>
|
||||||
|
{#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet}
|
||||||
|
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet}
|
||||||
|
</HoldConfirm>
|
||||||
|
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doHint}>
|
||||||
|
{#snippet trigger()}
|
||||||
|
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
|
||||||
|
<span class="lbl">{t('game.hint')}</span>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doHint(); }}>{t('game.confirm')} ✅</button>{/snippet}
|
||||||
|
</HoldConfirm>
|
||||||
|
<button class="tab" disabled={busy || placement.pending.length > 0} onclick={shuffle}>
|
||||||
|
<span class="sq">🔀</span>
|
||||||
|
</button>
|
||||||
|
</TabBar>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Screen>
|
||||||
|
|
||||||
{#if drag}
|
{#if drag}
|
||||||
<div class="ghost" style="left:{drag.x}px; top:{drag.y}px">
|
<div class="ghost" style="left:{drag.x}px; top:{drag.y}px">
|
||||||
@@ -432,6 +504,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if ambiguous && placement.pending.length > 0}
|
||||||
|
<button class="dirtoggle" onclick={toggleDir} aria-label="direction">{dir === 'H' ? '↔' : '↕'}</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if blankPrompt}
|
{#if blankPrompt}
|
||||||
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
|
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
|
||||||
<div class="alpha">
|
<div class="alpha">
|
||||||
@@ -460,8 +536,13 @@
|
|||||||
{#if checkOpen}
|
{#if checkOpen}
|
||||||
<Modal title={t('game.checkWord')} onclose={() => (checkOpen = false)}>
|
<Modal title={t('game.checkWord')} onclose={() => (checkOpen = false)}>
|
||||||
<div class="check">
|
<div class="check">
|
||||||
<input placeholder={t('game.checkWordPrompt')} bind:value={checkWord} onkeydown={(e) => e.key === 'Enter' && runCheck()} />
|
<input
|
||||||
<button onclick={runCheck}>{t('game.checkWord')}</button>
|
value={checkWord}
|
||||||
|
oninput={onCheckInput}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && runCheck()}
|
||||||
|
placeholder={t('game.checkWordPrompt')}
|
||||||
|
/>
|
||||||
|
<button onclick={runCheck}>{t('game.check')}</button>
|
||||||
</div>
|
</div>
|
||||||
{#if checkResult}
|
{#if checkResult}
|
||||||
<p class:ok={checkResult.legal} class:bad={!checkResult.legal}>
|
<p class:ok={checkResult.legal} class:bad={!checkResult.legal}>
|
||||||
@@ -489,27 +570,12 @@
|
|||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if panel === 'history' && view}
|
|
||||||
<Modal title={t('game.history')} onclose={() => (panel = 'none')}>
|
|
||||||
<ol class="history">
|
|
||||||
{#each moves as m, i (i)}
|
|
||||||
<li>
|
|
||||||
<span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
|
|
||||||
<span class="ha">{m.action === 'play' ? m.words.join(', ') : m.action}</span>
|
|
||||||
<span class="hs">{m.score}</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ol>
|
|
||||||
</Modal>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.scoreboard {
|
.scoreboard {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 6px var(--pad);
|
padding: 6px var(--pad);
|
||||||
background: var(--bg-elev);
|
background: var(--bg-elev);
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
}
|
||||||
.seat {
|
.seat {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -535,14 +601,61 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
.stage {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.history {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto 0;
|
||||||
|
z-index: 2;
|
||||||
|
max-height: 60%;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--surface-2);
|
||||||
|
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.history ol {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 14px;
|
||||||
|
list-style: decimal;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.history li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.hp {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.ha {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.hs {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.hempty {
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
.boardwrap {
|
.boardwrap {
|
||||||
padding: 8px;
|
padding: 6px;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
.boardwrap.slid {
|
||||||
|
transform: translateY(62%);
|
||||||
}
|
}
|
||||||
.status {
|
.status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 var(--pad) 8px;
|
padding: 2px var(--pad) 6px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
@@ -553,60 +666,62 @@
|
|||||||
.over {
|
.over {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.scores {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ok);
|
||||||
|
min-width: 64px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
.rack-row {
|
.rack-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
padding: 0 var(--pad);
|
padding: 0 var(--pad) 6px;
|
||||||
}
|
}
|
||||||
.rack-wrap {
|
.rack-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
:global(.rack-row .wrap) {
|
.flag {
|
||||||
display: flex;
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
:global(.make) {
|
||||||
|
min-width: 56px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.pop {
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.pop.go {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 4px;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
.loading {
|
.loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
}
|
}
|
||||||
.icon {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 1.3rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 8;
|
|
||||||
}
|
|
||||||
.dropdown {
|
|
||||||
position: absolute;
|
|
||||||
right: 8px;
|
|
||||||
top: 44px;
|
|
||||||
z-index: 9;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 160px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.dropdown button {
|
|
||||||
padding: 11px 14px;
|
|
||||||
text-align: left;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.dropdown button:hover {
|
|
||||||
background: var(--surface-2);
|
|
||||||
}
|
|
||||||
.ghost {
|
.ghost {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
@@ -623,6 +738,20 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
}
|
}
|
||||||
|
.dirtoggle {
|
||||||
|
position: fixed;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 84px;
|
||||||
|
z-index: 30;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
.alpha {
|
.alpha {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, 1fr);
|
grid-template-columns: repeat(6, 1fr);
|
||||||
@@ -677,6 +806,7 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.check button {
|
.check button {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -715,27 +845,4 @@
|
|||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
border-color: var(--danger) !important;
|
border-color: var(--danger) !important;
|
||||||
}
|
}
|
||||||
.history {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.history li {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.hp {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.ha {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.hs {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+18
-17
@@ -15,15 +15,16 @@
|
|||||||
selected: number | null;
|
selected: number | null;
|
||||||
ondown: (e: PointerEvent, index: number) => void;
|
ondown: (e: PointerEvent, index: number) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
// Used slots are hidden (the rack shifts left, freeing room on the right for the
|
||||||
|
// MakeMove control); the slot still exists in the model for per-tile recall.
|
||||||
|
const visible = $derived(slots.filter((s) => !s.used));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rack">
|
<div class="rack">
|
||||||
{#each slots as slot (slot.index)}
|
{#each visible as slot (slot.index)}
|
||||||
{#if slot.used}
|
|
||||||
<span class="slot empty"></span>
|
|
||||||
{:else}
|
|
||||||
<button
|
<button
|
||||||
class="slot tile"
|
class="tile"
|
||||||
class:selected={selected === slot.index}
|
class:selected={selected === slot.index}
|
||||||
data-rack-index={slot.index}
|
data-rack-index={slot.index}
|
||||||
onpointerdown={(e) => ondown(e, slot.index)}
|
onpointerdown={(e) => ondown(e, slot.index)}
|
||||||
@@ -31,29 +32,24 @@
|
|||||||
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
|
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
|
||||||
{#if slot.letter !== BLANK}<span class="val">{tileValue(variant, slot.letter)}</span>{/if}
|
{#if slot.letter !== BLANK}<span class="val">{tileValue(variant, slot.letter)}</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.rack {
|
.rack {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
align-items: center;
|
||||||
.slot {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
.empty {
|
|
||||||
background: var(--surface-2);
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
}
|
}
|
||||||
.tile {
|
.tile {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: min(12.5vw, 46px);
|
||||||
|
aspect-ratio: 1;
|
||||||
background: var(--tile-bg);
|
background: var(--tile-bg);
|
||||||
color: var(--tile-text);
|
color: var(--tile-text);
|
||||||
border: none;
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
box-shadow: inset 0 -3px 0 var(--tile-edge);
|
box-shadow: inset 0 -3px 0 var(--tile-edge);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
@@ -64,9 +60,14 @@
|
|||||||
outline: 3px solid var(--accent);
|
outline: 3px solid var(--accent);
|
||||||
outline-offset: -3px;
|
outline-offset: -3px;
|
||||||
}
|
}
|
||||||
|
.letter {
|
||||||
|
position: absolute;
|
||||||
|
top: 8%;
|
||||||
|
left: 14%;
|
||||||
|
}
|
||||||
.val {
|
.val {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 3px;
|
right: 4px;
|
||||||
bottom: 1px;
|
bottom: 1px;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createBannerRotator, defaultBannerConfig, linkify } from './banner';
|
||||||
|
|
||||||
|
describe('linkify', () => {
|
||||||
|
it('escapes html and renders markdown links', () => {
|
||||||
|
expect(linkify('a < b & c')).toBe('a < b & c');
|
||||||
|
expect(linkify('see [docs](https://x.com) now')).toBe(
|
||||||
|
'see <a href="https://x.com" target="_blank" rel="noopener noreferrer">docs</a> now',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('drops a non-http(s) link target (keeps the label)', () => {
|
||||||
|
expect(linkify('[x](ftp://evil)')).toBe('x');
|
||||||
|
expect(linkify('[y](javascript:boom)')).toBe('y');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('banner rotator', () => {
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it('holds a fitting message then advances, and scrolls an overflowing one', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const cfg = { ...defaultBannerConfig, holdMs: 1000, edgePauseMs: 100, fadeMs: 10, scrollPxPerSec: 50 };
|
||||||
|
const shown: number[] = [];
|
||||||
|
let scrolled = 0;
|
||||||
|
const overflow = [0, 200]; // item 0 fits, item 1 overflows
|
||||||
|
const r = createBannerRotator(
|
||||||
|
[{ md: 'a' }, { md: 'b' }],
|
||||||
|
{
|
||||||
|
overflowPx: (i) => overflow[i],
|
||||||
|
show: (i) => shown.push(i),
|
||||||
|
scrollTo: () => scrolled++,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
|
||||||
|
r.start();
|
||||||
|
expect(shown).toEqual([0]);
|
||||||
|
vi.advanceTimersByTime(cfg.fadeMs); // settle + measure item 0
|
||||||
|
vi.advanceTimersByTime(cfg.holdMs); // advance to item 1
|
||||||
|
expect(shown).toEqual([0, 1]);
|
||||||
|
vi.advanceTimersByTime(cfg.fadeMs); // settle + measure item 1 (overflows)
|
||||||
|
vi.advanceTimersByTime(cfg.edgePauseMs); // edge pause -> scrollTo
|
||||||
|
expect(scrolled).toBe(1);
|
||||||
|
r.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { bonusLabel } from './boardlabels';
|
||||||
|
|
||||||
|
describe('bonusLabel', () => {
|
||||||
|
it('none mode and plain squares have no label', () => {
|
||||||
|
expect(bonusLabel('none', 'TW', 'en')).toBeNull();
|
||||||
|
expect(bonusLabel('beginner', '', 'en')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classic is a localized single tag', () => {
|
||||||
|
expect(bonusLabel('classic', 'TW', 'en')).toEqual({ kind: 'single', text: '3W' });
|
||||||
|
expect(bonusLabel('classic', 'DL', 'en')).toEqual({ kind: 'single', text: '2L' });
|
||||||
|
expect(bonusLabel('classic', 'TW', 'ru')).toEqual({ kind: 'single', text: '3С' });
|
||||||
|
expect(bonusLabel('classic', 'DL', 'ru')).toEqual({ kind: 'single', text: '2Б' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('beginner is a localized split label', () => {
|
||||||
|
expect(bonusLabel('beginner', 'TW', 'en')).toEqual({ kind: 'split', top: '3×', bottom: 'word' });
|
||||||
|
expect(bonusLabel('beginner', 'DL', 'en')).toEqual({ kind: 'split', top: '2×', bottom: 'letter' });
|
||||||
|
expect(bonusLabel('beginner', 'TL', 'ru')).toEqual({ kind: 'split', top: '3×', bottom: 'буква' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
direction,
|
direction,
|
||||||
newPlacement,
|
newPlacement,
|
||||||
place,
|
place,
|
||||||
|
placementFromHint,
|
||||||
rackView,
|
rackView,
|
||||||
recallAt,
|
recallAt,
|
||||||
reset,
|
reset,
|
||||||
@@ -62,3 +63,19 @@ describe('placement state machine', () => {
|
|||||||
expect(toSubmit(newPlacement(rack))).toBeNull();
|
expect(toSubmit(newPlacement(rack))).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('placementFromHint', () => {
|
||||||
|
it('maps hint letters and blanks onto rack slots', () => {
|
||||||
|
const p = placementFromHint(
|
||||||
|
[
|
||||||
|
{ row: 7, col: 7, letter: 'C', blank: false },
|
||||||
|
{ row: 7, col: 8, letter: 'A', blank: false },
|
||||||
|
{ row: 7, col: 9, letter: 'B', blank: true },
|
||||||
|
],
|
||||||
|
['C', 'A', BLANK, 'T'],
|
||||||
|
);
|
||||||
|
expect(p.pending).toHaveLength(3);
|
||||||
|
expect(p.pending[0]).toMatchObject({ rackIndex: 0, letter: 'C', blank: false });
|
||||||
|
expect(p.pending[2]).toMatchObject({ rackIndex: 2, letter: 'B', blank: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+20
-1
@@ -4,7 +4,7 @@
|
|||||||
// payload. It is board-agnostic (the gateway/engine does full legality validation at
|
// payload. It is board-agnostic (the gateway/engine does full legality validation at
|
||||||
// submit), which keeps it trivially unit-testable.
|
// submit), which keeps it trivially unit-testable.
|
||||||
|
|
||||||
import type { Direction } from './model';
|
import type { Direction, Tile } from './model';
|
||||||
import type { PlacedTile } from './client';
|
import type { PlacedTile } from './client';
|
||||||
|
|
||||||
export interface PendingTile {
|
export interface PendingTile {
|
||||||
@@ -36,6 +36,25 @@ export function newPlacement(rack: string[]): Placement {
|
|||||||
return { rack: [...rack], pending: [] };
|
return { rack: [...rack], pending: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* placementFromHint turns a hint move's tiles into a pending placement by matching each
|
||||||
|
* tile to a rack slot (a blank "?" for blank tiles, else the matching letter), so the
|
||||||
|
* player sees the suggested move laid out and decides whether to commit it.
|
||||||
|
*/
|
||||||
|
export function placementFromHint(tiles: Tile[], rack: string[]): Placement {
|
||||||
|
const used = new Set<number>();
|
||||||
|
const pending: PendingTile[] = [];
|
||||||
|
const take = (pred: (letter: string, i: number) => boolean) => rack.findIndex((l, i) => !used.has(i) && pred(l, i));
|
||||||
|
for (const t of tiles) {
|
||||||
|
let idx = t.blank ? take((l) => l === BLANK) : take((l) => l === t.letter.toUpperCase());
|
||||||
|
if (idx < 0) idx = take((l) => l === BLANK); // fall back to a blank
|
||||||
|
if (idx < 0) continue;
|
||||||
|
used.add(idx);
|
||||||
|
pending.push({ rackIndex: idx, row: t.row, col: t.col, letter: t.letter.toUpperCase(), blank: rack[idx] === BLANK });
|
||||||
|
}
|
||||||
|
return { rack: [...rack], pending };
|
||||||
|
}
|
||||||
|
|
||||||
function usedIndexes(p: Placement): Set<number> {
|
function usedIndexes(p: Placement): Set<number> {
|
||||||
return new Set(p.pending.map((t) => t.rackIndex));
|
return new Set(p.pending.map((t) => t.rackIndex));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resultBadge } from './result';
|
||||||
|
import type { GameView, Seat } from './model';
|
||||||
|
|
||||||
|
const seat = (s: number, accountId: string, score: number, isWinner = false): Seat => ({
|
||||||
|
seat: s,
|
||||||
|
accountId,
|
||||||
|
displayName: accountId,
|
||||||
|
score,
|
||||||
|
hintsUsed: 0,
|
||||||
|
isWinner,
|
||||||
|
});
|
||||||
|
|
||||||
|
function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
|
||||||
|
return {
|
||||||
|
id: 'g',
|
||||||
|
variant: 'english',
|
||||||
|
dictVersion: 'v1',
|
||||||
|
status,
|
||||||
|
players: seats.length,
|
||||||
|
toMove,
|
||||||
|
turnTimeoutSecs: 0,
|
||||||
|
moveCount: 0,
|
||||||
|
endReason: '',
|
||||||
|
seats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resultBadge', () => {
|
||||||
|
it('active: your move vs opponent', () => {
|
||||||
|
const g = game([seat(0, 'me', 5), seat(1, 'a', 3)], 'active', 0);
|
||||||
|
expect(resultBadge(g, 'me')).toEqual({ key: 'result.yourMove', emoji: '🟢' });
|
||||||
|
expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finished two-player: victory / defeat / draw', () => {
|
||||||
|
expect(resultBadge(game([seat(0, 'me', 300, true), seat(1, 'a', 200)]), 'me')).toEqual({
|
||||||
|
key: 'result.victory',
|
||||||
|
emoji: '🏆',
|
||||||
|
});
|
||||||
|
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 300, true)]), 'me')).toEqual({
|
||||||
|
key: 'result.defeat',
|
||||||
|
emoji: '🥈',
|
||||||
|
});
|
||||||
|
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 200)]), 'me')).toEqual({
|
||||||
|
key: 'result.draw',
|
||||||
|
emoji: '🏅',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finished four-player: places by score', () => {
|
||||||
|
const last = game([seat(0, 'me', 100), seat(1, 'a', 400, true), seat(2, 'b', 300), seat(3, 'c', 200)]);
|
||||||
|
expect(resultBadge(last, 'me')).toEqual({ key: 'result.place4', emoji: '🏅' });
|
||||||
|
const second = game([seat(0, 'me', 300), seat(1, 'a', 400, true), seat(2, 'b', 200), seat(3, 'c', 100)]);
|
||||||
|
expect(resultBadge(second, 'me')).toEqual({ key: 'result.place2', emoji: '🥈' });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user