diff --git a/PLAN.md b/PLAN.md index 35e26dd..9a0423f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -46,7 +46,7 @@ independent (see ARCHITECTURE §9.1). | 10 | Admin & dictionary ops (complaint review, version reload) | **done** | | 11 | Account linking & merge | **done** | | 12 | Observability & performance (telemetry, metrics, guest GC) | **done** | -| 13 | Alphabet on the wire (UI alphabet-agnostic) | todo | +| 13 | Alphabet on the wire (UI alphabet-agnostic) | **done** | | 14 | CI & deploy (multi-service, dictionary artifacts) | todo | Scaffolding is incremental: `go.work` lists only existing modules; each stage @@ -893,6 +893,51 @@ dashboard stack; solver-publish vs clone-in-build; load expectations. `./backend/... ./gateway/... ./pkg/... ./platform/telegram/...`, integration stays `./backend/...`, and the default `none` exporter keeps CI collector-free. +- **Stage 13** (interview + implementation, discharges TODO-4): + - **Scope = live play only** (interview): indices ride the wire for `StateView.rack` + (out) and `SubmitPlay`/`Evaluate`/`Exchange`/`CheckWord` (in). The **board path is + untouched** — `MoveRecord` (history, move results, hint), formed `words`, + `ComplaintRequest.word` (durable, admin-reviewed) and `WordCheckResult.word` (echo) stay + decoded concrete characters, so the durable journal / GCG and the §9.1 invariant are + unchanged. **Hard cutover**, no dual letter/index fields (single client; the fbs Go + TS + regenerate in one PR; no external wire consumer). Exchange moved fully to indices, a + blank = the shared sentinel index **255** (`engine.BlankIndex`). + - **Edge-mapping layering** (engineering): the engine gained a cached per-variant codec — + `AlphabetTable` (the `(index, letter, value)` table from the solver ruleset), + `LetterForIndex`, `EncodeRack`, `DecodeTiles`, `DecodeWord` — and the backend **server + edge** owns the index↔letter mapping. `game.Service`'s domain methods, `engine.Game` and + the **robot** keep a single **letter-based** play path (untouched); a new thin + `game.Service.GameVariant` (a single-column `SELECT variant`, cheaper than `GetGame`) + lets the inbound handlers resolve the variant without doubling the play-path read. The + **gateway carries no alphabet table** — it passes indices through verbatim; `check_word` + rides as repeated `?idx=` query params. + - **`include_alphabet` flag** (interview): `StateRequest.include_alphabet` gates the table + so it is not resent on every poll; the client sets it only on a **per-variant cache + miss** (first open of a variant), and the table then arrives with the index rack so the + rack is always decodable. The client caches the table in memory by variant + (`ui/src/lib/alphabet.ts`). + - **Letter case** (discovered): the solver emits **lower-case** letters and the rest of + the UI works in **upper case**. The wire and the journal stay lower case; the **UI + normalises display to upper case** (the codec upper-cases decoded board tiles and words, + and the alphabet cache upper-cases on ingest), so `placement.ts` / `board.ts` / + `checkword.ts` are unchanged and the latent real-backend lower-case display is fixed. + - **Parity rework** (interview): the real value/alphabet parity moved to a **Go engine + test** (`engine.AlphabetTable`: EN/RU/Эрудит sizes, EN a=1/q=10, **Эрудит ё=index 6, + value 0**); `ui/src/lib/premiums.ts` is now **geometry only** (its value tables, + `tileValue` and `alphabet` were removed, its parity test trimmed to the premium grid); + the codec test round-trips the index tiles + the alphabet table; the **mock keeps a + fixture table** (relocated from `premiums.ts`) seeded into the client cache, so the + mock-driven UI is alphabet-agnostic too. + - **Wire/codegen/CI**: new fbs `AlphabetEntry` + `PlayTile`; `StateView.rack`→`[ubyte]` + + `alphabet`; `StateRequest.include_alphabet`; `SubmitPlay`/`Eval` tiles→`[PlayTile]`; + `Exchange` tiles→`[ubyte]`; `CheckWord.word`→`[ubyte]` (committed Go + TS regenerated). + UI ~90 KB gzip JS (budget 100 KB). **No CI workflow change** — the Go workflows already + span `./backend/... ./gateway/... ./pkg/...` and the UI workflow runs check/unit/build + + a chromium/webkit e2e. `docs/FUNCTIONAL.md` is **untouched** (no user-visible behaviour + change — the UI looks and plays the same; like Stage 2). The index-drift caveat is + handled by construction (the running backend produces the table, so client↔server cannot + drift); the DAWG/solver build-time agreement remains **Stage 14 / TODO-2**. + ## Deferred TODOs (cross-stage) - **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable, @@ -931,17 +976,16 @@ dashboard stack; solver-publish vs clone-in-build; load expectations. `last_seen_at`, so a lingering session never expires and **account age** is the abandonment trigger, not "last session gone". The reaped guest's `sessions`/`identities`/ `account_stats` fall away via their own `ON DELETE CASCADE`. -- **TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7).** Today the - client hardcodes each variant's letters/values (ported into `ui/src/lib/premiums.ts` - from `scrabble-solver/rules/rules.go`) and the edge exchanges plays/hints by concrete - letters. Consider extending `game.state` to carry the variant's `(letter, index, - value)` table so the UI stops duplicating it, and optionally moving tile exchange to - letter **indices** end-to-end. Caveat (as for the dictionaries, TODO-2): the wire table - must stay pinned to the same `rules.Alphabet` the engine uses, or indices drift. - **Planned for Stage 13**, expanded (owner) to a fully **alphabet-agnostic UI**: the - client caches the per-variant table (display only) behind an `include_alphabet` request - flag and exchanges indices both ways, word-check included; the durable journal stays - concrete characters (§9.1). See Stage 13. +- ~~**TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7).**~~ **Done in + Stage 13.** The client is now alphabet-agnostic: it caches each variant's `(index, letter, + value)` table — sent by the backend behind `StateRequest.include_alphabet` on a per-variant + cache miss — and live play exchanges **letter indices** both ways (rack, submit-play, + evaluate, exchange, word-check; a blank rides as the sentinel index 255). The table is + produced from the solver ruleset (`engine.AlphabetTable`), so it is pinned by the solver + version and cannot drift from the running backend, and `ui/src/lib/premiums.ts` is now + geometry only. The durable journal / history / GCG stay decoded concrete characters (§9.1, + unchanged). The DAWG/solver build-time agreement (the original caveat, shared with TODO-2) + remains Stage 14. - **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9:* the deep-link scheme now exists (`f`, shared Go ↔ TS), the bot redeems it on launch, and the UI shows a **share-to-Telegram** link for an issued code when diff --git a/backend/internal/engine/alphabet.go b/backend/internal/engine/alphabet.go new file mode 100644 index 0000000..f1f7492 --- /dev/null +++ b/backend/internal/engine/alphabet.go @@ -0,0 +1,150 @@ +package engine + +import ( + "fmt" + "strings" +) + +// AlphabetEntry is one letter of a variant's alphabet: its alphabet-index byte, the +// concrete character and its tile point value. It is the dictionary-independent display +// table the edge sends to the client (Stage 13), produced from the variant's solver +// ruleset (its alphabet and value table) and so pinned by the solver version, not by any +// dictionary. +type AlphabetEntry struct { + // Index is the alphabet-index byte the wire uses for this letter (0..Size-1). + Index byte + // Letter is the concrete character, in the case the solver ruleset emits (lower). + Letter string + // Value is the tile's point score. + Value int +} + +// BlankIndex is the wire sentinel for a blank tile inside an alphabet-index sequence (a +// rack or an exchange list). It is out of range of every offered variant's alphabet (the +// largest has 33 letters), so it never collides with a real letter index. A placed blank +// instead travels as an ordinary tile carrying its designated letter's index alongside a +// separate blank flag. The constant is untyped so it serves both byte (FlatBuffers ubyte) +// and int (the gateway/backend JSON edge) call sites. +const BlankIndex = 0xFF + +// variantCodec is the cached per-variant alphabet data backing the wire helpers: the +// ordered display table and a case-insensitive letter→index lookup. Both are derived once +// from the solver ruleset (see variantCodecs). +type variantCodec struct { + table []AlphabetEntry + letterToIndex map[string]byte +} + +// variantCodecs holds one codec per offered variant, built once at package load from each +// ruleset's alphabet and value table. The rulesets are needed only here (not per request), +// so the hot path never rebuilds them. +var variantCodecs = buildVariantCodecs() + +func buildVariantCodecs() map[Variant]*variantCodec { + m := make(map[Variant]*variantCodec, len(Variants())) + for _, v := range Variants() { + rs, ok := v.ruleset() + if !ok { + continue + } + size := rs.Alphabet.Size() + table := make([]AlphabetEntry, size) + lut := make(map[string]byte, size) + for i := range size { + ch, err := rs.Alphabet.Character(byte(i)) + if err != nil { + // An offered variant's alphabet never yields a bad index; skip defensively. + continue + } + table[i] = AlphabetEntry{Index: byte(i), Letter: ch, Value: rs.Values[i]} + lut[strings.ToLower(ch)] = byte(i) + } + m[v] = &variantCodec{table: table, letterToIndex: lut} + } + return m +} + +// AlphabetTable returns a copy of variant's full alphabet as an ordered (index, letter, +// value) table, or ErrUnknownVariant. Entry i has Index i, so the slice doubles as an +// index→(letter, value) lookup. It needs no dictionary — the data comes from the solver +// ruleset alone — so it is safe to build for any offered variant and is the same table the +// client caches for display while live play exchanges bare indices. +func AlphabetTable(v Variant) ([]AlphabetEntry, error) { + c, ok := variantCodecs[v] + if !ok { + return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v) + } + out := make([]AlphabetEntry, len(c.table)) + copy(out, c.table) + return out, nil +} + +// LetterForIndex maps one alphabet index to its concrete letter for variant. It is the +// wire-decode primitive for a placed tile (a blank carries its designated letter's index). +// An out-of-range index is an illegal play. +func LetterForIndex(v Variant, idx int) (string, error) { + c, ok := variantCodecs[v] + if !ok { + return "", fmt.Errorf("%w: %d", ErrUnknownVariant, v) + } + if idx < 0 || idx >= len(c.table) { + return "", fmt.Errorf("%w: alphabet index %d for %s", ErrIllegalPlay, idx, v) + } + return c.table[idx].Letter, nil +} + +// EncodeRack maps a decoded rack (the Game.Hand form: concrete letters with "?" for an +// undesignated blank) to wire alphabet indices, using BlankIndex for each blank. It backs +// the per-player state view, whose rack the client renders via the cached table. +func EncodeRack(v Variant, letters []string) ([]int, error) { + c, ok := variantCodecs[v] + if !ok { + return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v) + } + out := make([]int, len(letters)) + for i, l := range letters { + if l == blankLetter { + out[i] = BlankIndex + continue + } + idx, ok := c.letterToIndex[strings.ToLower(l)] + if !ok { + return nil, fmt.Errorf("%w: rack letter %q for %s", ErrTilesNotOnRack, l, v) + } + out[i] = int(idx) + } + return out, nil +} + +// DecodeTiles maps a wire rack/exchange index list back to the decoded letter form ("?" +// for a blank, BlankIndex), for handing to the existing letter-based exchange path. +func DecodeTiles(v Variant, idx []int) ([]string, error) { + out := make([]string, len(idx)) + for i, x := range idx { + if x == BlankIndex { + out[i] = blankLetter + continue + } + l, err := LetterForIndex(v, x) + if err != nil { + return nil, fmt.Errorf("%w (exchange)", err) + } + out[i] = l + } + return out, nil +} + +// DecodeWord maps a sequence of alphabet indices to a concrete word (word-check carries no +// blanks). The client constrains input to the variant's alphabet, so every index is a real +// letter. +func DecodeWord(v Variant, idx []int) (string, error) { + var sb strings.Builder + for _, x := range idx { + l, err := LetterForIndex(v, x) + if err != nil { + return "", fmt.Errorf("%w (word check)", err) + } + sb.WriteString(l) + } + return sb.String(), nil +} diff --git a/backend/internal/engine/alphabet_test.go b/backend/internal/engine/alphabet_test.go new file mode 100644 index 0000000..ea16d6c --- /dev/null +++ b/backend/internal/engine/alphabet_test.go @@ -0,0 +1,110 @@ +package engine + +import ( + "errors" + "slices" + "testing" +) + +// TestAlphabetTableEnglish pins the English table against the solver ruleset: 26 letters, +// contiguous indices, the concrete lower-case characters the solver emits and the standard +// tile values. This is the real parity check the UI no longer carries (Stage 13). +func TestAlphabetTableEnglish(t *testing.T) { + tab, err := AlphabetTable(VariantEnglish) + if err != nil { + t.Fatalf("AlphabetTable(english): %v", err) + } + if len(tab) != 26 { + t.Fatalf("size = %d, want 26", len(tab)) + } + for i, e := range tab { + if int(e.Index) != i { + t.Errorf("entry %d has Index %d, want %d (index must equal position)", i, e.Index, i) + } + } + // a=index0/value1, q=index16/value10, z=index25/value10. + if tab[0].Letter != "a" || tab[0].Value != 1 { + t.Errorf("entry 0 = %q/%d, want a/1", tab[0].Letter, tab[0].Value) + } + if tab[16].Letter != "q" || tab[16].Value != 10 { + t.Errorf("entry 16 = %q/%d, want q/10", tab[16].Letter, tab[16].Value) + } + if tab[25].Letter != "z" || tab[25].Value != 10 { + t.Errorf("entry 25 = %q/%d, want z/10", tab[25].Letter, tab[25].Value) + } +} + +// TestAlphabetTableRussianVariants pins both Russian variants: they share the 33-letter +// alphabet but differ in tile values — most visibly ё (index 6), worth 3 in Russian +// Scrabble and 0 in Эрудит. +func TestAlphabetTableRussianVariants(t *testing.T) { + ru, err := AlphabetTable(VariantRussianScrabble) + if err != nil { + t.Fatalf("AlphabetTable(russian_scrabble): %v", err) + } + er, err := AlphabetTable(VariantErudit) + if err != nil { + t.Fatalf("AlphabetTable(erudit): %v", err) + } + if len(ru) != 33 || len(er) != 33 { + t.Fatalf("sizes = %d/%d, want 33/33", len(ru), len(er)) + } + if ru[0].Letter != "а" || ru[0].Value != 1 { + t.Errorf("russian entry 0 = %q/%d, want а/1", ru[0].Letter, ru[0].Value) + } + if ru[6].Letter != "ё" || ru[6].Value != 3 { + t.Errorf("russian ё (entry 6) = %q/%d, want ё/3", ru[6].Letter, ru[6].Value) + } + if er[6].Letter != "ё" || er[6].Value != 0 { + t.Errorf("erudit ё (entry 6) = %q/%d, want ё/0", er[6].Letter, er[6].Value) + } + if ru[32].Letter != "я" || er[32].Letter != "я" { + t.Errorf("last letter = %q/%q, want я/я", ru[32].Letter, er[32].Letter) + } +} + +// TestAlphabetTableUnknownVariant rejects a variant outside the catalogue. +func TestAlphabetTableUnknownVariant(t *testing.T) { + if _, err := AlphabetTable(Variant(99)); !errors.Is(err, ErrUnknownVariant) { + t.Fatalf("got %v, want ErrUnknownVariant", err) + } +} + +// TestRackCodecRoundTrip pins the rack/exchange index codec the edge uses: EncodeRack maps +// concrete letters (with "?" for a blank) to indices (BlankIndex for the blank) and +// DecodeTiles inverts it. EncodeRack is case-insensitive so it accepts the lower-case +// Hand form and an upper-case letter alike. +func TestRackCodecRoundTrip(t *testing.T) { + letters := []string{"c", "a", "t", "?"} + idx, err := EncodeRack(VariantEnglish, letters) + if err != nil { + t.Fatalf("EncodeRack: %v", err) + } + if want := []int{2, 0, 19, BlankIndex}; !slices.Equal(idx, want) { + t.Fatalf("EncodeRack = %v, want %v", idx, want) + } + back, err := DecodeTiles(VariantEnglish, idx) + if err != nil { + t.Fatalf("DecodeTiles: %v", err) + } + if !slices.Equal(back, letters) { + t.Fatalf("DecodeTiles = %v, want %v", back, letters) + } + if up, err := EncodeRack(VariantEnglish, []string{"C"}); err != nil || !slices.Equal(up, []int{2}) { + t.Errorf("EncodeRack upper-case = %v,%v; want [2],nil", up, err) + } +} + +// TestDecodeWordAndBounds covers the word-check decode and the out-of-range guard. +func TestDecodeWordAndBounds(t *testing.T) { + w, err := DecodeWord(VariantEnglish, []int{2, 0, 19}) + if err != nil || w != "cat" { + t.Fatalf("DecodeWord = %q,%v; want cat,nil", w, err) + } + if _, err := LetterForIndex(VariantEnglish, 26); !errors.Is(err, ErrIllegalPlay) { + t.Errorf("out-of-range index: got %v, want ErrIllegalPlay", err) + } + if _, err := DecodeWord(VariantEnglish, []int{BlankIndex}); !errors.Is(err, ErrIllegalPlay) { + t.Errorf("blank in word: got %v, want ErrIllegalPlay", err) + } +} diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 287e336..0b220aa 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -178,6 +178,13 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo }) } +// GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet +// indices to concrete letters before delegating to the letter-based play, exchange and +// word-check methods (Stage 13), keeping a single domain path shared with the robot. +func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.Variant, error) { + return svc.store.GetGameVariant(ctx, gameID) +} + // transition validates the actor and turn, applies op under the per-game lock and // commits the result. func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) { diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index 16b5bdf..c06c508 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -135,6 +135,24 @@ func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) { return projectGame(grow, srows) } +// GetGameVariant reads just a game's variant — a cheap single-column lookup the edge uses +// to map wire alphabet indices to concrete letters (Stage 13) without loading the whole +// game and its seats. +func (s *Store) GetGameVariant(ctx context.Context, id uuid.UUID) (engine.Variant, error) { + stmt := postgres.SELECT(table.Games.Variant). + FROM(table.Games). + WHERE(table.Games.GameID.EQ(postgres.UUID(id))). + LIMIT(1) + var row model.Games + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return 0, ErrNotFound + } + return 0, fmt.Errorf("game: get variant %s: %w", id, err) + } + return engine.ParseVariant(row.Variant) +} + // SharedGameExists reports whether accounts a and b are both seated in at least // one game (active or finished). It backs the social package's "befriend an // opponent" gate via a self-join on game_players. diff --git a/backend/internal/inttest/game_test.go b/backend/internal/inttest/game_test.go index 482855f..0ce8914 100644 --- a/backend/internal/inttest/game_test.go +++ b/backend/internal/inttest/game_test.go @@ -428,6 +428,26 @@ func TestHintPolicy(t *testing.T) { } } +// TestGameVariant covers the edge's lightweight variant lookup (Stage 13): it returns the +// created game's variant and ErrNotFound for an unknown id. +func TestGameVariant(t *testing.T) { + ctx := context.Background() + svc := newGameService() + seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} + g, err := svc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: 1, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if v, err := svc.GameVariant(ctx, g.ID); err != nil || v != engine.VariantEnglish { + t.Fatalf("GameVariant = %v, %v; want english, nil", v, err) + } + if _, err := svc.GameVariant(ctx, uuid.New()); !errors.Is(err, game.ErrNotFound) { + t.Errorf("GameVariant(unknown) = %v, want ErrNotFound", err) + } +} + // TestCheckWordAndComplaint covers the word-check tool and complaint capture. func TestCheckWordAndComplaint(t *testing.T) { ctx := context.Background() diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 11e17e7..8fb648c 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -101,13 +101,24 @@ type moveResultDTO struct { Game gameDTO `json:"game"` } -// stateDTO is a player's view of a game. +// alphabetEntryDTO is one letter of a variant's alphabet (its index, concrete letter and +// tile value), embedded in the state view for display only when the client requests it +// (Stage 13). +type alphabetEntryDTO struct { + Index int `json:"index"` + Letter string `json:"letter"` + Value int `json:"value"` +} + +// stateDTO is a player's view of a game. Rack carries wire alphabet indices (Stage 13; a +// blank is engine.BlankIndex). Alphabet is present only when the request asked for it. type stateDTO struct { - Game gameDTO `json:"game"` - Seat int `json:"seat"` - Rack []string `json:"rack"` - BagLen int `json:"bag_len"` - HintsRemaining int `json:"hints_remaining"` + Game gameDTO `json:"game"` + Seat int `json:"seat"` + Rack []int `json:"rack"` + BagLen int `json:"bag_len"` + HintsRemaining int `json:"hints_remaining"` + Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"` } // matchDTO reports whether the caller has been paired into a game. @@ -217,15 +228,32 @@ func moveResultDTOFrom(r game.MoveResult) moveResultDTO { return moveResultDTO{Move: moveRecordDTOFrom(r.Move), Game: gameDTOFromGame(r.Game)} } -// stateDTOFrom projects a player's state view into its DTO. -func stateDTOFrom(v game.StateView) stateDTO { - return stateDTO{ +// stateDTOFrom projects a player's state view into its DTO, encoding the rack as wire +// alphabet indices (Stage 13). When includeAlphabet is set it also embeds the variant's +// display table, which the client caches per variant and renders the rack with. +func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) { + rack, err := engine.EncodeRack(v.Game.Variant, v.Rack) + if err != nil { + return stateDTO{}, err + } + dto := stateDTO{ Game: gameDTOFromGame(v.Game), Seat: v.Seat, - Rack: v.Rack, + Rack: rack, BagLen: v.BagLen, HintsRemaining: v.HintsRemaining, } + if includeAlphabet { + tab, err := engine.AlphabetTable(v.Game.Variant) + if err != nil { + return stateDTO{}, err + } + dto.Alphabet = make([]alphabetEntryDTO, len(tab)) + for i, e := range tab { + dto.Alphabet[i] = alphabetEntryDTO{Index: int(e.Index), Letter: e.Letter, Value: e.Value} + } + } + return dto, nil } // matchDTOFrom projects an enqueue/poll result into its DTO. diff --git a/backend/internal/server/handlers_game.go b/backend/internal/server/handlers_game.go index b1a8087..b3f58ce 100644 --- a/backend/internal/server/handlers_game.go +++ b/backend/internal/server/handlers_game.go @@ -3,6 +3,7 @@ package server import ( "context" "net/http" + "strconv" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -51,9 +52,10 @@ type chatListDTO struct { Messages []chatDTO `json:"messages"` } -// exchangeRequest swaps the given rack tiles back into the bag. +// exchangeRequest swaps the given rack tiles back into the bag. Tiles are wire alphabet +// indices (Stage 13); a blank is engine.BlankIndex. type exchangeRequest struct { - Tiles []string `json:"tiles"` + Tiles []int `json:"tiles"` } // complaintRequest disputes a word-check result. @@ -125,7 +127,17 @@ func (s *Server) handleExchange(c *gin.Context) { abortBadRequest(c, "invalid request body") return } - res, err := s.games.Exchange(c.Request.Context(), gameID, uid, req.Tiles) + variant, err := s.games.GameVariant(c.Request.Context(), gameID) + if err != nil { + s.abortErr(c, err) + return + } + tiles, err := engine.DecodeTiles(variant, req.Tiles) + if err != nil { + s.abortErr(c, err) + return + } + res, err := s.games.Exchange(c.Request.Context(), gameID, uid, tiles) if err != nil { s.abortErr(c, err) return @@ -180,9 +192,15 @@ func (s *Server) handleEvaluate(c *gin.Context) { abortBadRequest(c, "dir must be H or V") return } - tiles := make([]engine.TileRecord, 0, len(req.Tiles)) - for _, t := range req.Tiles { - tiles = append(tiles, engine.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank}) + variant, err := s.games.GameVariant(c.Request.Context(), gameID) + if err != nil { + s.abortErr(c, err) + return + } + tiles, err := tilesFromRequest(variant, req) + if err != nil { + s.abortErr(c, err) + return } ev, err := s.games.EvaluatePlay(c.Request.Context(), gameID, uid, dir, tiles) if err != nil { @@ -192,13 +210,29 @@ func (s *Server) handleEvaluate(c *gin.Context) { c.JSON(http.StatusOK, evalResultDTO{Legal: ev.Valid, Score: ev.Score, Words: ev.Words}) } -// handleCheckWord looks a word up in the game's pinned dictionary. +// handleCheckWord looks a word up in the game's pinned dictionary. The word arrives as +// repeated ?idx= alphabet indices (Stage 13); the backend decodes them to the concrete +// word for the lookup and echoes that concrete word back for the client's result cache. func (s *Server) handleCheckWord(c *gin.Context) { _, gameID, ok := s.userGame(c) if !ok { return } - word := c.Query("word") + idx, err := queryIndexes(c, "idx") + if err != nil { + abortBadRequest(c, "invalid word") + return + } + variant, err := s.games.GameVariant(c.Request.Context(), gameID) + if err != nil { + s.abortErr(c, err) + return + } + word, err := engine.DecodeWord(variant, idx) + if err != nil { + s.abortErr(c, err) + return + } legal, err := s.games.CheckWord(c.Request.Context(), gameID, word) if err != nil { s.abortErr(c, err) @@ -207,6 +241,21 @@ func (s *Server) handleCheckWord(c *gin.Context) { c.JSON(http.StatusOK, wordCheckDTO{Word: word, Legal: legal}) } +// queryIndexes parses repeated integer query parameters (e.g. ?idx=2&idx=0) into a slice. +// It carries a word-check query as alphabet indices on a GET (Stage 13). +func queryIndexes(c *gin.Context, key string) ([]int, error) { + raw := c.QueryArray(key) + out := make([]int, 0, len(raw)) + for _, s := range raw { + n, err := strconv.Atoi(s) + if err != nil { + return nil, err + } + out = append(out, n) + } + return out, nil +} + // handleComplaint files a word-check complaint into the admin review queue. func (s *Server) handleComplaint(c *gin.Context) { uid, gameID, ok := s.userGame(c) diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index 60d6a1e..7e181fc 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -26,17 +26,33 @@ func (s *Server) handleProfile(c *gin.Context) { c.JSON(http.StatusOK, profileResponseFor(acc)) } -// submitPlayRequest places tiles in a direction on the player's turn. +// submitPlayRequest places tiles in a direction on the player's turn. Each tile's Letter +// is a wire alphabet index (Stage 13); for a blank it is the designated letter's index. type submitPlayRequest struct { Dir string `json:"dir"` Tiles []struct { - Row int `json:"row"` - Col int `json:"col"` - Letter string `json:"letter"` - Blank bool `json:"blank"` + Row int `json:"row"` + Col int `json:"col"` + Letter int `json:"letter"` + Blank bool `json:"blank"` } `json:"tiles"` } +// tilesFromRequest maps a play/evaluate request's index-addressed tiles to engine tile +// records for the game's variant (Stage 13: a placed blank carries its designated letter's +// index with Blank set). An out-of-range index surfaces as engine.ErrIllegalPlay (HTTP 400). +func tilesFromRequest(variant engine.Variant, req submitPlayRequest) ([]engine.TileRecord, error) { + tiles := make([]engine.TileRecord, 0, len(req.Tiles)) + for _, t := range req.Tiles { + letter, err := engine.LetterForIndex(variant, t.Letter) + if err != nil { + return nil, err + } + tiles = append(tiles, engine.TileRecord{Row: t.Row, Col: t.Col, Letter: letter, Blank: t.Blank}) + } + return tiles, nil +} + // handleSubmitPlay validates, scores and commits a placement. func (s *Server) handleSubmitPlay(c *gin.Context) { uid, ok := userID(c) @@ -59,9 +75,15 @@ func (s *Server) handleSubmitPlay(c *gin.Context) { abortBadRequest(c, "dir must be H or V") return } - tiles := make([]engine.TileRecord, 0, len(req.Tiles)) - for _, t := range req.Tiles { - tiles = append(tiles, engine.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank}) + variant, err := s.games.GameVariant(c.Request.Context(), gameID) + if err != nil { + s.abortErr(c, err) + return + } + tiles, err := tilesFromRequest(variant, req) + if err != nil { + s.abortErr(c, err) + return } res, err := s.games.SubmitPlay(c.Request.Context(), gameID, uid, dir, tiles) if err != nil { @@ -88,7 +110,11 @@ func (s *Server) handleGameState(c *gin.Context) { s.abortErr(c, err) return } - dto := stateDTOFrom(view) + dto, err := stateDTOFrom(view, c.Query("include_alphabet") == "true") + if err != nil { + s.abortErr(c, err) + return + } s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{}) c.JSON(http.StatusOK, dto) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ff5d018..5010676 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -87,6 +87,18 @@ dropped). Horizontal scaling is explicit future work. operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP 200); only edge failures (rate limit, missing session, unknown type, internal) surface as Connect error codes. +- **Alphabet on the wire (Stage 13)**: live play exchanges **alphabet indices**, not + concrete letters. The rack (`StateView.rack`), the `SubmitPlay`/`Evaluate` tiles, the + `Exchange` tiles and the `CheckWord` word are `ubyte` indices into the variant's alphabet + (a blank is the sentinel index **255**). The client is **alphabet-agnostic**: on a + per-variant cache miss it sets `StateRequest.include_alphabet`, and the backend embeds the + variant's `(index, letter, value)` table (`engine.AlphabetTable`, derived from the solver + ruleset — no dictionary) for display; the client caches it by variant and renders the rack + and the blank chooser from it. The backend maps index↔letter at its REST edge, so the + gateway forwards indices **verbatim** (it holds no alphabet table) and the engine's + letter-based domain API — shared with the robot — is unchanged. The table is pinned by the + solver version, so it cannot drift from the running backend. The **move journal, history + and GCG are unaffected** (they stay decoded concrete characters, §9.1). - **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects `X-User-ID` for authenticated requests; `backend` never re-derives identity from the body. @@ -407,6 +419,11 @@ does not cover. **GCG export is offered only on a finished game** (`game.ErrGame otherwise, Stage 8), so an in-progress journal is never leaked mid-play; the client shares the `.gcg` file via the Web Share API where available, else downloads it. +The Stage 13 alphabet-on-the-wire change does **not** touch this invariant: the live edge +exchanges alphabet indices, but the persisted journal (and everything derived from it — +replay, history, GCG) keeps the decoded concrete letters described above, so an archived +game still replays with the variant's `rules.Alphabet` alone, independent of any dictionary. + ## 10. Notifications Two channels: the **in-app live stream** (delivered from Stage 6) and diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 221680c..168882f 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/url" + "strconv" ) // The structs below mirror the backend's JSON DTOs (backend/internal/server @@ -47,7 +48,8 @@ type LinkResultResp struct { Profile *ProfileResp `json:"profile"` } -// TileJSON is one placed tile, used in both play requests and move responses. +// TileJSON is one tile in a decoded move response (history, move result, hint); its Letter +// is a concrete character (Stage 13 keeps the move journal in letters). type TileJSON struct { Row int `json:"row"` Col int `json:"col"` @@ -55,6 +57,15 @@ type TileJSON struct { Blank bool `json:"blank"` } +// PlayTileJSON is one inbound tile to place, addressed by alphabet index (Stage 13). For a +// blank, Letter is the designated letter's index and Blank is true. +type PlayTileJSON struct { + Row int `json:"row"` + Col int `json:"col"` + Letter int `json:"letter"` + Blank bool `json:"blank"` +} + // MoveRecordResp is a decoded move. type MoveRecordResp struct { Player int `json:"player"` @@ -99,13 +110,23 @@ type MoveResultResp struct { Game GameResp `json:"game"` } -// StateResp is a player's view of a game. +// AlphabetEntryJSON is one letter of a variant's alphabet (its index, concrete letter and +// tile value), present in StateResp only when the client requested it (Stage 13). +type AlphabetEntryJSON struct { + Index int `json:"index"` + Letter string `json:"letter"` + Value int `json:"value"` +} + +// StateResp is a player's view of a game. Rack carries wire alphabet indices (Stage 13); +// Alphabet is present only when the request asked for it. type StateResp struct { - Game GameResp `json:"game"` - Seat int `json:"seat"` - Rack []string `json:"rack"` - BagLen int `json:"bag_len"` - HintsRemaining int `json:"hints_remaining"` + Game GameResp `json:"game"` + Seat int `json:"seat"` + Rack []int `json:"rack"` + BagLen int `json:"bag_len"` + HintsRemaining int `json:"hints_remaining"` + Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"` } // MatchResp reports an auto-match outcome. @@ -194,18 +215,25 @@ func (c *Client) Profile(ctx context.Context, userID string) (ProfileResp, error return out, err } -// SubmitPlay commits a placement on the player's turn. -func (c *Client) SubmitPlay(ctx context.Context, userID, gameID, dir string, tiles []TileJSON) (MoveResultResp, error) { +// SubmitPlay commits a placement on the player's turn. The tiles are addressed by alphabet +// index (Stage 13). +func (c *Client) SubmitPlay(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (MoveResultResp, error) { var out MoveResultResp body := map[string]any{"dir": dir, "tiles": tiles} err := c.do(ctx, http.MethodPost, "/api/v1/user/games/"+url.PathEscape(gameID)+"/play", userID, "", body, &out) return out, err } -// GameState returns the player's view of a game. -func (c *Client) GameState(ctx context.Context, userID, gameID string) (StateResp, error) { +// GameState returns the player's view of a game. When includeAlphabet is set the backend +// embeds the variant's alphabet table (Stage 13); the client asks for it on a per-variant +// cache miss only. +func (c *Client) GameState(ctx context.Context, userID, gameID string, includeAlphabet bool) (StateResp, error) { var out StateResp - err := c.do(ctx, http.MethodGet, "/api/v1/user/games/"+url.PathEscape(gameID)+"/state", userID, "", nil, &out) + path := "/api/v1/user/games/" + url.PathEscape(gameID) + "/state" + if includeAlphabet { + path += "?include_alphabet=true" + } + err := c.do(ctx, http.MethodGet, path, userID, "", nil, &out) return out, err } @@ -278,8 +306,9 @@ func (c *Client) Pass(ctx context.Context, userID, gameID string) (MoveResultRes return out, err } -// Exchange swaps the chosen rack tiles back into the bag. -func (c *Client) Exchange(ctx context.Context, userID, gameID string, tiles []string) (MoveResultResp, error) { +// Exchange swaps the chosen rack tiles back into the bag. Tiles are wire alphabet indices +// (Stage 13; a blank is engine.BlankIndex). +func (c *Client) Exchange(ctx context.Context, userID, gameID string, tiles []int) (MoveResultResp, error) { var out MoveResultResp err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/exchange"), userID, "", map[string]any{"tiles": tiles}, &out) @@ -300,18 +329,24 @@ func (c *Client) Hint(ctx context.Context, userID, gameID string) (HintResultRes return out, err } -// Evaluate previews a tentative play's legality and score. -func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles []TileJSON) (EvalResultResp, error) { +// Evaluate previews a tentative play's legality and score. The tiles are addressed by +// alphabet index (Stage 13). +func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (EvalResultResp, error) { var out EvalResultResp err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/evaluate"), userID, "", map[string]any{"dir": dir, "tiles": tiles}, &out) return out, err } -// CheckWord looks a word up in the game's pinned dictionary. -func (c *Client) CheckWord(ctx context.Context, userID, gameID, word string) (WordCheckResp, error) { +// CheckWord looks a word up in the game's pinned dictionary. The word is carried as +// repeated ?idx= alphabet indices (Stage 13); the backend echoes the decoded concrete word. +func (c *Client) CheckWord(ctx context.Context, userID, gameID string, word []int) (WordCheckResp, error) { var out WordCheckResp - err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/check_word")+"?word="+url.QueryEscape(word), userID, "", nil, &out) + q := url.Values{} + for _, x := range word { + q.Add("idx", strconv.Itoa(x)) + } + err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/check_word")+"?"+q.Encode(), userID, "", nil, &out) return out, err } diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index aa8840d..f2e5489 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -107,21 +107,53 @@ func encodeMoveResult(r backendclient.MoveResultResp) []byte { return b.FinishedBytes() } -// encodeState builds a StateView payload. +// encodeState builds a StateView payload. The rack is a vector of alphabet indices and the +// alphabet display table is included only when the backend returned it (Stage 13: the +// client requests it on a per-variant cache miss). func encodeState(s backendclient.StateResp) []byte { b := flatbuffers.NewBuilder(512) game := buildGameView(b, s.Game) - rack := buildStringVector(b, s.Rack, fb.StateViewStartRackVector) + rackBytes := make([]byte, len(s.Rack)) + for i, v := range s.Rack { + rackBytes[i] = byte(v) + } + rack := b.CreateByteVector(rackBytes) + hasAlphabet := len(s.Alphabet) > 0 + var alphabet flatbuffers.UOffsetT + if hasAlphabet { + alphabet = buildAlphabet(b, s.Alphabet) + } fb.StateViewStart(b) fb.StateViewAddGame(b, game) fb.StateViewAddSeat(b, int32(s.Seat)) fb.StateViewAddRack(b, rack) fb.StateViewAddBagLen(b, int32(s.BagLen)) fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining)) + if hasAlphabet { + fb.StateViewAddAlphabet(b, alphabet) + } b.Finish(fb.StateViewEnd(b)) return b.FinishedBytes() } +// buildAlphabet builds the AlphabetEntry vector for a StateView and returns its offset. +func buildAlphabet(b *flatbuffers.Builder, entries []backendclient.AlphabetEntryJSON) flatbuffers.UOffsetT { + offs := make([]flatbuffers.UOffsetT, len(entries)) + for i, e := range entries { + letter := b.CreateString(e.Letter) + fb.AlphabetEntryStart(b) + fb.AlphabetEntryAddIndex(b, byte(e.Index)) + fb.AlphabetEntryAddLetter(b, letter) + fb.AlphabetEntryAddValue(b, int32(e.Value)) + offs[i] = fb.AlphabetEntryEnd(b) + } + fb.StateViewStartAlphabetVector(b, len(offs)) + for i := len(offs) - 1; i >= 0; i-- { + b.PrependUOffsetT(offs[i]) + } + return b.EndVector(len(offs)) +} + // encodeMatch builds a MatchResult payload. func encodeMatch(m backendclient.MatchResp) []byte { b := flatbuffers.NewBuilder(512) diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 2d36814..7188458 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -198,7 +198,7 @@ func submitPlayHandler(backend *backendclient.Client) Handler { func gameStateHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsStateRequest(req.Payload, 0) - st, err := backend.GameState(ctx, req.UserID, string(in.GameId())) + st, err := backend.GameState(ctx, req.UserID, string(in.GameId()), in.IncludeAlphabet()) if err != nil { return nil, err } @@ -238,17 +238,17 @@ func chatPostHandler(backend *backendclient.Client) Handler { } } -// decodeTiles reads the placed tiles from a SubmitPlayRequest. -func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.TileJSON { +// decodeTiles reads the index-addressed tiles to place from a SubmitPlayRequest (Stage 13). +func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.PlayTileJSON { n := in.TilesLength() - tiles := make([]backendclient.TileJSON, 0, n) - var t fb.TileRecord + tiles := make([]backendclient.PlayTileJSON, 0, n) + var t fb.PlayTile for i := 0; i < n; i++ { if in.Tiles(&t, i) { - tiles = append(tiles, backendclient.TileJSON{ + tiles = append(tiles, backendclient.PlayTileJSON{ Row: int(t.Row()), Col: int(t.Col()), - Letter: string(t.Letter()), + Letter: int(t.Letter()), Blank: t.Blank(), }) } @@ -256,17 +256,17 @@ func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.TileJSON { return tiles } -// decodeEvalTiles reads the tentative tiles from an EvalRequest. -func decodeEvalTiles(in *fb.EvalRequest) []backendclient.TileJSON { +// decodeEvalTiles reads the index-addressed tentative tiles from an EvalRequest (Stage 13). +func decodeEvalTiles(in *fb.EvalRequest) []backendclient.PlayTileJSON { n := in.TilesLength() - tiles := make([]backendclient.TileJSON, 0, n) - var t fb.TileRecord + tiles := make([]backendclient.PlayTileJSON, 0, n) + var t fb.PlayTile for i := 0; i < n; i++ { if in.Tiles(&t, i) { - tiles = append(tiles, backendclient.TileJSON{ + tiles = append(tiles, backendclient.PlayTileJSON{ Row: int(t.Row()), Col: int(t.Col()), - Letter: string(t.Letter()), + Letter: int(t.Letter()), Blank: t.Blank(), }) } @@ -274,12 +274,12 @@ func decodeEvalTiles(in *fb.EvalRequest) []backendclient.TileJSON { return tiles } -// decodeStringVector reads the exchange tiles from an ExchangeRequest. -func decodeStringVector(in *fb.ExchangeRequest) []string { - n := in.TilesLength() - out := make([]string, 0, n) - for i := 0; i < n; i++ { - out = append(out, string(in.Tiles(i))) +// bytesToInts widens a FlatBuffers ubyte vector (an alphabet-index list) to []int for the +// backend JSON edge (Stage 13: rack-exchange tiles and the word-check query). +func bytesToInts(bs []byte) []int { + out := make([]int, len(bs)) + for i, b := range bs { + out[i] = int(b) } return out } @@ -319,7 +319,7 @@ func resignHandler(backend *backendclient.Client) Handler { func exchangeHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsExchangeRequest(req.Payload, 0) - res, err := backend.Exchange(ctx, req.UserID, string(in.GameId()), decodeStringVector(in)) + res, err := backend.Exchange(ctx, req.UserID, string(in.GameId()), bytesToInts(in.TilesBytes())) if err != nil { return nil, err } @@ -352,7 +352,7 @@ func evaluateHandler(backend *backendclient.Client) Handler { func checkWordHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsCheckWordRequest(req.Payload, 0) - res, err := backend.CheckWord(ctx, req.UserID, string(in.GameId()), string(in.Word())) + res, err := backend.CheckWord(ctx, req.UserID, string(in.GameId()), bytesToInts(in.WordBytes())) if err != nil { return nil, err } diff --git a/gateway/internal/transcode/transcode_alphabet_test.go b/gateway/internal/transcode/transcode_alphabet_test.go new file mode 100644 index 0000000..61c73f2 --- /dev/null +++ b/gateway/internal/transcode/transcode_alphabet_test.go @@ -0,0 +1,199 @@ +package transcode_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + + flatbuffers "github.com/google/flatbuffers/go" + + "scrabble/gateway/internal/transcode" + fb "scrabble/pkg/fbs/scrabblefb" +) + +// TestGameStateIncludesAlphabet checks the include_alphabet flag reaches the backend and +// the returned alphabet table plus the index rack (a blank is 255) are encoded into the +// StateView (Stage 13). +func TestGameStateIncludesAlphabet(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("include_alphabet"); got != "true" { + t.Errorf("include_alphabet query = %q, want true", got) + } + _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"seats":[]},"seat":0,"rack":[0,255],"bag_len":50,"hints_remaining":0,"alphabet":[{"index":0,"letter":"a","value":1},{"index":1,"letter":"b","value":3}]}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgGameState) + + b := flatbuffers.NewBuilder(32) + gid := b.CreateString("g-1") + fb.StateRequestStart(b) + fb.StateRequestAddGameId(b, gid) + fb.StateRequestAddIncludeAlphabet(b, true) + b.Finish(fb.StateRequestEnd(b)) + + payload, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + st := fb.GetRootAsStateView(payload, 0) + if st.RackLength() != 2 || st.Rack(0) != 0 || st.Rack(1) != 255 { + t.Fatalf("rack indices wrong: len=%d [0]=%d [1]=%d", st.RackLength(), st.Rack(0), st.Rack(1)) + } + if st.AlphabetLength() != 2 { + t.Fatalf("alphabet length = %d, want 2", st.AlphabetLength()) + } + var e fb.AlphabetEntry + st.Alphabet(&e, 0) + if e.Index() != 0 || string(e.Letter()) != "a" || e.Value() != 1 { + t.Errorf("alphabet[0] = %d/%q/%d, want 0/a/1", e.Index(), e.Letter(), e.Value()) + } +} + +// TestGameStateOmitsAlphabetByDefault checks the table is neither requested nor encoded on +// the steady-state poll (no include_alphabet flag). +func TestGameStateOmitsAlphabetByDefault(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("include_alphabet") == "true" { + t.Error("include_alphabet should be unset") + } + _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"seats":[]},"seat":0,"rack":[2,0,19],"bag_len":50,"hints_remaining":0}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgGameState) + b := flatbuffers.NewBuilder(32) + gid := b.CreateString("g-1") + fb.StateRequestStart(b) + fb.StateRequestAddGameId(b, gid) + b.Finish(fb.StateRequestEnd(b)) + payload, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + st := fb.GetRootAsStateView(payload, 0) + if st.AlphabetLength() != 0 { + t.Errorf("alphabet length = %d, want 0", st.AlphabetLength()) + } + if st.RackLength() != 3 { + t.Errorf("rack length = %d, want 3", st.RackLength()) + } +} + +// TestSubmitPlayForwardsIndexTiles checks PlayTile indices reach the backend as integer +// letter fields in the JSON body, blank flag preserved (Stage 13). +func TestSubmitPlayForwardsIndexTiles(t *testing.T) { + var body struct { + Dir string `json:"dir"` + Tiles []struct { + Row int `json:"row"` + Col int `json:"col"` + Letter int `json:"letter"` + Blank bool `json:"blank"` + } `json:"tiles"` + } + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("decode body: %v", err) + } + _, _ = w.Write([]byte(`{"move":{"player":0,"action":"play","words":["CAT"],"score":9},"game":{"id":"g-5","status":"active","seats":[]}}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgGameSubmitPlay) + + b := flatbuffers.NewBuilder(64) + gid := b.CreateString("g-5") + dir := b.CreateString("H") + fb.PlayTileStart(b) + fb.PlayTileAddRow(b, 7) + fb.PlayTileAddCol(b, 7) + fb.PlayTileAddLetter(b, 2) + fb.PlayTileAddBlank(b, true) + tile := fb.PlayTileEnd(b) + fb.SubmitPlayRequestStartTilesVector(b, 1) + b.PrependUOffsetT(tile) + tiles := b.EndVector(1) + fb.SubmitPlayRequestStart(b) + fb.SubmitPlayRequestAddGameId(b, gid) + fb.SubmitPlayRequestAddDir(b, dir) + fb.SubmitPlayRequestAddTiles(b, tiles) + b.Finish(fb.SubmitPlayRequestEnd(b)) + + if _, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-1"}); err != nil { + t.Fatalf("handler: %v", err) + } + if len(body.Tiles) != 1 || body.Tiles[0].Letter != 2 || !body.Tiles[0].Blank || body.Tiles[0].Row != 7 { + t.Fatalf("forwarded tiles wrong: %+v", body.Tiles) + } +} + +// TestCheckWordForwardsIndices checks the word-check query rides as repeated ?idx= params +// and the decoded concrete word echoes back (Stage 13). +func TestCheckWordForwardsIndices(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query()["idx"]; len(got) != 3 || got[0] != "2" || got[2] != "19" { + t.Errorf("idx params = %v, want [2 0 19]", got) + } + _, _ = w.Write([]byte(`{"word":"cat","legal":true}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgGameCheckWord) + + b := flatbuffers.NewBuilder(32) + gid := b.CreateString("g-1") + word := b.CreateByteVector([]byte{2, 0, 19}) + fb.CheckWordRequestStart(b) + fb.CheckWordRequestAddGameId(b, gid) + fb.CheckWordRequestAddWord(b, word) + b.Finish(fb.CheckWordRequestEnd(b)) + + payload, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + res := fb.GetRootAsWordCheckResult(payload, 0) + if string(res.Word()) != "cat" || !res.Legal() { + t.Errorf("word check = %q/%v, want cat/true", res.Word(), res.Legal()) + } +} + +// TestExchangeForwardsIndices checks rack-exchange indices (blank 255) reach the backend +// body (Stage 13). +func TestExchangeForwardsIndices(t *testing.T) { + var body struct { + Tiles []int `json:"tiles"` + } + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &body) + _, _ = w.Write([]byte(`{"move":{"player":0,"action":"exchange","count":2},"game":{"id":"g-1","status":"active","seats":[]}}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgGameExchange) + + b := flatbuffers.NewBuilder(32) + gid := b.CreateString("g-1") + tiles := b.CreateByteVector([]byte{0, 255}) + fb.ExchangeRequestStart(b) + fb.ExchangeRequestAddGameId(b, gid) + fb.ExchangeRequestAddTiles(b, tiles) + b.Finish(fb.ExchangeRequestEnd(b)) + + if _, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-1"}); err != nil { + t.Fatalf("handler: %v", err) + } + if len(body.Tiles) != 2 || body.Tiles[0] != 0 || body.Tiles[1] != 255 { + t.Errorf("forwarded exchange tiles = %v, want [0 255]", body.Tiles) + } +} diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go index 2af5a4b..f9cb2b7 100644 --- a/gateway/internal/transcode/transcode_test.go +++ b/gateway/internal/transcode/transcode_test.go @@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) { if r.URL.Path != "/api/v1/user/games/g-1/state" { t.Errorf("unexpected path %q", r.URL.Path) } - _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"english","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":["A","B"],"bag_len":80,"hints_remaining":1}`)) + _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"english","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":1}`)) }) defer cleanup() diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 8e38778..d451a44 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -14,8 +14,9 @@ namespace scrabblefb; // --- shared building blocks --- -// TileRecord is one placed (or to-place) tile: its board coordinate, the concrete -// letter ("?" when read from a hand for a blank) and whether it came from a blank. +// TileRecord is one tile in a decoded move record (history, move result, hint): its +// board coordinate, the concrete letter ("?" when read from a hand for a blank) and +// whether it came from a blank. Inbound tiles to place use PlayTile (alphabet indices). table TileRecord { row:int; col:int; @@ -23,6 +24,25 @@ table TileRecord { blank:bool; } +// PlayTile is one inbound tile to place, addressed by its alphabet index rather than a +// concrete letter (Stage 13). For a blank, letter carries the designated letter's index +// and blank is true. The board coordinate is its target square. +table PlayTile { + row:int; + col:int; + letter:ubyte; + blank:bool; +} + +// AlphabetEntry is one letter of a variant's alphabet, sent for display only (Stage 13): +// index is the engine alphabet-index byte the wire uses for this letter, letter is the +// concrete character and value is its tile score. The client caches the table per variant. +table AlphabetEntry { + index:ubyte; + letter:string; + value:int; +} + // SeatView is one seat's public standing in a game. display_name is resolved by the // backend from the account store (added trailing — backward-compatible). table SeatView { @@ -123,11 +143,12 @@ table Profile { // --- game (authenticated) --- -// SubmitPlayRequest places tiles in a direction on the player's turn. +// SubmitPlayRequest places tiles in a direction on the player's turn. tiles are addressed +// by alphabet index (Stage 13). table SubmitPlayRequest { game_id:string; dir:string; - tiles:[TileRecord]; + tiles:[PlayTile]; } // MoveResult is the outcome of a committed move: the move and the post-move game. @@ -136,19 +157,25 @@ table MoveResult { game:GameView; } -// StateRequest asks for the requesting player's view of a game. +// StateRequest asks for the requesting player's view of a game. include_alphabet asks the +// backend to embed the variant's AlphabetEntry table in the reply (Stage 13); the client +// sets it only on a per-variant cache miss so the table is not resent on every poll. table StateRequest { game_id:string; + include_alphabet:bool = false; } -// StateView is a player's view of a game: the shared summary plus their private -// rack, the bag size and their remaining hint budget. +// StateView is a player's view of a game: the shared summary plus their private rack, the +// bag size and their remaining hint budget. rack carries alphabet indices (Stage 13); a +// blank tile is the sentinel index 255. alphabet is present only when the request set +// include_alphabet (a display table the client caches per variant). table StateView { game:GameView; seat:int; - rack:[string]; + rack:[ubyte]; bag_len:int; hints_remaining:int; + alphabet:[AlphabetEntry]; } // GameActionRequest carries just a game id (pass / resign / hint / history). @@ -156,17 +183,19 @@ table GameActionRequest { game_id:string; } -// ExchangeRequest swaps the listed rack tiles back into the bag. +// ExchangeRequest swaps the listed rack tiles back into the bag. tiles are alphabet +// indices (Stage 13); a blank is the sentinel index 255. table ExchangeRequest { game_id:string; - tiles:[string]; + tiles:[ubyte]; } -// EvalRequest previews a tentative play without committing it. +// EvalRequest previews a tentative play without committing it. tiles are addressed by +// alphabet index (Stage 13). table EvalRequest { game_id:string; dir:string; - tiles:[TileRecord]; + tiles:[PlayTile]; } // EvalResult is an unlimited move preview: legality, score and the words formed. @@ -176,10 +205,11 @@ table EvalResult { words:[string]; } -// CheckWordRequest looks a word up in the game's pinned dictionary. +// CheckWordRequest looks a word up in the game's pinned dictionary. word is a sequence of +// alphabet indices (Stage 13); the client constrains input to the variant's alphabet. table CheckWordRequest { game_id:string; - word:string; + word:[ubyte]; } // WordCheckResult is the dictionary lookup outcome. diff --git a/pkg/fbs/scrabblefb/AlphabetEntry.go b/pkg/fbs/scrabblefb/AlphabetEntry.go new file mode 100644 index 0000000..51ae788 --- /dev/null +++ b/pkg/fbs/scrabblefb/AlphabetEntry.go @@ -0,0 +1,90 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type AlphabetEntry struct { + _tab flatbuffers.Table +} + +func GetRootAsAlphabetEntry(buf []byte, offset flatbuffers.UOffsetT) *AlphabetEntry { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &AlphabetEntry{} + x.Init(buf, n+offset) + return x +} + +func FinishAlphabetEntryBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsAlphabetEntry(buf []byte, offset flatbuffers.UOffsetT) *AlphabetEntry { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &AlphabetEntry{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedAlphabetEntryBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *AlphabetEntry) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *AlphabetEntry) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *AlphabetEntry) Index() byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.GetByte(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *AlphabetEntry) MutateIndex(n byte) bool { + return rcv._tab.MutateByteSlot(4, n) +} + +func (rcv *AlphabetEntry) Letter() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *AlphabetEntry) Value() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *AlphabetEntry) MutateValue(n int32) bool { + return rcv._tab.MutateInt32Slot(8, n) +} + +func AlphabetEntryStart(builder *flatbuffers.Builder) { + builder.StartObject(3) +} +func AlphabetEntryAddIndex(builder *flatbuffers.Builder, index byte) { + builder.PrependByteSlot(0, index, 0) +} +func AlphabetEntryAddLetter(builder *flatbuffers.Builder, letter flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(letter), 0) +} +func AlphabetEntryAddValue(builder *flatbuffers.Builder, value int32) { + builder.PrependInt32Slot(2, value, 0) +} +func AlphabetEntryEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/CheckWordRequest.go b/pkg/fbs/scrabblefb/CheckWordRequest.go index 1d5759d..384fedd 100644 --- a/pkg/fbs/scrabblefb/CheckWordRequest.go +++ b/pkg/fbs/scrabblefb/CheckWordRequest.go @@ -49,7 +49,24 @@ func (rcv *CheckWordRequest) GameId() []byte { return nil } -func (rcv *CheckWordRequest) Word() []byte { +func (rcv *CheckWordRequest) Word(j int) byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + a := rcv._tab.Vector(o) + return rcv._tab.GetByte(a + flatbuffers.UOffsetT(j*1)) + } + return 0 +} + +func (rcv *CheckWordRequest) WordLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func (rcv *CheckWordRequest) WordBytes() []byte { o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) if o != 0 { return rcv._tab.ByteVector(o + rcv._tab.Pos) @@ -57,6 +74,15 @@ func (rcv *CheckWordRequest) Word() []byte { return nil } +func (rcv *CheckWordRequest) MutateWord(j int, n byte) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + a := rcv._tab.Vector(o) + return rcv._tab.MutateByte(a+flatbuffers.UOffsetT(j*1), n) + } + return false +} + func CheckWordRequestStart(builder *flatbuffers.Builder) { builder.StartObject(2) } @@ -66,6 +92,9 @@ func CheckWordRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers. func CheckWordRequestAddWord(builder *flatbuffers.Builder, word flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(word), 0) } +func CheckWordRequestStartWordVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(1, numElems, 1) +} func CheckWordRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/EvalRequest.go b/pkg/fbs/scrabblefb/EvalRequest.go index 7eeebae..1687a67 100644 --- a/pkg/fbs/scrabblefb/EvalRequest.go +++ b/pkg/fbs/scrabblefb/EvalRequest.go @@ -57,7 +57,7 @@ func (rcv *EvalRequest) Dir() []byte { return nil } -func (rcv *EvalRequest) Tiles(obj *TileRecord, j int) bool { +func (rcv *EvalRequest) Tiles(obj *PlayTile, j int) bool { o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) if o != 0 { x := rcv._tab.Vector(o) diff --git a/pkg/fbs/scrabblefb/ExchangeRequest.go b/pkg/fbs/scrabblefb/ExchangeRequest.go index 0226c5e..e1114d5 100644 --- a/pkg/fbs/scrabblefb/ExchangeRequest.go +++ b/pkg/fbs/scrabblefb/ExchangeRequest.go @@ -49,13 +49,13 @@ func (rcv *ExchangeRequest) GameId() []byte { return nil } -func (rcv *ExchangeRequest) Tiles(j int) []byte { +func (rcv *ExchangeRequest) Tiles(j int) byte { o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) if o != 0 { a := rcv._tab.Vector(o) - return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4)) + return rcv._tab.GetByte(a + flatbuffers.UOffsetT(j*1)) } - return nil + return 0 } func (rcv *ExchangeRequest) TilesLength() int { @@ -66,6 +66,23 @@ func (rcv *ExchangeRequest) TilesLength() int { return 0 } +func (rcv *ExchangeRequest) TilesBytes() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *ExchangeRequest) MutateTiles(j int, n byte) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + a := rcv._tab.Vector(o) + return rcv._tab.MutateByte(a+flatbuffers.UOffsetT(j*1), n) + } + return false +} + func ExchangeRequestStart(builder *flatbuffers.Builder) { builder.StartObject(2) } @@ -76,7 +93,7 @@ func ExchangeRequestAddTiles(builder *flatbuffers.Builder, tiles flatbuffers.UOf builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(tiles), 0) } func ExchangeRequestStartTilesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { - return builder.StartVector(4, numElems, 4) + return builder.StartVector(1, numElems, 1) } func ExchangeRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() diff --git a/pkg/fbs/scrabblefb/PlayTile.go b/pkg/fbs/scrabblefb/PlayTile.go new file mode 100644 index 0000000..6592600 --- /dev/null +++ b/pkg/fbs/scrabblefb/PlayTile.go @@ -0,0 +1,109 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type PlayTile struct { + _tab flatbuffers.Table +} + +func GetRootAsPlayTile(buf []byte, offset flatbuffers.UOffsetT) *PlayTile { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &PlayTile{} + x.Init(buf, n+offset) + return x +} + +func FinishPlayTileBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsPlayTile(buf []byte, offset flatbuffers.UOffsetT) *PlayTile { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &PlayTile{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedPlayTileBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *PlayTile) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *PlayTile) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *PlayTile) Row() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *PlayTile) MutateRow(n int32) bool { + return rcv._tab.MutateInt32Slot(4, n) +} + +func (rcv *PlayTile) Col() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *PlayTile) MutateCol(n int32) bool { + return rcv._tab.MutateInt32Slot(6, n) +} + +func (rcv *PlayTile) Letter() byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.GetByte(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *PlayTile) MutateLetter(n byte) bool { + return rcv._tab.MutateByteSlot(8, n) +} + +func (rcv *PlayTile) Blank() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *PlayTile) MutateBlank(n bool) bool { + return rcv._tab.MutateBoolSlot(10, n) +} + +func PlayTileStart(builder *flatbuffers.Builder) { + builder.StartObject(4) +} +func PlayTileAddRow(builder *flatbuffers.Builder, row int32) { + builder.PrependInt32Slot(0, row, 0) +} +func PlayTileAddCol(builder *flatbuffers.Builder, col int32) { + builder.PrependInt32Slot(1, col, 0) +} +func PlayTileAddLetter(builder *flatbuffers.Builder, letter byte) { + builder.PrependByteSlot(2, letter, 0) +} +func PlayTileAddBlank(builder *flatbuffers.Builder, blank bool) { + builder.PrependBoolSlot(3, blank, false) +} +func PlayTileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/StateRequest.go b/pkg/fbs/scrabblefb/StateRequest.go index db1badb..3fb2f61 100644 --- a/pkg/fbs/scrabblefb/StateRequest.go +++ b/pkg/fbs/scrabblefb/StateRequest.go @@ -49,12 +49,27 @@ func (rcv *StateRequest) GameId() []byte { return nil } +func (rcv *StateRequest) IncludeAlphabet() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *StateRequest) MutateIncludeAlphabet(n bool) bool { + return rcv._tab.MutateBoolSlot(6, n) +} + func StateRequestStart(builder *flatbuffers.Builder) { - builder.StartObject(1) + builder.StartObject(2) } func StateRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) } +func StateRequestAddIncludeAlphabet(builder *flatbuffers.Builder, includeAlphabet bool) { + builder.PrependBoolSlot(1, includeAlphabet, false) +} func StateRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/StateView.go b/pkg/fbs/scrabblefb/StateView.go index a37b943..2c0138f 100644 --- a/pkg/fbs/scrabblefb/StateView.go +++ b/pkg/fbs/scrabblefb/StateView.go @@ -66,13 +66,13 @@ func (rcv *StateView) MutateSeat(n int32) bool { return rcv._tab.MutateInt32Slot(6, n) } -func (rcv *StateView) Rack(j int) []byte { +func (rcv *StateView) Rack(j int) byte { o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) if o != 0 { a := rcv._tab.Vector(o) - return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4)) + return rcv._tab.GetByte(a + flatbuffers.UOffsetT(j*1)) } - return nil + return 0 } func (rcv *StateView) RackLength() int { @@ -83,6 +83,23 @@ func (rcv *StateView) RackLength() int { return 0 } +func (rcv *StateView) RackBytes() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *StateView) MutateRack(j int, n byte) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + a := rcv._tab.Vector(o) + return rcv._tab.MutateByte(a+flatbuffers.UOffsetT(j*1), n) + } + return false +} + func (rcv *StateView) BagLen() int32 { o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) if o != 0 { @@ -107,8 +124,28 @@ func (rcv *StateView) MutateHintsRemaining(n int32) bool { return rcv._tab.MutateInt32Slot(12, n) } +func (rcv *StateView) Alphabet(obj *AlphabetEntry, j int) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + x := rcv._tab.Vector(o) + x += flatbuffers.UOffsetT(j) * 4 + x = rcv._tab.Indirect(x) + obj.Init(rcv._tab.Bytes, x) + return true + } + return false +} + +func (rcv *StateView) AlphabetLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + func StateViewStart(builder *flatbuffers.Builder) { - builder.StartObject(5) + builder.StartObject(6) } func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(game), 0) @@ -120,7 +157,7 @@ func StateViewAddRack(builder *flatbuffers.Builder, rack flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(rack), 0) } func StateViewStartRackVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { - return builder.StartVector(4, numElems, 4) + return builder.StartVector(1, numElems, 1) } func StateViewAddBagLen(builder *flatbuffers.Builder, bagLen int32) { builder.PrependInt32Slot(3, bagLen, 0) @@ -128,6 +165,12 @@ func StateViewAddBagLen(builder *flatbuffers.Builder, bagLen int32) { func StateViewAddHintsRemaining(builder *flatbuffers.Builder, hintsRemaining int32) { builder.PrependInt32Slot(4, hintsRemaining, 0) } +func StateViewAddAlphabet(builder *flatbuffers.Builder, alphabet flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(alphabet), 0) +} +func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/SubmitPlayRequest.go b/pkg/fbs/scrabblefb/SubmitPlayRequest.go index 49c1ed1..9ae7be0 100644 --- a/pkg/fbs/scrabblefb/SubmitPlayRequest.go +++ b/pkg/fbs/scrabblefb/SubmitPlayRequest.go @@ -57,7 +57,7 @@ func (rcv *SubmitPlayRequest) Dir() []byte { return nil } -func (rcv *SubmitPlayRequest) Tiles(obj *TileRecord, j int) bool { +func (rcv *SubmitPlayRequest) Tiles(obj *PlayTile, j int) bool { o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) if o != 0 { x := rcv._tab.Vector(o) diff --git a/ui/README.md b/ui/README.md index 0e667ba..ca6af00 100644 --- a/ui/README.md +++ b/ui/README.md @@ -42,9 +42,14 @@ out of production). Both speak the plain `lib/model.ts` types via `lib/codec.ts` **No board on the wire:** `StateView` is a summary + rack only, so the client reconstructs the 15×15 board by replaying the decoded move journal (`game.history`). -Premium squares and tile values (`lib/premiums.ts`) are a client-side map **ported from -`scrabble-solver/rules/rules.go`** (pinned by a Vitest parity test). Board, tiles and -effects are pure CSS + Unicode — no image/font/SVG assets. +**The play loop is alphabet-agnostic (Stage 13):** the rack and the play / exchange / +word-check requests carry **alphabet indices**, and the client caches each variant's +`(index, letter, value)` table — sent once behind `StateRequest.include_alphabet` — in +`lib/alphabet.ts`, rendering the rack and blank chooser from it. **Premium squares** +(`lib/premiums.ts`) stay a client-side geometry map **ported from +`scrabble-solver/rules/rules.go`** (pinned by a Vitest parity test); **tile values and the +alphabet now come from the server table** (their parity lives in the Go `engine.AlphabetTable` +test). Board, tiles and effects are pure CSS + Unicode — no image/font/SVG assets. ## Codegen @@ -65,7 +70,8 @@ runtime; the Telegram SDK itself is wired in the Telegram stage. ``` src/ lib/ model, client facade, transport (+ mock), codec, board replay, - placement state machine, premiums, stats, share, i18n, theme, session, router, app store + placement state machine, premiums (geometry), alphabet cache, stats, share, + i18n, theme, session, router, app store components/ Header, Menu (+ badge), Modal, Toast, TabBar, Screen screens/ Login, Lobby, NewGame, Profile, Settings, About, Friends, Stats game/ Game, Board, Rack, Controls, MakeMove, Chat diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 6256e04..731fb01 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -1,7 +1,7 @@