mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
Merge branch 'worktree-agent-a582df720cc6e3dea' into dev
# Conflicts: # server/internal/wsapi/wsapi_test.go # web/src/lib/ws/messages.js # web/src/routes/online/+page.svelte
This commit is contained in:
commit
6a7cb34897
27 files changed
+1600
-41
No files matched your search
@@ -147,17 +147,24 @@ once per turn and never over text the player has already typed, so no compositio
|
||||
progress when it happens.
|
||||
|
||||
The chain is listed newest first, and a finished game can be downloaded as a plain-text
|
||||
transcript — the chain in playing order, who played what, and the final score.
|
||||
transcript — the chain in playing order, who played what, and the final score. Each word in
|
||||
the chain shows the named terms its points came from, since the client has no wordlist of its
|
||||
own to re-derive that from a single number.
|
||||
|
||||
A player handed a syllable nothing answers is not left to wait out the clock: a "Bí từ" button
|
||||
next to the input claims the dead end, and the server settles it immediately — correctly if the
|
||||
position really is one, refused with the clock still running if it is not.
|
||||
|
||||
Every Vietnamese string lives in `web/src/lib/i18n/vi.js`, including the map from
|
||||
`RejectReason` to a message. That is why `ServerError.code` is a UI key such as
|
||||
`room_not_found` and never prose. A test walks the generated enums and fails when a value has
|
||||
no message, so a schema change cannot quietly ship an untranslated screen.
|
||||
|
||||
The rules — the chain, the clock, a dead end, elimination, scoring, room codes and reconnect
|
||||
grace — are written out once on `/rules`, a single anchored page linked from the landing
|
||||
screen and from a small "Luật chơi" link beside the connection badge in the game header and
|
||||
the lobby. It is not a button: reading the rules is not a way to start playing.
|
||||
The rules — the chain, the clock, a dead end and the claim button for it, elimination, scoring
|
||||
and its visible breakdown, room codes and reconnect grace — are written out once on `/rules`, a
|
||||
single anchored page linked from the landing screen and from a small "Luật chơi" link beside
|
||||
the connection badge in the game header and the lobby. It is not a button: reading the rules is
|
||||
not a way to start playing.
|
||||
|
||||
The countdown is drawn against the server's clock, estimated from the `Ping`/`Pong` round
|
||||
trip, and settles 300ms early so the ring never claims more time than the server allows.
|
||||
|
||||
+15
-3
@@ -174,9 +174,11 @@ as not having it. The counters, all prefixed `noitu_`: connections open and
|
||||
total; rooms live and total, each split `bot`/`pvp`; games started and
|
||||
finished the same way; words submitted, accepted, and rejected by reason;
|
||||
eliminations by reason; chat lines; join attempts refused, by whether it was
|
||||
the rate limit, an unknown code, or a full room; bot moves by difficulty; and
|
||||
resumes attempted versus succeeded. None of it is read by the game itself —
|
||||
it is a second write next to a decision already made, not an input to one.
|
||||
the rate limit, an unknown code, or a full room; bot moves by difficulty; dead-
|
||||
end claims by whether the position actually had no legal move; words reported
|
||||
as real by `ReportWord`; and resumes attempted versus succeeded. None of it is
|
||||
read by the game itself — it is a second write next to a decision already
|
||||
made, not an input to one.
|
||||
|
||||
Every rejected word also gets one structured log line at `Info`,
|
||||
`word_rejected`, carrying `reason`, `word`, `link` (the syllable it had to
|
||||
@@ -187,6 +189,16 @@ collect and is exactly the corpus-review question this project has open:
|
||||
which words players type that the dictionary does not have. Nothing else a
|
||||
player types is logged: not chat, not a nickname, not an accepted word.
|
||||
|
||||
A player who disputes a rejection this way — `ReportWord` — gets the same
|
||||
treatment: one `Info` line, `word_reported`, carrying `word` (normalized the
|
||||
same way), `link` (the syllable in play, empty when the report was not filed
|
||||
mid-game), `mode` (`bot`/`pvp`/`none`) and `room`. Only words of at least two
|
||||
syllables are recorded, and a session may file at most 20 distinct ones — never
|
||||
the player's nickname, on both counts for the same reason `word_rejected`
|
||||
never carries one. Between the two, this is the whole of the corpus dispute
|
||||
loop: triaged by hand today, into whatever curated word list eventually
|
||||
applies the fix.
|
||||
|
||||
## Health and readiness
|
||||
|
||||
`GET /healthz` returns 200 once the dictionary has loaded, for the rest of the
|
||||
|
||||
@@ -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`.
|
||||
@@ -24,9 +24,12 @@ import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"math/rand/v2"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
@@ -73,6 +76,11 @@ type Store struct {
|
||||
meanings map[string][]Sense
|
||||
meaningCount int
|
||||
|
||||
// stripped maps a word's diacritic-stripped form to every canonical word
|
||||
// that reduces to it, for NearMiss. Built once at Open from the same words
|
||||
// map, never mutated afterwards.
|
||||
stripped map[string][]string
|
||||
|
||||
license string
|
||||
}
|
||||
|
||||
@@ -131,6 +139,7 @@ func Open(path string) (*Store, error) {
|
||||
if err := s.validate(declaredWords, declaredMeanings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.buildStrippedIndex()
|
||||
|
||||
sort.SliceStable(s.openers, func(i, j int) bool {
|
||||
return s.openers[i].lastOutDegree > s.openers[j].lastOutDegree
|
||||
@@ -360,6 +369,69 @@ func (s *Store) Resolve(word string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// buildStrippedIndex populates the near-miss lookup from the words already
|
||||
// loaded. Run once at Open, after loadWords: every canonical word maps to the
|
||||
// bucket of every other canonical word that shares its diacritic-stripped
|
||||
// form.
|
||||
func (s *Store) buildStrippedIndex() {
|
||||
s.stripped = make(map[string][]string, len(s.words))
|
||||
for word := range s.words {
|
||||
key := stripDiacritics(word)
|
||||
s.stripped[key] = append(s.stripped[key], word)
|
||||
}
|
||||
}
|
||||
|
||||
// stripDiacritics reduces a word to the letters a Vietnamese input method
|
||||
// actually types, so two spellings that differ only in tone or vowel marks
|
||||
// compare equal. NFD decomposition drops every combining mark (Mn), and đ/Đ
|
||||
// are folded onto d/D by hand because they are single codepoints with no
|
||||
// canonical decomposition — a separate letter in the alphabet, but the same
|
||||
// key on a Telex or VNI keyboard as d.
|
||||
//
|
||||
// This is purely a typing-distance measure, never a vocabulary one: it is
|
||||
// used only to find a real dictionary word near a mistyped one, not to decide
|
||||
// whether a word is playable.
|
||||
func stripDiacritics(word string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(word))
|
||||
for _, r := range norm.NFD.String(word) {
|
||||
switch {
|
||||
case unicode.Is(unicode.Mn, r):
|
||||
continue
|
||||
case r == 'đ':
|
||||
r = 'd'
|
||||
case r == 'Đ':
|
||||
r = 'D'
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return strings.ToLower(b.String())
|
||||
}
|
||||
|
||||
// NearMiss finds the one real word a normalized submission is a diacritic
|
||||
// typo away from.
|
||||
//
|
||||
// It reports a match only when the stripped form has exactly one canonical
|
||||
// word behind it, other than normalized itself: an ambiguous stem ("ngu" for
|
||||
// both "ngữ" and "ngủ") corrects to nothing, since guessing which one the
|
||||
// player meant would be handing out a word they may not know, and normalized
|
||||
// is excluded so a word already in the dictionary is never "corrected" to
|
||||
// itself. Callers are expected to call this only after Resolve has already
|
||||
// failed.
|
||||
func (s *Store) NearMiss(normalized string) (string, bool) {
|
||||
match, count := "", 0
|
||||
for _, w := range s.stripped[stripDiacritics(normalized)] {
|
||||
if w == normalized {
|
||||
continue
|
||||
}
|
||||
match, count = w, count+1
|
||||
}
|
||||
if count != 1 {
|
||||
return "", false
|
||||
}
|
||||
return match, true
|
||||
}
|
||||
|
||||
// FirstSyllable returns the syllable a canonical word begins with: the syllable
|
||||
// it must link from. Reports false for an alias, which is not a playable entry.
|
||||
func (s *Store) FirstSyllable(word string) (string, bool) {
|
||||
|
||||
@@ -269,6 +269,95 @@ func TestResolveUnknown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// nearMissFixtureAt builds a tiny dictionary purpose-built for NearMiss: one
|
||||
// word with a unique stripped form, one ambiguous pair that shares a stripped
|
||||
// form with each other, and nothing else that could coincidentally collide.
|
||||
func nearMissFixtureAt(tb testing.TB, dir string) string {
|
||||
tb.Helper()
|
||||
|
||||
path := filepath.Join(dir, "nearmiss.db")
|
||||
db, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
data := fixtureSchema + `
|
||||
INSERT INTO meta VALUES ('source_license','CC BY-SA 4.0'),('word_count','4'),('meaning_count','0');
|
||||
INSERT INTO words VALUES
|
||||
('bình yên','bình','yên',2),
|
||||
('an nhàn','an','nhàn',2),
|
||||
-- "ngữ" and "ngừ" both strip to "ngu": an ambiguous stem with two real
|
||||
-- candidates behind it.
|
||||
('ngữ nghĩa','ngữ','nghĩa',2),
|
||||
('ngừ nghĩa','ngừ','nghĩa',2);
|
||||
INSERT INTO syllables VALUES ('bình',1),('an',1),('ngữ',1),('ngừ',1);
|
||||
`
|
||||
if _, err := db.Exec(data); err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func nearMissFixture(tb testing.TB) *Store {
|
||||
tb.Helper()
|
||||
|
||||
store, err := Open(nearMissFixtureAt(tb, tb.TempDir()))
|
||||
if err != nil {
|
||||
tb.Fatalf("Open: %v", err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// stripDiacritics is the typing-distance normalization NearMiss is built on:
|
||||
// NFD plus dropping combining marks handles tones and vowel horns, but đ has
|
||||
// no canonical decomposition, so it needs its own fold onto d.
|
||||
func TestStripDiacriticsFoldsDBreve(t *testing.T) {
|
||||
if got := stripDiacritics("đông đảo"); got != "dong dao" {
|
||||
t.Errorf("stripDiacritics(%q) = %q, want %q", "đông đảo", got, "dong dao")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearMissFindsAUniqueDiacriticTypo(t *testing.T) {
|
||||
s := nearMissFixture(t)
|
||||
|
||||
got, ok := s.NearMiss("binh yen")
|
||||
if !ok || got != "bình yên" {
|
||||
t.Errorf(`NearMiss("binh yen") = (%q, %v), want ("bình yên", true)`, got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Two real words differing only by diacritics from each other must not
|
||||
// resolve to either: correcting to the wrong one would hand out a word the
|
||||
// player did not know, which is the one thing NearMiss must never do.
|
||||
func TestNearMissRefusesAnAmbiguousStem(t *testing.T) {
|
||||
s := nearMissFixture(t)
|
||||
|
||||
if got, ok := s.NearMiss("ngu nghia"); ok {
|
||||
t.Errorf(`NearMiss("ngu nghia") = (%q, true), want no match for an ambiguous stem`, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A word already spelled correctly must never be offered as its own
|
||||
// suggestion — Resolve would already have accepted it, so reaching NearMiss
|
||||
// with it at all means something upstream skipped that check, and this is the
|
||||
// last line of defence against showing "ý bạn là bình yên?" under "bình yên".
|
||||
func TestNearMissNeverSuggestsTheWordItself(t *testing.T) {
|
||||
s := nearMissFixture(t)
|
||||
|
||||
if got, ok := s.NearMiss("bình yên"); ok {
|
||||
t.Errorf(`NearMiss("bình yên") = (%q, true), want no suggestion for an exact dictionary word`, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearMissRefusesAnUnrelatedWord(t *testing.T) {
|
||||
s := nearMissFixture(t)
|
||||
|
||||
if got, ok := s.NearMiss("hoàn toàn"); ok {
|
||||
t.Errorf(`NearMiss("hoàn toàn") = (%q, true), want no match`, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstAndLastSyllable(t *testing.T) {
|
||||
s := fixture(t)
|
||||
|
||||
|
||||
@@ -231,6 +231,7 @@ func (e *Engine) Submit(p PlayerID, raw string, now time.Time) (Move, RejectReas
|
||||
return Move{}, ReasonNotInDictionary
|
||||
}
|
||||
|
||||
points, parts := e.pointsFor(len(syllables), first, now)
|
||||
move := Move{
|
||||
Player: p,
|
||||
Word: canonical,
|
||||
@@ -238,7 +239,8 @@ func (e *Engine) Submit(p PlayerID, raw string, now time.Time) (Move, RejectReas
|
||||
First: first,
|
||||
Last: last,
|
||||
Syllables: len(syllables),
|
||||
Points: e.pointsFor(len(syllables), first, now),
|
||||
Points: points,
|
||||
Parts: parts,
|
||||
At: now,
|
||||
}
|
||||
|
||||
@@ -262,13 +264,57 @@ func (e *Engine) Submit(p PlayerID, raw string, now time.Time) (Move, RejectReas
|
||||
// already down, opening word included, which is what ChainLength reports; link
|
||||
// is the syllable the word answers, and now is when it was played, so both the
|
||||
// speed and the rarity term have to be read before the move is applied.
|
||||
func (e *Engine) pointsFor(syllables int, link string, now time.Time) int {
|
||||
points := basePoints +
|
||||
chainBonus*min(e.ChainLength(), chainBonusWords) +
|
||||
syllableBonus*(syllables-vietnamese.MinSyllables) +
|
||||
e.speedPoints(now) +
|
||||
e.rarityPoints(link)
|
||||
return min(points, maxPointsPerWord)
|
||||
//
|
||||
// Returns the total alongside the named terms it is made of, capped together:
|
||||
// a client with no wordlist of its own cannot re-derive why a word scored what
|
||||
// it did, so the breakdown travels with the total rather than being dropped
|
||||
// once it agrees with it.
|
||||
func (e *Engine) pointsFor(syllables int, link string, now time.Time) (int, []PointPart) {
|
||||
parts := []PointPart{
|
||||
{Kind: PointKindBase, Value: basePoints},
|
||||
{Kind: PointKindChain, Value: chainBonus * min(e.ChainLength(), chainBonusWords)},
|
||||
{Kind: PointKindSyllables, Value: syllableBonus * (syllables - vietnamese.MinSyllables)},
|
||||
{Kind: PointKindSpeed, Value: e.speedPoints(now)},
|
||||
{Kind: PointKindRarity, Value: e.rarityPoints(link)},
|
||||
}
|
||||
parts = capParts(parts)
|
||||
|
||||
total := 0
|
||||
for _, p := range parts {
|
||||
total += p.Value
|
||||
}
|
||||
return total, parts
|
||||
}
|
||||
|
||||
// capParts trims a word's score down to maxPointsPerWord when the terms
|
||||
// pointsFor computed add up to more, and drops whatever term that leaves at
|
||||
// zero — a PointPart exists only for a term that actually contributed.
|
||||
//
|
||||
// Trimmed from the end: rarity first, then speed, then syllables, then chain.
|
||||
// Base and the chain term never need touching to make room — the chain term
|
||||
// is itself capped at chainBonusWords words (10 base + 2*15 chain = 40 at
|
||||
// most), well under the cap — so the loop always finds enough in the later
|
||||
// terms and stops before reaching them.
|
||||
func capParts(parts []PointPart) []PointPart {
|
||||
total := 0
|
||||
for _, p := range parts {
|
||||
total += p.Value
|
||||
}
|
||||
if overflow := total - maxPointsPerWord; overflow > 0 {
|
||||
for i := len(parts) - 1; i >= 0 && overflow > 0; i-- {
|
||||
cut := min(parts[i].Value, overflow)
|
||||
parts[i].Value -= cut
|
||||
overflow -= cut
|
||||
}
|
||||
}
|
||||
|
||||
kept := parts[:0]
|
||||
for _, p := range parts {
|
||||
if p.Value > 0 {
|
||||
kept = append(kept, p)
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// speedPoints pays for the share of the turn the player left on the clock.
|
||||
|
||||
@@ -399,6 +399,109 @@ func TestSubmitScoringStopsPayingForChainLength(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Parts must name every term the total is made of, with nothing left over and
|
||||
// nothing zero: a term that did not contribute has nothing to show a player.
|
||||
func TestSubmitScoringPartsMatchTheTotal(t *testing.T) {
|
||||
d := newDict("ngôn ngữ", "ngữ pháp", "pháp vô tuyến điện")
|
||||
e := newGame(t, d, "ngôn ngữ")
|
||||
|
||||
two, _ := e.Submit(alice, "ngữ pháp", t0)
|
||||
if want := []PointPart{
|
||||
{Kind: PointKindBase, Value: 10},
|
||||
{Kind: PointKindChain, Value: 2},
|
||||
{Kind: PointKindSpeed, Value: 10},
|
||||
{Kind: PointKindRarity, Value: 15},
|
||||
}; !slices.Equal(two.Parts, want) {
|
||||
t.Errorf("two-syllable word parts = %+v, want %+v", two.Parts, want)
|
||||
}
|
||||
if sum := sumParts(two.Parts); sum != two.Points {
|
||||
t.Errorf("parts sum to %d, want Points %d", sum, two.Points)
|
||||
}
|
||||
|
||||
// Four syllables is two past the minimum, so this word's syllable term is
|
||||
// the one difference from the case above and must appear rather than be
|
||||
// silently folded into the total.
|
||||
four, r := e.Submit(bob, "pháp vô tuyến điện", t0)
|
||||
if r != ReasonNone {
|
||||
t.Fatalf("four-syllable word rejected: %s", r)
|
||||
}
|
||||
if want := []PointPart{
|
||||
{Kind: PointKindBase, Value: 10},
|
||||
{Kind: PointKindChain, Value: 4},
|
||||
{Kind: PointKindSyllables, Value: 10},
|
||||
{Kind: PointKindSpeed, Value: 10},
|
||||
{Kind: PointKindRarity, Value: 15},
|
||||
}; !slices.Equal(four.Parts, want) {
|
||||
t.Errorf("four-syllable word parts = %+v, want %+v", four.Parts, want)
|
||||
}
|
||||
if sum := sumParts(four.Parts); sum != four.Points {
|
||||
t.Errorf("parts sum to %d, want Points %d", sum, four.Points)
|
||||
}
|
||||
}
|
||||
|
||||
// When the naive sum overruns maxPointsPerWord, the parts must still sum to
|
||||
// the capped total exactly — trimmed from the end (rarity, then speed, then
|
||||
// syllables) rather than the total being clamped while the breakdown still
|
||||
// claims the uncapped numbers.
|
||||
func TestSubmitScoringPartsAreTrimmedAtTheCap(t *testing.T) {
|
||||
// A 20-syllable word answering "ngữ", the only continuation the dictionary
|
||||
// offers there, so rarity pays its maximum: base 10 + chain 2 + syllables
|
||||
// 5*18=90 + speed 10 + rarity 15 = 127 raw, 27 over the cap.
|
||||
tokens := []string{"ngữ"}
|
||||
for i := range 18 {
|
||||
tokens = append(tokens, fmt.Sprintf("s%d", i))
|
||||
}
|
||||
tokens = append(tokens, "z")
|
||||
long := strings.Join(tokens, " ")
|
||||
|
||||
e := newGame(t, newDict("ngôn ngữ", long), "ngôn ngữ")
|
||||
move, r := e.Submit(alice, long, t0)
|
||||
if r != ReasonNone {
|
||||
t.Fatalf("long word rejected: %s", r)
|
||||
}
|
||||
if move.Points != maxPointsPerWord {
|
||||
t.Fatalf("Points = %d, want the cap %d", move.Points, maxPointsPerWord)
|
||||
}
|
||||
if sum := sumParts(move.Parts); sum != maxPointsPerWord {
|
||||
t.Errorf("parts sum to %d, want the cap %d", sum, maxPointsPerWord)
|
||||
}
|
||||
// Rarity and speed are trimmed away entirely (15 + 10 = 25 of the 27
|
||||
// overflow) before syllables gives up the remaining 2, so base and chain
|
||||
// are untouched and syllables lands at 88 rather than its raw 90.
|
||||
if want := []PointPart{
|
||||
{Kind: PointKindBase, Value: 10},
|
||||
{Kind: PointKindChain, Value: 2},
|
||||
{Kind: PointKindSyllables, Value: 88},
|
||||
}; !slices.Equal(move.Parts, want) {
|
||||
t.Errorf("trimmed parts = %+v, want %+v", move.Parts, want)
|
||||
}
|
||||
}
|
||||
|
||||
func sumParts(parts []PointPart) int {
|
||||
sum := 0
|
||||
for _, p := range parts {
|
||||
sum += p.Value
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
func TestPointKindStrings(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for k := PointKind(0); k < NumPointKinds; k++ {
|
||||
s := k.String()
|
||||
if s == "" || s == "unknown" {
|
||||
t.Errorf("PointKind(%d).String() = %q", k, s)
|
||||
}
|
||||
if seen[s] {
|
||||
t.Errorf("duplicate description %q", s)
|
||||
}
|
||||
seen[s] = true
|
||||
}
|
||||
if got := PointKind(99).String(); got != "unknown" {
|
||||
t.Errorf("unknown PointKind string = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegalMovesAndHasLegalMove(t *testing.T) {
|
||||
e := newGame(t, standardDict(), "ngôn ngữ")
|
||||
|
||||
|
||||
@@ -86,7 +86,55 @@ type Move struct {
|
||||
Last string
|
||||
Syllables int
|
||||
Points int
|
||||
At time.Time
|
||||
// Parts is how Points was arrived at: one entry per non-zero term, summing
|
||||
// exactly to Points even when the maxPointsPerWord cap trimmed them. See
|
||||
// pointsFor.
|
||||
Parts []PointPart
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// PointKind names one term of a word's score. PointKindNone is the zero value
|
||||
// and never appears in a PointPart — it exists only so the wire mapping in
|
||||
// wsapi/convert.go has somewhere unreachable to send an unmapped value, the
|
||||
// same shape RejectReason and EndReason already use.
|
||||
type PointKind int
|
||||
|
||||
const (
|
||||
PointKindNone PointKind = iota
|
||||
PointKindBase
|
||||
PointKindChain
|
||||
PointKindSyllables
|
||||
PointKindSpeed
|
||||
PointKindRarity
|
||||
|
||||
// NumPointKinds is one past the last defined kind. See NumRejectReasons
|
||||
// for why the count is a constant rather than a walk.
|
||||
NumPointKinds
|
||||
)
|
||||
|
||||
func (k PointKind) String() string {
|
||||
switch k {
|
||||
case PointKindNone:
|
||||
return "none"
|
||||
case PointKindBase:
|
||||
return "base"
|
||||
case PointKindChain:
|
||||
return "chain"
|
||||
case PointKindSyllables:
|
||||
return "syllables"
|
||||
case PointKindSpeed:
|
||||
return "speed"
|
||||
case PointKindRarity:
|
||||
return "rarity"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// PointPart is one named term of a word's score: how many points it
|
||||
// contributed, and which of pointsFor's terms it was.
|
||||
type PointPart struct {
|
||||
Kind PointKind
|
||||
Value int
|
||||
}
|
||||
|
||||
// EndReason says how a finished game ended.
|
||||
|
||||
@@ -59,9 +59,19 @@ func welcomeMsg(sessionID, resumeToken, nickname string) *noituv1.ServerMessage
|
||||
}}}
|
||||
}
|
||||
|
||||
func moveRejectedMsg(reason noituv1.RejectReason, word string, turnSeq uint32) *noituv1.ServerMessage {
|
||||
// suggestion is REJECT_REASON_NOT_IN_DICTIONARY only: the one real word the
|
||||
// submission differs from by diacritics alone, or empty when none applies.
|
||||
func moveRejectedMsg(reason noituv1.RejectReason, word string, turnSeq uint32, suggestion string) *noituv1.ServerMessage {
|
||||
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_MoveRejected{
|
||||
MoveRejected: &noituv1.MoveRejected{Reason: reason, Word: word, TurnSeq: turnSeq},
|
||||
MoveRejected: &noituv1.MoveRejected{Reason: reason, Word: word, TurnSeq: turnSeq, Suggestion: suggestion},
|
||||
}}
|
||||
}
|
||||
|
||||
// wordReportedMsg acknowledges a ReportWord, echoing the word as the server
|
||||
// recorded it so the player sees that it was heard.
|
||||
func wordReportedMsg(word string) *noituv1.ServerMessage {
|
||||
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_WordReported{
|
||||
WordReported: &noituv1.WordReported{Word: word},
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,40 @@ func EndReason(r game.EndReason) noituv1.GameEndReason {
|
||||
return noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED
|
||||
}
|
||||
|
||||
// PointKind maps an engine score term onto the wire enum.
|
||||
//
|
||||
// game.PointKindNone has no wire counterpart: a PointPart exists only for a
|
||||
// term that actually contributed, so no move ever carries one and this arm is
|
||||
// reached only by the exhaustiveness test.
|
||||
func PointKind(k game.PointKind) noituv1.PointKind {
|
||||
switch k {
|
||||
case game.PointKindNone:
|
||||
return noituv1.PointKind_POINT_KIND_UNSPECIFIED
|
||||
case game.PointKindBase:
|
||||
return noituv1.PointKind_POINT_KIND_BASE
|
||||
case game.PointKindChain:
|
||||
return noituv1.PointKind_POINT_KIND_CHAIN
|
||||
case game.PointKindSyllables:
|
||||
return noituv1.PointKind_POINT_KIND_SYLLABLES
|
||||
case game.PointKindSpeed:
|
||||
return noituv1.PointKind_POINT_KIND_SPEED
|
||||
case game.PointKindRarity:
|
||||
return noituv1.PointKind_POINT_KIND_RARITY
|
||||
}
|
||||
log.Printf("wsapi: no wire mapping for game.PointKind(%d) %q", int(k), k)
|
||||
return noituv1.PointKind_POINT_KIND_UNSPECIFIED
|
||||
}
|
||||
|
||||
// PointParts renders a move's score breakdown for the wire, in the order the
|
||||
// engine produced them.
|
||||
func PointParts(parts []game.PointPart) []*noituv1.PointPart {
|
||||
out := make([]*noituv1.PointPart, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
out = append(out, &noituv1.PointPart{Kind: PointKind(p.Kind), Value: uint32(p.Value)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Difficulty maps a wire difficulty onto a bot strategy selector.
|
||||
//
|
||||
// This one runs client to server, so an unrecognized value is untrusted input
|
||||
@@ -113,6 +147,7 @@ func PlayedWord(m game.Move, byMe bool, meanings []dictionary.Sense) *noituv1.Pl
|
||||
Points: uint32(m.Points),
|
||||
Syllables: uint32(m.Syllables),
|
||||
Meanings: Senses(meanings),
|
||||
Parts: PointParts(m.Parts),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,85 @@ func TestEndReasonMappingIsExhaustive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func enginePointKinds() []game.PointKind {
|
||||
out := make([]game.PointKind, 0, game.NumPointKinds)
|
||||
for i := game.PointKind(0); i < game.NumPointKinds; i++ {
|
||||
out = append(out, i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestPointKindMappingIsExhaustive mirrors the reject-reason walk. Only
|
||||
// PointKindNone is allowed to land on UNSPECIFIED: a PointPart exists only for
|
||||
// a term that contributed, so no real move ever carries that value.
|
||||
func TestPointKindMappingIsExhaustive(t *testing.T) {
|
||||
kinds := enginePointKinds()
|
||||
if len(kinds) < 2 {
|
||||
t.Fatalf("enumerated %d engine point kinds, expected the full set", len(kinds))
|
||||
}
|
||||
|
||||
seen := make(map[noituv1.PointKind]game.PointKind, len(kinds))
|
||||
for _, k := range kinds {
|
||||
got := PointKind(k)
|
||||
if k == game.PointKindNone {
|
||||
if got != noituv1.PointKind_POINT_KIND_UNSPECIFIED {
|
||||
t.Errorf("PointKindNone should map to UNSPECIFIED, got %v", got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got == noituv1.PointKind_POINT_KIND_UNSPECIFIED {
|
||||
t.Errorf("game.PointKind(%d) %q has no wire mapping", int(k), k)
|
||||
continue
|
||||
}
|
||||
if prev, dup := seen[got]; dup {
|
||||
t.Errorf("%v is produced by both %q and %q", got, prev, k)
|
||||
}
|
||||
seen[got] = k
|
||||
}
|
||||
|
||||
for _, v := range enumValues(noituv1.PointKind_POINT_KIND_UNSPECIFIED.Descriptor()) {
|
||||
w := noituv1.PointKind(v)
|
||||
if w == noituv1.PointKind_POINT_KIND_UNSPECIFIED {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[w]; !ok {
|
||||
t.Errorf("wire value %v is unreachable: no engine point kind maps to it", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayedWordCarriesItsScoreBreakdown guards the invariant the client
|
||||
// relies on: the parts it draws beside the total must sum to it exactly.
|
||||
func TestPlayedWordCarriesItsScoreBreakdown(t *testing.T) {
|
||||
m := game.Move{
|
||||
Player: "p1",
|
||||
Word: "bình yên",
|
||||
Points: 25,
|
||||
Parts: []game.PointPart{
|
||||
{Kind: game.PointKindBase, Value: 10},
|
||||
{Kind: game.PointKindSpeed, Value: 15},
|
||||
},
|
||||
}
|
||||
|
||||
rendered := PlayedWord(m, true, nil)
|
||||
if len(rendered.GetParts()) != 2 {
|
||||
t.Fatalf("rendered %d parts, want 2", len(rendered.GetParts()))
|
||||
}
|
||||
sum := uint32(0)
|
||||
for _, p := range rendered.GetParts() {
|
||||
sum += p.GetValue()
|
||||
}
|
||||
if sum != rendered.GetPoints() {
|
||||
t.Errorf("parts sum to %d, want points %d", sum, rendered.GetPoints())
|
||||
}
|
||||
if rendered.GetParts()[0].GetKind() != noituv1.PointKind_POINT_KIND_BASE {
|
||||
t.Errorf("first part kind = %v, want BASE", rendered.GetParts()[0].GetKind())
|
||||
}
|
||||
if rendered.GetParts()[1].GetKind() != noituv1.PointKind_POINT_KIND_SPEED {
|
||||
t.Errorf("second part kind = %v, want SPEED", rendered.GetParts()[1].GetKind())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDifficultyMapping checks each level and, more importantly, that an
|
||||
// unknown wire difficulty is refused rather than defaulted. Difficulty arrives
|
||||
// from the client, so silently treating garbage as Easy would let a malformed
|
||||
|
||||
@@ -42,6 +42,13 @@ type metricSet struct {
|
||||
|
||||
chatLines *expvar.Int
|
||||
|
||||
// deadEndClaims is keyed "true" or "false": whether the claimed position
|
||||
// actually had no legal move.
|
||||
deadEndClaims *expvar.Map
|
||||
// wordsReported counts accepted ReportWord messages — never the refused
|
||||
// ones, which cost the sender an error and nothing else.
|
||||
wordsReported *expvar.Int
|
||||
|
||||
// joinsRefused is keyed by the same UI error code the client is sent —
|
||||
// "too_many_attempts", "room_not_found" or "room_full" — so a reader
|
||||
// checking this counter against the frontend copy is checking against the
|
||||
@@ -83,6 +90,8 @@ func newMetricSet() *metricSet {
|
||||
wordsRejected: expvar.NewMap("noitu_words_rejected"),
|
||||
eliminations: expvar.NewMap("noitu_eliminations"),
|
||||
chatLines: expvar.NewInt("noitu_chat_lines"),
|
||||
deadEndClaims: expvar.NewMap("noitu_dead_end_claims"),
|
||||
wordsReported: expvar.NewInt("noitu_words_reported"),
|
||||
joinsRefused: expvar.NewMap("noitu_joins_refused"),
|
||||
botMoves: expvar.NewMap("noitu_bot_moves"),
|
||||
resumesAttempted: expvar.NewInt("noitu_resumes_attempted"),
|
||||
|
||||
@@ -160,6 +160,22 @@ type resignInput struct {
|
||||
player game.PlayerID
|
||||
}
|
||||
|
||||
// claimDeadEndInput is the player to act saying the syllable has no answer
|
||||
// left. Carries the connection, not just the claimed seat, for the same
|
||||
// reason resignInput does.
|
||||
type claimDeadEndInput struct {
|
||||
sess *session
|
||||
player game.PlayerID
|
||||
}
|
||||
|
||||
// reportWordInput is a word the session has already validated as reportable —
|
||||
// long enough, and within its own per-session cap — waiting only on the room
|
||||
// for the context a report is logged with: the syllable in play, if any.
|
||||
type reportWordInput struct {
|
||||
sess *session
|
||||
word string
|
||||
}
|
||||
|
||||
type disconnectInput struct {
|
||||
player game.PlayerID
|
||||
// sess identifies which connection dropped. A player who already
|
||||
@@ -313,6 +329,9 @@ type Dictionary interface {
|
||||
RandomOpeningWord(minOutDegree int) (string, error)
|
||||
// Meanings returns a canonical word's senses in order, nil for none.
|
||||
Meanings(word string) []dictionary.Sense
|
||||
// NearMiss finds the one real word a normalized submission differs from by
|
||||
// diacritics alone, reported only when exactly one such word exists.
|
||||
NearMiss(normalized string) (string, bool)
|
||||
}
|
||||
|
||||
func newRoom(h *hub, code string, turnLimit, graceFor, idleFor time.Duration, mode string) *room {
|
||||
@@ -474,6 +493,11 @@ func (r *room) run() {
|
||||
idleActivity = false
|
||||
case resignInput:
|
||||
r.handleResign(m)
|
||||
case claimDeadEndInput:
|
||||
r.handleClaimDeadEnd(m)
|
||||
case reportWordInput:
|
||||
r.handleReportWord(m)
|
||||
idleActivity = false
|
||||
case disconnectInput:
|
||||
// A dropped connection is not a player leaving: the seat is
|
||||
// held for the reconnect window whether a game is running or
|
||||
@@ -589,6 +613,43 @@ func (r *room) handleResign(m resignInput) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleClaimDeadEnd is the player to act saying the syllable in play has no
|
||||
// answer left, checked rather than trusted.
|
||||
//
|
||||
// A true claim takes them out at once with EndNoLegalMove — exactly what the
|
||||
// clock would eventually rule, so the game's own outcome is unchanged and
|
||||
// only the wait is gone. A false claim changes nothing at all: the clock
|
||||
// keeps running and the claimant is simply told a word exists, which is hint
|
||||
// enough to be the whole cost of asking wrongly.
|
||||
func (r *room) handleClaimDeadEnd(m claimDeadEndInput) {
|
||||
if !r.occupies(m.sess, m.player) {
|
||||
m.sess.send(errorMsg("not_your_seat"))
|
||||
return
|
||||
}
|
||||
if r.engine == nil {
|
||||
m.sess.send(errorMsg("game_not_started"))
|
||||
return
|
||||
}
|
||||
if r.engine.Over() {
|
||||
return
|
||||
}
|
||||
if r.engine.Turn() != m.player {
|
||||
m.sess.send(errorMsg("not_your_turn"))
|
||||
return
|
||||
}
|
||||
if r.engine.HasLegalMove() {
|
||||
metrics.deadEndClaims.Add("false", 1)
|
||||
m.sess.send(errorMsg("not_a_dead_end"))
|
||||
return
|
||||
}
|
||||
|
||||
metrics.deadEndClaims.Add("true", 1)
|
||||
before := r.mark()
|
||||
if r.engine.NoMove(time.Now()) {
|
||||
r.applyEliminations(before)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStartBot seats a bot opposite the player and begins immediately.
|
||||
func (r *room) handleStartBot(m startBotInput) {
|
||||
strategy, err := bot.New(m.difficulty, rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())))
|
||||
@@ -894,7 +955,7 @@ func (r *room) handleSubmit(m submitInput) {
|
||||
metrics.wordsSubmitted.Add(1)
|
||||
|
||||
if m.turnSeq != r.turnSeq {
|
||||
r.sendTo(m.player, moveRejectedMsg(noituv1.RejectReason_REJECT_REASON_NOT_YOUR_TURN, m.word, r.turnSeq))
|
||||
r.sendTo(m.player, moveRejectedMsg(noituv1.RejectReason_REJECT_REASON_NOT_YOUR_TURN, m.word, r.turnSeq, ""))
|
||||
r.recordRejection(game.ReasonNotYourTurn, m.word)
|
||||
return
|
||||
}
|
||||
@@ -908,7 +969,7 @@ func (r *room) handleSubmit(m submitInput) {
|
||||
before := r.mark()
|
||||
move, reason := r.engine.Submit(m.player, word, time.Now())
|
||||
if reason != game.ReasonNone {
|
||||
r.sendTo(m.player, moveRejectedMsg(RejectReason(reason), word, m.turnSeq))
|
||||
r.sendTo(m.player, moveRejectedMsg(RejectReason(reason), word, m.turnSeq, r.nearMissFor(reason, word)))
|
||||
r.recordRejection(reason, word)
|
||||
// A rejection for an expired turn also took this player out of the
|
||||
// game, and everybody has to be told which.
|
||||
@@ -924,6 +985,25 @@ func (r *room) handleSubmit(m submitInput) {
|
||||
r.maybeScheduleBot()
|
||||
}
|
||||
|
||||
// nearMissFor finds a diacritic-typo suggestion for a word the dictionary
|
||||
// refused. Only for REJECT_REASON_NOT_IN_DICTIONARY: every other rejection
|
||||
// means the word IS in the dictionary and was refused for some other reason,
|
||||
// where a spelling suggestion would be misleading rather than helpful.
|
||||
func (r *room) nearMissFor(reason game.RejectReason, raw string) string {
|
||||
if reason != game.ReasonNotInDictionary {
|
||||
return ""
|
||||
}
|
||||
normalized, _, err := vietnamese.Normalize(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
suggestion, ok := r.dict.NearMiss(normalized)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return suggestion
|
||||
}
|
||||
|
||||
// recordRejection counts one rejected submission and logs it at Info.
|
||||
//
|
||||
// This is the corpus feedback loop the improvement report calls the input to
|
||||
@@ -956,6 +1036,24 @@ func (r *room) recordRejection(reason game.RejectReason, raw string) {
|
||||
)
|
||||
}
|
||||
|
||||
// handleReportWord logs one report with this room's context.
|
||||
//
|
||||
// The session has already checked the word is long enough and within its own
|
||||
// per-session cap before routing it here — this is only about what to log,
|
||||
// and the syllable in play, this room's mode and its code are all room
|
||||
// goroutine state that only the room may read. Never the reporting player's
|
||||
// seat or name: recordRejection keeps the same information out of the corpus
|
||||
// feedback loop for the same reason.
|
||||
func (r *room) handleReportWord(m reportWordInput) {
|
||||
link := ""
|
||||
if r.engine != nil {
|
||||
link = r.engine.Current()
|
||||
}
|
||||
metrics.wordsReported.Add(1)
|
||||
slog.Info("word_reported", "word", m.word, "link", link, "mode", r.mode, "room", r.code)
|
||||
m.sess.send(wordReportedMsg(m.word))
|
||||
}
|
||||
|
||||
// handleBotMove applies what the worker chose.
|
||||
func (r *room) handleBotMove(m botMoveInput) {
|
||||
if r.engine == nil || r.engine.Over() {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/coder/websocket"
|
||||
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
||||
"github.com/tiennm99dev/noitu/server/internal/game"
|
||||
"github.com/tiennm99dev/noitu/server/internal/vietnamese"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -49,6 +50,13 @@ const (
|
||||
joinsPerSecond = 1
|
||||
joinBurst = 5
|
||||
|
||||
// maxWordReportsPerSession bounds how many distinct words one session may
|
||||
// file with ReportWord. A duplicate report of a word already filed does
|
||||
// not count against it — it costs nothing new to acknowledge again — but
|
||||
// an unbounded stream of distinct ones would turn the corpus feedback loop
|
||||
// into a log-filling vector.
|
||||
maxWordReportsPerSession = 20
|
||||
|
||||
// Room creation is far more expensive than a join: each one is a
|
||||
// goroutine, an engine and a registry entry held until the game ends.
|
||||
roomsPerSecond = 0.2
|
||||
@@ -121,6 +129,13 @@ type session struct {
|
||||
chatLimiter *bucket
|
||||
frameLimiter *bucket
|
||||
|
||||
// reportedWords is every distinct word this session has filed with
|
||||
// ReportWord, capped at maxWordReportsPerSession. Touched only from
|
||||
// dispatch, which is the sole reader of this connection's frames, so it
|
||||
// needs no lock of its own — unlike nick/room/playerID above, nothing else
|
||||
// ever reads or writes it.
|
||||
reportedWords map[string]struct{}
|
||||
|
||||
// greeted marks the handshake done. It is a one-shot transition: a second
|
||||
// Hello would re-register the session and rewrite its nickname mid-game.
|
||||
greeted bool
|
||||
@@ -147,6 +162,7 @@ func newSession(ctx context.Context, conn *websocket.Conn, h *hub, remoteIP stri
|
||||
roomLimiter: newBucket(roomsPerSecond, roomBurst, time.Now()),
|
||||
chatLimiter: newBucket(chatsPerSecond, chatBurst, time.Now()),
|
||||
frameLimiter: newBucket(framesPerSecond, frameBurst, time.Now()),
|
||||
reportedWords: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,6 +521,24 @@ func (s *session) dispatch(msg *noituv1.ClientMessage) error {
|
||||
s.send(errorMsg("not_in_a_game"))
|
||||
}
|
||||
|
||||
case *noituv1.ClientMessage_ClaimDeadEnd:
|
||||
// Rate-limited on the same budget as a submission: a claim is the
|
||||
// alternative to playing a word, not a second action alongside it.
|
||||
if !s.submitLimiter.allow(time.Now()) {
|
||||
s.send(errorMsg("too_fast"))
|
||||
return nil
|
||||
}
|
||||
if r, id := s.currentRoom(); r != nil {
|
||||
if !r.send(claimDeadEndInput{sess: s, player: id}) {
|
||||
s.send(errorMsg("busy"))
|
||||
}
|
||||
} else {
|
||||
s.send(errorMsg("not_in_a_game"))
|
||||
}
|
||||
|
||||
case *noituv1.ClientMessage_ReportWord:
|
||||
s.handleReportWord(p.ReportWord)
|
||||
|
||||
case *noituv1.ClientMessage_SetReady:
|
||||
s.toRoom(lobbyInput{sess: s, action: lobbyReady, ready: p.SetReady.GetReady()})
|
||||
|
||||
@@ -652,6 +686,49 @@ func (s *session) handleSubmit(w *noituv1.SubmitWord) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleReportWord validates a word report and, once it is worth logging,
|
||||
// hands it to the current room for the context only the room goroutine may
|
||||
// read — the syllable in play, and the room's own mode and code.
|
||||
//
|
||||
// Validation happens here rather than in the room because it is entirely
|
||||
// about this connection: its own rate budget, and its own running count of
|
||||
// distinct words already filed. Neither needs the room at all, and a session
|
||||
// playing no game — smoke-testing the wire directly, per the README — can
|
||||
// still file a report, acknowledged with mode "none" and no link.
|
||||
func (s *session) handleReportWord(m *noituv1.ReportWord) {
|
||||
if !s.chatLimiter.allow(time.Now()) {
|
||||
s.send(errorMsg("too_fast"))
|
||||
return
|
||||
}
|
||||
|
||||
word, syllables, err := vietnamese.Normalize(sanitizeText(m.GetWord(), maxWordRunes, maxNicknameMarks))
|
||||
if err != nil || !vietnamese.HasEnoughSyllables(syllables) {
|
||||
s.send(errorMsg("word_report_refused"))
|
||||
return
|
||||
}
|
||||
|
||||
if _, already := s.reportedWords[word]; !already {
|
||||
if len(s.reportedWords) >= maxWordReportsPerSession {
|
||||
s.send(errorMsg("word_report_limit"))
|
||||
return
|
||||
}
|
||||
s.reportedWords[word] = struct{}{}
|
||||
}
|
||||
|
||||
// currentRoom's player id is not needed here: the log line is about the
|
||||
// word and the room's context, never about who filed it.
|
||||
if r, _ := s.currentRoom(); r != nil {
|
||||
if !r.send(reportWordInput{sess: s, word: word}) {
|
||||
s.send(errorMsg("busy"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
metrics.wordsReported.Add(1)
|
||||
slog.Info("word_reported", "word", word, "link", "", "mode", "none", "room", "")
|
||||
s.send(wordReportedMsg(word))
|
||||
}
|
||||
|
||||
// leaveRoom tells the room this connection is gone, so the seat enters its
|
||||
// grace window rather than the game simply stalling.
|
||||
func (s *session) leaveRoom() {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"github.com/tiennm99dev/noitu/server/internal/bot"
|
||||
"github.com/tiennm99dev/noitu/server/internal/dictionary"
|
||||
"github.com/tiennm99dev/noitu/server/internal/game"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
@@ -90,6 +92,41 @@ func (d *testDict) OutDegree(syllable string) (int, error) {
|
||||
|
||||
func (d *testDict) RandomOpeningWord(int) (string, error) { return d.opening, nil }
|
||||
|
||||
// NearMiss strips tone marks and the đ/d distinction by hand — the same
|
||||
// typing-distance fold dictionary.stripDiacritics performs — since this test
|
||||
// graph is built by hand rather than through the real Store.
|
||||
func (d *testDict) NearMiss(normalized string) (string, bool) {
|
||||
strip := func(s string) string {
|
||||
s = norm.NFD.String(s)
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case unicode.Is(unicode.Mn, r):
|
||||
continue
|
||||
case r == 'đ':
|
||||
r = 'd'
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
key := strip(normalized)
|
||||
match, count := "", 0
|
||||
for w := range d.words {
|
||||
if w == normalized {
|
||||
continue
|
||||
}
|
||||
if strip(w) == key {
|
||||
match, count = w, count+1
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
return "", false
|
||||
}
|
||||
return match, true
|
||||
}
|
||||
|
||||
func maps(m map[string][2]string) iter.Seq[string] {
|
||||
return func(yield func(string) bool) {
|
||||
for k := range m {
|
||||
@@ -279,6 +316,8 @@ func payloadCase(m *noituv1.ServerMessage) string {
|
||||
return "chat_history"
|
||||
case *noituv1.ServerMessage_QuickMatchStatus:
|
||||
return "quick_match_status"
|
||||
case *noituv1.ServerMessage_WordReported:
|
||||
return "word_reported"
|
||||
}
|
||||
// Named rather than empty: a missing arm here makes every await for that
|
||||
// message time out with nothing to say about why.
|
||||
@@ -1170,6 +1209,20 @@ func (c *testClient) resign() {
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
|
||||
}
|
||||
|
||||
func (c *testClient) claimDeadEnd() {
|
||||
c.t.Helper()
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_ClaimDeadEnd{
|
||||
ClaimDeadEnd: &noituv1.ClaimDeadEnd{},
|
||||
}})
|
||||
}
|
||||
|
||||
func (c *testClient) reportWord(word string) {
|
||||
c.t.Helper()
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_ReportWord{
|
||||
ReportWord: &noituv1.ReportWord{Word: word},
|
||||
}})
|
||||
}
|
||||
|
||||
// resignFrom has one player of a two-player game give up, whichever of them
|
||||
// drew the first turn.
|
||||
//
|
||||
@@ -2090,6 +2143,15 @@ func offlineSession(t *testing.T, capacity int) *session {
|
||||
cancel: cancel,
|
||||
out: make(chan []byte, capacity),
|
||||
flushed: make(chan struct{}),
|
||||
// Generous rather than zero: most callers exercise a handler directly
|
||||
// and never touch a limiter, but one that does — handleReportWord — must
|
||||
// not panic on a nil bucket, and a test about something else has no
|
||||
// reason to also be a test of rate limiting.
|
||||
submitLimiter: newBucket(1000, 1000, time.Now()),
|
||||
roomLimiter: newBucket(1000, 1000, time.Now()),
|
||||
chatLimiter: newBucket(1000, 1000, time.Now()),
|
||||
frameLimiter: newBucket(1000, 1000, time.Now()),
|
||||
reportedWords: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2306,3 +2368,167 @@ func TestLobbyActionsAreRateLimited(t *testing.T) {
|
||||
}
|
||||
t.Error("a burst of lobby actions was never refused")
|
||||
}
|
||||
|
||||
// --- dead-end claims ---------------------------------------------------
|
||||
|
||||
// TestClaimDeadEndEliminatesImmediately walks a player into a real dead end
|
||||
// and has them claim it rather than wait out the clock. The outcome must be
|
||||
// exactly what a timeout would have produced: NO_LEGAL_MOVE, no answerable
|
||||
// suggestions.
|
||||
func TestClaimDeadEndEliminatesImmediately(t *testing.T) {
|
||||
// "b" starts nothing after "b c" is played, so whoever inherits "c" has no
|
||||
// move at all.
|
||||
_, url := newTestServer(t, newTestDict("a b", "b c"), Config{})
|
||||
host, guest, start := pvpRoom(t, url)
|
||||
lead, stuck := host, guest
|
||||
if !start.GetMyTurn() {
|
||||
lead, stuck = guest, host
|
||||
}
|
||||
|
||||
lead.submit("b c", start.GetTurnSeq())
|
||||
lead.await("turn_update")
|
||||
turn := stuck.await("turn_update").GetTurnUpdate()
|
||||
if turn.GetCurrentSyllable() != "c" {
|
||||
t.Fatalf("current syllable = %q, want %q", turn.GetCurrentSyllable(), "c")
|
||||
}
|
||||
if !turn.GetMyTurn() {
|
||||
t.Fatal("the player left with the dead end should be on turn")
|
||||
}
|
||||
|
||||
stuck.claimDeadEnd()
|
||||
|
||||
out := stuck.await("player_eliminated").GetPlayerEliminated()
|
||||
if !out.GetIsMe() {
|
||||
t.Error("the claimant should be the one eliminated")
|
||||
}
|
||||
if out.GetReason() != noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE {
|
||||
t.Errorf("reason = %v, want NO_LEGAL_MOVE", out.GetReason())
|
||||
}
|
||||
if len(out.GetSuggestions()) != 0 {
|
||||
t.Errorf("suggestions = %v, want none for a genuine dead end", out.GetSuggestions())
|
||||
}
|
||||
|
||||
stuck.await("game_over")
|
||||
lead.await("game_over")
|
||||
}
|
||||
|
||||
// TestClaimDeadEndRefusedWhenAMoveExists checks the false claim costs nothing
|
||||
// but the answer: the clock is untouched, proven by the original turn_seq
|
||||
// still being accepted afterwards.
|
||||
func TestClaimDeadEndRefusedWhenAMoveExists(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, start := pvpRoom(t, url)
|
||||
lead := host
|
||||
if !start.GetMyTurn() {
|
||||
lead = guest
|
||||
}
|
||||
|
||||
lead.claimDeadEnd()
|
||||
if code := lead.await("error").GetError().GetCode(); code != "not_a_dead_end" {
|
||||
t.Errorf("code = %q, want not_a_dead_end", code)
|
||||
}
|
||||
|
||||
// The turn_seq the claim was answered on is still the current one: a
|
||||
// submission stamped with it is still accepted rather than refused as
|
||||
// stale.
|
||||
lead.submit("b c", start.GetTurnSeq())
|
||||
lead.await("turn_update")
|
||||
}
|
||||
|
||||
// TestClaimDeadEndOutOfTurnRefused: only the player to act may spend a claim,
|
||||
// exactly as only they may spend a resignation.
|
||||
func TestClaimDeadEndOutOfTurnRefused(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, start := pvpRoom(t, url)
|
||||
waits := guest
|
||||
if !start.GetMyTurn() {
|
||||
waits = host
|
||||
}
|
||||
|
||||
waits.claimDeadEnd()
|
||||
if code := waits.await("error").GetError().GetCode(); code != "not_your_turn" {
|
||||
t.Errorf("code = %q, want not_your_turn", code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- near-miss suggestions ----------------------------------------------
|
||||
|
||||
// TestNearMissSuggestionOnWire is the end-to-end proof that a diacritic typo
|
||||
// carries a suggestion: the dictionary layer is unit-tested on its own, but
|
||||
// only this shows the room actually wires MoveRejected.suggestion up.
|
||||
func TestNearMissSuggestionOnWire(t *testing.T) {
|
||||
_, url := newTestServer(t, newTestDict("ngôn ngữ", "ngữ pháp"), Config{})
|
||||
c := dial(t, url)
|
||||
c.hello("Người chơi")
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{
|
||||
StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY},
|
||||
}})
|
||||
started := c.await("game_started").GetGameStarted()
|
||||
|
||||
// Typed with no diacritics at all, which the dictionary does not know as a
|
||||
// word but which strips to exactly one real one.
|
||||
c.submit("ngu phap", started.GetTurnSeq())
|
||||
|
||||
rejected := c.await("move_rejected").GetMoveRejected()
|
||||
if rejected.GetReason() != noituv1.RejectReason_REJECT_REASON_NOT_IN_DICTIONARY {
|
||||
t.Fatalf("reason = %v, want NOT_IN_DICTIONARY", rejected.GetReason())
|
||||
}
|
||||
if rejected.GetSuggestion() != "ngữ pháp" {
|
||||
t.Errorf("suggestion = %q, want %q", rejected.GetSuggestion(), "ngữ pháp")
|
||||
}
|
||||
}
|
||||
|
||||
// --- word reports ---------------------------------------------------------
|
||||
|
||||
// TestReportWordAcceptsAndEchoes needs no room at all: filing a report is a
|
||||
// session-level fact, which is also what a direct wire client per the README
|
||||
// would exercise.
|
||||
func TestReportWordAcceptsAndEchoes(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
c := dial(t, url)
|
||||
c.hello("Người chơi")
|
||||
|
||||
c.reportWord("bình tâm")
|
||||
got := c.await("word_reported").GetWordReported()
|
||||
if got.GetWord() != "bình tâm" {
|
||||
t.Errorf("echoed word = %q, want %q", got.GetWord(), "bình tâm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportWordRefusesOneSyllable(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
c := dial(t, url)
|
||||
c.hello("Người chơi")
|
||||
|
||||
c.reportWord("một")
|
||||
if code := c.await("error").GetError().GetCode(); code != "word_report_refused" {
|
||||
t.Errorf("code = %q, want word_report_refused", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportWordEnforcesPerSessionCap drives the cap directly against the
|
||||
// session rather than through a real socket: 21 reports through the chat rate
|
||||
// limiter (chatBurst=5) would be a test of two budgets fighting each other
|
||||
// rather than of the cap itself.
|
||||
func TestReportWordEnforcesPerSessionCap(t *testing.T) {
|
||||
s := offlineSession(t, maxWordReportsPerSession+8)
|
||||
|
||||
for i := range maxWordReportsPerSession {
|
||||
s.handleReportWord(&noituv1.ReportWord{Word: fmt.Sprintf("từ số %d", i)})
|
||||
}
|
||||
accepted := queued(t, s)
|
||||
if len(accepted) != maxWordReportsPerSession {
|
||||
t.Fatalf("got %d replies for %d distinct reports, want one each", len(accepted), maxWordReportsPerSession)
|
||||
}
|
||||
for _, m := range accepted {
|
||||
if m.GetWordReported() == nil {
|
||||
t.Errorf("a report inside the cap was refused: %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
s.handleReportWord(&noituv1.ReportWord{Word: "một từ khác nữa"})
|
||||
overflow := queued(t, s)
|
||||
if len(overflow) != 1 || overflow[0].GetError().GetCode() != "word_report_limit" {
|
||||
t.Fatalf("the report past the cap = %+v, want a single word_report_limit error", overflow)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { fill, pointKindLabels, t } from '$lib/i18n/vi.js';
|
||||
import { scrollBehavior } from '$lib/motion.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
@@ -106,6 +106,16 @@
|
||||
{fill(t.correctedFrom, { typed: entry.typed, word: entry.word })}
|
||||
</span>
|
||||
{/if}
|
||||
{#if entry.parts.length}
|
||||
<!-- Why the word scored what it did, not only that it did: the
|
||||
client has no wordlist to re-derive this from, so the terms
|
||||
travel with the total. -->
|
||||
<p class="parts">
|
||||
{entry.parts
|
||||
.map((/** @type {any} */ p) => `+${p.value} ${pointKindLabels[p.kind] ?? ''}`)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
{/if}
|
||||
{#if open}
|
||||
<!-- Plain text from the server, rendered as text: the builder
|
||||
stripped the wiki markup and nothing here re-interprets it. -->
|
||||
@@ -302,4 +312,12 @@
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-3);
|
||||
}
|
||||
|
||||
.parts {
|
||||
margin: 0;
|
||||
padding: 0 12px 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -22,6 +22,8 @@
|
||||
* modeLabel?: string,
|
||||
* onsubmit: (word: string) => boolean,
|
||||
* onresign: () => void,
|
||||
* onclaimdeadend: () => void,
|
||||
* onreportword: (word: string) => void,
|
||||
* gameOver: import('svelte').Snippet,
|
||||
* banner?: import('svelte').Snippet,
|
||||
* chatUnread?: number,
|
||||
@@ -32,19 +34,25 @@
|
||||
modeLabel = '',
|
||||
onsubmit,
|
||||
onresign,
|
||||
onclaimdeadend,
|
||||
onreportword,
|
||||
gameOver,
|
||||
banner,
|
||||
chatUnread = 0,
|
||||
onchatopen
|
||||
} = $props();
|
||||
|
||||
/** How long an armed resign button waits before it goes back to being safe. */
|
||||
/** How long an armed resign or claim button waits before it goes back to being safe. */
|
||||
const ARM_MS = 4000;
|
||||
|
||||
let arming = $state(false);
|
||||
/** @type {ReturnType<typeof setTimeout>} */
|
||||
let armTimer;
|
||||
|
||||
let claimArming = $state(false);
|
||||
/** @type {ReturnType<typeof setTimeout>} */
|
||||
let claimArmTimer;
|
||||
|
||||
// Whose turn it is, said by name. With four people at the table "the
|
||||
// opponent is thinking" stops naming anybody.
|
||||
const turnLabel = $derived.by(() => {
|
||||
@@ -69,6 +77,16 @@
|
||||
arming = false;
|
||||
});
|
||||
|
||||
// A claim is the same offer resign is: made only on the player's own turn,
|
||||
// on the same reasoning canResign already states.
|
||||
const canClaimDeadEnd = $derived(game.state.myTurn && !offline);
|
||||
|
||||
$effect(() => {
|
||||
if (canClaimDeadEnd) return;
|
||||
clearTimeout(claimArmTimer);
|
||||
claimArming = false;
|
||||
});
|
||||
|
||||
/**
|
||||
* Two presses, in place of a native confirm().
|
||||
*
|
||||
@@ -90,7 +108,21 @@
|
||||
armTimer = setTimeout(() => (arming = false), ARM_MS);
|
||||
}
|
||||
|
||||
/** Same two-press shape as armOrResign, and for the same reason. */
|
||||
function armOrClaim() {
|
||||
if (claimArming) {
|
||||
clearTimeout(claimArmTimer);
|
||||
claimArming = false;
|
||||
onclaimdeadend();
|
||||
return;
|
||||
}
|
||||
claimArming = true;
|
||||
clearTimeout(claimArmTimer);
|
||||
claimArmTimer = setTimeout(() => (claimArming = false), ARM_MS);
|
||||
}
|
||||
|
||||
$effect(() => () => clearTimeout(armTimer));
|
||||
$effect(() => () => clearTimeout(claimArmTimer));
|
||||
</script>
|
||||
|
||||
<section class="board" data-phase={game.state.phase}>
|
||||
@@ -191,7 +223,33 @@
|
||||
{#if game.iAmOut}
|
||||
<p class="spectating">{t.spectating}</p>
|
||||
{:else}
|
||||
<WordInput {onsubmit} />
|
||||
<WordInput {onsubmit} {onreportword} />
|
||||
{#if game.state.myTurn}
|
||||
<!-- Next to the input, not down by resign: a dead end is read off
|
||||
the current syllable, which only means something on this
|
||||
player's own turn — unlike resign, there is no "not yet" state
|
||||
worth showing for it off turn. -->
|
||||
<button
|
||||
type="button"
|
||||
class="claim-dead-end"
|
||||
class:arming={claimArming}
|
||||
disabled={!canClaimDeadEnd}
|
||||
onclick={armOrClaim}
|
||||
>
|
||||
{claimArming ? t.claimDeadEndSure : t.claimDeadEnd}
|
||||
</button>
|
||||
{/if}
|
||||
{#if game.state.claimError}
|
||||
<p class="claim-error" role="alert">
|
||||
{game.state.claimError}
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button"
|
||||
onclick={() => game.clearClaimError()}
|
||||
aria-label={t.dismiss}>×</button
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -390,4 +448,48 @@
|
||||
background: var(--danger-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Beside the input rather than down with resign: a dead-end claim is about
|
||||
the syllable on screen right now, so it reads as part of answering it
|
||||
rather than as a way out of the game. Secondary weight either way — it
|
||||
is not the way to play a turn, just a shortcut past an empty one. */
|
||||
.claim-dead-end {
|
||||
align-self: flex-start;
|
||||
min-height: 32px;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: var(--text-3);
|
||||
transition: background-color 150ms ease-out;
|
||||
}
|
||||
|
||||
.claim-dead-end:hover:enabled {
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
.claim-dead-end:disabled {
|
||||
border-color: var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.claim-dead-end.arming {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.claim-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: 10px var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
font-size: var(--text-5);
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,16 @@
|
||||
<script>
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { RejectReason } from '$lib/proto/noitu/v1/game_pb.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
import { Status, connection } from '$lib/ws/connection.svelte.js';
|
||||
|
||||
/** @type {{ onsubmit: (word: string) => boolean }} */
|
||||
let { onsubmit } = $props();
|
||||
/**
|
||||
* @type {{
|
||||
* onsubmit: (word: string) => boolean,
|
||||
* onreportword: (word: string) => void
|
||||
* }}
|
||||
*/
|
||||
let { onsubmit, onreportword } = $props();
|
||||
|
||||
/** @type {HTMLInputElement | undefined} */
|
||||
let field = $state();
|
||||
@@ -145,6 +151,25 @@
|
||||
// every turn to get the keyboard back.
|
||||
field.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the field with the server's suggestion and focuses it. A direct
|
||||
* write, unlike everywhere else in this field: it happens only from this
|
||||
* click, a gesture the player just made rather than one mid-keystroke, so
|
||||
* no IME composition can be in progress for it to cancel.
|
||||
*/
|
||||
function useSuggestion() {
|
||||
const suggestion = game.state.rejection?.suggestion;
|
||||
if (!suggestion || !field) return;
|
||||
field.value = suggestion;
|
||||
field.focus();
|
||||
field.setSelectionRange(field.value.length, field.value.length);
|
||||
}
|
||||
|
||||
function report() {
|
||||
const word = game.state.rejection?.word;
|
||||
if (word) onreportword(word);
|
||||
}
|
||||
</script>
|
||||
|
||||
<form class="input-row" onsubmit={handleSubmit}>
|
||||
@@ -194,6 +219,22 @@
|
||||
clock, with nothing to compare against. -->
|
||||
<p class="rejection" id="word-rejection" role="alert">
|
||||
<strong>{game.state.rejection.word}</strong> — {game.state.rejection.message}
|
||||
{#if game.state.rejection.suggestion}
|
||||
<!-- Corrects typing, not vocabulary: the server only ever offers this
|
||||
for a word that differs from a real one by diacritics alone. -->
|
||||
<button type="button" class="suggestion" onclick={useSuggestion}>
|
||||
{fill(t.suggestionPrompt, { word: game.state.rejection.suggestion })}
|
||||
</button>
|
||||
{/if}
|
||||
{#if game.state.rejection.reason === RejectReason.NOT_IN_DICTIONARY}
|
||||
<button type="button" class="report" onclick={report}>{t.reportWord}</button>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if game.state.reportConfirmation}
|
||||
<p class="report-confirmation" role="status">
|
||||
{game.state.reportConfirmation}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -244,6 +285,10 @@
|
||||
}
|
||||
|
||||
.rejection {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px var(--space-2);
|
||||
margin: var(--space-2) 0 0;
|
||||
padding: 10px var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -255,4 +300,24 @@
|
||||
.rejection strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.suggestion,
|
||||
.report {
|
||||
padding: 2px var(--space-2);
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--radius-pill);
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: var(--text-3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.report-confirmation {
|
||||
margin: var(--space-2) 0 0;
|
||||
padding: 10px var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
font-size: var(--text-5);
|
||||
}
|
||||
</style>
|
||||
+35
-4
@@ -1,4 +1,4 @@
|
||||
import { RejectReason, GameEndReason, Difficulty } from '$lib/proto/noitu/v1/game_pb.js';
|
||||
import { RejectReason, GameEndReason, Difficulty, PointKind } from '$lib/proto/noitu/v1/game_pb.js';
|
||||
|
||||
/**
|
||||
* Every user-facing string in the app. Nothing outside this file writes
|
||||
@@ -37,6 +37,17 @@ export const t = {
|
||||
resignSure: 'Chắc chắn đầu hàng?',
|
||||
retry: 'Thử lại',
|
||||
|
||||
// The dead-end claim: "bí từ" is the same phrase the rules page uses for
|
||||
// the rule itself, so the button reads as naming the situation rather than
|
||||
// as a new piece of vocabulary.
|
||||
claimDeadEnd: 'Bí từ',
|
||||
claimDeadEndSure: 'Chắc chắn bí từ?',
|
||||
|
||||
// A player disputing a rejection, and the server's acknowledgement of it.
|
||||
reportWord: 'Báo từ này là từ thật',
|
||||
wordReported: 'Đã ghi nhận “{word}”. Cảm ơn bạn!',
|
||||
suggestionPrompt: 'Ý bạn là “{word}”?',
|
||||
|
||||
yourTurn: 'Đến lượt bạn',
|
||||
opponentTurn: 'Đối thủ đang suy nghĩ…',
|
||||
playerTurn: 'Đến lượt {name}…',
|
||||
@@ -181,7 +192,7 @@ export const t = {
|
||||
|
||||
rulesDeadEndTitle: 'Bí từ',
|
||||
rulesDeadEndBody:
|
||||
'Nối một từ khiến người kế tiếp không còn tiếng nào để nối không phải là thắng ngay lập tức: người bị bí vẫn được chơi lượt của mình và vẫn thua vào đồng hồ như bình thường, không phải thua ngay khi bí. Nếu họ không nối được, màn hình kết quả sẽ cho xem vài từ tiếng đó còn nối được — hoặc cho biết tiếng đó đã hết từ để nối.',
|
||||
'Nối một từ khiến người kế tiếp không còn tiếng nào để nối không phải là thắng ngay lập tức: người bị bí vẫn được chơi lượt của mình và vẫn thua vào đồng hồ như bình thường, không phải thua ngay khi bí. Nếu họ không nối được, màn hình kết quả sẽ cho xem vài từ tiếng đó còn nối được — hoặc cho biết tiếng đó đã hết từ để nối. Thay vì chờ hết giờ, người bị bí có thể bấm nút “Bí từ” cạnh ô nhập để máy chủ xác nhận ngay — nếu đúng là hết từ thì thua lượt ngay lập tức, còn nếu vẫn còn từ nối được thì bị từ chối và đồng hồ vẫn chạy tiếp như cũ.',
|
||||
|
||||
rulesEliminationTitle: 'Bị loại và người thắng cuộc',
|
||||
rulesEliminationBody:
|
||||
@@ -197,6 +208,8 @@ export const t = {
|
||||
rulesScoringRarity:
|
||||
'Cộng tối đa {bonus} điểm nếu tiếng đó hiếm từ để nối: được cộng đủ khi từ điển chỉ có đúng một từ bắt đầu bằng tiếng đó, giảm {penalty} điểm mỗi khi số từ có thể nối tăng gấp đôi.',
|
||||
rulesScoringCap: 'Dù cộng đủ mọi phần, một từ không bao giờ được quá {cap} điểm.',
|
||||
rulesScoringBreakdown:
|
||||
'Mỗi từ nối được hiển thị kèm phần tính điểm chi tiết ngay bên cạnh, để thấy rõ điểm đến từ đâu chứ không chỉ một con số.',
|
||||
|
||||
rulesRoomTitle: 'Mã phòng và sẵn sàng',
|
||||
rulesRoomBody:
|
||||
@@ -236,6 +249,19 @@ export const rejectMessages = {
|
||||
[RejectReason.GAME_OVER]: 'Ván đấu đã kết thúc.'
|
||||
};
|
||||
|
||||
/**
|
||||
* Short labels for a word's score breakdown, one per PointKind. Chip-sized on
|
||||
* purpose: they sit beside the total in the chain, not in a sentence.
|
||||
* @type {Record<number, string>}
|
||||
*/
|
||||
export const pointKindLabels = {
|
||||
[PointKind.BASE]: 'nền',
|
||||
[PointKind.CHAIN]: 'chuỗi',
|
||||
[PointKind.SYLLABLES]: 'dài',
|
||||
[PointKind.SPEED]: 'nhanh',
|
||||
[PointKind.RARITY]: 'hiếm'
|
||||
};
|
||||
|
||||
/**
|
||||
* How a finished game ended, phrased from the losing or winning side.
|
||||
* @type {Record<number, string>}
|
||||
@@ -270,12 +296,15 @@ export const errorMessages = {
|
||||
cannot_kick_self: 'Bạn không thể tự mời mình ra khỏi phòng.',
|
||||
must_unready_first: 'Hãy bỏ sẵn sàng trước khi rời phòng.',
|
||||
need_more_players: 'Cần ít nhất hai người chơi mới bắt đầu được.',
|
||||
not_a_dead_end: 'Vẫn còn từ nối được. Hãy thử lại.',
|
||||
not_everyone_ready: 'Vẫn còn người chưa sẵn sàng.',
|
||||
not_in_a_game: 'Bạn không ở trong ván đấu nào.',
|
||||
not_in_a_room: 'Bạn không ở trong phòng nào.',
|
||||
not_the_owner: 'Chỉ chủ phòng làm được việc này.',
|
||||
not_your_seat: 'Bạn không phải người chơi trong ván này.',
|
||||
not_your_turn: 'Chỉ đầu hàng được trong lượt của bạn.',
|
||||
// Shared by resigning and claiming a dead end: both are things only the
|
||||
// player to act may spend.
|
||||
not_your_turn: 'Chưa đến lượt bạn.',
|
||||
no_one_to_kick: 'Chưa có ai trong phòng để mời ra.',
|
||||
player_offline: 'Vẫn còn người đang mất kết nối.',
|
||||
owner_needs_no_ready: 'Chủ phòng không cần bấm sẵn sàng.',
|
||||
@@ -291,7 +320,9 @@ export const errorMessages = {
|
||||
too_fast: 'Bạn thao tác quá nhanh. Chậm lại một chút nhé.',
|
||||
too_many_attempts: 'Bạn thử vào phòng quá nhiều lần. Hãy đợi một lát.',
|
||||
too_many_rooms: 'Bạn tạo phòng quá nhanh. Hãy đợi một lát.',
|
||||
unknown_difficulty: 'Độ khó không hợp lệ.'
|
||||
unknown_difficulty: 'Độ khó không hợp lệ.',
|
||||
word_report_limit: 'Bạn đã báo quá nhiều từ trong phiên này.',
|
||||
word_report_refused: 'Từ này phải có ít nhất 2 tiếng mới báo được.'
|
||||
};
|
||||
|
||||
export const errorFallback = 'Đã có lỗi xảy ra. Hãy thử lại.';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { rejectMessage, errorMessage } from '$lib/i18n/vi.js';
|
||||
import { rejectMessage, errorMessage, fill, t } from '$lib/i18n/vi.js';
|
||||
|
||||
/**
|
||||
* How many messages the panel holds. The same window the server keeps, so the
|
||||
@@ -21,9 +21,14 @@ export const CHAT_WINDOW = 20;
|
||||
* @property {boolean} opening - the seed word, played by neither side
|
||||
* @property {Sense[]} meanings - what the word means, at most five; empty when
|
||||
* the dictionary has none
|
||||
* @property {PointPart[]} parts - how points was arrived at, one entry per
|
||||
* non-zero term, summing to points; empty for the opening word
|
||||
* @typedef {object} Sense
|
||||
* @property {string} pos - Vietnamese part-of-speech label, empty when unknown
|
||||
* @property {string} gloss - the definition, plain text
|
||||
* @typedef {object} PointPart
|
||||
* @property {number} kind - a PointKind enum value
|
||||
* @property {number} value
|
||||
* @typedef {object} PlayerSlot
|
||||
* @property {string} playerId
|
||||
* @property {string} name
|
||||
@@ -150,8 +155,28 @@ function initialState() {
|
||||
*/
|
||||
chatCount: 0,
|
||||
|
||||
/** @type {{ word: string, message: string } | null} */
|
||||
/**
|
||||
* `reason` is a RejectReason enum value, kept alongside the rendered
|
||||
* message so the UI can decide whether reporting the word applies
|
||||
* (only for NOT_IN_DICTIONARY) without re-deriving it from the text.
|
||||
* `suggestion` is the one real word the input differs from by
|
||||
* diacritics alone, empty when none applies.
|
||||
* @type {{ word: string, message: string, reason: number, suggestion: string } | null}
|
||||
*/
|
||||
rejection: null,
|
||||
/**
|
||||
* A dead-end claim the server refused because a move still existed.
|
||||
* Shown inline near the input rather than in the top banner: it is
|
||||
* specific to the move just attempted, not a room-wide condition.
|
||||
* @type {string | null}
|
||||
*/
|
||||
claimError: null,
|
||||
/**
|
||||
* The confirmation text for the last word this session reported, once
|
||||
* the server has acknowledged it.
|
||||
* @type {string | null}
|
||||
*/
|
||||
reportConfirmation: null,
|
||||
/**
|
||||
* The finished game, from this player's side. The table it came with
|
||||
* is `standings`; this is the part about them.
|
||||
@@ -172,6 +197,15 @@ function toSenses(senses) {
|
||||
return (senses ?? []).map((/** @type {any} */ s) => ({ pos: s.pos, gloss: s.gloss }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a move's score breakdown off the wire.
|
||||
* @param {any[] | undefined} parts
|
||||
* @returns {PointPart[]}
|
||||
*/
|
||||
function toParts(parts) {
|
||||
return (parts ?? []).map((/** @type {any} */ p) => ({ kind: p.kind, value: p.value }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one PlayerScore off the wire.
|
||||
* @param {any} p
|
||||
@@ -292,7 +326,8 @@ export function createGameStore() {
|
||||
points: 0,
|
||||
syllables: 0,
|
||||
opening: true,
|
||||
meanings: toSenses(value.openingMeanings)
|
||||
meanings: toSenses(value.openingMeanings),
|
||||
parts: []
|
||||
}
|
||||
];
|
||||
// The opening word is the newest word there is.
|
||||
@@ -324,7 +359,8 @@ export function createGameStore() {
|
||||
points: played.points,
|
||||
syllables: played.syllables,
|
||||
opening: false,
|
||||
meanings: toSenses(played.meanings)
|
||||
meanings: toSenses(played.meanings),
|
||||
parts: toParts(played.parts)
|
||||
});
|
||||
state.expanded = state.expanded.filter((/** @type {string} */ w) => w !== previous);
|
||||
if (!state.expanded.includes(played.word)) state.expanded.push(played.word);
|
||||
@@ -342,15 +378,29 @@ export function createGameStore() {
|
||||
// was just refused, and wiping the reason off their screen is
|
||||
// one player's exit costing another the only explanation they
|
||||
// had.
|
||||
if (played) state.rejection = null;
|
||||
if (played) {
|
||||
state.rejection = null;
|
||||
state.reportConfirmation = null;
|
||||
}
|
||||
// A false dead-end claim is about the position this update
|
||||
// just moved past, however the turn moved.
|
||||
state.claimError = null;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'moveRejected':
|
||||
state.rejection = {
|
||||
word: value.word,
|
||||
message: rejectMessage(value.reason, state.currentSyllable)
|
||||
message: rejectMessage(value.reason, state.currentSyllable),
|
||||
reason: value.reason,
|
||||
suggestion: value.suggestion ?? ''
|
||||
};
|
||||
// A new rejection has nothing reported against it yet.
|
||||
state.reportConfirmation = null;
|
||||
break;
|
||||
|
||||
case 'wordReported':
|
||||
state.reportConfirmation = fill(t.wordReported, { word: value.word });
|
||||
break;
|
||||
|
||||
case 'playerEliminated':
|
||||
@@ -425,6 +475,13 @@ export function createGameStore() {
|
||||
// the model has to stop describing one. Set after, because
|
||||
// leaving clears everything including the message.
|
||||
if (value.code === 'kicked' || value.code === 'room_idle_closed') leave();
|
||||
// A false dead-end claim is answered next to the input, not in
|
||||
// the top banner: it is about the move just attempted, not a
|
||||
// room-wide condition every screen has to show.
|
||||
if (value.code === 'not_a_dead_end') {
|
||||
state.claimError = errorMessage(value.code);
|
||||
break;
|
||||
}
|
||||
state.error = errorMessage(value.code);
|
||||
break;
|
||||
|
||||
@@ -532,6 +589,12 @@ export function createGameStore() {
|
||||
clearError() {
|
||||
state.error = null;
|
||||
},
|
||||
clearClaimError() {
|
||||
state.claimError = null;
|
||||
},
|
||||
clearReportConfirmation() {
|
||||
state.reportConfirmation = null;
|
||||
},
|
||||
/**
|
||||
* Whether a word's meaning panel is open.
|
||||
* @param {string} word
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from '@bufbuild/protobuf';
|
||||
import {
|
||||
CancelQuickMatchSchema,
|
||||
ClaimDeadEndSchema,
|
||||
ClientMessageSchema,
|
||||
CreateRoomSchema,
|
||||
HelloSchema,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
LeaveRoomSchema,
|
||||
PingSchema,
|
||||
QuickMatchSchema,
|
||||
ReportWordSchema,
|
||||
ResignSchema,
|
||||
SendChatSchema,
|
||||
SetReadySchema,
|
||||
@@ -150,6 +152,28 @@ export function cancelQuickMatch() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Claims the syllable in play has no answer left, in place of waiting out the
|
||||
* clock. The server checks: a true claim ends the turn exactly as a timeout
|
||||
* would, a false one costs nothing but the answer it gives away.
|
||||
*/
|
||||
export function claimDeadEnd() {
|
||||
return create(ClientMessageSchema, {
|
||||
payload: { case: 'claimDeadEnd', value: create(ClaimDeadEndSchema, {}) }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disputes a rejection: this word is real. Acknowledged with `WordReported`;
|
||||
* nothing about the game in progress changes.
|
||||
* @param {string} word
|
||||
*/
|
||||
export function reportWord(word) {
|
||||
return create(ClientMessageSchema, {
|
||||
payload: { case: 'reportWord', value: create(ReportWordSchema, { word }) }
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {number} clientTimeMs */
|
||||
export function ping(clientTimeMs) {
|
||||
return create(ClientMessageSchema, {
|
||||
|
||||
@@ -16,11 +16,13 @@
|
||||
import { settings } from '$lib/stores/settings.svelte.js';
|
||||
import {
|
||||
cancelQuickMatch,
|
||||
claimDeadEnd,
|
||||
createRoom,
|
||||
joinRoom,
|
||||
kickPlayer,
|
||||
leaveRoom,
|
||||
quickMatch,
|
||||
reportWord,
|
||||
resign,
|
||||
sendChat,
|
||||
setReady,
|
||||
@@ -378,6 +380,17 @@
|
||||
// Armed by the board with a second press, for the same reason as kick.
|
||||
send(resign());
|
||||
}
|
||||
|
||||
function claim() {
|
||||
// Armed by the board the same way giving up is: a second press, so a
|
||||
// stray tap cannot spend it.
|
||||
send(claimDeadEnd());
|
||||
}
|
||||
|
||||
/** @param {string} word */
|
||||
function report(word) {
|
||||
send(reportWord(word));
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -405,6 +418,8 @@
|
||||
modeLabel={game.state.roomCode}
|
||||
onsubmit={play}
|
||||
onresign={giveUp}
|
||||
onclaimdeadend={claim}
|
||||
onreportword={report}
|
||||
chatUnread={wide ? 0 : chatUnread}
|
||||
onchatopen={wide ? undefined : openChat}
|
||||
>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { createBotSession } from '$lib/stores/bot-session.svelte.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
import { settings } from '$lib/stores/settings.svelte.js';
|
||||
import { resign, startBotGame, submitWord } from '$lib/ws/messages.js';
|
||||
import { claimDeadEnd, reportWord, resign, startBotGame, submitWord } from '$lib/ws/messages.js';
|
||||
import { Status, connect, connection, disconnect, send } from '$lib/ws/connection.svelte.js';
|
||||
|
||||
/**
|
||||
@@ -93,6 +93,15 @@
|
||||
// dialog could cost the turn it was meant to protect.
|
||||
send(resign());
|
||||
}
|
||||
|
||||
function claim() {
|
||||
send(claimDeadEnd());
|
||||
}
|
||||
|
||||
/** @param {string} word */
|
||||
function report(word) {
|
||||
send(reportWord(word));
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -101,7 +110,13 @@
|
||||
|
||||
<h1 class="sr-only">{t.titlePlay}</h1>
|
||||
|
||||
<GameBoard modeLabel={difficultyLabels[difficulty]} onsubmit={play} onresign={giveUp}>
|
||||
<GameBoard
|
||||
modeLabel={difficultyLabels[difficulty]}
|
||||
onsubmit={play}
|
||||
onresign={giveUp}
|
||||
onclaimdeadend={claim}
|
||||
onreportword={report}
|
||||
>
|
||||
{#snippet gameOver()}
|
||||
<!-- A bot always plays again, so there is nothing to negotiate: the
|
||||
button starts the next game rather than offering one. -->
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
<p>{fill(t.rulesScoringCap, { cap: SCORING.maxPointsPerWord })}</p>
|
||||
<p>{t.rulesScoringBreakdown}</p>
|
||||
</section>
|
||||
|
||||
<section id="room">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { create } from '@bufbuild/protobuf';
|
||||
import {
|
||||
GameEndReason,
|
||||
PointKind,
|
||||
RejectReason,
|
||||
ServerMessageSchema
|
||||
} from '../src/lib/proto/noitu/v1/game_pb.js';
|
||||
@@ -297,7 +298,9 @@ describe('moveRejected', () => {
|
||||
|
||||
expect(store.state.rejection).toEqual({
|
||||
word: 'học sinh',
|
||||
message: 'Từ này đã được dùng rồi.'
|
||||
message: 'Từ này đã được dùng rồi.',
|
||||
reason: RejectReason.ALREADY_USED,
|
||||
suggestion: ''
|
||||
});
|
||||
});
|
||||
|
||||
@@ -308,6 +311,28 @@ describe('moveRejected', () => {
|
||||
|
||||
expect(store.state.rejection?.message).toBe('Từ phải bắt đầu bằng tiếng “sinh”.');
|
||||
});
|
||||
|
||||
it('carries a near-miss suggestion for a diacritic typo', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(
|
||||
msg('moveRejected', {
|
||||
reason: RejectReason.NOT_IN_DICTIONARY,
|
||||
word: 'sinh vien',
|
||||
suggestion: 'sinh viên'
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.state.rejection?.suggestion).toBe('sinh viên');
|
||||
});
|
||||
|
||||
it('reports no suggestion when the server sent none', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(msg('moveRejected', { reason: RejectReason.NOT_IN_DICTIONARY, word: 'xyz' }));
|
||||
|
||||
expect(store.state.rejection?.suggestion).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gameOver', () => {
|
||||
@@ -775,3 +800,113 @@ describe('the lobby', () => {
|
||||
expect(store.state.error).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('score breakdown', () => {
|
||||
it('carries the parts a played word scored, opening word excluded', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
expect(store.state.chain[0].parts).toEqual([]);
|
||||
|
||||
store.apply(
|
||||
msg('turnUpdate', {
|
||||
played: {
|
||||
word: 'sinh viên',
|
||||
byMe: true,
|
||||
points: 25,
|
||||
syllables: 2,
|
||||
typed: 'sinh viên',
|
||||
playerId: 'p1',
|
||||
parts: [
|
||||
{ kind: PointKind.BASE, value: 10 },
|
||||
{ kind: PointKind.SPEED, value: 15 }
|
||||
]
|
||||
},
|
||||
currentSyllable: 'viên',
|
||||
myTurn: false,
|
||||
turnSeq: 2,
|
||||
chainLength: 2,
|
||||
players: table(25, 0),
|
||||
turnPlayerId: 'p2'
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.state.chain[1].parts).toEqual([
|
||||
{ kind: PointKind.BASE, value: 10 },
|
||||
{ kind: PointKind.SPEED, value: 15 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('word report', () => {
|
||||
it('shows the confirmation once the server acknowledges a report', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(msg('wordReported', { word: 'bình tâm' }));
|
||||
|
||||
expect(store.state.reportConfirmation).toBe('Đã ghi nhận “bình tâm”. Cảm ơn bạn!');
|
||||
});
|
||||
|
||||
it('clears a stale confirmation when a new rejection arrives', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(msg('wordReported', { word: 'bình tâm' }));
|
||||
store.apply(msg('moveRejected', { reason: RejectReason.NOT_IN_DICTIONARY, word: 'xyz' }));
|
||||
|
||||
expect(store.state.reportConfirmation).toBeNull();
|
||||
});
|
||||
|
||||
it('clears the confirmation once an accepted move answers the rejection it was about', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(msg('wordReported', { word: 'bình tâm' }));
|
||||
store.apply(
|
||||
msg('turnUpdate', {
|
||||
played: { word: 'sinh viên', byMe: true, points: 1, syllables: 2, typed: '' },
|
||||
currentSyllable: 'viên',
|
||||
myTurn: false,
|
||||
turnSeq: 2,
|
||||
chainLength: 2
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.state.reportConfirmation).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dead-end claim', () => {
|
||||
it('answers a false claim inline rather than in the general error banner', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(msg('error', { code: 'not_a_dead_end', message: '' }));
|
||||
|
||||
expect(store.state.claimError).not.toBeNull();
|
||||
expect(store.state.error).toBeNull();
|
||||
});
|
||||
|
||||
it('clears the claim error once the position moves on', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(msg('error', { code: 'not_a_dead_end', message: '' }));
|
||||
store.apply(
|
||||
msg('turnUpdate', {
|
||||
currentSyllable: 'sinh',
|
||||
myTurn: true,
|
||||
turnSeq: 2,
|
||||
chainLength: 1,
|
||||
players: table(),
|
||||
turnPlayerId: 'p1'
|
||||
})
|
||||
);
|
||||
|
||||
expect(store.state.claimError).toBeNull();
|
||||
});
|
||||
|
||||
it('is dismissed the same way a general error is', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(msg('error', { code: 'not_a_dead_end', message: '' }));
|
||||
store.clearClaimError();
|
||||
|
||||
expect(store.state.claimError).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -132,4 +132,43 @@ describe('generated wire types', () => {
|
||||
const msg = decode('client_create_room');
|
||||
expect(msg.payload.case).toBe('createRoom');
|
||||
});
|
||||
|
||||
// A word's score breakdown must sum to the same total the player sees next
|
||||
// to it — the invariant the client relies on to draw the chips at all.
|
||||
it('carries a score breakdown that sums to the total', () => {
|
||||
const turn = decode('server_turn_update');
|
||||
const parts = turn.payload.value.played.parts;
|
||||
expect(parts.length).toBeGreaterThan(0);
|
||||
const sum = parts.reduce((total, /** @type {any} */ p) => total + p.value, 0);
|
||||
expect(sum).toBe(turn.payload.value.played.points);
|
||||
});
|
||||
|
||||
it('carries a near-miss suggestion for a diacritic typo', () => {
|
||||
const rejected = decode('server_move_rejected_near_miss');
|
||||
expect(rejected.payload.case).toBe('moveRejected');
|
||||
expect(rejected.payload.value.reason).toBe(RejectReason.NOT_IN_DICTIONARY);
|
||||
expect(rejected.payload.value.suggestion).toBe('bình yên');
|
||||
});
|
||||
|
||||
it('carries no suggestion when a rejection has none', () => {
|
||||
const rejected = decode('server_move_rejected');
|
||||
expect(rejected.payload.value.suggestion).toBe('');
|
||||
});
|
||||
|
||||
it('echoes a reported word', () => {
|
||||
const reported = decode('server_word_reported');
|
||||
expect(reported.payload.case).toBe('wordReported');
|
||||
expect(reported.payload.value.word).toBe('bình tâm');
|
||||
});
|
||||
|
||||
it('decodes an empty client claim', () => {
|
||||
const claim = decode('client_claim_dead_end');
|
||||
expect(claim.payload.case).toBe('claimDeadEnd');
|
||||
});
|
||||
|
||||
it('carries the word a client reports', () => {
|
||||
const report = decode('client_report_word');
|
||||
expect(report.payload.case).toBe('reportWord');
|
||||
expect(report.payload.value.word).toBe('bình tâm');
|
||||
});
|
||||
});
|
||||
+19
-1
@@ -6,10 +6,12 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
Difficulty,
|
||||
GameEndReason,
|
||||
PointKind,
|
||||
RejectReason,
|
||||
RejectReasonSchema,
|
||||
GameEndReasonSchema,
|
||||
DifficultySchema
|
||||
DifficultySchema,
|
||||
PointKindSchema
|
||||
} from '../src/lib/proto/noitu/v1/game_pb.js';
|
||||
import {
|
||||
difficultyLabels,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
errorFallback,
|
||||
errorMessage,
|
||||
fill,
|
||||
pointKindLabels,
|
||||
rejectMessage,
|
||||
rejectMessages
|
||||
} from '../src/lib/i18n/vi.js';
|
||||
@@ -80,6 +83,21 @@ describe('end reason messages', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('point kind labels', () => {
|
||||
it('names every scoring term except the unspecified one', () => {
|
||||
const real = valuesOf(PointKindSchema).filter((v) => v !== PointKind.UNSPECIFIED);
|
||||
expect(real.length).toBeGreaterThan(0);
|
||||
for (const value of real) {
|
||||
expect(pointKindLabels[value], `PointKind ${value} has no label`).toBeTypeOf('string');
|
||||
expect(pointKindLabels[value].length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('carries no label for the unspecified kind, which never reaches a player', () => {
|
||||
expect(pointKindLabels[PointKind.UNSPECIFIED]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('difficulty labels', () => {
|
||||
it('names every playable difficulty', () => {
|
||||
const playable = valuesOf(DifficultySchema).filter((v) => v !== Difficulty.UNSPECIFIED);
|
||||
|
||||
Reference in new issue
Block a user