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
+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
}