Merge pull request 'Stage 13: alphabet on the wire (UI alphabet-agnostic, TODO-4)' (#14) from feature/stage-13-alphabet-on-the-wire into master
Tests · Go / test (push) Successful in 10s
Tests · Integration / integration (push) Successful in 12s
Tests · UI / test (push) Successful in 19s

This commit was merged in pull request #14.
This commit is contained in:
2026-06-04 15:00:57 +00:00
47 changed files with 1812 additions and 272 deletions
+56 -12
View File
@@ -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<code>`, shared Go ↔ TS), the bot redeems it on
launch, and the UI shows a **share-to-Telegram** link for an issued code when
+150
View File
@@ -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
}
+110
View File
@@ -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)
}
}
+7
View File
@@ -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) {
+18
View File
@@ -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.
+20
View File
@@ -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()
+38 -10
View File
@@ -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.
+57 -8
View File
@@ -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)
+35 -9
View File
@@ -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)
}
+17
View File
@@ -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
+54 -19
View File
@@ -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
}
+34 -2
View File
@@ -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)
+21 -21
View File
@@ -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
}
@@ -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)
}
}
+1 -1
View File
@@ -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()
+44 -14
View File
@@ -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.
+90
View File
@@ -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()
}
+30 -1
View File
@@ -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()
}
+1 -1
View File
@@ -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)
+21 -4
View File
@@ -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()
+109
View File
@@ -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()
}
+16 -1
View File
@@ -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()
}
+48 -5
View File
@@ -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()
}
+1 -1
View File
@@ -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)
+10 -4
View File
@@ -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
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { BoardCell } from '../lib/board';
import type { Premium } from '../lib/premiums';
import { tileValue } from '../lib/premiums';
import { valueForLetter } from '../lib/alphabet';
import type { Variant } from '../lib/model';
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
import type { Locale } from '../lib/i18n/catalog';
@@ -98,7 +98,7 @@
>
{#if letter}
<span class="letter">{letter}</span>
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
{#if !blank}<span class="val">{valueForLetter(variant, letter)}</span>{/if}
{:else if r === centre.row && c === centre.col}
<span class="star"></span>
{:else if bl?.kind === 'single'}
+19 -10
View File
@@ -14,7 +14,8 @@
import { t } from '../lib/i18n/index.svelte';
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
import { replay } from '../lib/board';
import { alphabet, centre, premiumGrid } from '../lib/premiums';
import { centre, premiumGrid } from '../lib/premiums';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import { shareOrDownloadGcg } from '../lib/share';
import {
@@ -84,7 +85,13 @@
async function load() {
try {
const [st, hist] = await Promise.all([gateway.gameState(id), gateway.gameHistory(id)]);
// Ask for the alphabet table only on a per-variant cache miss (the first open of a
// game whose variant the client has not cached yet); steady-state polls omit it.
const includeAlphabet = !view || !hasAlphabet(view.game.variant);
const [st, hist] = await Promise.all([
gateway.gameState(id, includeAlphabet),
gateway.gameHistory(id),
]);
view = st;
moves = hist.moves;
placement = newPlacement(st.rack);
@@ -206,7 +213,7 @@
if (!sub) return;
previewTimer = setTimeout(async () => {
try {
preview = await gateway.evaluate(id, sub.dir, sub.tiles);
preview = await gateway.evaluate(id, sub.dir, sub.tiles, variant);
} catch {
/* best-effort */
}
@@ -218,7 +225,7 @@
if (!sub) return;
busy = true;
try {
await gateway.submitPlay(id, sub.dir, sub.tiles);
await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
zoomed = false;
await load();
} catch (e) {
@@ -298,7 +305,7 @@
exchangeOpen = false;
busy = true;
try {
await gateway.exchange(id, tiles);
await gateway.exchange(id, tiles, variant);
await load();
} catch (e) {
handleError(e);
@@ -313,7 +320,7 @@
checkOpen = true;
}
function onCheckInput(e: Event) {
checkWord = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabet(variant));
checkWord = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabetLetters(variant));
}
// Check is disabled while cooling down, for an already-checked word, or an out-of-range
// length. The input filter already restricts to the variant's alphabet.
@@ -326,9 +333,11 @@
cooling = true;
setTimeout(() => (cooling = false), 5000);
try {
const r = await gateway.checkWord(id, w);
checkedWords.set(r.word.toUpperCase(), r.legal);
checkResult = r;
const r = await gateway.checkWord(id, w, variant);
// Key the cache and the displayed result on the upper-case word the player typed; the
// server echoes the decoded concrete word in the solver's lower case.
checkedWords.set(w, r.legal);
checkResult = { word: w, legal: r.legal };
} catch (e) {
handleError(e);
}
@@ -535,7 +544,7 @@
{#if blankPrompt}
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
<div class="alpha">
{#each alphabet(variant) as ch (ch)}
{#each alphabetLetters(variant) as ch (ch)}
<button onclick={() => chooseBlank(ch)}>{ch}</button>
{/each}
</div>
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { RackSlot } from '../lib/placement';
import { BLANK } from '../lib/placement';
import { tileValue } from '../lib/premiums';
import { valueForLetter } from '../lib/alphabet';
import type { Variant } from '../lib/model';
let {
@@ -30,7 +30,7 @@
onpointerdown={(e) => ondown(e, slot.index)}
>
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
{#if slot.letter !== BLANK}<span class="val">{tileValue(variant, slot.letter)}</span>{/if}
{#if slot.letter !== BLANK}<span class="val">{valueForLetter(variant, slot.letter)}</span>{/if}
</button>
{/each}
</div>
+2
View File
@@ -2,6 +2,7 @@
export { AccountRef } from './scrabblefb/account-ref.js';
export { Ack } from './scrabblefb/ack.js';
export { AlphabetEntry } from './scrabblefb/alphabet-entry.js';
export { BlockList } from './scrabblefb/block-list.js';
export { ChatList } from './scrabblefb/chat-list.js';
export { ChatMessage } from './scrabblefb/chat-message.js';
@@ -41,6 +42,7 @@ export { MoveResult } from './scrabblefb/move-result.js';
export { NotificationEvent } from './scrabblefb/notification-event.js';
export { NudgeEvent } from './scrabblefb/nudge-event.js';
export { OpponentMovedEvent } from './scrabblefb/opponent-moved-event.js';
export { PlayTile } from './scrabblefb/play-tile.js';
export { Profile } from './scrabblefb/profile.js';
export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js';
export { RedeemResult } from './scrabblefb/redeem-result.js';
@@ -0,0 +1,68 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class AlphabetEntry {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):AlphabetEntry {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsAlphabetEntry(bb:flatbuffers.ByteBuffer, obj?:AlphabetEntry):AlphabetEntry {
return (obj || new AlphabetEntry()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsAlphabetEntry(bb:flatbuffers.ByteBuffer, obj?:AlphabetEntry):AlphabetEntry {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new AlphabetEntry()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
index():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.readUint8(this.bb_pos + offset) : 0;
}
letter():string|null
letter(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
letter(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
value():number {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
static startAlphabetEntry(builder:flatbuffers.Builder) {
builder.startObject(3);
}
static addIndex(builder:flatbuffers.Builder, index:number) {
builder.addFieldInt8(0, index, 0);
}
static addLetter(builder:flatbuffers.Builder, letterOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, letterOffset, 0);
}
static addValue(builder:flatbuffers.Builder, value:number) {
builder.addFieldInt32(2, value, 0);
}
static endAlphabetEntry(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createAlphabetEntry(builder:flatbuffers.Builder, index:number, letterOffset:flatbuffers.Offset, value:number):flatbuffers.Offset {
AlphabetEntry.startAlphabetEntry(builder);
AlphabetEntry.addIndex(builder, index);
AlphabetEntry.addLetter(builder, letterOffset);
AlphabetEntry.addValue(builder, value);
return AlphabetEntry.endAlphabetEntry(builder);
}
}
@@ -27,11 +27,19 @@ gameId(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
word():string|null
word(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
word(optionalEncoding?:any):string|Uint8Array|null {
word(index: number):number|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
return offset ? this.bb!.readUint8(this.bb!.__vector(this.bb_pos + offset) + index) : 0;
}
wordLength():number {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
wordArray():Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? new Uint8Array(this.bb!.bytes().buffer, this.bb!.bytes().byteOffset + this.bb!.__vector(this.bb_pos + offset), this.bb!.__vector_len(this.bb_pos + offset)) : null;
}
static startCheckWordRequest(builder:flatbuffers.Builder) {
@@ -46,6 +54,18 @@ static addWord(builder:flatbuffers.Builder, wordOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, wordOffset, 0);
}
static createWordVector(builder:flatbuffers.Builder, data:number[]|Uint8Array):flatbuffers.Offset {
builder.startVector(1, data.length, 1);
for (let i = data.length - 1; i >= 0; i--) {
builder.addInt8(data[i]!);
}
return builder.endVector();
}
static startWordVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(1, numElems, 1);
}
static endCheckWordRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
+3 -3
View File
@@ -2,7 +2,7 @@
import * as flatbuffers from 'flatbuffers';
import { TileRecord } from '../scrabblefb/tile-record.js';
import { PlayTile } from '../scrabblefb/play-tile.js';
export class EvalRequest {
@@ -37,9 +37,9 @@ dir(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
tiles(index: number, obj?:TileRecord):TileRecord|null {
tiles(index: number, obj?:PlayTile):PlayTile|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? (obj || new TileRecord()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
return offset ? (obj || new PlayTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
tilesLength():number {
+11 -8
View File
@@ -27,11 +27,9 @@ gameId(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
tiles(index: number):string
tiles(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
tiles(index: number,optionalEncoding?:any):string|Uint8Array|null {
tiles(index: number):number|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
return offset ? this.bb!.readUint8(this.bb!.__vector(this.bb_pos + offset) + index) : 0;
}
tilesLength():number {
@@ -39,6 +37,11 @@ tilesLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
tilesArray():Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? new Uint8Array(this.bb!.bytes().buffer, this.bb!.bytes().byteOffset + this.bb!.__vector(this.bb_pos + offset), this.bb!.__vector_len(this.bb_pos + offset)) : null;
}
static startExchangeRequest(builder:flatbuffers.Builder) {
builder.startObject(2);
}
@@ -51,16 +54,16 @@ static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, tilesOffset, 0);
}
static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
static createTilesVector(builder:flatbuffers.Builder, data:number[]|Uint8Array):flatbuffers.Offset {
builder.startVector(1, data.length, 1);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
builder.addInt8(data[i]!);
}
return builder.endVector();
}
static startTilesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
builder.startVector(1, numElems, 1);
}
static endExchangeRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
+76
View File
@@ -0,0 +1,76 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class PlayTile {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):PlayTile {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsPlayTile(bb:flatbuffers.ByteBuffer, obj?:PlayTile):PlayTile {
return (obj || new PlayTile()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsPlayTile(bb:flatbuffers.ByteBuffer, obj?:PlayTile):PlayTile {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new PlayTile()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
row():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
col():number {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
letter():number {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.readUint8(this.bb_pos + offset) : 0;
}
blank():boolean {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startPlayTile(builder:flatbuffers.Builder) {
builder.startObject(4);
}
static addRow(builder:flatbuffers.Builder, row:number) {
builder.addFieldInt32(0, row, 0);
}
static addCol(builder:flatbuffers.Builder, col:number) {
builder.addFieldInt32(1, col, 0);
}
static addLetter(builder:flatbuffers.Builder, letter:number) {
builder.addFieldInt8(2, letter, 0);
}
static addBlank(builder:flatbuffers.Builder, blank:boolean) {
builder.addFieldInt8(3, +blank, +false);
}
static endPlayTile(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createPlayTile(builder:flatbuffers.Builder, row:number, col:number, letter:number, blank:boolean):flatbuffers.Offset {
PlayTile.startPlayTile(builder);
PlayTile.addRow(builder, row);
PlayTile.addCol(builder, col);
PlayTile.addLetter(builder, letter);
PlayTile.addBlank(builder, blank);
return PlayTile.endPlayTile(builder);
}
}
+12 -2
View File
@@ -27,22 +27,32 @@ gameId(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
includeAlphabet():boolean {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startStateRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
builder.startObject(2);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameIdOffset, 0);
}
static addIncludeAlphabet(builder:flatbuffers.Builder, includeAlphabet:boolean) {
builder.addFieldInt8(1, +includeAlphabet, +false);
}
static endStateRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createStateRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset):flatbuffers.Offset {
static createStateRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, includeAlphabet:boolean):flatbuffers.Offset {
StateRequest.startStateRequest(builder);
StateRequest.addGameId(builder, gameIdOffset);
StateRequest.addIncludeAlphabet(builder, includeAlphabet);
return StateRequest.endStateRequest(builder);
}
}
+41 -10
View File
@@ -2,6 +2,7 @@
import * as flatbuffers from 'flatbuffers';
import { AlphabetEntry } from '../scrabblefb/alphabet-entry.js';
import { GameView } from '../scrabblefb/game-view.js';
@@ -33,11 +34,9 @@ seat():number {
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
rack(index: number):string
rack(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
rack(index: number,optionalEncoding?:any):string|Uint8Array|null {
rack(index: number):number|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
return offset ? this.bb!.readUint8(this.bb!.__vector(this.bb_pos + offset) + index) : 0;
}
rackLength():number {
@@ -45,6 +44,11 @@ rackLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
rackArray():Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? new Uint8Array(this.bb!.bytes().buffer, this.bb!.bytes().byteOffset + this.bb!.__vector(this.bb_pos + offset), this.bb!.__vector_len(this.bb_pos + offset)) : null;
}
bagLen():number {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
@@ -55,8 +59,18 @@ hintsRemaining():number {
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
alphabet(index: number, obj?:AlphabetEntry):AlphabetEntry|null {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? (obj || new AlphabetEntry()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
alphabetLength():number {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startStateView(builder:flatbuffers.Builder) {
builder.startObject(5);
builder.startObject(6);
}
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
@@ -71,16 +85,16 @@ static addRack(builder:flatbuffers.Builder, rackOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, rackOffset, 0);
}
static createRackVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
static createRackVector(builder:flatbuffers.Builder, data:number[]|Uint8Array):flatbuffers.Offset {
builder.startVector(1, data.length, 1);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
builder.addInt8(data[i]!);
}
return builder.endVector();
}
static startRackVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
builder.startVector(1, numElems, 1);
}
static addBagLen(builder:flatbuffers.Builder, bagLen:number) {
@@ -91,18 +105,35 @@ static addHintsRemaining(builder:flatbuffers.Builder, hintsRemaining:number) {
builder.addFieldInt32(4, hintsRemaining, 0);
}
static addAlphabet(builder:flatbuffers.Builder, alphabetOffset:flatbuffers.Offset) {
builder.addFieldOffset(5, alphabetOffset, 0);
}
static createAlphabetVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startAlphabetVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number):flatbuffers.Offset {
static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset):flatbuffers.Offset {
StateView.startStateView(builder);
StateView.addGame(builder, gameOffset);
StateView.addSeat(builder, seat);
StateView.addRack(builder, rackOffset);
StateView.addBagLen(builder, bagLen);
StateView.addHintsRemaining(builder, hintsRemaining);
StateView.addAlphabet(builder, alphabetOffset);
return StateView.endStateView(builder);
}
}
@@ -2,7 +2,7 @@
import * as flatbuffers from 'flatbuffers';
import { TileRecord } from '../scrabblefb/tile-record.js';
import { PlayTile } from '../scrabblefb/play-tile.js';
export class SubmitPlayRequest {
@@ -37,9 +37,9 @@ dir(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
tiles(index: number, obj?:TileRecord):TileRecord|null {
tiles(index: number, obj?:PlayTile):PlayTile|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? (obj || new TileRecord()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
return offset ? (obj || new PlayTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
tilesLength():number {
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import {
alphabetLetters,
BLANK_INDEX,
hasAlphabet,
indexForLetter,
letterForIndex,
setAlphabet,
valueForLetter,
} from './alphabet';
// The cache module is per-file-isolated by vitest, so only what these tests seed exists.
describe('alphabet cache (Stage 13)', () => {
it('upper-cases letters for display and maps indices and values case-insensitively', () => {
setAlphabet('english', [
{ index: 0, letter: 'a', value: 1 },
{ index: 16, letter: 'q', value: 10 },
]);
expect(hasAlphabet('english')).toBe(true);
expect(letterForIndex('english', 0)).toBe('A');
expect(letterForIndex('english', 16)).toBe('Q');
expect(indexForLetter('english', 'a')).toBe(0);
expect(indexForLetter('english', 'Q')).toBe(16);
expect(valueForLetter('english', 'a')).toBe(1);
expect(valueForLetter('english', 'Q')).toBe(10);
});
it('handles the blank sentinel and unknown letters/indices', () => {
setAlphabet('english', [{ index: 0, letter: 'a', value: 1 }]);
expect(letterForIndex('english', BLANK_INDEX)).toBe('?');
expect(indexForLetter('english', '?')).toBe(BLANK_INDEX);
expect(valueForLetter('english', '?')).toBe(0);
expect(letterForIndex('english', 99)).toBe(''); // out of range
expect(valueForLetter('english', 'Z')).toBe(0); // not in this alphabet
expect(() => indexForLetter('english', 'Z')).toThrow();
});
it('lists the alphabet for the blank chooser and is empty for an uncached variant', () => {
setAlphabet('english', [
{ index: 0, letter: 'a', value: 1 },
{ index: 1, letter: 'b', value: 3 },
]);
expect(alphabetLetters('english')).toEqual(['A', 'B']);
expect(hasAlphabet('erudit')).toBe(false);
expect(alphabetLetters('erudit')).toEqual([]);
expect(valueForLetter('erudit', 'A')).toBe(0);
});
});
+84
View File
@@ -0,0 +1,84 @@
// Per-variant alphabet table cache (Stage 13). The client is alphabet-agnostic: it caches
// each variant's (index, letter, value) table — sent by the server on a per-variant cache
// miss, behind game.state's include_alphabet flag — and renders the rack and the blank
// chooser with it while live play exchanges bare alphabet indices on the wire. Letters are
// stored upper-cased for display (the rest of the UI works in upper case) and index lookups
// are case-insensitive. A blank rides as the sentinel index 255 in a rack/exchange list; a
// placed blank instead travels as its designated letter's index with a separate blank flag.
import type { Variant } from './model';
/** BLANK_INDEX is the wire sentinel for a blank tile in a rack/exchange index list. */
export const BLANK_INDEX = 255;
/** BLANK_LETTER is the glyph a blank rack tile decodes to (matches placement.BLANK). */
const BLANK_LETTER = '?';
/** AlphabetEntryWire is one raw alphabet row as received from the wire or a mock fixture. */
export interface AlphabetEntryWire {
index: number;
letter: string;
value: number;
}
interface Table {
letters: string[]; // by index, upper-cased
values: number[]; // by index
indexByLetter: Map<string, number>; // upper-cased letter -> index
}
const cache = new Map<Variant, Table>();
/** setAlphabet caches a variant's table, upper-casing letters for display. */
export function setAlphabet(variant: Variant, entries: AlphabetEntryWire[]): void {
let size = 0;
for (const e of entries) size = Math.max(size, e.index + 1);
const letters = new Array<string>(size).fill('');
const values = new Array<number>(size).fill(0);
const indexByLetter = new Map<string, number>();
for (const e of entries) {
const up = e.letter.toUpperCase();
letters[e.index] = up;
values[e.index] = e.value;
indexByLetter.set(up, e.index);
}
cache.set(variant, { letters, values, indexByLetter });
}
/** hasAlphabet reports whether a variant's table is cached (so the client can skip asking
* the server to resend it). */
export function hasAlphabet(variant: Variant): boolean {
return cache.has(variant);
}
/** alphabetLetters lists a variant's letters (upper-cased) for the blank chooser and the
* word-check input filter; empty when the table is not yet cached. */
export function alphabetLetters(variant: Variant): string[] {
return cache.get(variant)?.letters.slice() ?? [];
}
/** letterForIndex maps a wire rack index to its display letter: the blank sentinel maps to
* "?", an unknown index to "". */
export function letterForIndex(variant: Variant, index: number): string {
if (index === BLANK_INDEX) return BLANK_LETTER;
return cache.get(variant)?.letters[index] ?? '';
}
/** valueForLetter returns a tile's point value; a blank ("?") and an unknown letter score 0. */
export function valueForLetter(variant: Variant, letter: string): number {
if (!letter || letter === BLANK_LETTER) return 0;
const t = cache.get(variant);
if (!t) return 0;
const i = t.indexByLetter.get(letter.toUpperCase());
return i === undefined ? 0 : t.values[i];
}
/** indexForLetter maps a display letter to its wire index; a blank ("?") maps to the blank
* sentinel. It throws when the letter is outside the cached alphabet — a placement bug, not
* user input (the UI constrains every entry point to the variant's alphabet). */
export function indexForLetter(variant: Variant, letter: string): number {
if (letter === BLANK_LETTER) return BLANK_INDEX;
const i = cache.get(variant)?.indexByLetter.get(letter.toUpperCase());
if (i === undefined) throw new Error(`alphabet: no index for "${letter}" in ${variant}`);
return i;
}
+8 -5
View File
@@ -67,15 +67,18 @@ export interface GatewayClient {
lobbyPoll(): Promise<MatchResult>;
// --- game ---
gameState(gameId: string): Promise<StateView>;
// Stage 13: the play loop exchanges alphabet indices, so submit/evaluate/exchange/
// check-word take the game's variant (to map letters<->indices via the cached alphabet
// table), and gameState's includeAlphabet asks the server to embed that table.
gameState(gameId: string, includeAlphabet: boolean): Promise<StateView>;
gameHistory(gameId: string): Promise<History>;
submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise<MoveResult>;
submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise<MoveResult>;
pass(gameId: string): Promise<MoveResult>;
exchange(gameId: string, tiles: string[]): Promise<MoveResult>;
exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult>;
resign(gameId: string): Promise<MoveResult>;
hint(gameId: string): Promise<HintResult>;
evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise<EvalResult>;
checkWord(gameId: string, word: string): Promise<WordCheckResult>;
evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise<EvalResult>;
checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
complaint(gameId: string, word: string, note: string): Promise<void>;
// --- chat ---
+90 -5
View File
@@ -1,28 +1,44 @@
import { Builder, ByteBuffer } from 'flatbuffers';
import { describe, expect, it } from 'vitest';
import * as fb from '../gen/fbs/scrabblefb';
import { BLANK_INDEX, setAlphabet } from './alphabet';
import {
decodeFriendList,
decodeGameList,
decodeInvitation,
decodeLinkResult,
decodeSession,
decodeStateView,
decodeStats,
encodeCheckWord,
encodeExchange,
encodeStateRequest,
encodeSubmitPlay,
encodeTarget,
} from './codec';
describe('codec', () => {
it('encodes a SubmitPlayRequest the gateway can read', () => {
const buf = encodeSubmitPlay('g1', 'H', [
{ row: 7, col: 7, letter: 'A', blank: false },
{ row: 7, col: 8, letter: 'B', blank: true },
it('encodes a SubmitPlayRequest with alphabet indices (Stage 13)', () => {
setAlphabet('english', [
{ index: 0, letter: 'a', value: 1 },
{ index: 1, letter: 'b', value: 3 },
]);
// A placed blank carries its designated letter's index with the blank flag set.
const buf = encodeSubmitPlay(
'g1',
'H',
[
{ row: 7, col: 7, letter: 'A', blank: false },
{ row: 7, col: 8, letter: 'B', blank: true },
],
'english',
);
const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf));
expect(r.gameId()).toBe('g1');
expect(r.dir()).toBe('H');
expect(r.tilesLength()).toBe(2);
expect(r.tiles(0)?.letter()).toBe('A');
expect(r.tiles(0)?.letter()).toBe(0);
expect(r.tiles(1)?.letter()).toBe(1);
expect(r.tiles(1)?.blank()).toBe(true);
});
@@ -214,3 +230,72 @@ describe('codec', () => {
expect(inv.variant).toBe('english');
});
});
// Stage 13: the live play loop exchanges alphabet indices, mapped through the per-variant
// table cached in lib/alphabet. Each test seeds the cache it needs (setAlphabet replaces
// the whole table), so they are independent of order.
describe('codec — alphabet on the wire (Stage 13)', () => {
it('encodes an ExchangeRequest as alphabet indices, blank as the sentinel', () => {
setAlphabet('english', [
{ index: 0, letter: 'a', value: 1 },
{ index: 1, letter: 'b', value: 3 },
]);
const r = fb.ExchangeRequest.getRootAsExchangeRequest(
new ByteBuffer(encodeExchange('g1', ['A', '?'], 'english')),
);
expect(r.tilesLength()).toBe(2);
expect(r.tiles(0)).toBe(0);
expect(r.tiles(1)).toBe(BLANK_INDEX);
});
it('encodes a CheckWordRequest as alphabet indices', () => {
setAlphabet('english', [
{ index: 0, letter: 'a', value: 1 },
{ index: 2, letter: 'c', value: 3 },
{ index: 19, letter: 't', value: 1 },
]);
const r = fb.CheckWordRequest.getRootAsCheckWordRequest(
new ByteBuffer(encodeCheckWord('g1', 'CAT', 'english')),
);
expect(r.wordLength()).toBe(3);
expect([r.word(0), r.word(1), r.word(2)]).toEqual([2, 0, 19]);
});
it('carries the include_alphabet flag on a StateRequest', () => {
const on = fb.StateRequest.getRootAsStateRequest(new ByteBuffer(encodeStateRequest('g1', true)));
expect(on.gameId()).toBe('g1');
expect(on.includeAlphabet()).toBe(true);
const off = fb.StateRequest.getRootAsStateRequest(new ByteBuffer(encodeStateRequest('g1', false)));
expect(off.includeAlphabet()).toBe(false);
});
it('caches the alphabet table from a StateView and decodes the index rack to letters', () => {
const b = new Builder(128);
const la = b.createString('a');
fb.AlphabetEntry.startAlphabetEntry(b);
fb.AlphabetEntry.addIndex(b, 0);
fb.AlphabetEntry.addLetter(b, la);
fb.AlphabetEntry.addValue(b, 1);
const ea = fb.AlphabetEntry.endAlphabetEntry(b);
const lb = b.createString('b');
fb.AlphabetEntry.startAlphabetEntry(b);
fb.AlphabetEntry.addIndex(b, 1);
fb.AlphabetEntry.addLetter(b, lb);
fb.AlphabetEntry.addValue(b, 3);
const eb = fb.AlphabetEntry.endAlphabetEntry(b);
const alpha = fb.StateView.createAlphabetVector(b, [ea, eb]);
const rack = fb.StateView.createRackVector(b, [0, BLANK_INDEX]);
fb.StateView.startStateView(b);
fb.StateView.addSeat(b, 0);
fb.StateView.addRack(b, rack);
fb.StateView.addBagLen(b, 10);
fb.StateView.addHintsRemaining(b, 0);
fb.StateView.addAlphabet(b, alpha);
b.finish(fb.StateView.endStateView(b));
// No GameView on the buffer, so decode falls back to the default variant 'english';
// the embedded table is cached under it and the rack [0, blank] decodes to letters.
const sv = decodeStateView(b.asUint8Array());
expect(sv.rack).toEqual(['A', '?']);
});
});
+52 -22
View File
@@ -5,6 +5,7 @@
import { Builder, ByteBuffer, type Offset } from 'flatbuffers';
import * as fb from '../gen/fbs/scrabblefb';
import { indexForLetter, letterForIndex, setAlphabet, type AlphabetEntryWire } from './alphabet';
import type { PlacedTile } from './client';
import type {
AccountRef,
@@ -37,14 +38,15 @@ import type {
// --- request encoders ---
function buildTile(b: Builder, t: PlacedTile): Offset {
const letter = b.createString(t.letter);
fb.TileRecord.startTileRecord(b);
fb.TileRecord.addRow(b, t.row);
fb.TileRecord.addCol(b, t.col);
fb.TileRecord.addLetter(b, letter);
fb.TileRecord.addBlank(b, t.blank);
return fb.TileRecord.endTileRecord(b);
// buildPlayTile encodes one to-place tile by its alphabet index (Stage 13); a placed blank
// carries its designated letter's index with blank set.
function buildPlayTile(b: Builder, t: PlacedTile, variant: Variant): Offset {
fb.PlayTile.startPlayTile(b);
fb.PlayTile.addRow(b, t.row);
fb.PlayTile.addCol(b, t.col);
fb.PlayTile.addLetter(b, indexForLetter(variant, t.letter));
fb.PlayTile.addBlank(b, t.blank);
return fb.PlayTile.endPlayTile(b);
}
function finish(b: Builder, root: Offset): Uint8Array {
@@ -62,17 +64,23 @@ export function encodeGameAction(gameId: string): Uint8Array {
return finish(b, fb.GameActionRequest.endGameActionRequest(b));
}
export function encodeStateRequest(gameId: string): Uint8Array {
export function encodeStateRequest(gameId: string, includeAlphabet: boolean): Uint8Array {
const b = new Builder(64);
const gid = b.createString(gameId);
fb.StateRequest.startStateRequest(b);
fb.StateRequest.addGameId(b, gid);
fb.StateRequest.addIncludeAlphabet(b, includeAlphabet);
return finish(b, fb.StateRequest.endStateRequest(b));
}
export function encodeSubmitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Uint8Array {
export function encodeSubmitPlay(
gameId: string,
dir: 'H' | 'V',
tiles: PlacedTile[],
variant: Variant,
): Uint8Array {
const b = new Builder(256);
const tileOffs = tiles.map((t) => buildTile(b, t));
const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
const vec = fb.SubmitPlayRequest.createTilesVector(b, tileOffs);
const gid = b.createString(gameId);
const d = b.createString(dir);
@@ -83,9 +91,14 @@ export function encodeSubmitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTi
return finish(b, fb.SubmitPlayRequest.endSubmitPlayRequest(b));
}
export function encodeEval(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Uint8Array {
export function encodeEval(
gameId: string,
dir: 'H' | 'V',
tiles: PlacedTile[],
variant: Variant,
): Uint8Array {
const b = new Builder(256);
const tileOffs = tiles.map((t) => buildTile(b, t));
const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
const vec = fb.EvalRequest.createTilesVector(b, tileOffs);
const gid = b.createString(gameId);
const d = b.createString(dir);
@@ -96,10 +109,12 @@ export function encodeEval(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]):
return finish(b, fb.EvalRequest.endEvalRequest(b));
}
export function encodeExchange(gameId: string, tiles: string[]): Uint8Array {
export function encodeExchange(gameId: string, tiles: string[], variant: Variant): Uint8Array {
const b = new Builder(128);
const offs = tiles.map((s) => b.createString(s));
const vec = fb.ExchangeRequest.createTilesVector(b, offs);
const vec = fb.ExchangeRequest.createTilesVector(
b,
tiles.map((l) => indexForLetter(variant, l)),
);
const gid = b.createString(gameId);
fb.ExchangeRequest.startExchangeRequest(b);
fb.ExchangeRequest.addGameId(b, gid);
@@ -107,13 +122,16 @@ export function encodeExchange(gameId: string, tiles: string[]): Uint8Array {
return finish(b, fb.ExchangeRequest.endExchangeRequest(b));
}
export function encodeCheckWord(gameId: string, word: string): Uint8Array {
export function encodeCheckWord(gameId: string, word: string, variant: Variant): Uint8Array {
const b = new Builder(128);
const vec = fb.CheckWordRequest.createWordVector(
b,
Array.from(word).map((ch) => indexForLetter(variant, ch)),
);
const gid = b.createString(gameId);
const w = b.createString(word);
fb.CheckWordRequest.startCheckWordRequest(b);
fb.CheckWordRequest.addGameId(b, gid);
fb.CheckWordRequest.addWord(b, w);
fb.CheckWordRequest.addWord(b, vec);
return finish(b, fb.CheckWordRequest.endCheckWordRequest(b));
}
@@ -188,7 +206,8 @@ function s(v: string | null): string {
}
function decodeTile(t: fb.TileRecord): Tile {
return { row: t.row(), col: t.col(), letter: s(t.letter()), blank: t.blank() };
// The wire keeps the move journal in the solver's lower case; the UI renders upper case.
return { row: t.row(), col: t.col(), letter: s(t.letter()).toUpperCase(), blank: t.blank() };
}
function decodeSeat(v: fb.SeatView): Seat {
@@ -229,7 +248,7 @@ function decodeMove(m: fb.MoveRecord): MoveRecord {
if (t) tiles.push(decodeTile(t));
}
const words: string[] = [];
for (let i = 0; i < m.wordsLength(); i++) words.push(s(m.words(i)));
for (let i = 0; i < m.wordsLength(); i++) words.push(s(m.words(i)).toUpperCase());
return {
player: m.player(),
action: s(m.action()),
@@ -280,8 +299,19 @@ export function decodeProfile(buf: Uint8Array): Profile {
export function decodeStateView(buf: Uint8Array): StateView {
const v = fb.StateView.getRootAsStateView(new ByteBuffer(buf));
const g = v.game();
const variant = (g ? s(g.variant()) : 'english') as Variant;
// Cache the alphabet table when the server included it (a per-variant cache miss), then
// decode the index rack to display letters with it (Stage 13).
if (v.alphabetLength() > 0) {
const entries: AlphabetEntryWire[] = [];
for (let i = 0; i < v.alphabetLength(); i++) {
const e = v.alphabet(i);
if (e) entries.push({ index: e.index(), letter: s(e.letter()), value: e.value() });
}
setAlphabet(variant, entries);
}
const rack: string[] = [];
for (let i = 0; i < v.rackLength(); i++) rack.push(s(v.rack(i)));
for (let i = 0; i < v.rackLength(); i++) rack.push(letterForIndex(variant, v.rack(i) ?? 0));
return {
game: g ? decodeGameView(g) : emptyGame(),
seat: v.seat(),
+41
View File
@@ -0,0 +1,41 @@
// Mock alphabet fixtures (Stage 13). In production the per-variant (index, letter, value)
// table comes from the server; the mock seeds the same client cache from a local copy so
// the rack, the blank chooser and the mock's scoring work with no backend. The data is the
// solver's value tables (scrabble-solver/rules/rules.go), in alphabet-index order, so a
// token's position is its index — the same shape the wire delivers.
import { setAlphabet, type AlphabetEntryWire } from '../alphabet';
import type { Variant } from '../model';
// "letter+value" tokens in alphabet-index order. English Latin a..z; Russian а..я incl. ё;
// Эрудит а..я incl. ё=0.
const SPECS: Record<Variant, string> = {
english:
'a1 b3 c3 d2 e1 f4 g2 h4 i1 j8 k5 l1 m3 n1 o1 p3 q10 r1 s1 t1 u1 v4 w4 x8 y4 z10',
russian:
'а1 б3 в1 г3 д2 е1 ё3 ж5 з5 и1 й4 к2 л2 м2 н1 о1 п2 р1 с1 т1 у2 ф10 х5 ц5 ч5 ш8 щ10 ъ10 ы4 ь3 э8 ю8 я3',
erudit:
'а1 б3 в2 г3 д2 е1 ё0 ж5 з5 и1 й2 к2 л2 м2 н1 о1 п2 р2 с2 т2 у3 ф10 х5 ц10 ч5 ш10 щ10 ъ10 ы5 ь5 э10 ю10 я3',
};
function parse(spec: string): AlphabetEntryWire[] {
return spec
.trim()
.split(/\s+/)
.map((tok, index) => {
const m = tok.match(/^(.+?)(\d+)$/);
return { index, letter: m ? m[1] : tok, value: m ? Number(m[2]) : 0 };
});
}
let seeded = false;
/** seedMockAlphabets populates the alphabet cache for every variant once, mirroring the
* server-sent tables so the mock-driven UI is alphabet-agnostic too. */
export function seedMockAlphabets(): void {
if (seeded) return;
for (const variant of Object.keys(SPECS) as Variant[]) {
setAlphabet(variant, parse(SPECS[variant]));
}
seeded = true;
}
+16 -9
View File
@@ -34,7 +34,8 @@ import type {
Variant,
WordCheckResult,
} from '../model';
import { tileValue } from '../premiums';
import { valueForLetter } from '../alphabet';
import { seedMockAlphabets } from './alphabet';
import {
ME,
MOCK_FRIENDS,
@@ -93,6 +94,12 @@ export class MockGateway implements GatewayClient {
private invitations: Invitation[] = mockInvitations();
private readonly stats: Stats = { ...MOCK_STATS };
constructor() {
// Seed the per-variant alphabet cache the rack, blank chooser and scoring read, so the
// mock-driven UI is alphabet-agnostic without a backend (Stage 13).
seedMockAlphabets();
}
setToken(_token: string | null): void {
// The mock needs no auth; the real transport stores the bearer token.
}
@@ -174,7 +181,7 @@ export class MockGateway implements GatewayClient {
}
// --- game ---
async gameState(gameId: string): Promise<StateView> {
async gameState(gameId: string, _includeAlphabet: boolean): Promise<StateView> {
const g = this.game(gameId);
return {
game: structuredClone(g.view),
@@ -190,12 +197,12 @@ export class MockGateway implements GatewayClient {
return { gameId, moves: structuredClone(g.moves) };
}
async submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise<MoveResult> {
async submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], _variant: Variant): Promise<MoveResult> {
const g = this.game(gameId);
const seat = this.mySeat(g);
if (g.view.toMove !== seat) throw new GatewayError('not_your_turn');
const variant = g.view.variant;
let score = tiles.reduce((s, t) => s + tileValue(variant, t.blank ? '?' : t.letter), 0);
let score = tiles.reduce((s, t) => s + valueForLetter(variant, t.blank ? '?' : t.letter), 0);
if (tiles.length === 7) score += 50;
const total = g.view.seats[seat].score + score;
const move = {
@@ -265,7 +272,7 @@ export class MockGateway implements GatewayClient {
pass(gameId: string): Promise<MoveResult> {
return this.simpleAction(gameId, 'pass');
}
exchange(gameId: string, tiles: string[]): Promise<MoveResult> {
exchange(gameId: string, tiles: string[], _variant: Variant): Promise<MoveResult> {
return this.simpleAction(gameId, 'exchange', tiles);
}
resign(gameId: string): Promise<MoveResult> {
@@ -287,22 +294,22 @@ export class MockGateway implements GatewayClient {
tiles: [{ row: 7, col: 7, letter, blank: false }],
words: [letter],
count: 1,
score: tileValue(g.view.variant, letter),
score: valueForLetter(g.view.variant, letter),
total: 0,
},
hintsRemaining: g.hintsRemaining,
};
}
async evaluate(gameId: string, _dir: 'H' | 'V', tiles: PlacedTile[]): Promise<EvalResult> {
async evaluate(gameId: string, _dir: 'H' | 'V', tiles: PlacedTile[], _variant: Variant): Promise<EvalResult> {
const g = this.game(gameId);
if (tiles.length === 0) return { legal: false, score: 0, words: [] };
let score = tiles.reduce((s, t) => s + tileValue(g.view.variant, t.blank ? '?' : t.letter), 0);
let score = tiles.reduce((s, t) => s + valueForLetter(g.view.variant, t.blank ? '?' : t.letter), 0);
if (tiles.length === 7) score += 50;
return { legal: true, score, words: [tiles.map((t) => t.letter).join('')] };
}
async checkWord(_gameId: string, word: string): Promise<WordCheckResult> {
async checkWord(_gameId: string, word: string, _variant: Variant): Promise<WordCheckResult> {
return { word, legal: word.trim().length >= 2 };
}
async complaint(): Promise<void> {}
+5 -19
View File
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import { alphabet, BOARD_SIZE, centre, premiumGrid, tileValue } from './premiums';
import { BOARD_SIZE, centre, premiumGrid } from './premiums';
// Parity with scrabble-solver/rules/rules.go: english/russian share standardBoard
// (centre is a double word); erudit shares the geometry but a non-doubling centre.
// Premium-square geometry parity with scrabble-solver/rules/rules.go: english/russian
// share standardBoard (centre is a double word); erudit shares the geometry but a
// non-doubling centre. Tile-value and alphabet parity moved to the Go engine test
// (backend/internal/engine AlphabetTable) in Stage 13 — the server now owns that table.
describe('premium layout', () => {
it('is a 15x15 grid with TW corners', () => {
const g = premiumGrid('english');
@@ -35,19 +37,3 @@ describe('premium layout', () => {
expect(count('DW')).toBe(17); // 16 double-word squares + the centre
});
});
describe('tile values', () => {
it('scores letters per variant and zero for a blank', () => {
expect(tileValue('english', 'A')).toBe(1);
expect(tileValue('english', 'Q')).toBe(10);
expect(tileValue('english', '?')).toBe(0);
expect(tileValue('russian', 'Ф')).toBe(10);
expect(tileValue('erudit', 'Ё')).toBe(0);
});
it('exposes the full alphabet for the blank chooser', () => {
expect(alphabet('english')).toHaveLength(26);
expect(alphabet('russian')).toHaveLength(33);
expect(alphabet('erudit')).toHaveLength(33);
});
});
+8 -45
View File
@@ -1,8 +1,9 @@
// Board premium layout and tile values — ported verbatim from the engine source of
// truth, scrabble-solver/rules/rules.go (standardBoard / eruditBoard, and the
// per-variant value tables). These are NOT transmitted on the wire (StateView has
// no board), so the client renders them locally. A Vitest parity test pins the
// layout against the known geometry. Keep this in lockstep with the solver.
// Board premium layout — the 15x15 premium-square geometry, ported from the engine source
// of truth, scrabble-solver/rules/rules.go (standardBoard / eruditBoard). The board is not
// transmitted on the wire (StateView has no board), so the client renders the premiums
// locally; only the centre differs by variant. A Vitest parity test pins the geometry.
// Tile values and the alphabet moved to the server-sent per-variant table in Stage 13 (see
// lib/alphabet.ts), so this file is geometry only.
import type { Variant } from './model';
@@ -84,43 +85,5 @@ export function centre(variant: Variant): { row: number; col: number } {
return { row: 7, col: 7 };
}
// --- tile values (points shown on the tile face); blank scores 0 ---
// English Latin a..z (rules.go English()).
const enValues =
'a1 b3 c3 d2 e1 f4 g2 h4 i1 j8 k5 l1 m3 n1 o1 p3 q10 r1 s1 t1 u1 v4 w4 x8 y4 z10';
// Russian а..я incl. ё (rules.go RussianScrabble()).
const ruValues =
'а1 б3 в1 г3 д2 е1 ё3 ж5 з5 и1 й4 к2 л2 м2 н1 о1 п2 р1 с1 т1 у2 ф10 х5 ц5 ч5 ш8 щ10 ъ10 ы4 ь3 э8 ю8 я3';
// Эрудит а..я incl. ё=0 (rules.go Erudit()).
const eruditValues =
'а1 б3 в2 г3 д2 е1 ё0 ж5 з5 и1 й2 к2 л2 м2 н1 о1 п2 р2 с2 т2 у3 ф10 х5 ц10 ч5 ш10 щ10 ъ10 ы5 ь5 э10 ю10 я3';
// Split each "letter+value" token into its letter (all but trailing digits) and its
// integer value (the trailing digits).
function valueTable(spec: string): Map<string, number> {
const m = new Map<string, number>();
for (const pair of spec.trim().split(/\s+/)) {
const match = pair.match(/^(.+?)(\d+)$/);
if (!match) continue;
m.set(match[1].toUpperCase(), Number(match[2]));
}
return m;
}
const VALUES: Record<Variant, Map<string, number>> = {
english: valueTable(enValues),
russian: valueTable(ruValues),
erudit: valueTable(eruditValues),
};
/** tileValue returns the point value of a concrete letter; a blank ("?") is 0. */
export function tileValue(variant: Variant, letter: string): number {
if (!letter || letter === '?') return 0;
return VALUES[variant]?.get(letter.toUpperCase()) ?? 0;
}
/** alphabet lists a variant's letters in dictionary order (used by the blank chooser). */
export function alphabet(variant: Variant): string[] {
return [...VALUES[variant].keys()];
}
// Tile values and the per-variant alphabet now arrive from the server (lib/alphabet.ts,
// Stage 13); the board geometry above is all this module owns.
+10 -10
View File
@@ -81,20 +81,20 @@ export function createTransport(baseUrl: string): GatewayClient {
return codec.decodeMatchResult(await exec('lobby.poll', codec.empty()));
},
async gameState(id) {
return codec.decodeStateView(await exec('game.state', codec.encodeStateRequest(id)));
async gameState(id, includeAlphabet) {
return codec.decodeStateView(await exec('game.state', codec.encodeStateRequest(id, includeAlphabet)));
},
async gameHistory(id) {
return codec.decodeHistory(await exec('game.history', codec.encodeGameAction(id)));
},
async submitPlay(id, dir, tiles) {
return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, dir, tiles)));
async submitPlay(id, dir, tiles, variant) {
return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, dir, tiles, variant)));
},
async pass(id) {
return codec.decodeMoveResult(await exec('game.pass', codec.encodeGameAction(id)));
},
async exchange(id, tiles) {
return codec.decodeMoveResult(await exec('game.exchange', codec.encodeExchange(id, tiles)));
async exchange(id, tiles, variant) {
return codec.decodeMoveResult(await exec('game.exchange', codec.encodeExchange(id, tiles, variant)));
},
async resign(id) {
return codec.decodeMoveResult(await exec('game.resign', codec.encodeGameAction(id)));
@@ -102,11 +102,11 @@ export function createTransport(baseUrl: string): GatewayClient {
async hint(id) {
return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id)));
},
async evaluate(id, dir, tiles) {
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, dir, tiles)));
async evaluate(id, dir, tiles, variant) {
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, dir, tiles, variant)));
},
async checkWord(id, word) {
return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word)));
async checkWord(id, word, variant) {
return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word, variant)));
},
async complaint(id, word, note) {
await exec('game.complaint', codec.encodeComplaint(id, word, note));