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