diff --git a/backend/internal/server/dto_test.go b/backend/internal/server/dto_test.go index fc6f7df..fea97e0 100644 --- a/backend/internal/server/dto_test.go +++ b/backend/internal/server/dto_test.go @@ -51,6 +51,8 @@ func TestStatusForError(t *testing.T) { "code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"}, "session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"}, "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"}, } for name, tc := range cases { diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index dc822aa..de8411a 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -121,7 +121,11 @@ func statusForError(err error) (int, string) { return http.StatusConflict, "already_queued" case errors.Is(err, game.ErrInvalidConfig): 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" case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver): return http.StatusUnprocessableEntity, "illegal_play" diff --git a/ui/e2e/smoke.spec.ts b/ui/e2e/smoke.spec.ts index f43e669..1a6a4fa 100644 --- a/ui/e2e/smoke.spec.ts +++ b/ui/e2e/smoke.spec.ts @@ -22,8 +22,9 @@ test('guest reaches a board and previews a placement', async ({ page }) => { await rackTile.click(); await page.locator('[data-cell]:not(.filled)').nth(30).click(); 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. - await expect(page.getByRole('button', { name: /make move/i })).toBeVisible(); + // The contextual MakeMove control (🏁) appears once a tile is pending. + await expect(page.locator('.make')).toBeVisible(); }); diff --git a/ui/src/app.css b/ui/src/app.css index 4e4ab26..79623ef 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -23,7 +23,7 @@ /* board + tiles (all drawn with CSS primitives) */ --board-bg: #cdd6cf; --cell-bg: #e7ece8; - --cell-line: #b6c0b8; + --cell-line: #7f8d83; --tile-bg: #f4e2b8; --tile-edge: #d8c190; --tile-text: #2a2113; @@ -62,7 +62,7 @@ --board-bg: #2a3330; --cell-bg: #222a27; - --cell-line: #38433d; + --cell-line: #56655c; --tile-bg: #d9c79a; --tile-edge: #b6a473; --tile-text: #20190d; @@ -136,6 +136,8 @@ button { font: inherit; color: inherit; cursor: pointer; + user-select: none; + -webkit-user-select: none; } .reduce-motion * { diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 7a12ebb..63d5403 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -3,6 +3,8 @@ import type { Premium } from '../lib/premiums'; import { tileValue } from '../lib/premiums'; import type { Variant } from '../lib/model'; + import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels'; + import type { Locale } from '../lib/i18n/catalog'; let { board, @@ -12,6 +14,9 @@ centre, zoomed, variant, + labelMode, + locale, + focus, oncell, ontogglezoom, }: { @@ -22,18 +27,30 @@ centre: { row: number; col: number }; zoomed: boolean; variant: Variant; + labelMode: BoardLabelMode; + locale: Locale; + focus: { row: number; col: number } | null; oncell: (row: number, col: number) => void; ontogglezoom: () => void; } = $props(); - const premClass: Record = { - '': '', - TW: 'tw', - DW: 'dw', - TL: 'tl', - DL: 'dl', - }; - const premLabel: Record = { '': '', TW: '3W', DW: '2W', TL: '3L', DL: '2L' }; + const Z = 1.85; + const z = $derived(zoomed ? Z : 1); + const premClass: Record = { '': '', TW: 'tw', DW: 'dw', TL: 'tl', DL: 'dl' }; + + let viewport = $state(); + + // When zoomed in (typically on a placement), centre the focus cell. + $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. let lastTap = 0; @@ -48,13 +65,12 @@ 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(); let startDist = 0; function dist(): number { const p = [...pts.values()]; - if (p.length < 2) return 0; - return Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y); + return p.length < 2 ? 0 : Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y); } function onPointerDown(e: PointerEvent) { pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); @@ -79,83 +95,88 @@ if (pts.size < 2) startDist = 0; } - function key(r: number, c: number): string { - return `${r},${c}`; - } + const key = (r: number, c: number) => `${r},${c}`;
-
- {#each board as rowCells, r (r)} - {#each rowCells as cell, c (c)} - {@const p = pending.get(key(r, c))} - {@const letter = cell?.letter ?? p?.letter ?? ''} - {@const blank = cell?.blank ?? p?.blank ?? false} - +
+
+ {#each board as rowCells, r (r)} + {#each rowCells as cell, c (c)} + {@const p = pending.get(key(r, c))} + {@const letter = cell?.letter ?? p?.letter ?? ''} + {@const blank = cell?.blank ?? p?.blank ?? false} + {@const bl = letter ? null : bonusLabel(labelMode, premium[r][c], locale)} + + {/each} {/each} - {/each} +
diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 95726f3..49e452a 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -1,14 +1,16 @@ -
+ {#snippet menu()} - - {#if menuOpen} - - -
(menuOpen = false)}>
- - {/if} + {/snippet} -
-{#if view} -
- {#each view.game.seats as s (s.seat)} -
-
{s.accountId === app.session?.userId ? t('common.you') : s.displayName}
-
{s.score}
-
- {/each} -
- -
- (zoomed = !zoomed)} - /> -
- -
- {t('game.bag', { n: view.bagLen })} - {#if gameOver} - {t('game.over')} β€” {resultText()} - {:else} - {isMyTurn ? t('game.yourTurn') : t('game.waiting', { name: view.game.seats[view.game.toMove]?.displayName ?? '' })} - {/if} - {t('game.hints', { n: view.hintsRemaining })} -
- - {#if !gameOver} -
-
- -
- {#if placement.pending.length > 0} - - {/if} + {#if view} +
+ {#each view.game.seats as s (s.seat)} +
+
{s.accountId === app.session?.userId ? t('common.you') : s.displayName}
+
{s.score}
+
+ {/each}
- +
+ {#if historyOpen} +
+
    + {#each moves as m, i (i)} +
  1. + {view.game.seats[m.player]?.displayName ?? m.player} + {m.action === 'play' ? m.words.join(', ') : m.action} + {m.score} +
  2. + {/each} + {#if moves.length === 0}
  3. β€”
  4. {/if} +
+
+ {/if} + + + +
historyOpen && (historyOpen = false)} + > + (zoomed = !zoomed)} + /> +
+
+ +
+ {t('game.bag', { n: view.bagLen })} + {#if gameOver} + {t('game.over')} β€” {resultText()} + {:else} + {isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''} + {/if} + + {#if preview}{preview.legal ? t('game.scores', { n: preview.score }) : t('game.previewIllegal')}{/if} + +
+ + {#if !gameOver} +
+
+ +
+ {#if placement.pending.length > 0} + + {#snippet trigger()}🏁{/snippet} + {#snippet popover(close)} + + + {/snippet} + + {/if} +
+ {/if} + {:else} +

{t('common.loading')}

{/if} -{:else} -

{t('common.loading')}

-{/if} + + {#snippet tabbar()} + {#if view && !gameOver} + + + {#snippet trigger()}πŸ”„{t('game.draw')}{/snippet} + {#snippet popover(close)}{/snippet} + + + {#snippet trigger()}πŸ₯Ί{t('game.skip')}{/snippet} + {#snippet popover(close)}{/snippet} + + + {#snippet trigger()} + πŸ›Ÿ{#if (view?.hintsRemaining ?? 0) > 0}{view?.hintsRemaining}{/if} + {t('game.hint')} + {/snippet} + {#snippet popover(close)}{/snippet} + + + + {/if} + {/snippet} + {#if drag}
@@ -432,6 +504,10 @@
{/if} +{#if ambiguous && placement.pending.length > 0} + +{/if} + {#if blankPrompt} (blankPrompt = null)}>
@@ -460,8 +536,13 @@ {#if checkOpen} (checkOpen = false)}>
- e.key === 'Enter' && runCheck()} /> - + e.key === 'Enter' && runCheck()} + placeholder={t('game.checkWordPrompt')} + /> +
{#if checkResult}

@@ -489,27 +570,12 @@ {/if} -{#if panel === 'history' && view} - (panel = 'none')}> -

    - {#each moves as m, i (i)} -
  1. - {view.game.seats[m.player]?.displayName ?? m.player} - {m.action === 'play' ? m.words.join(', ') : m.action} - {m.score} -
  2. - {/each} -
-
-{/if} - diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index e00beae..9a690bb 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -15,45 +15,41 @@ selected: number | null; ondown: (e: PointerEvent, index: number) => void; } = $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));
- {#each slots as slot (slot.index)} - {#if slot.used} - - {:else} - - {/if} + {#each visible as slot (slot.index)} + {/each}