docs(plans): record the in-game feedback implementation report

This commit is contained in:
tiennm99 committed 2026-09-21 01:55:46 +07:00
1 parent e52b0ef2c3
commit 2adab1318b
1 file changed
+122
@@ -0,0 +1,122 @@
# In-game feedback: score breakdown, dead-end claim, near-miss suggestion, word report
Branch: `worktree-agent-a582df720cc6e3dea`
Worktree: `/workspace/tiennm99/noitu/.claude/worktrees/agent-a582df720cc6e3dea`
Base: rebased onto `dev` at `865222d` (proto/generated code and cross-language
fixtures for all four features were already committed there; this work is the
implementation against that schema).
Commits: `16fd379` (game/dictionary/wsapi), `0003a45` (web), `e52b0ef` (docs).
## Scope delivered
**A2 — score breakdown.** `game.Move.Parts []PointPart{Kind, Value}`, computed
by `pointsFor`, one entry per non-zero term (base, chain, syllables, speed,
rarity), trimmed from the end (rarity, speed, syllables — never reaches chain
or base) when `maxPointsPerWord` bites, always summing exactly to `Points`.
`wsapi.PointKind`/`PointParts` map onto the wire, `PlayedWord.Parts` set from
them. Client: `ChainEntry.parts`, rendered as `+10 nền · +15 nhanh` chips in
`ChainHistory`; labels in `vi.js` keyed by `PointKind`. Bot rooms get it for
free — same `Submit` path.
**A3 — dead-end claim.** `ClaimDeadEnd` dispatched like `Resign` (seat via
`occupies`, rate-limited on `submitLimiter`). Room: no game → `game_not_started`;
not the claimant's turn → `not_your_turn`; `HasLegalMove()` true → `not_a_dead_end`,
nothing else changes; false → `NoMove` + `applyEliminations`, same as the
clock. `metrics.deadEndClaims` keyed `"true"`/`"false"`. Client: "Bí từ" button
next to the input, visible only on the player's own turn, armed like resign;
a false claim answers inline near the input (`state.claimError`) rather than
the general error banner.
**C3 — near-miss suggestion.** `dictionary.Store.stripped` index built at
`Open` (NFD, drop `Mn`, lowercase, đ/Đ→d/D — typing differences only).
`NearMiss(normalized) (string, bool)` returns the one candidate when exactly
one exists and is not the input itself. Added to `wsapi.Dictionary` and the
test doubles. Room fills `MoveRejected.suggestion` on
`ReasonNotInDictionary`. Client shows "Ý bạn là "…"?" as a button that fills
the (uncontrolled) field and focuses it — a user click, never a write during
composition.
**E3 — word report.** `ReportWord` rate-limited by `chatLimiter`, validated
entirely on the session (own rate budget, own per-session distinct-word set,
capped at 20) before being routed to the room only for the log line's
room-owned context (syllable in play, mode, code) — `handleReportWord` never
touches player identity. `word_reported` slog line documented next to
`word_rejected` in `docs/deployment.md`; `metrics.wordsReported` counter.
Client offers "Báo từ này là từ thật" after a `NOT_IN_DICTIONARY` rejection
and shows the confirmation on `WordReported`.
**Rules/README.** `/rules` dead-end and scoring copy mention the claim button
and the visible breakdown; README's "The frontend" and rules paragraphs each
gained one sentence. `errorMessages` gained `not_a_dead_end`,
`word_report_refused`, `word_report_limit`; `not_your_turn`'s copy was
generalized since resign and the claim now share the code.
## Files changed
Server: `server/internal/game/{state.go,engine.go,engine_test.go}`,
`server/internal/dictionary/{store.go,store_test.go}`,
`server/internal/wsapi/{codec.go,convert.go,convert_test.go,metrics.go,room.go,session.go,wsapi_test.go}`.
Web: `web/src/lib/{i18n/vi.js,stores/game.svelte.js,ws/messages.js}`,
`web/src/lib/components/{ChainHistory.svelte,GameBoard.svelte,WordInput.svelte}`,
`web/src/routes/{online,play}/+page.svelte`, `web/src/routes/rules/+page.svelte`,
`web/tests/{game-store.test.js,game-wire.test.js,i18n.test.js}`.
Docs: `README.md`, `docs/deployment.md`.
Not touched: `proto/`, `server/gen/`, `web/src/lib/proto/`, quick-match
(`QuickMatch`/`CancelQuickMatch`/`QuickMatchStatus`) — the sibling agent's arms.
## Verification
```
cd server && gofmt -l . && go vet ./... && golangci-lint run ./... # clean, 0 issues
cd server && go test ./... -race -count=1 # all packages ok
cd web && npm run lint # 0 errors (33 pre-existing `any` warnings)
cd web && npm run check # 0 errors, 0 warnings
cd web && npm test # 214 tests passed, build+bundle check included
```
Go test tail:
```
ok github.com/tiennm99dev/noitu/server/cmd/build-dictionary
ok github.com/tiennm99dev/noitu/server/internal/bot
ok github.com/tiennm99dev/noitu/server/internal/dictionary
ok github.com/tiennm99dev/noitu/server/internal/game
ok github.com/tiennm99dev/noitu/server/internal/vietnamese
ok github.com/tiennm99dev/noitu/server/internal/wsapi
```
Web test tail: `Test Files 12 passed (12)`, `Tests 214 passed (214)`.
## Tests added
- `game`: `TestSubmitScoringPartsMatchTheTotal`, `TestSubmitScoringPartsAreTrimmedAtTheCap`, `TestPointKindStrings`.
- `wsapi`: `TestPointKindMappingIsExhaustive`, `TestPlayedWordCarriesItsScoreBreakdown`,
`TestClaimDeadEndEliminatesImmediately`, `TestClaimDeadEndRefusedWhenAMoveExists`,
`TestClaimDeadEndOutOfTurnRefused`, `TestNearMissSuggestionOnWire`,
`TestReportWordAcceptsAndEchoes`, `TestReportWordRefusesOneSyllable`,
`TestReportWordEnforcesPerSessionCap`.
- `dictionary`: `TestStripDiacriticsFoldsDBreve`, `TestNearMissFindsAUniqueDiacriticTypo`,
`TestNearMissRefusesAnAmbiguousStem`, `TestNearMissNeverSuggestsTheWordItself`,
`TestNearMissRefusesAnUnrelatedWord`.
- `web`: score-breakdown/word-report/dead-end-claim `describe` blocks in
`game-store.test.js`; near-miss/parts/wordReported assertions in
`game-wire.test.js`; a `point kind labels` block in `i18n.test.js`.
## Deferred / not in scope
- Playwright/e2e coverage for the new buttons — this environment cannot run a
browser (project constraint); left to the existing `test:e2e` suite to pick
up when run elsewhere.
- Quick-match (owned by the sibling agent).
## Unresolved questions
- None blocking. One judgment call worth flagging: `not_your_turn`'s
Vietnamese copy was generalized from "Chỉ đầu hàng được trong lượt của bạn."
to "Chưa đến lượt bạn." because the code is now shared between `Resign` and
`ClaimDeadEnd`. No test pinned the old string; if the merge with the
sibling's changes wants a different wording, it's a one-line change in
`vi.js`.