Stage 13: alphabet on the wire (UI alphabet-agnostic, TODO-4) #14
@@ -46,7 +46,7 @@ independent (see ARCHITECTURE §9.1).
|
||||
| 10 | Admin & dictionary ops (complaint review, version reload) | **done** |
|
||||
| 11 | Account linking & merge | **done** |
|
||||
| 12 | Observability & performance (telemetry, metrics, guest GC) | **done** |
|
||||
| 13 | Alphabet on the wire (UI alphabet-agnostic) | todo |
|
||||
| 13 | Alphabet on the wire (UI alphabet-agnostic) | **done** |
|
||||
| 14 | CI & deploy (multi-service, dictionary artifacts) | todo |
|
||||
|
||||
Scaffolding is incremental: `go.work` lists only existing modules; each stage
|
||||
@@ -893,6 +893,51 @@ dashboard stack; solver-publish vs clone-in-build; load expectations.
|
||||
`./backend/... ./gateway/... ./pkg/... ./platform/telegram/...`, integration stays
|
||||
`./backend/...`, and the default `none` exporter keeps CI collector-free.
|
||||
|
||||
- **Stage 13** (interview + implementation, discharges TODO-4):
|
||||
- **Scope = live play only** (interview): indices ride the wire for `StateView.rack`
|
||||
(out) and `SubmitPlay`/`Evaluate`/`Exchange`/`CheckWord` (in). The **board path is
|
||||
untouched** — `MoveRecord` (history, move results, hint), formed `words`,
|
||||
`ComplaintRequest.word` (durable, admin-reviewed) and `WordCheckResult.word` (echo) stay
|
||||
decoded concrete characters, so the durable journal / GCG and the §9.1 invariant are
|
||||
unchanged. **Hard cutover**, no dual letter/index fields (single client; the fbs Go + TS
|
||||
regenerate in one PR; no external wire consumer). Exchange moved fully to indices, a
|
||||
blank = the shared sentinel index **255** (`engine.BlankIndex`).
|
||||
- **Edge-mapping layering** (engineering): the engine gained a cached per-variant codec —
|
||||
`AlphabetTable` (the `(index, letter, value)` table from the solver ruleset),
|
||||
`LetterForIndex`, `EncodeRack`, `DecodeTiles`, `DecodeWord` — and the backend **server
|
||||
edge** owns the index↔letter mapping. `game.Service`'s domain methods, `engine.Game` and
|
||||
the **robot** keep a single **letter-based** play path (untouched); a new thin
|
||||
`game.Service.GameVariant` (a single-column `SELECT variant`, cheaper than `GetGame`)
|
||||
lets the inbound handlers resolve the variant without doubling the play-path read. The
|
||||
**gateway carries no alphabet table** — it passes indices through verbatim; `check_word`
|
||||
rides as repeated `?idx=` query params.
|
||||
- **`include_alphabet` flag** (interview): `StateRequest.include_alphabet` gates the table
|
||||
so it is not resent on every poll; the client sets it only on a **per-variant cache
|
||||
miss** (first open of a variant), and the table then arrives with the index rack so the
|
||||
rack is always decodable. The client caches the table in memory by variant
|
||||
(`ui/src/lib/alphabet.ts`).
|
||||
- **Letter case** (discovered): the solver emits **lower-case** letters and the rest of
|
||||
the UI works in **upper case**. The wire and the journal stay lower case; the **UI
|
||||
normalises display to upper case** (the codec upper-cases decoded board tiles and words,
|
||||
and the alphabet cache upper-cases on ingest), so `placement.ts` / `board.ts` /
|
||||
`checkword.ts` are unchanged and the latent real-backend lower-case display is fixed.
|
||||
- **Parity rework** (interview): the real value/alphabet parity moved to a **Go engine
|
||||
test** (`engine.AlphabetTable`: EN/RU/Эрудит sizes, EN a=1/q=10, **Эрудит ё=index 6,
|
||||
value 0**); `ui/src/lib/premiums.ts` is now **geometry only** (its value tables,
|
||||
`tileValue` and `alphabet` were removed, its parity test trimmed to the premium grid);
|
||||
the codec test round-trips the index tiles + the alphabet table; the **mock keeps a
|
||||
fixture table** (relocated from `premiums.ts`) seeded into the client cache, so the
|
||||
mock-driven UI is alphabet-agnostic too.
|
||||
- **Wire/codegen/CI**: new fbs `AlphabetEntry` + `PlayTile`; `StateView.rack`→`[ubyte]` +
|
||||
`alphabet`; `StateRequest.include_alphabet`; `SubmitPlay`/`Eval` tiles→`[PlayTile]`;
|
||||
`Exchange` tiles→`[ubyte]`; `CheckWord.word`→`[ubyte]` (committed Go + TS regenerated).
|
||||
UI ~90 KB gzip JS (budget 100 KB). **No CI workflow change** — the Go workflows already
|
||||
span `./backend/... ./gateway/... ./pkg/...` and the UI workflow runs check/unit/build +
|
||||
a chromium/webkit e2e. `docs/FUNCTIONAL.md` is **untouched** (no user-visible behaviour
|
||||
change — the UI looks and plays the same; like Stage 2). The index-drift caveat is
|
||||
handled by construction (the running backend produces the table, so client↔server cannot
|
||||
drift); the DAWG/solver build-time agreement remains **Stage 14 / TODO-2**.
|
||||
|
||||
## Deferred TODOs (cross-stage)
|
||||
|
||||
- **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable,
|
||||
@@ -931,17 +976,16 @@ dashboard stack; solver-publish vs clone-in-build; load expectations.
|
||||
`last_seen_at`, so a lingering session never expires and **account age** is the
|
||||
abandonment trigger, not "last session gone". The reaped guest's `sessions`/`identities`/
|
||||
`account_stats` fall away via their own `ON DELETE CASCADE`.
|
||||
- **TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7).** Today the
|
||||
client hardcodes each variant's letters/values (ported into `ui/src/lib/premiums.ts`
|
||||
from `scrabble-solver/rules/rules.go`) and the edge exchanges plays/hints by concrete
|
||||
letters. Consider extending `game.state` to carry the variant's `(letter, index,
|
||||
value)` table so the UI stops duplicating it, and optionally moving tile exchange to
|
||||
letter **indices** end-to-end. Caveat (as for the dictionaries, TODO-2): the wire table
|
||||
must stay pinned to the same `rules.Alphabet` the engine uses, or indices drift.
|
||||
**Planned for Stage 13**, expanded (owner) to a fully **alphabet-agnostic UI**: the
|
||||
client caches the per-variant table (display only) behind an `include_alphabet` request
|
||||
flag and exchanges indices both ways, word-check included; the durable journal stays
|
||||
concrete characters (§9.1). See Stage 13.
|
||||
- ~~**TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7).**~~ **Done in
|
||||
Stage 13.** The client is now alphabet-agnostic: it caches each variant's `(index, letter,
|
||||
value)` table — sent by the backend behind `StateRequest.include_alphabet` on a per-variant
|
||||
cache miss — and live play exchanges **letter indices** both ways (rack, submit-play,
|
||||
evaluate, exchange, word-check; a blank rides as the sentinel index 255). The table is
|
||||
produced from the solver ruleset (`engine.AlphabetTable`), so it is pinned by the solver
|
||||
version and cannot drift from the running backend, and `ui/src/lib/premiums.ts` is now
|
||||
geometry only. The durable journal / history / GCG stay decoded concrete characters (§9.1,
|
||||
unchanged). The DAWG/solver build-time agreement (the original caveat, shared with TODO-2)
|
||||
remains Stage 14.
|
||||
- **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9:*
|
||||
the deep-link scheme now exists (`f<code>`, shared Go ↔ TS), the bot redeems it on
|
||||
launch, and the UI shows a **share-to-Telegram** link for an issued code when
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -87,6 +87,18 @@ dropped). Horizontal scaling is explicit future work.
|
||||
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
||||
200); only edge failures (rate limit, missing session, unknown type, internal)
|
||||
surface as Connect error codes.
|
||||
- **Alphabet on the wire (Stage 13)**: live play exchanges **alphabet indices**, not
|
||||
concrete letters. The rack (`StateView.rack`), the `SubmitPlay`/`Evaluate` tiles, the
|
||||
`Exchange` tiles and the `CheckWord` word are `ubyte` indices into the variant's alphabet
|
||||
(a blank is the sentinel index **255**). The client is **alphabet-agnostic**: on a
|
||||
per-variant cache miss it sets `StateRequest.include_alphabet`, and the backend embeds the
|
||||
variant's `(index, letter, value)` table (`engine.AlphabetTable`, derived from the solver
|
||||
ruleset — no dictionary) for display; the client caches it by variant and renders the rack
|
||||
and the blank chooser from it. The backend maps index↔letter at its REST edge, so the
|
||||
gateway forwards indices **verbatim** (it holds no alphabet table) and the engine's
|
||||
letter-based domain API — shared with the robot — is unchanged. The table is pinned by the
|
||||
solver version, so it cannot drift from the running backend. The **move journal, history
|
||||
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
|
||||
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
|
||||
`X-User-ID` for authenticated requests; `backend` never re-derives identity
|
||||
from the body.
|
||||
@@ -407,6 +419,11 @@ does not cover. **GCG export is offered only on a finished game** (`game.ErrGame
|
||||
otherwise, Stage 8), so an in-progress journal is never leaked mid-play; the client
|
||||
shares the `.gcg` file via the Web Share API where available, else downloads it.
|
||||
|
||||
The Stage 13 alphabet-on-the-wire change does **not** touch this invariant: the live edge
|
||||
exchanges alphabet indices, but the persisted journal (and everything derived from it —
|
||||
replay, history, GCG) keeps the decoded concrete letters described above, so an archived
|
||||
game still replays with the variant's `rules.Alphabet` alone, independent of any dictionary.
|
||||
|
||||
## 10. Notifications
|
||||
|
||||
Two channels: the **in-app live stream** (delivered from Stage 6) and
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
+44
-14
@@ -14,8 +14,9 @@ namespace scrabblefb;
|
||||
|
||||
// --- shared building blocks ---
|
||||
|
||||
// TileRecord is one placed (or to-place) tile: its board coordinate, the concrete
|
||||
// letter ("?" when read from a hand for a blank) and whether it came from a blank.
|
||||
// TileRecord is one tile in a decoded move record (history, move result, hint): its
|
||||
// board coordinate, the concrete letter ("?" when read from a hand for a blank) and
|
||||
// whether it came from a blank. Inbound tiles to place use PlayTile (alphabet indices).
|
||||
table TileRecord {
|
||||
row:int;
|
||||
col:int;
|
||||
@@ -23,6 +24,25 @@ table TileRecord {
|
||||
blank:bool;
|
||||
}
|
||||
|
||||
// PlayTile is one inbound tile to place, addressed by its alphabet index rather than a
|
||||
// concrete letter (Stage 13). For a blank, letter carries the designated letter's index
|
||||
// and blank is true. The board coordinate is its target square.
|
||||
table PlayTile {
|
||||
row:int;
|
||||
col:int;
|
||||
letter:ubyte;
|
||||
blank:bool;
|
||||
}
|
||||
|
||||
// AlphabetEntry is one letter of a variant's alphabet, sent for display only (Stage 13):
|
||||
// index is the engine alphabet-index byte the wire uses for this letter, letter is the
|
||||
// concrete character and value is its tile score. The client caches the table per variant.
|
||||
table AlphabetEntry {
|
||||
index:ubyte;
|
||||
letter:string;
|
||||
value:int;
|
||||
}
|
||||
|
||||
// SeatView is one seat's public standing in a game. display_name is resolved by the
|
||||
// backend from the account store (added trailing — backward-compatible).
|
||||
table SeatView {
|
||||
@@ -123,11 +143,12 @@ table Profile {
|
||||
|
||||
// --- game (authenticated) ---
|
||||
|
||||
// SubmitPlayRequest places tiles in a direction on the player's turn.
|
||||
// SubmitPlayRequest places tiles in a direction on the player's turn. tiles are addressed
|
||||
// by alphabet index (Stage 13).
|
||||
table SubmitPlayRequest {
|
||||
game_id:string;
|
||||
dir:string;
|
||||
tiles:[TileRecord];
|
||||
tiles:[PlayTile];
|
||||
}
|
||||
|
||||
// MoveResult is the outcome of a committed move: the move and the post-move game.
|
||||
@@ -136,19 +157,25 @@ table MoveResult {
|
||||
game:GameView;
|
||||
}
|
||||
|
||||
// StateRequest asks for the requesting player's view of a game.
|
||||
// StateRequest asks for the requesting player's view of a game. include_alphabet asks the
|
||||
// backend to embed the variant's AlphabetEntry table in the reply (Stage 13); the client
|
||||
// sets it only on a per-variant cache miss so the table is not resent on every poll.
|
||||
table StateRequest {
|
||||
game_id:string;
|
||||
include_alphabet:bool = false;
|
||||
}
|
||||
|
||||
// StateView is a player's view of a game: the shared summary plus their private
|
||||
// rack, the bag size and their remaining hint budget.
|
||||
// StateView is a player's view of a game: the shared summary plus their private rack, the
|
||||
// bag size and their remaining hint budget. rack carries alphabet indices (Stage 13); a
|
||||
// blank tile is the sentinel index 255. alphabet is present only when the request set
|
||||
// include_alphabet (a display table the client caches per variant).
|
||||
table StateView {
|
||||
game:GameView;
|
||||
seat:int;
|
||||
rack:[string];
|
||||
rack:[ubyte];
|
||||
bag_len:int;
|
||||
hints_remaining:int;
|
||||
alphabet:[AlphabetEntry];
|
||||
}
|
||||
|
||||
// GameActionRequest carries just a game id (pass / resign / hint / history).
|
||||
@@ -156,17 +183,19 @@ table GameActionRequest {
|
||||
game_id:string;
|
||||
}
|
||||
|
||||
// ExchangeRequest swaps the listed rack tiles back into the bag.
|
||||
// ExchangeRequest swaps the listed rack tiles back into the bag. tiles are alphabet
|
||||
// indices (Stage 13); a blank is the sentinel index 255.
|
||||
table ExchangeRequest {
|
||||
game_id:string;
|
||||
tiles:[string];
|
||||
tiles:[ubyte];
|
||||
}
|
||||
|
||||
// EvalRequest previews a tentative play without committing it.
|
||||
// EvalRequest previews a tentative play without committing it. tiles are addressed by
|
||||
// alphabet index (Stage 13).
|
||||
table EvalRequest {
|
||||
game_id:string;
|
||||
dir:string;
|
||||
tiles:[TileRecord];
|
||||
tiles:[PlayTile];
|
||||
}
|
||||
|
||||
// EvalResult is an unlimited move preview: legality, score and the words formed.
|
||||
@@ -176,10 +205,11 @@ table EvalResult {
|
||||
words:[string];
|
||||
}
|
||||
|
||||
// CheckWordRequest looks a word up in the game's pinned dictionary.
|
||||
// CheckWordRequest looks a word up in the game's pinned dictionary. word is a sequence of
|
||||
// alphabet indices (Stage 13); the client constrains input to the variant's alphabet.
|
||||
table CheckWordRequest {
|
||||
game_id:string;
|
||||
word:string;
|
||||
word:[ubyte];
|
||||
}
|
||||
|
||||
// WordCheckResult is the dictionary lookup outcome.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package scrabblefb
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type AlphabetEntry struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsAlphabetEntry(buf []byte, offset flatbuffers.UOffsetT) *AlphabetEntry {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &AlphabetEntry{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishAlphabetEntryBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsAlphabetEntry(buf []byte, offset flatbuffers.UOffsetT) *AlphabetEntry {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &AlphabetEntry{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedAlphabetEntryBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *AlphabetEntry) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *AlphabetEntry) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *AlphabetEntry) Index() byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetByte(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *AlphabetEntry) MutateIndex(n byte) bool {
|
||||
return rcv._tab.MutateByteSlot(4, n)
|
||||
}
|
||||
|
||||
func (rcv *AlphabetEntry) Letter() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *AlphabetEntry) Value() int32 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *AlphabetEntry) MutateValue(n int32) bool {
|
||||
return rcv._tab.MutateInt32Slot(8, n)
|
||||
}
|
||||
|
||||
func AlphabetEntryStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(3)
|
||||
}
|
||||
func AlphabetEntryAddIndex(builder *flatbuffers.Builder, index byte) {
|
||||
builder.PrependByteSlot(0, index, 0)
|
||||
}
|
||||
func AlphabetEntryAddLetter(builder *flatbuffers.Builder, letter flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(letter), 0)
|
||||
}
|
||||
func AlphabetEntryAddValue(builder *flatbuffers.Builder, value int32) {
|
||||
builder.PrependInt32Slot(2, value, 0)
|
||||
}
|
||||
func AlphabetEntryEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -49,7 +49,24 @@ func (rcv *CheckWordRequest) GameId() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *CheckWordRequest) Word() []byte {
|
||||
func (rcv *CheckWordRequest) Word(j int) byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
a := rcv._tab.Vector(o)
|
||||
return rcv._tab.GetByte(a + flatbuffers.UOffsetT(j*1))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *CheckWordRequest) WordLength() int {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.VectorLen(o)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *CheckWordRequest) WordBytes() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
@@ -57,6 +74,15 @@ func (rcv *CheckWordRequest) Word() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *CheckWordRequest) MutateWord(j int, n byte) bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
a := rcv._tab.Vector(o)
|
||||
return rcv._tab.MutateByte(a+flatbuffers.UOffsetT(j*1), n)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func CheckWordRequestStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(2)
|
||||
}
|
||||
@@ -66,6 +92,9 @@ func CheckWordRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.
|
||||
func CheckWordRequestAddWord(builder *flatbuffers.Builder, word flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(word), 0)
|
||||
}
|
||||
func CheckWordRequestStartWordVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||
return builder.StartVector(1, numElems, 1)
|
||||
}
|
||||
func CheckWordRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func (rcv *EvalRequest) Dir() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *EvalRequest) Tiles(obj *TileRecord, j int) bool {
|
||||
func (rcv *EvalRequest) Tiles(obj *PlayTile, j int) bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Vector(o)
|
||||
|
||||
@@ -49,13 +49,13 @@ func (rcv *ExchangeRequest) GameId() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *ExchangeRequest) Tiles(j int) []byte {
|
||||
func (rcv *ExchangeRequest) Tiles(j int) byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
a := rcv._tab.Vector(o)
|
||||
return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4))
|
||||
return rcv._tab.GetByte(a + flatbuffers.UOffsetT(j*1))
|
||||
}
|
||||
return nil
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *ExchangeRequest) TilesLength() int {
|
||||
@@ -66,6 +66,23 @@ func (rcv *ExchangeRequest) TilesLength() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *ExchangeRequest) TilesBytes() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *ExchangeRequest) MutateTiles(j int, n byte) bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
a := rcv._tab.Vector(o)
|
||||
return rcv._tab.MutateByte(a+flatbuffers.UOffsetT(j*1), n)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ExchangeRequestStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(2)
|
||||
}
|
||||
@@ -76,7 +93,7 @@ func ExchangeRequestAddTiles(builder *flatbuffers.Builder, tiles flatbuffers.UOf
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(tiles), 0)
|
||||
}
|
||||
func ExchangeRequestStartTilesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||
return builder.StartVector(4, numElems, 4)
|
||||
return builder.StartVector(1, numElems, 1)
|
||||
}
|
||||
func ExchangeRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package scrabblefb
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type PlayTile struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsPlayTile(buf []byte, offset flatbuffers.UOffsetT) *PlayTile {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &PlayTile{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishPlayTileBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsPlayTile(buf []byte, offset flatbuffers.UOffsetT) *PlayTile {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &PlayTile{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedPlayTileBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *PlayTile) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *PlayTile) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *PlayTile) Row() int32 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *PlayTile) MutateRow(n int32) bool {
|
||||
return rcv._tab.MutateInt32Slot(4, n)
|
||||
}
|
||||
|
||||
func (rcv *PlayTile) Col() int32 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *PlayTile) MutateCol(n int32) bool {
|
||||
return rcv._tab.MutateInt32Slot(6, n)
|
||||
}
|
||||
|
||||
func (rcv *PlayTile) Letter() byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetByte(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *PlayTile) MutateLetter(n byte) bool {
|
||||
return rcv._tab.MutateByteSlot(8, n)
|
||||
}
|
||||
|
||||
func (rcv *PlayTile) Blank() bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetBool(o + rcv._tab.Pos)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (rcv *PlayTile) MutateBlank(n bool) bool {
|
||||
return rcv._tab.MutateBoolSlot(10, n)
|
||||
}
|
||||
|
||||
func PlayTileStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(4)
|
||||
}
|
||||
func PlayTileAddRow(builder *flatbuffers.Builder, row int32) {
|
||||
builder.PrependInt32Slot(0, row, 0)
|
||||
}
|
||||
func PlayTileAddCol(builder *flatbuffers.Builder, col int32) {
|
||||
builder.PrependInt32Slot(1, col, 0)
|
||||
}
|
||||
func PlayTileAddLetter(builder *flatbuffers.Builder, letter byte) {
|
||||
builder.PrependByteSlot(2, letter, 0)
|
||||
}
|
||||
func PlayTileAddBlank(builder *flatbuffers.Builder, blank bool) {
|
||||
builder.PrependBoolSlot(3, blank, false)
|
||||
}
|
||||
func PlayTileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -49,12 +49,27 @@ func (rcv *StateRequest) GameId() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *StateRequest) IncludeAlphabet() bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetBool(o + rcv._tab.Pos)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (rcv *StateRequest) MutateIncludeAlphabet(n bool) bool {
|
||||
return rcv._tab.MutateBoolSlot(6, n)
|
||||
}
|
||||
|
||||
func StateRequestStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(1)
|
||||
builder.StartObject(2)
|
||||
}
|
||||
func StateRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
|
||||
}
|
||||
func StateRequestAddIncludeAlphabet(builder *flatbuffers.Builder, includeAlphabet bool) {
|
||||
builder.PrependBoolSlot(1, includeAlphabet, false)
|
||||
}
|
||||
func StateRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -66,13 +66,13 @@ func (rcv *StateView) MutateSeat(n int32) bool {
|
||||
return rcv._tab.MutateInt32Slot(6, n)
|
||||
}
|
||||
|
||||
func (rcv *StateView) Rack(j int) []byte {
|
||||
func (rcv *StateView) Rack(j int) byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
a := rcv._tab.Vector(o)
|
||||
return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4))
|
||||
return rcv._tab.GetByte(a + flatbuffers.UOffsetT(j*1))
|
||||
}
|
||||
return nil
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *StateView) RackLength() int {
|
||||
@@ -83,6 +83,23 @@ func (rcv *StateView) RackLength() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *StateView) RackBytes() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *StateView) MutateRack(j int, n byte) bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
a := rcv._tab.Vector(o)
|
||||
return rcv._tab.MutateByte(a+flatbuffers.UOffsetT(j*1), n)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (rcv *StateView) BagLen() int32 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||
if o != 0 {
|
||||
@@ -107,8 +124,28 @@ func (rcv *StateView) MutateHintsRemaining(n int32) bool {
|
||||
return rcv._tab.MutateInt32Slot(12, n)
|
||||
}
|
||||
|
||||
func (rcv *StateView) Alphabet(obj *AlphabetEntry, j int) bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Vector(o)
|
||||
x += flatbuffers.UOffsetT(j) * 4
|
||||
x = rcv._tab.Indirect(x)
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (rcv *StateView) AlphabetLength() int {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
|
||||
if o != 0 {
|
||||
return rcv._tab.VectorLen(o)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func StateViewStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(5)
|
||||
builder.StartObject(6)
|
||||
}
|
||||
func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(game), 0)
|
||||
@@ -120,7 +157,7 @@ func StateViewAddRack(builder *flatbuffers.Builder, rack flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(rack), 0)
|
||||
}
|
||||
func StateViewStartRackVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||
return builder.StartVector(4, numElems, 4)
|
||||
return builder.StartVector(1, numElems, 1)
|
||||
}
|
||||
func StateViewAddBagLen(builder *flatbuffers.Builder, bagLen int32) {
|
||||
builder.PrependInt32Slot(3, bagLen, 0)
|
||||
@@ -128,6 +165,12 @@ func StateViewAddBagLen(builder *flatbuffers.Builder, bagLen int32) {
|
||||
func StateViewAddHintsRemaining(builder *flatbuffers.Builder, hintsRemaining int32) {
|
||||
builder.PrependInt32Slot(4, hintsRemaining, 0)
|
||||
}
|
||||
func StateViewAddAlphabet(builder *flatbuffers.Builder, alphabet flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(alphabet), 0)
|
||||
}
|
||||
func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||
return builder.StartVector(4, numElems, 4)
|
||||
}
|
||||
func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func (rcv *SubmitPlayRequest) Dir() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *SubmitPlayRequest) Tiles(obj *TileRecord, j int) bool {
|
||||
func (rcv *SubmitPlayRequest) Tiles(obj *PlayTile, j int) bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Vector(o)
|
||||
|
||||
+10
-4
@@ -42,9 +42,14 @@ out of production). Both speak the plain `lib/model.ts` types via `lib/codec.ts`
|
||||
|
||||
**No board on the wire:** `StateView` is a summary + rack only, so the client
|
||||
reconstructs the 15×15 board by replaying the decoded move journal (`game.history`).
|
||||
Premium squares and tile values (`lib/premiums.ts`) are a client-side map **ported from
|
||||
`scrabble-solver/rules/rules.go`** (pinned by a Vitest parity test). Board, tiles and
|
||||
effects are pure CSS + Unicode — no image/font/SVG assets.
|
||||
**The play loop is alphabet-agnostic (Stage 13):** the rack and the play / exchange /
|
||||
word-check requests carry **alphabet indices**, and the client caches each variant's
|
||||
`(index, letter, value)` table — sent once behind `StateRequest.include_alphabet` — in
|
||||
`lib/alphabet.ts`, rendering the rack and blank chooser from it. **Premium squares**
|
||||
(`lib/premiums.ts`) stay a client-side geometry map **ported from
|
||||
`scrabble-solver/rules/rules.go`** (pinned by a Vitest parity test); **tile values and the
|
||||
alphabet now come from the server table** (their parity lives in the Go `engine.AlphabetTable`
|
||||
test). Board, tiles and effects are pure CSS + Unicode — no image/font/SVG assets.
|
||||
|
||||
## Codegen
|
||||
|
||||
@@ -65,7 +70,8 @@ runtime; the Telegram SDK itself is wired in the Telegram stage.
|
||||
```
|
||||
src/
|
||||
lib/ model, client facade, transport (+ mock), codec, board replay,
|
||||
placement state machine, premiums, stats, share, i18n, theme, session, router, app store
|
||||
placement state machine, premiums (geometry), alphabet cache, stats, share,
|
||||
i18n, theme, session, router, app store
|
||||
components/ Header, Menu (+ badge), Modal, Toast, TabBar, Screen
|
||||
screens/ Login, Lobby, NewGame, Profile, Settings, About, Friends, Stats
|
||||
game/ Game, Board, Rack, Controls, MakeMove, Chat
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { BoardCell } from '../lib/board';
|
||||
import type { Premium } from '../lib/premiums';
|
||||
import { tileValue } from '../lib/premiums';
|
||||
import { valueForLetter } from '../lib/alphabet';
|
||||
import type { Variant } from '../lib/model';
|
||||
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
|
||||
import type { Locale } from '../lib/i18n/catalog';
|
||||
@@ -98,7 +98,7 @@
|
||||
>
|
||||
{#if letter}
|
||||
<span class="letter">{letter}</span>
|
||||
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
|
||||
{#if !blank}<span class="val">{valueForLetter(variant, letter)}</span>{/if}
|
||||
{:else if r === centre.row && c === centre.col}
|
||||
<span class="star">★</span>
|
||||
{:else if bl?.kind === 'single'}
|
||||
|
||||
+19
-10
@@ -14,7 +14,8 @@
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
|
||||
import { replay } from '../lib/board';
|
||||
import { alphabet, centre, premiumGrid } from '../lib/premiums';
|
||||
import { centre, premiumGrid } from '../lib/premiums';
|
||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
|
||||
import { shareOrDownloadGcg } from '../lib/share';
|
||||
import {
|
||||
@@ -84,7 +85,13 @@
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [st, hist] = await Promise.all([gateway.gameState(id), gateway.gameHistory(id)]);
|
||||
// Ask for the alphabet table only on a per-variant cache miss (the first open of a
|
||||
// game whose variant the client has not cached yet); steady-state polls omit it.
|
||||
const includeAlphabet = !view || !hasAlphabet(view.game.variant);
|
||||
const [st, hist] = await Promise.all([
|
||||
gateway.gameState(id, includeAlphabet),
|
||||
gateway.gameHistory(id),
|
||||
]);
|
||||
view = st;
|
||||
moves = hist.moves;
|
||||
placement = newPlacement(st.rack);
|
||||
@@ -206,7 +213,7 @@
|
||||
if (!sub) return;
|
||||
previewTimer = setTimeout(async () => {
|
||||
try {
|
||||
preview = await gateway.evaluate(id, sub.dir, sub.tiles);
|
||||
preview = await gateway.evaluate(id, sub.dir, sub.tiles, variant);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
@@ -218,7 +225,7 @@
|
||||
if (!sub) return;
|
||||
busy = true;
|
||||
try {
|
||||
await gateway.submitPlay(id, sub.dir, sub.tiles);
|
||||
await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
|
||||
zoomed = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
@@ -298,7 +305,7 @@
|
||||
exchangeOpen = false;
|
||||
busy = true;
|
||||
try {
|
||||
await gateway.exchange(id, tiles);
|
||||
await gateway.exchange(id, tiles, variant);
|
||||
await load();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
@@ -313,7 +320,7 @@
|
||||
checkOpen = true;
|
||||
}
|
||||
function onCheckInput(e: Event) {
|
||||
checkWord = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabet(variant));
|
||||
checkWord = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabetLetters(variant));
|
||||
}
|
||||
// Check is disabled while cooling down, for an already-checked word, or an out-of-range
|
||||
// length. The input filter already restricts to the variant's alphabet.
|
||||
@@ -326,9 +333,11 @@
|
||||
cooling = true;
|
||||
setTimeout(() => (cooling = false), 5000);
|
||||
try {
|
||||
const r = await gateway.checkWord(id, w);
|
||||
checkedWords.set(r.word.toUpperCase(), r.legal);
|
||||
checkResult = r;
|
||||
const r = await gateway.checkWord(id, w, variant);
|
||||
// Key the cache and the displayed result on the upper-case word the player typed; the
|
||||
// server echoes the decoded concrete word in the solver's lower case.
|
||||
checkedWords.set(w, r.legal);
|
||||
checkResult = { word: w, legal: r.legal };
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
@@ -535,7 +544,7 @@
|
||||
{#if blankPrompt}
|
||||
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
|
||||
<div class="alpha">
|
||||
{#each alphabet(variant) as ch (ch)}
|
||||
{#each alphabetLetters(variant) as ch (ch)}
|
||||
<button onclick={() => chooseBlank(ch)}>{ch}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { RackSlot } from '../lib/placement';
|
||||
import { BLANK } from '../lib/placement';
|
||||
import { tileValue } from '../lib/premiums';
|
||||
import { valueForLetter } from '../lib/alphabet';
|
||||
import type { Variant } from '../lib/model';
|
||||
|
||||
let {
|
||||
@@ -30,7 +30,7 @@
|
||||
onpointerdown={(e) => ondown(e, slot.index)}
|
||||
>
|
||||
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
|
||||
{#if slot.letter !== BLANK}<span class="val">{tileValue(variant, slot.letter)}</span>{/if}
|
||||
{#if slot.letter !== BLANK}<span class="val">{valueForLetter(variant, slot.letter)}</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
export { AccountRef } from './scrabblefb/account-ref.js';
|
||||
export { Ack } from './scrabblefb/ack.js';
|
||||
export { AlphabetEntry } from './scrabblefb/alphabet-entry.js';
|
||||
export { BlockList } from './scrabblefb/block-list.js';
|
||||
export { ChatList } from './scrabblefb/chat-list.js';
|
||||
export { ChatMessage } from './scrabblefb/chat-message.js';
|
||||
@@ -41,6 +42,7 @@ export { MoveResult } from './scrabblefb/move-result.js';
|
||||
export { NotificationEvent } from './scrabblefb/notification-event.js';
|
||||
export { NudgeEvent } from './scrabblefb/nudge-event.js';
|
||||
export { OpponentMovedEvent } from './scrabblefb/opponent-moved-event.js';
|
||||
export { PlayTile } from './scrabblefb/play-tile.js';
|
||||
export { Profile } from './scrabblefb/profile.js';
|
||||
export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js';
|
||||
export { RedeemResult } from './scrabblefb/redeem-result.js';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class AlphabetEntry {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):AlphabetEntry {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsAlphabetEntry(bb:flatbuffers.ByteBuffer, obj?:AlphabetEntry):AlphabetEntry {
|
||||
return (obj || new AlphabetEntry()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsAlphabetEntry(bb:flatbuffers.ByteBuffer, obj?:AlphabetEntry):AlphabetEntry {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new AlphabetEntry()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
index():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.readUint8(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
letter():string|null
|
||||
letter(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
letter(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
value():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startAlphabetEntry(builder:flatbuffers.Builder) {
|
||||
builder.startObject(3);
|
||||
}
|
||||
|
||||
static addIndex(builder:flatbuffers.Builder, index:number) {
|
||||
builder.addFieldInt8(0, index, 0);
|
||||
}
|
||||
|
||||
static addLetter(builder:flatbuffers.Builder, letterOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, letterOffset, 0);
|
||||
}
|
||||
|
||||
static addValue(builder:flatbuffers.Builder, value:number) {
|
||||
builder.addFieldInt32(2, value, 0);
|
||||
}
|
||||
|
||||
static endAlphabetEntry(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createAlphabetEntry(builder:flatbuffers.Builder, index:number, letterOffset:flatbuffers.Offset, value:number):flatbuffers.Offset {
|
||||
AlphabetEntry.startAlphabetEntry(builder);
|
||||
AlphabetEntry.addIndex(builder, index);
|
||||
AlphabetEntry.addLetter(builder, letterOffset);
|
||||
AlphabetEntry.addValue(builder, value);
|
||||
return AlphabetEntry.endAlphabetEntry(builder);
|
||||
}
|
||||
}
|
||||
@@ -27,11 +27,19 @@ gameId(optionalEncoding?:any):string|Uint8Array|null {
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
word():string|null
|
||||
word(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
word(optionalEncoding?:any):string|Uint8Array|null {
|
||||
word(index: number):number|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
return offset ? this.bb!.readUint8(this.bb!.__vector(this.bb_pos + offset) + index) : 0;
|
||||
}
|
||||
|
||||
wordLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
wordArray():Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? new Uint8Array(this.bb!.bytes().buffer, this.bb!.bytes().byteOffset + this.bb!.__vector(this.bb_pos + offset), this.bb!.__vector_len(this.bb_pos + offset)) : null;
|
||||
}
|
||||
|
||||
static startCheckWordRequest(builder:flatbuffers.Builder) {
|
||||
@@ -46,6 +54,18 @@ static addWord(builder:flatbuffers.Builder, wordOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, wordOffset, 0);
|
||||
}
|
||||
|
||||
static createWordVector(builder:flatbuffers.Builder, data:number[]|Uint8Array):flatbuffers.Offset {
|
||||
builder.startVector(1, data.length, 1);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addInt8(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startWordVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(1, numElems, 1);
|
||||
}
|
||||
|
||||
static endCheckWordRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { TileRecord } from '../scrabblefb/tile-record.js';
|
||||
import { PlayTile } from '../scrabblefb/play-tile.js';
|
||||
|
||||
|
||||
export class EvalRequest {
|
||||
@@ -37,9 +37,9 @@ dir(optionalEncoding?:any):string|Uint8Array|null {
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
tiles(index: number, obj?:TileRecord):TileRecord|null {
|
||||
tiles(index: number, obj?:PlayTile):PlayTile|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? (obj || new TileRecord()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
return offset ? (obj || new PlayTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
}
|
||||
|
||||
tilesLength():number {
|
||||
|
||||
@@ -27,11 +27,9 @@ gameId(optionalEncoding?:any):string|Uint8Array|null {
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
tiles(index: number):string
|
||||
tiles(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
|
||||
tiles(index: number,optionalEncoding?:any):string|Uint8Array|null {
|
||||
tiles(index: number):number|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
|
||||
return offset ? this.bb!.readUint8(this.bb!.__vector(this.bb_pos + offset) + index) : 0;
|
||||
}
|
||||
|
||||
tilesLength():number {
|
||||
@@ -39,6 +37,11 @@ tilesLength():number {
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
tilesArray():Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? new Uint8Array(this.bb!.bytes().buffer, this.bb!.bytes().byteOffset + this.bb!.__vector(this.bb_pos + offset), this.bb!.__vector_len(this.bb_pos + offset)) : null;
|
||||
}
|
||||
|
||||
static startExchangeRequest(builder:flatbuffers.Builder) {
|
||||
builder.startObject(2);
|
||||
}
|
||||
@@ -51,16 +54,16 @@ static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, tilesOffset, 0);
|
||||
}
|
||||
|
||||
static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
builder.startVector(4, data.length, 4);
|
||||
static createTilesVector(builder:flatbuffers.Builder, data:number[]|Uint8Array):flatbuffers.Offset {
|
||||
builder.startVector(1, data.length, 1);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(data[i]!);
|
||||
builder.addInt8(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startTilesVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
builder.startVector(1, numElems, 1);
|
||||
}
|
||||
|
||||
static endExchangeRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class PlayTile {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):PlayTile {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsPlayTile(bb:flatbuffers.ByteBuffer, obj?:PlayTile):PlayTile {
|
||||
return (obj || new PlayTile()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsPlayTile(bb:flatbuffers.ByteBuffer, obj?:PlayTile):PlayTile {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new PlayTile()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
row():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
col():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
letter():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? this.bb!.readUint8(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
blank():boolean {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||
}
|
||||
|
||||
static startPlayTile(builder:flatbuffers.Builder) {
|
||||
builder.startObject(4);
|
||||
}
|
||||
|
||||
static addRow(builder:flatbuffers.Builder, row:number) {
|
||||
builder.addFieldInt32(0, row, 0);
|
||||
}
|
||||
|
||||
static addCol(builder:flatbuffers.Builder, col:number) {
|
||||
builder.addFieldInt32(1, col, 0);
|
||||
}
|
||||
|
||||
static addLetter(builder:flatbuffers.Builder, letter:number) {
|
||||
builder.addFieldInt8(2, letter, 0);
|
||||
}
|
||||
|
||||
static addBlank(builder:flatbuffers.Builder, blank:boolean) {
|
||||
builder.addFieldInt8(3, +blank, +false);
|
||||
}
|
||||
|
||||
static endPlayTile(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createPlayTile(builder:flatbuffers.Builder, row:number, col:number, letter:number, blank:boolean):flatbuffers.Offset {
|
||||
PlayTile.startPlayTile(builder);
|
||||
PlayTile.addRow(builder, row);
|
||||
PlayTile.addCol(builder, col);
|
||||
PlayTile.addLetter(builder, letter);
|
||||
PlayTile.addBlank(builder, blank);
|
||||
return PlayTile.endPlayTile(builder);
|
||||
}
|
||||
}
|
||||
@@ -27,22 +27,32 @@ gameId(optionalEncoding?:any):string|Uint8Array|null {
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
includeAlphabet():boolean {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||
}
|
||||
|
||||
static startStateRequest(builder:flatbuffers.Builder) {
|
||||
builder.startObject(1);
|
||||
builder.startObject(2);
|
||||
}
|
||||
|
||||
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, gameIdOffset, 0);
|
||||
}
|
||||
|
||||
static addIncludeAlphabet(builder:flatbuffers.Builder, includeAlphabet:boolean) {
|
||||
builder.addFieldInt8(1, +includeAlphabet, +false);
|
||||
}
|
||||
|
||||
static endStateRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createStateRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
static createStateRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, includeAlphabet:boolean):flatbuffers.Offset {
|
||||
StateRequest.startStateRequest(builder);
|
||||
StateRequest.addGameId(builder, gameIdOffset);
|
||||
StateRequest.addIncludeAlphabet(builder, includeAlphabet);
|
||||
return StateRequest.endStateRequest(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { AlphabetEntry } from '../scrabblefb/alphabet-entry.js';
|
||||
import { GameView } from '../scrabblefb/game-view.js';
|
||||
|
||||
|
||||
@@ -33,11 +34,9 @@ seat():number {
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
rack(index: number):string
|
||||
rack(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
|
||||
rack(index: number,optionalEncoding?:any):string|Uint8Array|null {
|
||||
rack(index: number):number|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
|
||||
return offset ? this.bb!.readUint8(this.bb!.__vector(this.bb_pos + offset) + index) : 0;
|
||||
}
|
||||
|
||||
rackLength():number {
|
||||
@@ -45,6 +44,11 @@ rackLength():number {
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
rackArray():Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? new Uint8Array(this.bb!.bytes().buffer, this.bb!.bytes().byteOffset + this.bb!.__vector(this.bb_pos + offset), this.bb!.__vector_len(this.bb_pos + offset)) : null;
|
||||
}
|
||||
|
||||
bagLen():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
@@ -55,8 +59,18 @@ hintsRemaining():number {
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
alphabet(index: number, obj?:AlphabetEntry):AlphabetEntry|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 14);
|
||||
return offset ? (obj || new AlphabetEntry()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
}
|
||||
|
||||
alphabetLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 14);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startStateView(builder:flatbuffers.Builder) {
|
||||
builder.startObject(5);
|
||||
builder.startObject(6);
|
||||
}
|
||||
|
||||
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
|
||||
@@ -71,16 +85,16 @@ static addRack(builder:flatbuffers.Builder, rackOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(2, rackOffset, 0);
|
||||
}
|
||||
|
||||
static createRackVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
builder.startVector(4, data.length, 4);
|
||||
static createRackVector(builder:flatbuffers.Builder, data:number[]|Uint8Array):flatbuffers.Offset {
|
||||
builder.startVector(1, data.length, 1);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(data[i]!);
|
||||
builder.addInt8(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startRackVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
builder.startVector(1, numElems, 1);
|
||||
}
|
||||
|
||||
static addBagLen(builder:flatbuffers.Builder, bagLen:number) {
|
||||
@@ -91,18 +105,35 @@ static addHintsRemaining(builder:flatbuffers.Builder, hintsRemaining:number) {
|
||||
builder.addFieldInt32(4, hintsRemaining, 0);
|
||||
}
|
||||
|
||||
static addAlphabet(builder:flatbuffers.Builder, alphabetOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(5, alphabetOffset, 0);
|
||||
}
|
||||
|
||||
static createAlphabetVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
builder.startVector(4, data.length, 4);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startAlphabetVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number):flatbuffers.Offset {
|
||||
static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
StateView.startStateView(builder);
|
||||
StateView.addGame(builder, gameOffset);
|
||||
StateView.addSeat(builder, seat);
|
||||
StateView.addRack(builder, rackOffset);
|
||||
StateView.addBagLen(builder, bagLen);
|
||||
StateView.addHintsRemaining(builder, hintsRemaining);
|
||||
StateView.addAlphabet(builder, alphabetOffset);
|
||||
return StateView.endStateView(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { TileRecord } from '../scrabblefb/tile-record.js';
|
||||
import { PlayTile } from '../scrabblefb/play-tile.js';
|
||||
|
||||
|
||||
export class SubmitPlayRequest {
|
||||
@@ -37,9 +37,9 @@ dir(optionalEncoding?:any):string|Uint8Array|null {
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
tiles(index: number, obj?:TileRecord):TileRecord|null {
|
||||
tiles(index: number, obj?:PlayTile):PlayTile|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? (obj || new TileRecord()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
return offset ? (obj || new PlayTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
}
|
||||
|
||||
tilesLength():number {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
alphabetLetters,
|
||||
BLANK_INDEX,
|
||||
hasAlphabet,
|
||||
indexForLetter,
|
||||
letterForIndex,
|
||||
setAlphabet,
|
||||
valueForLetter,
|
||||
} from './alphabet';
|
||||
|
||||
// The cache module is per-file-isolated by vitest, so only what these tests seed exists.
|
||||
describe('alphabet cache (Stage 13)', () => {
|
||||
it('upper-cases letters for display and maps indices and values case-insensitively', () => {
|
||||
setAlphabet('english', [
|
||||
{ index: 0, letter: 'a', value: 1 },
|
||||
{ index: 16, letter: 'q', value: 10 },
|
||||
]);
|
||||
expect(hasAlphabet('english')).toBe(true);
|
||||
expect(letterForIndex('english', 0)).toBe('A');
|
||||
expect(letterForIndex('english', 16)).toBe('Q');
|
||||
expect(indexForLetter('english', 'a')).toBe(0);
|
||||
expect(indexForLetter('english', 'Q')).toBe(16);
|
||||
expect(valueForLetter('english', 'a')).toBe(1);
|
||||
expect(valueForLetter('english', 'Q')).toBe(10);
|
||||
});
|
||||
|
||||
it('handles the blank sentinel and unknown letters/indices', () => {
|
||||
setAlphabet('english', [{ index: 0, letter: 'a', value: 1 }]);
|
||||
expect(letterForIndex('english', BLANK_INDEX)).toBe('?');
|
||||
expect(indexForLetter('english', '?')).toBe(BLANK_INDEX);
|
||||
expect(valueForLetter('english', '?')).toBe(0);
|
||||
expect(letterForIndex('english', 99)).toBe(''); // out of range
|
||||
expect(valueForLetter('english', 'Z')).toBe(0); // not in this alphabet
|
||||
expect(() => indexForLetter('english', 'Z')).toThrow();
|
||||
});
|
||||
|
||||
it('lists the alphabet for the blank chooser and is empty for an uncached variant', () => {
|
||||
setAlphabet('english', [
|
||||
{ index: 0, letter: 'a', value: 1 },
|
||||
{ index: 1, letter: 'b', value: 3 },
|
||||
]);
|
||||
expect(alphabetLetters('english')).toEqual(['A', 'B']);
|
||||
expect(hasAlphabet('erudit')).toBe(false);
|
||||
expect(alphabetLetters('erudit')).toEqual([]);
|
||||
expect(valueForLetter('erudit', 'A')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// Per-variant alphabet table cache (Stage 13). The client is alphabet-agnostic: it caches
|
||||
// each variant's (index, letter, value) table — sent by the server on a per-variant cache
|
||||
// miss, behind game.state's include_alphabet flag — and renders the rack and the blank
|
||||
// chooser with it while live play exchanges bare alphabet indices on the wire. Letters are
|
||||
// stored upper-cased for display (the rest of the UI works in upper case) and index lookups
|
||||
// are case-insensitive. A blank rides as the sentinel index 255 in a rack/exchange list; a
|
||||
// placed blank instead travels as its designated letter's index with a separate blank flag.
|
||||
|
||||
import type { Variant } from './model';
|
||||
|
||||
/** BLANK_INDEX is the wire sentinel for a blank tile in a rack/exchange index list. */
|
||||
export const BLANK_INDEX = 255;
|
||||
|
||||
/** BLANK_LETTER is the glyph a blank rack tile decodes to (matches placement.BLANK). */
|
||||
const BLANK_LETTER = '?';
|
||||
|
||||
/** AlphabetEntryWire is one raw alphabet row as received from the wire or a mock fixture. */
|
||||
export interface AlphabetEntryWire {
|
||||
index: number;
|
||||
letter: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface Table {
|
||||
letters: string[]; // by index, upper-cased
|
||||
values: number[]; // by index
|
||||
indexByLetter: Map<string, number>; // upper-cased letter -> index
|
||||
}
|
||||
|
||||
const cache = new Map<Variant, Table>();
|
||||
|
||||
/** setAlphabet caches a variant's table, upper-casing letters for display. */
|
||||
export function setAlphabet(variant: Variant, entries: AlphabetEntryWire[]): void {
|
||||
let size = 0;
|
||||
for (const e of entries) size = Math.max(size, e.index + 1);
|
||||
const letters = new Array<string>(size).fill('');
|
||||
const values = new Array<number>(size).fill(0);
|
||||
const indexByLetter = new Map<string, number>();
|
||||
for (const e of entries) {
|
||||
const up = e.letter.toUpperCase();
|
||||
letters[e.index] = up;
|
||||
values[e.index] = e.value;
|
||||
indexByLetter.set(up, e.index);
|
||||
}
|
||||
cache.set(variant, { letters, values, indexByLetter });
|
||||
}
|
||||
|
||||
/** hasAlphabet reports whether a variant's table is cached (so the client can skip asking
|
||||
* the server to resend it). */
|
||||
export function hasAlphabet(variant: Variant): boolean {
|
||||
return cache.has(variant);
|
||||
}
|
||||
|
||||
/** alphabetLetters lists a variant's letters (upper-cased) for the blank chooser and the
|
||||
* word-check input filter; empty when the table is not yet cached. */
|
||||
export function alphabetLetters(variant: Variant): string[] {
|
||||
return cache.get(variant)?.letters.slice() ?? [];
|
||||
}
|
||||
|
||||
/** letterForIndex maps a wire rack index to its display letter: the blank sentinel maps to
|
||||
* "?", an unknown index to "". */
|
||||
export function letterForIndex(variant: Variant, index: number): string {
|
||||
if (index === BLANK_INDEX) return BLANK_LETTER;
|
||||
return cache.get(variant)?.letters[index] ?? '';
|
||||
}
|
||||
|
||||
/** valueForLetter returns a tile's point value; a blank ("?") and an unknown letter score 0. */
|
||||
export function valueForLetter(variant: Variant, letter: string): number {
|
||||
if (!letter || letter === BLANK_LETTER) return 0;
|
||||
const t = cache.get(variant);
|
||||
if (!t) return 0;
|
||||
const i = t.indexByLetter.get(letter.toUpperCase());
|
||||
return i === undefined ? 0 : t.values[i];
|
||||
}
|
||||
|
||||
/** indexForLetter maps a display letter to its wire index; a blank ("?") maps to the blank
|
||||
* sentinel. It throws when the letter is outside the cached alphabet — a placement bug, not
|
||||
* user input (the UI constrains every entry point to the variant's alphabet). */
|
||||
export function indexForLetter(variant: Variant, letter: string): number {
|
||||
if (letter === BLANK_LETTER) return BLANK_INDEX;
|
||||
const i = cache.get(variant)?.indexByLetter.get(letter.toUpperCase());
|
||||
if (i === undefined) throw new Error(`alphabet: no index for "${letter}" in ${variant}`);
|
||||
return i;
|
||||
}
|
||||
@@ -67,15 +67,18 @@ export interface GatewayClient {
|
||||
lobbyPoll(): Promise<MatchResult>;
|
||||
|
||||
// --- game ---
|
||||
gameState(gameId: string): Promise<StateView>;
|
||||
// Stage 13: the play loop exchanges alphabet indices, so submit/evaluate/exchange/
|
||||
// check-word take the game's variant (to map letters<->indices via the cached alphabet
|
||||
// table), and gameState's includeAlphabet asks the server to embed that table.
|
||||
gameState(gameId: string, includeAlphabet: boolean): Promise<StateView>;
|
||||
gameHistory(gameId: string): Promise<History>;
|
||||
submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise<MoveResult>;
|
||||
submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise<MoveResult>;
|
||||
pass(gameId: string): Promise<MoveResult>;
|
||||
exchange(gameId: string, tiles: string[]): Promise<MoveResult>;
|
||||
exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult>;
|
||||
resign(gameId: string): Promise<MoveResult>;
|
||||
hint(gameId: string): Promise<HintResult>;
|
||||
evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise<EvalResult>;
|
||||
checkWord(gameId: string, word: string): Promise<WordCheckResult>;
|
||||
evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise<EvalResult>;
|
||||
checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
|
||||
complaint(gameId: string, word: string, note: string): Promise<void>;
|
||||
|
||||
// --- chat ---
|
||||
|
||||
@@ -1,28 +1,44 @@
|
||||
import { Builder, ByteBuffer } from 'flatbuffers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fb from '../gen/fbs/scrabblefb';
|
||||
import { BLANK_INDEX, setAlphabet } from './alphabet';
|
||||
import {
|
||||
decodeFriendList,
|
||||
decodeGameList,
|
||||
decodeInvitation,
|
||||
decodeLinkResult,
|
||||
decodeSession,
|
||||
decodeStateView,
|
||||
decodeStats,
|
||||
encodeCheckWord,
|
||||
encodeExchange,
|
||||
encodeStateRequest,
|
||||
encodeSubmitPlay,
|
||||
encodeTarget,
|
||||
} from './codec';
|
||||
|
||||
describe('codec', () => {
|
||||
it('encodes a SubmitPlayRequest the gateway can read', () => {
|
||||
const buf = encodeSubmitPlay('g1', 'H', [
|
||||
{ row: 7, col: 7, letter: 'A', blank: false },
|
||||
{ row: 7, col: 8, letter: 'B', blank: true },
|
||||
it('encodes a SubmitPlayRequest with alphabet indices (Stage 13)', () => {
|
||||
setAlphabet('english', [
|
||||
{ index: 0, letter: 'a', value: 1 },
|
||||
{ index: 1, letter: 'b', value: 3 },
|
||||
]);
|
||||
// A placed blank carries its designated letter's index with the blank flag set.
|
||||
const buf = encodeSubmitPlay(
|
||||
'g1',
|
||||
'H',
|
||||
[
|
||||
{ row: 7, col: 7, letter: 'A', blank: false },
|
||||
{ row: 7, col: 8, letter: 'B', blank: true },
|
||||
],
|
||||
'english',
|
||||
);
|
||||
const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf));
|
||||
expect(r.gameId()).toBe('g1');
|
||||
expect(r.dir()).toBe('H');
|
||||
expect(r.tilesLength()).toBe(2);
|
||||
expect(r.tiles(0)?.letter()).toBe('A');
|
||||
expect(r.tiles(0)?.letter()).toBe(0);
|
||||
expect(r.tiles(1)?.letter()).toBe(1);
|
||||
expect(r.tiles(1)?.blank()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -214,3 +230,72 @@ describe('codec', () => {
|
||||
expect(inv.variant).toBe('english');
|
||||
});
|
||||
});
|
||||
|
||||
// Stage 13: the live play loop exchanges alphabet indices, mapped through the per-variant
|
||||
// table cached in lib/alphabet. Each test seeds the cache it needs (setAlphabet replaces
|
||||
// the whole table), so they are independent of order.
|
||||
describe('codec — alphabet on the wire (Stage 13)', () => {
|
||||
it('encodes an ExchangeRequest as alphabet indices, blank as the sentinel', () => {
|
||||
setAlphabet('english', [
|
||||
{ index: 0, letter: 'a', value: 1 },
|
||||
{ index: 1, letter: 'b', value: 3 },
|
||||
]);
|
||||
const r = fb.ExchangeRequest.getRootAsExchangeRequest(
|
||||
new ByteBuffer(encodeExchange('g1', ['A', '?'], 'english')),
|
||||
);
|
||||
expect(r.tilesLength()).toBe(2);
|
||||
expect(r.tiles(0)).toBe(0);
|
||||
expect(r.tiles(1)).toBe(BLANK_INDEX);
|
||||
});
|
||||
|
||||
it('encodes a CheckWordRequest as alphabet indices', () => {
|
||||
setAlphabet('english', [
|
||||
{ index: 0, letter: 'a', value: 1 },
|
||||
{ index: 2, letter: 'c', value: 3 },
|
||||
{ index: 19, letter: 't', value: 1 },
|
||||
]);
|
||||
const r = fb.CheckWordRequest.getRootAsCheckWordRequest(
|
||||
new ByteBuffer(encodeCheckWord('g1', 'CAT', 'english')),
|
||||
);
|
||||
expect(r.wordLength()).toBe(3);
|
||||
expect([r.word(0), r.word(1), r.word(2)]).toEqual([2, 0, 19]);
|
||||
});
|
||||
|
||||
it('carries the include_alphabet flag on a StateRequest', () => {
|
||||
const on = fb.StateRequest.getRootAsStateRequest(new ByteBuffer(encodeStateRequest('g1', true)));
|
||||
expect(on.gameId()).toBe('g1');
|
||||
expect(on.includeAlphabet()).toBe(true);
|
||||
const off = fb.StateRequest.getRootAsStateRequest(new ByteBuffer(encodeStateRequest('g1', false)));
|
||||
expect(off.includeAlphabet()).toBe(false);
|
||||
});
|
||||
|
||||
it('caches the alphabet table from a StateView and decodes the index rack to letters', () => {
|
||||
const b = new Builder(128);
|
||||
const la = b.createString('a');
|
||||
fb.AlphabetEntry.startAlphabetEntry(b);
|
||||
fb.AlphabetEntry.addIndex(b, 0);
|
||||
fb.AlphabetEntry.addLetter(b, la);
|
||||
fb.AlphabetEntry.addValue(b, 1);
|
||||
const ea = fb.AlphabetEntry.endAlphabetEntry(b);
|
||||
const lb = b.createString('b');
|
||||
fb.AlphabetEntry.startAlphabetEntry(b);
|
||||
fb.AlphabetEntry.addIndex(b, 1);
|
||||
fb.AlphabetEntry.addLetter(b, lb);
|
||||
fb.AlphabetEntry.addValue(b, 3);
|
||||
const eb = fb.AlphabetEntry.endAlphabetEntry(b);
|
||||
const alpha = fb.StateView.createAlphabetVector(b, [ea, eb]);
|
||||
const rack = fb.StateView.createRackVector(b, [0, BLANK_INDEX]);
|
||||
fb.StateView.startStateView(b);
|
||||
fb.StateView.addSeat(b, 0);
|
||||
fb.StateView.addRack(b, rack);
|
||||
fb.StateView.addBagLen(b, 10);
|
||||
fb.StateView.addHintsRemaining(b, 0);
|
||||
fb.StateView.addAlphabet(b, alpha);
|
||||
b.finish(fb.StateView.endStateView(b));
|
||||
|
||||
// No GameView on the buffer, so decode falls back to the default variant 'english';
|
||||
// the embedded table is cached under it and the rack [0, blank] decodes to letters.
|
||||
const sv = decodeStateView(b.asUint8Array());
|
||||
expect(sv.rack).toEqual(['A', '?']);
|
||||
});
|
||||
});
|
||||
|
||||
+52
-22
@@ -5,6 +5,7 @@
|
||||
|
||||
import { Builder, ByteBuffer, type Offset } from 'flatbuffers';
|
||||
import * as fb from '../gen/fbs/scrabblefb';
|
||||
import { indexForLetter, letterForIndex, setAlphabet, type AlphabetEntryWire } from './alphabet';
|
||||
import type { PlacedTile } from './client';
|
||||
import type {
|
||||
AccountRef,
|
||||
@@ -37,14 +38,15 @@ import type {
|
||||
|
||||
// --- request encoders ---
|
||||
|
||||
function buildTile(b: Builder, t: PlacedTile): Offset {
|
||||
const letter = b.createString(t.letter);
|
||||
fb.TileRecord.startTileRecord(b);
|
||||
fb.TileRecord.addRow(b, t.row);
|
||||
fb.TileRecord.addCol(b, t.col);
|
||||
fb.TileRecord.addLetter(b, letter);
|
||||
fb.TileRecord.addBlank(b, t.blank);
|
||||
return fb.TileRecord.endTileRecord(b);
|
||||
// buildPlayTile encodes one to-place tile by its alphabet index (Stage 13); a placed blank
|
||||
// carries its designated letter's index with blank set.
|
||||
function buildPlayTile(b: Builder, t: PlacedTile, variant: Variant): Offset {
|
||||
fb.PlayTile.startPlayTile(b);
|
||||
fb.PlayTile.addRow(b, t.row);
|
||||
fb.PlayTile.addCol(b, t.col);
|
||||
fb.PlayTile.addLetter(b, indexForLetter(variant, t.letter));
|
||||
fb.PlayTile.addBlank(b, t.blank);
|
||||
return fb.PlayTile.endPlayTile(b);
|
||||
}
|
||||
|
||||
function finish(b: Builder, root: Offset): Uint8Array {
|
||||
@@ -62,17 +64,23 @@ export function encodeGameAction(gameId: string): Uint8Array {
|
||||
return finish(b, fb.GameActionRequest.endGameActionRequest(b));
|
||||
}
|
||||
|
||||
export function encodeStateRequest(gameId: string): Uint8Array {
|
||||
export function encodeStateRequest(gameId: string, includeAlphabet: boolean): Uint8Array {
|
||||
const b = new Builder(64);
|
||||
const gid = b.createString(gameId);
|
||||
fb.StateRequest.startStateRequest(b);
|
||||
fb.StateRequest.addGameId(b, gid);
|
||||
fb.StateRequest.addIncludeAlphabet(b, includeAlphabet);
|
||||
return finish(b, fb.StateRequest.endStateRequest(b));
|
||||
}
|
||||
|
||||
export function encodeSubmitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Uint8Array {
|
||||
export function encodeSubmitPlay(
|
||||
gameId: string,
|
||||
dir: 'H' | 'V',
|
||||
tiles: PlacedTile[],
|
||||
variant: Variant,
|
||||
): Uint8Array {
|
||||
const b = new Builder(256);
|
||||
const tileOffs = tiles.map((t) => buildTile(b, t));
|
||||
const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
|
||||
const vec = fb.SubmitPlayRequest.createTilesVector(b, tileOffs);
|
||||
const gid = b.createString(gameId);
|
||||
const d = b.createString(dir);
|
||||
@@ -83,9 +91,14 @@ export function encodeSubmitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTi
|
||||
return finish(b, fb.SubmitPlayRequest.endSubmitPlayRequest(b));
|
||||
}
|
||||
|
||||
export function encodeEval(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Uint8Array {
|
||||
export function encodeEval(
|
||||
gameId: string,
|
||||
dir: 'H' | 'V',
|
||||
tiles: PlacedTile[],
|
||||
variant: Variant,
|
||||
): Uint8Array {
|
||||
const b = new Builder(256);
|
||||
const tileOffs = tiles.map((t) => buildTile(b, t));
|
||||
const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
|
||||
const vec = fb.EvalRequest.createTilesVector(b, tileOffs);
|
||||
const gid = b.createString(gameId);
|
||||
const d = b.createString(dir);
|
||||
@@ -96,10 +109,12 @@ export function encodeEval(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]):
|
||||
return finish(b, fb.EvalRequest.endEvalRequest(b));
|
||||
}
|
||||
|
||||
export function encodeExchange(gameId: string, tiles: string[]): Uint8Array {
|
||||
export function encodeExchange(gameId: string, tiles: string[], variant: Variant): Uint8Array {
|
||||
const b = new Builder(128);
|
||||
const offs = tiles.map((s) => b.createString(s));
|
||||
const vec = fb.ExchangeRequest.createTilesVector(b, offs);
|
||||
const vec = fb.ExchangeRequest.createTilesVector(
|
||||
b,
|
||||
tiles.map((l) => indexForLetter(variant, l)),
|
||||
);
|
||||
const gid = b.createString(gameId);
|
||||
fb.ExchangeRequest.startExchangeRequest(b);
|
||||
fb.ExchangeRequest.addGameId(b, gid);
|
||||
@@ -107,13 +122,16 @@ export function encodeExchange(gameId: string, tiles: string[]): Uint8Array {
|
||||
return finish(b, fb.ExchangeRequest.endExchangeRequest(b));
|
||||
}
|
||||
|
||||
export function encodeCheckWord(gameId: string, word: string): Uint8Array {
|
||||
export function encodeCheckWord(gameId: string, word: string, variant: Variant): Uint8Array {
|
||||
const b = new Builder(128);
|
||||
const vec = fb.CheckWordRequest.createWordVector(
|
||||
b,
|
||||
Array.from(word).map((ch) => indexForLetter(variant, ch)),
|
||||
);
|
||||
const gid = b.createString(gameId);
|
||||
const w = b.createString(word);
|
||||
fb.CheckWordRequest.startCheckWordRequest(b);
|
||||
fb.CheckWordRequest.addGameId(b, gid);
|
||||
fb.CheckWordRequest.addWord(b, w);
|
||||
fb.CheckWordRequest.addWord(b, vec);
|
||||
return finish(b, fb.CheckWordRequest.endCheckWordRequest(b));
|
||||
}
|
||||
|
||||
@@ -188,7 +206,8 @@ function s(v: string | null): string {
|
||||
}
|
||||
|
||||
function decodeTile(t: fb.TileRecord): Tile {
|
||||
return { row: t.row(), col: t.col(), letter: s(t.letter()), blank: t.blank() };
|
||||
// The wire keeps the move journal in the solver's lower case; the UI renders upper case.
|
||||
return { row: t.row(), col: t.col(), letter: s(t.letter()).toUpperCase(), blank: t.blank() };
|
||||
}
|
||||
|
||||
function decodeSeat(v: fb.SeatView): Seat {
|
||||
@@ -229,7 +248,7 @@ function decodeMove(m: fb.MoveRecord): MoveRecord {
|
||||
if (t) tiles.push(decodeTile(t));
|
||||
}
|
||||
const words: string[] = [];
|
||||
for (let i = 0; i < m.wordsLength(); i++) words.push(s(m.words(i)));
|
||||
for (let i = 0; i < m.wordsLength(); i++) words.push(s(m.words(i)).toUpperCase());
|
||||
return {
|
||||
player: m.player(),
|
||||
action: s(m.action()),
|
||||
@@ -280,8 +299,19 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
||||
export function decodeStateView(buf: Uint8Array): StateView {
|
||||
const v = fb.StateView.getRootAsStateView(new ByteBuffer(buf));
|
||||
const g = v.game();
|
||||
const variant = (g ? s(g.variant()) : 'english') as Variant;
|
||||
// Cache the alphabet table when the server included it (a per-variant cache miss), then
|
||||
// decode the index rack to display letters with it (Stage 13).
|
||||
if (v.alphabetLength() > 0) {
|
||||
const entries: AlphabetEntryWire[] = [];
|
||||
for (let i = 0; i < v.alphabetLength(); i++) {
|
||||
const e = v.alphabet(i);
|
||||
if (e) entries.push({ index: e.index(), letter: s(e.letter()), value: e.value() });
|
||||
}
|
||||
setAlphabet(variant, entries);
|
||||
}
|
||||
const rack: string[] = [];
|
||||
for (let i = 0; i < v.rackLength(); i++) rack.push(s(v.rack(i)));
|
||||
for (let i = 0; i < v.rackLength(); i++) rack.push(letterForIndex(variant, v.rack(i) ?? 0));
|
||||
return {
|
||||
game: g ? decodeGameView(g) : emptyGame(),
|
||||
seat: v.seat(),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Mock alphabet fixtures (Stage 13). In production the per-variant (index, letter, value)
|
||||
// table comes from the server; the mock seeds the same client cache from a local copy so
|
||||
// the rack, the blank chooser and the mock's scoring work with no backend. The data is the
|
||||
// solver's value tables (scrabble-solver/rules/rules.go), in alphabet-index order, so a
|
||||
// token's position is its index — the same shape the wire delivers.
|
||||
|
||||
import { setAlphabet, type AlphabetEntryWire } from '../alphabet';
|
||||
import type { Variant } from '../model';
|
||||
|
||||
// "letter+value" tokens in alphabet-index order. English Latin a..z; Russian а..я incl. ё;
|
||||
// Эрудит а..я incl. ё=0.
|
||||
const SPECS: Record<Variant, string> = {
|
||||
english:
|
||||
'a1 b3 c3 d2 e1 f4 g2 h4 i1 j8 k5 l1 m3 n1 o1 p3 q10 r1 s1 t1 u1 v4 w4 x8 y4 z10',
|
||||
russian:
|
||||
'а1 б3 в1 г3 д2 е1 ё3 ж5 з5 и1 й4 к2 л2 м2 н1 о1 п2 р1 с1 т1 у2 ф10 х5 ц5 ч5 ш8 щ10 ъ10 ы4 ь3 э8 ю8 я3',
|
||||
erudit:
|
||||
'а1 б3 в2 г3 д2 е1 ё0 ж5 з5 и1 й2 к2 л2 м2 н1 о1 п2 р2 с2 т2 у3 ф10 х5 ц10 ч5 ш10 щ10 ъ10 ы5 ь5 э10 ю10 я3',
|
||||
};
|
||||
|
||||
function parse(spec: string): AlphabetEntryWire[] {
|
||||
return spec
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((tok, index) => {
|
||||
const m = tok.match(/^(.+?)(\d+)$/);
|
||||
return { index, letter: m ? m[1] : tok, value: m ? Number(m[2]) : 0 };
|
||||
});
|
||||
}
|
||||
|
||||
let seeded = false;
|
||||
|
||||
/** seedMockAlphabets populates the alphabet cache for every variant once, mirroring the
|
||||
* server-sent tables so the mock-driven UI is alphabet-agnostic too. */
|
||||
export function seedMockAlphabets(): void {
|
||||
if (seeded) return;
|
||||
for (const variant of Object.keys(SPECS) as Variant[]) {
|
||||
setAlphabet(variant, parse(SPECS[variant]));
|
||||
}
|
||||
seeded = true;
|
||||
}
|
||||
@@ -34,7 +34,8 @@ import type {
|
||||
Variant,
|
||||
WordCheckResult,
|
||||
} from '../model';
|
||||
import { tileValue } from '../premiums';
|
||||
import { valueForLetter } from '../alphabet';
|
||||
import { seedMockAlphabets } from './alphabet';
|
||||
import {
|
||||
ME,
|
||||
MOCK_FRIENDS,
|
||||
@@ -93,6 +94,12 @@ export class MockGateway implements GatewayClient {
|
||||
private invitations: Invitation[] = mockInvitations();
|
||||
private readonly stats: Stats = { ...MOCK_STATS };
|
||||
|
||||
constructor() {
|
||||
// Seed the per-variant alphabet cache the rack, blank chooser and scoring read, so the
|
||||
// mock-driven UI is alphabet-agnostic without a backend (Stage 13).
|
||||
seedMockAlphabets();
|
||||
}
|
||||
|
||||
setToken(_token: string | null): void {
|
||||
// The mock needs no auth; the real transport stores the bearer token.
|
||||
}
|
||||
@@ -174,7 +181,7 @@ export class MockGateway implements GatewayClient {
|
||||
}
|
||||
|
||||
// --- game ---
|
||||
async gameState(gameId: string): Promise<StateView> {
|
||||
async gameState(gameId: string, _includeAlphabet: boolean): Promise<StateView> {
|
||||
const g = this.game(gameId);
|
||||
return {
|
||||
game: structuredClone(g.view),
|
||||
@@ -190,12 +197,12 @@ export class MockGateway implements GatewayClient {
|
||||
return { gameId, moves: structuredClone(g.moves) };
|
||||
}
|
||||
|
||||
async submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise<MoveResult> {
|
||||
async submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], _variant: Variant): Promise<MoveResult> {
|
||||
const g = this.game(gameId);
|
||||
const seat = this.mySeat(g);
|
||||
if (g.view.toMove !== seat) throw new GatewayError('not_your_turn');
|
||||
const variant = g.view.variant;
|
||||
let score = tiles.reduce((s, t) => s + tileValue(variant, t.blank ? '?' : t.letter), 0);
|
||||
let score = tiles.reduce((s, t) => s + valueForLetter(variant, t.blank ? '?' : t.letter), 0);
|
||||
if (tiles.length === 7) score += 50;
|
||||
const total = g.view.seats[seat].score + score;
|
||||
const move = {
|
||||
@@ -265,7 +272,7 @@ export class MockGateway implements GatewayClient {
|
||||
pass(gameId: string): Promise<MoveResult> {
|
||||
return this.simpleAction(gameId, 'pass');
|
||||
}
|
||||
exchange(gameId: string, tiles: string[]): Promise<MoveResult> {
|
||||
exchange(gameId: string, tiles: string[], _variant: Variant): Promise<MoveResult> {
|
||||
return this.simpleAction(gameId, 'exchange', tiles);
|
||||
}
|
||||
resign(gameId: string): Promise<MoveResult> {
|
||||
@@ -287,22 +294,22 @@ export class MockGateway implements GatewayClient {
|
||||
tiles: [{ row: 7, col: 7, letter, blank: false }],
|
||||
words: [letter],
|
||||
count: 1,
|
||||
score: tileValue(g.view.variant, letter),
|
||||
score: valueForLetter(g.view.variant, letter),
|
||||
total: 0,
|
||||
},
|
||||
hintsRemaining: g.hintsRemaining,
|
||||
};
|
||||
}
|
||||
|
||||
async evaluate(gameId: string, _dir: 'H' | 'V', tiles: PlacedTile[]): Promise<EvalResult> {
|
||||
async evaluate(gameId: string, _dir: 'H' | 'V', tiles: PlacedTile[], _variant: Variant): Promise<EvalResult> {
|
||||
const g = this.game(gameId);
|
||||
if (tiles.length === 0) return { legal: false, score: 0, words: [] };
|
||||
let score = tiles.reduce((s, t) => s + tileValue(g.view.variant, t.blank ? '?' : t.letter), 0);
|
||||
let score = tiles.reduce((s, t) => s + valueForLetter(g.view.variant, t.blank ? '?' : t.letter), 0);
|
||||
if (tiles.length === 7) score += 50;
|
||||
return { legal: true, score, words: [tiles.map((t) => t.letter).join('')] };
|
||||
}
|
||||
|
||||
async checkWord(_gameId: string, word: string): Promise<WordCheckResult> {
|
||||
async checkWord(_gameId: string, word: string, _variant: Variant): Promise<WordCheckResult> {
|
||||
return { word, legal: word.trim().length >= 2 };
|
||||
}
|
||||
async complaint(): Promise<void> {}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { alphabet, BOARD_SIZE, centre, premiumGrid, tileValue } from './premiums';
|
||||
import { BOARD_SIZE, centre, premiumGrid } from './premiums';
|
||||
|
||||
// Parity with scrabble-solver/rules/rules.go: english/russian share standardBoard
|
||||
// (centre is a double word); erudit shares the geometry but a non-doubling centre.
|
||||
// Premium-square geometry parity with scrabble-solver/rules/rules.go: english/russian
|
||||
// share standardBoard (centre is a double word); erudit shares the geometry but a
|
||||
// non-doubling centre. Tile-value and alphabet parity moved to the Go engine test
|
||||
// (backend/internal/engine AlphabetTable) in Stage 13 — the server now owns that table.
|
||||
describe('premium layout', () => {
|
||||
it('is a 15x15 grid with TW corners', () => {
|
||||
const g = premiumGrid('english');
|
||||
@@ -35,19 +37,3 @@ describe('premium layout', () => {
|
||||
expect(count('DW')).toBe(17); // 16 double-word squares + the centre
|
||||
});
|
||||
});
|
||||
|
||||
describe('tile values', () => {
|
||||
it('scores letters per variant and zero for a blank', () => {
|
||||
expect(tileValue('english', 'A')).toBe(1);
|
||||
expect(tileValue('english', 'Q')).toBe(10);
|
||||
expect(tileValue('english', '?')).toBe(0);
|
||||
expect(tileValue('russian', 'Ф')).toBe(10);
|
||||
expect(tileValue('erudit', 'Ё')).toBe(0);
|
||||
});
|
||||
|
||||
it('exposes the full alphabet for the blank chooser', () => {
|
||||
expect(alphabet('english')).toHaveLength(26);
|
||||
expect(alphabet('russian')).toHaveLength(33);
|
||||
expect(alphabet('erudit')).toHaveLength(33);
|
||||
});
|
||||
});
|
||||
|
||||
+8
-45
@@ -1,8 +1,9 @@
|
||||
// Board premium layout and tile values — ported verbatim from the engine source of
|
||||
// truth, scrabble-solver/rules/rules.go (standardBoard / eruditBoard, and the
|
||||
// per-variant value tables). These are NOT transmitted on the wire (StateView has
|
||||
// no board), so the client renders them locally. A Vitest parity test pins the
|
||||
// layout against the known geometry. Keep this in lockstep with the solver.
|
||||
// Board premium layout — the 15x15 premium-square geometry, ported from the engine source
|
||||
// of truth, scrabble-solver/rules/rules.go (standardBoard / eruditBoard). The board is not
|
||||
// transmitted on the wire (StateView has no board), so the client renders the premiums
|
||||
// locally; only the centre differs by variant. A Vitest parity test pins the geometry.
|
||||
// Tile values and the alphabet moved to the server-sent per-variant table in Stage 13 (see
|
||||
// lib/alphabet.ts), so this file is geometry only.
|
||||
|
||||
import type { Variant } from './model';
|
||||
|
||||
@@ -84,43 +85,5 @@ export function centre(variant: Variant): { row: number; col: number } {
|
||||
return { row: 7, col: 7 };
|
||||
}
|
||||
|
||||
// --- tile values (points shown on the tile face); blank scores 0 ---
|
||||
|
||||
// English Latin a..z (rules.go English()).
|
||||
const enValues =
|
||||
'a1 b3 c3 d2 e1 f4 g2 h4 i1 j8 k5 l1 m3 n1 o1 p3 q10 r1 s1 t1 u1 v4 w4 x8 y4 z10';
|
||||
// Russian а..я incl. ё (rules.go RussianScrabble()).
|
||||
const ruValues =
|
||||
'а1 б3 в1 г3 д2 е1 ё3 ж5 з5 и1 й4 к2 л2 м2 н1 о1 п2 р1 с1 т1 у2 ф10 х5 ц5 ч5 ш8 щ10 ъ10 ы4 ь3 э8 ю8 я3';
|
||||
// Эрудит а..я incl. ё=0 (rules.go Erudit()).
|
||||
const eruditValues =
|
||||
'а1 б3 в2 г3 д2 е1 ё0 ж5 з5 и1 й2 к2 л2 м2 н1 о1 п2 р2 с2 т2 у3 ф10 х5 ц10 ч5 ш10 щ10 ъ10 ы5 ь5 э10 ю10 я3';
|
||||
|
||||
// Split each "letter+value" token into its letter (all but trailing digits) and its
|
||||
// integer value (the trailing digits).
|
||||
function valueTable(spec: string): Map<string, number> {
|
||||
const m = new Map<string, number>();
|
||||
for (const pair of spec.trim().split(/\s+/)) {
|
||||
const match = pair.match(/^(.+?)(\d+)$/);
|
||||
if (!match) continue;
|
||||
m.set(match[1].toUpperCase(), Number(match[2]));
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
const VALUES: Record<Variant, Map<string, number>> = {
|
||||
english: valueTable(enValues),
|
||||
russian: valueTable(ruValues),
|
||||
erudit: valueTable(eruditValues),
|
||||
};
|
||||
|
||||
/** tileValue returns the point value of a concrete letter; a blank ("?") is 0. */
|
||||
export function tileValue(variant: Variant, letter: string): number {
|
||||
if (!letter || letter === '?') return 0;
|
||||
return VALUES[variant]?.get(letter.toUpperCase()) ?? 0;
|
||||
}
|
||||
|
||||
/** alphabet lists a variant's letters in dictionary order (used by the blank chooser). */
|
||||
export function alphabet(variant: Variant): string[] {
|
||||
return [...VALUES[variant].keys()];
|
||||
}
|
||||
// Tile values and the per-variant alphabet now arrive from the server (lib/alphabet.ts,
|
||||
// Stage 13); the board geometry above is all this module owns.
|
||||
|
||||
+10
-10
@@ -81,20 +81,20 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
return codec.decodeMatchResult(await exec('lobby.poll', codec.empty()));
|
||||
},
|
||||
|
||||
async gameState(id) {
|
||||
return codec.decodeStateView(await exec('game.state', codec.encodeStateRequest(id)));
|
||||
async gameState(id, includeAlphabet) {
|
||||
return codec.decodeStateView(await exec('game.state', codec.encodeStateRequest(id, includeAlphabet)));
|
||||
},
|
||||
async gameHistory(id) {
|
||||
return codec.decodeHistory(await exec('game.history', codec.encodeGameAction(id)));
|
||||
},
|
||||
async submitPlay(id, dir, tiles) {
|
||||
return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, dir, tiles)));
|
||||
async submitPlay(id, dir, tiles, variant) {
|
||||
return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, dir, tiles, variant)));
|
||||
},
|
||||
async pass(id) {
|
||||
return codec.decodeMoveResult(await exec('game.pass', codec.encodeGameAction(id)));
|
||||
},
|
||||
async exchange(id, tiles) {
|
||||
return codec.decodeMoveResult(await exec('game.exchange', codec.encodeExchange(id, tiles)));
|
||||
async exchange(id, tiles, variant) {
|
||||
return codec.decodeMoveResult(await exec('game.exchange', codec.encodeExchange(id, tiles, variant)));
|
||||
},
|
||||
async resign(id) {
|
||||
return codec.decodeMoveResult(await exec('game.resign', codec.encodeGameAction(id)));
|
||||
@@ -102,11 +102,11 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
async hint(id) {
|
||||
return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id)));
|
||||
},
|
||||
async evaluate(id, dir, tiles) {
|
||||
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, dir, tiles)));
|
||||
async evaluate(id, dir, tiles, variant) {
|
||||
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, dir, tiles, variant)));
|
||||
},
|
||||
async checkWord(id, word) {
|
||||
return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word)));
|
||||
async checkWord(id, word, variant) {
|
||||
return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word, variant)));
|
||||
},
|
||||
async complaint(id, word, note) {
|
||||
await exec('game.complaint', codec.encodeComplaint(id, word, note));
|
||||
|
||||
Reference in New Issue
Block a user