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
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user