From 0dd4099d68c2f76edd4689d36ed6dea2271636be Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 19 Jun 2026 12:03:27 +0200 Subject: [PATCH 1/2] perf(ui): reuse hint result as move preview, skip redundant evaluate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Taking a hint auto-placed the suggested tiles and then called recompute(), which round-trips a debounced evaluate for that exact placement. The hint is the engine's own top-ranked, fully scored legal move, so its move already carries the score, words and direction an evaluate would return — the second call was pure duplicate work and added a visible disabled->enabled flicker on the submit button over slow links. Seed the move preview directly from the hint move via previewFromHint and cancel any pending evaluate timer so a stale one cannot clobber it. A later manual edit re-arms recompute() as before, so rearranged tiles are re-evaluated normally. Client-only; no backend, wire or schema change. --- ui/src/game/Game.svelte | 10 +++++++++- ui/src/lib/placement.test.ts | 19 +++++++++++++++++++ ui/src/lib/placement.ts | 12 +++++++++++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 75e3558..5e5e8d0 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -29,6 +29,7 @@ newPlacement, place, placementFromHint, + previewFromHint, rackView, recallAt, recallToSlot, @@ -751,7 +752,14 @@ if (isCoarse()) zoomed = true; view = { ...view, hintsRemaining: h.hintsRemaining, walletBalance: h.walletBalance }; syncWallet(h.walletBalance); - recompute(); + // The hint is the engine's own top-ranked, fully scored legal move: reuse it as the + // preview instead of a redundant evaluate (same engine call, same placement). Cancel any + // pending evaluate so a stale one cannot clobber it; a later manual edit re-arms recompute(). + if (previewTimer) { + clearTimeout(previewTimer); + previewTimer = null; + } + preview = previewFromHint(h.move); } } catch (e) { // The backend does not spend a hint when there is no move. diff --git a/ui/src/lib/placement.test.ts b/ui/src/lib/placement.test.ts index 241b9e6..b765d53 100644 --- a/ui/src/lib/placement.test.ts +++ b/ui/src/lib/placement.test.ts @@ -6,6 +6,7 @@ import { newPlacement, place, placementFromHint, + previewFromHint, rackView, recallAt, recallIndex, @@ -105,6 +106,24 @@ describe('placementFromHint', () => { }); }); +describe('previewFromHint', () => { + it('reuses the hint move as a legal preview, carrying score, words and direction', () => { + const preview = previewFromHint({ + player: 0, + action: 'play', + dir: 'V', + mainRow: 7, + mainCol: 7, + tiles: [{ row: 7, col: 7, letter: 'C', blank: false }], + words: ['CAT', 'AN'], + count: 0, + score: 18, + total: 42, + }); + expect(preview).toEqual({ legal: true, score: 18, words: ['CAT', 'AN'], dir: 'V' }); + }); +}); + describe('reorderIndices', () => { it('lifts an element and drops it at the given slot among the others', () => { expect(reorderIndices(4, 0, 2)).toEqual([1, 2, 0, 3]); diff --git a/ui/src/lib/placement.ts b/ui/src/lib/placement.ts index 8c551d6..f7438ed 100644 --- a/ui/src/lib/placement.ts +++ b/ui/src/lib/placement.ts @@ -4,7 +4,7 @@ // payload. It is board-agnostic (the gateway/engine does full legality validation at // submit), which keeps it trivially unit-testable. -import type { Tile } from './model'; +import type { EvalResult, MoveRecord, Tile } from './model'; import type { PlacedTile } from './client'; export interface PendingTile { @@ -55,6 +55,16 @@ export function placementFromHint(tiles: Tile[], rack: string[]): Placement { return { rack: [...rack], pending }; } +/** + * previewFromHint turns a hint's move into a move preview. A hint is the engine's own + * top-ranked, fully scored legal play, so its move already carries the score, words and + * direction an evaluate would compute for the same placement; reusing it lets the client + * skip a redundant evaluate round-trip after auto-placing the hint. + */ +export function previewFromHint(move: MoveRecord): EvalResult { + return { legal: true, score: move.score, words: move.words, dir: move.dir }; +} + function usedIndexes(p: Placement): Set { return new Set(p.pending.map((t) => t.rackIndex)); } -- 2.52.0 From d0681c5efe4b177a0913366897ebded7a0dce451 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 19 Jun 2026 12:12:53 +0200 Subject: [PATCH 2/2] fix(ui): lower-case hint preview words to match evaluate A hint's MoveRecord words are upper-cased on decode (for the move-history view), whereas an EvalResult keeps the backend's lower case. Seeding the move preview from the hint verbatim flipped the score caption to upper case and nudged its height. Lower-case the words in previewFromHint so the caption reads the same as the evaluate path it replaces. --- ui/src/lib/placement.test.ts | 4 ++-- ui/src/lib/placement.ts | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/ui/src/lib/placement.test.ts b/ui/src/lib/placement.test.ts index b765d53..30a4523 100644 --- a/ui/src/lib/placement.test.ts +++ b/ui/src/lib/placement.test.ts @@ -107,7 +107,7 @@ describe('placementFromHint', () => { }); describe('previewFromHint', () => { - it('reuses the hint move as a legal preview, carrying score, words and direction', () => { + it('reuses the hint move as a legal preview, lower-casing words to match evaluate', () => { const preview = previewFromHint({ player: 0, action: 'play', @@ -120,7 +120,7 @@ describe('previewFromHint', () => { score: 18, total: 42, }); - expect(preview).toEqual({ legal: true, score: 18, words: ['CAT', 'AN'], dir: 'V' }); + expect(preview).toEqual({ legal: true, score: 18, words: ['cat', 'an'], dir: 'V' }); }); }); diff --git a/ui/src/lib/placement.ts b/ui/src/lib/placement.ts index f7438ed..1767941 100644 --- a/ui/src/lib/placement.ts +++ b/ui/src/lib/placement.ts @@ -59,10 +59,13 @@ export function placementFromHint(tiles: Tile[], rack: string[]): Placement { * previewFromHint turns a hint's move into a move preview. A hint is the engine's own * top-ranked, fully scored legal play, so its move already carries the score, words and * direction an evaluate would compute for the same placement; reusing it lets the client - * skip a redundant evaluate round-trip after auto-placing the hint. + * skip a redundant evaluate round-trip after auto-placing the hint. The words are + * lower-cased to match the evaluate result the caption renders: a hint's MoveRecord words + * are upper-cased on decode (for the move-history view), whereas an EvalResult keeps the + * backend's lower case, so reusing them verbatim would otherwise change the caption's case. */ export function previewFromHint(move: MoveRecord): EvalResult { - return { legal: true, score: move.score, words: move.words, dir: move.dir }; + return { legal: true, score: move.score, words: move.words.map((w) => w.toLowerCase()), dir: move.dir }; } function usedIndexes(p: Placement): Set { -- 2.52.0