Stage 13: alphabet on the wire (UI alphabet-agnostic, TODO-4)
Tests · Go / test (push) Successful in 10s
Tests · Integration / integration (push) Successful in 12s
Tests · UI / test (push) Successful in 19s
Tests · Go / test (pull_request) Successful in 9s
Tests · Integration / integration (pull_request) Successful in 12s
Tests · UI / test (pull_request) Successful in 19s

Live play now exchanges per-variant alphabet indices instead of concrete
letters (rack out; submit-play, evaluate, exchange, word-check in). The client
caches each variant's (index, letter, value) table behind
StateRequest.include_alphabet and renders the rack and blank chooser from it,
dropping the hardcoded value/alphabet tables. History, the durable journal and
GCG stay decoded concrete characters (ARCHITECTURE §9.1, unchanged).

- pkg/fbs: new AlphabetEntry + PlayTile; StateView.rack -> [ubyte] + alphabet;
  StateRequest.include_alphabet; SubmitPlay/Eval tiles -> [PlayTile];
  Exchange tiles + CheckWord word -> [ubyte] (committed Go + TS regenerated).
- engine: AlphabetTable + a cached per-variant codec (LetterForIndex/EncodeRack/
  DecodeTiles/DecodeWord) + BlankIndex sentinel; Go parity test.
- backend server edge maps index<->letter (new thin game.Service.GameVariant);
  game.Service domain methods, engine.Game and the robot keep one letter-based
  play path. The gateway forwards indices verbatim (no alphabet table).
- ui: lib/alphabet.ts in-memory cache; codec encodes/decodes indices; premiums.ts
  is geometry-only; the mock seeds a fixture table; the UI normalises display to
  upper case (codec + cache), leaving placement/board/checkword unchanged.

Parity moved to the Go engine.AlphabetTable test; premiums.ts loses its value
tables. Discharges TODO-4.
This commit is contained in:
Ilia Denisov
2026-06-04 16:26:43 +02:00
parent 6537082397
commit 90eaf4964b
47 changed files with 1812 additions and 272 deletions
+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)
}