Files
Ilia Denisov 90eaf4964b
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
Stage 13: alphabet on the wire (UI alphabet-agnostic, TODO-4)
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.
2026-06-04 16:26:43 +02:00

151 lines
5.2 KiB
Go

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
}