fix(ui): highlight the word the engine played, not the run it abuts #291

Merged
developer merged 1 commits from fix/offline-highlight-direction into development 2026-07-27 21:46:33 +00:00
Owner

The bug

In a one-word-per-turn game, staging a single tile that plays a valid word down a column while merely abutting a longer horizontal word greened that horizontal run instead — a word the play does not form. The score, the caption and the committed move were all correct; only the highlight (and the score badge, which anchors on the same main word) were wrong.

Reported from an offline vs-robot game, but the trigger is the rule, not the transport: formedGeometry is called on both paths, so online one-word games were affected too.

Root cause

Two independent implementations of the play's orientation:

  • lib/dict/direction.ts — a faithful port of engine.resolveDirection + (*Game).playDirection. For a lone tile under the single-word rule the two orientations can differ in legality, so it runs both through the validator and keeps the higher-scoring legal one.
  • lib/formed.ts — the highlight geometry, which re-guessed the axis as h.len >= v.len ? 'H' : 'V'. It knows nothing about the dictionary, so it took the longer run even when that run is no word at all.

The earlier fix bf46b94 ("one-word games must not highlight phantom cross words") closed the cross-word half of this file; the orientation half was missed.

The fix

The engine's answer is already on the wire and had no consumer: EvalResult.dir, filled by the on-device evaluator, the network evaluate and a reused hint alike. formedGeometry now takes it as a parameter and no longer infers the axis at all — there is no second implementation left to drift.

preview is nulled on every placement change, so preview.dir always describes the current staging.

Tests

  • formed.test.ts — the new contract, plus a regression case built from the reported position.
  • formed.engine.test.ts (new) — a differential sweep: random positions × both rules × two dictionaries; for every legal play the engine port finds, the highlighted cells must equal the cells of the words the engine says the play forms. Reverting formed.ts to the old inference fails it on exactly the one-word-rule cases and leaves the multi-word ones green.

Verified locally, the whole ui CI job: check, test:unit (674 passed), build, bundle-size (app entry 130.3 / 131 KB), test:e2e (256 passed, chromium + webkit).

No schema, wire or doc change — docs/FUNCTIONAL.md already describes the intended behaviour.

## The bug In a **one-word-per-turn** game, staging a *single* tile that plays a valid word down a column while merely **abutting** a longer horizontal word greened that horizontal run instead — a word the play does not form. The score, the caption and the committed move were all correct; only the highlight (and the score badge, which anchors on the same main word) were wrong. Reported from an offline vs-robot game, but the trigger is the **rule, not the transport**: `formedGeometry` is called on both paths, so online one-word games were affected too. ## Root cause Two independent implementations of the play's orientation: - `lib/dict/direction.ts` — a faithful port of `engine.resolveDirection` + `(*Game).playDirection`. For a lone tile under the single-word rule the two orientations can differ in legality, so it runs **both through the validator** and keeps the higher-scoring legal one. - `lib/formed.ts` — the highlight geometry, which re-guessed the axis as `h.len >= v.len ? 'H' : 'V'`. It knows nothing about the dictionary, so it took the longer run even when that run is no word at all. The earlier fix bf46b94 ("one-word games must not highlight phantom cross words") closed the cross-word half of this file; the orientation half was missed. ## The fix The engine's answer is already on the wire and had no consumer: `EvalResult.dir`, filled by the on-device evaluator, the network `evaluate` and a reused hint alike. `formedGeometry` now takes it as a parameter and no longer infers the axis at all — there is no second implementation left to drift. `preview` is nulled on every placement change, so `preview.dir` always describes the current staging. ## Tests - `formed.test.ts` — the new contract, plus a regression case built from the reported position. - `formed.engine.test.ts` (new) — a differential sweep: random positions × both rules × two dictionaries; for every legal play the engine port finds, the highlighted cells must equal the cells of the words the engine says the play forms. Reverting `formed.ts` to the old inference fails it on exactly the one-word-rule cases and leaves the multi-word ones green. Verified locally, the whole `ui` CI job: `check`, `test:unit` (674 passed), `build`, `bundle-size` (app entry 130.3 / 131 KB), `test:e2e` (256 passed, chromium + webkit). No schema, wire or doc change — `docs/FUNCTIONAL.md` already describes the intended behaviour.
developer added 1 commit 2026-07-27 21:33:19 +00:00
fix(ui): highlight the word the engine played, not the run it abuts
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m20s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
27e19e82bd
The board's green highlight re-derived the play's orientation itself
("the longer run wins"), while the engine settles a lone staged tile
under the single-word rule through the validator, because its two
orientations can differ in legality. A vertical play that merely abuts a
longer horizontal word therefore greened that horizontal run — a word
the play does not form — and anchored the score badge on it, while the
caption, the score and the committed move all stayed correct.

formedGeometry now takes the orientation from the preview, which every
preview source already carries (EvalResult.dir: the on-device evaluator,
the network evaluate and a reused hint), and no longer infers it. The
rule, not the transport, is the trigger, so this fixes online one-word
games as well as offline ones.

A differential test now walks random positions, asks the engine port for
every legal play it can find, and asserts the highlighted cells are
exactly the cells of the words the engine says that play forms.
owner approved these changes 2026-07-27 21:45:39 +00:00
developer merged commit 985ed40639 into development 2026-07-27 21:46:33 +00:00
developer deleted branch fix/offline-highlight-direction 2026-07-27 21:46:33 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: developer/scrabble-game#291