Stage 13: alphabet on the wire (UI alphabet-agnostic, TODO-4)
Tests · Go / test (push) Successful in 10s
Tests · Integration / integration (push) Successful in 12s
Tests · UI / test (push) Successful in 19s
Tests · Go / test (pull_request) Successful in 9s
Tests · Integration / integration (pull_request) Successful in 12s
Tests · UI / test (pull_request) Successful in 19s
Tests · Go / test (push) Successful in 10s
Tests · Integration / integration (push) Successful in 12s
Tests · UI / test (push) Successful in 19s
Tests · Go / test (pull_request) Successful in 9s
Tests · Integration / integration (pull_request) Successful in 12s
Tests · UI / test (pull_request) Successful in 19s
Live play now exchanges per-variant alphabet indices instead of concrete letters (rack out; submit-play, evaluate, exchange, word-check in). The client caches each variant's (index, letter, value) table behind StateRequest.include_alphabet and renders the rack and blank chooser from it, dropping the hardcoded value/alphabet tables. History, the durable journal and GCG stay decoded concrete characters (ARCHITECTURE §9.1, unchanged). - pkg/fbs: new AlphabetEntry + PlayTile; StateView.rack -> [ubyte] + alphabet; StateRequest.include_alphabet; SubmitPlay/Eval tiles -> [PlayTile]; Exchange tiles + CheckWord word -> [ubyte] (committed Go + TS regenerated). - engine: AlphabetTable + a cached per-variant codec (LetterForIndex/EncodeRack/ DecodeTiles/DecodeWord) + BlankIndex sentinel; Go parity test. - backend server edge maps index<->letter (new thin game.Service.GameVariant); game.Service domain methods, engine.Game and the robot keep one letter-based play path. The gateway forwards indices verbatim (no alphabet table). - ui: lib/alphabet.ts in-memory cache; codec encodes/decodes indices; premiums.ts is geometry-only; the mock seeds a fixture table; the UI normalises display to upper case (codec + cache), leaving placement/board/checkword unchanged. Parity moved to the Go engine.AlphabetTable test; premiums.ts loses its value tables. Discharges TODO-4.
This commit is contained in:
@@ -46,7 +46,7 @@ independent (see ARCHITECTURE §9.1).
|
|||||||
| 10 | Admin & dictionary ops (complaint review, version reload) | **done** |
|
| 10 | Admin & dictionary ops (complaint review, version reload) | **done** |
|
||||||
| 11 | Account linking & merge | **done** |
|
| 11 | Account linking & merge | **done** |
|
||||||
| 12 | Observability & performance (telemetry, metrics, guest GC) | **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 |
|
| 14 | CI & deploy (multi-service, dictionary artifacts) | todo |
|
||||||
|
|
||||||
Scaffolding is incremental: `go.work` lists only existing modules; each stage
|
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/... ./gateway/... ./pkg/... ./platform/telegram/...`, integration stays
|
||||||
`./backend/...`, and the default `none` exporter keeps CI collector-free.
|
`./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)
|
## Deferred TODOs (cross-stage)
|
||||||
|
|
||||||
- **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable,
|
- **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
|
`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`/
|
abandonment trigger, not "last session gone". The reaped guest's `sessions`/`identities`/
|
||||||
`account_stats` fall away via their own `ON DELETE CASCADE`.
|
`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
|
- ~~**TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7).**~~ **Done in
|
||||||
client hardcodes each variant's letters/values (ported into `ui/src/lib/premiums.ts`
|
Stage 13.** The client is now alphabet-agnostic: it caches each variant's `(index, letter,
|
||||||
from `scrabble-solver/rules/rules.go`) and the edge exchanges plays/hints by concrete
|
value)` table — sent by the backend behind `StateRequest.include_alphabet` on a per-variant
|
||||||
letters. Consider extending `game.state` to carry the variant's `(letter, index,
|
cache miss — and live play exchanges **letter indices** both ways (rack, submit-play,
|
||||||
value)` table so the UI stops duplicating it, and optionally moving tile exchange to
|
evaluate, exchange, word-check; a blank rides as the sentinel index 255). The table is
|
||||||
letter **indices** end-to-end. Caveat (as for the dictionaries, TODO-2): the wire table
|
produced from the solver ruleset (`engine.AlphabetTable`), so it is pinned by the solver
|
||||||
must stay pinned to the same `rules.Alphabet` the engine uses, or indices drift.
|
version and cannot drift from the running backend, and `ui/src/lib/premiums.ts` is now
|
||||||
**Planned for Stage 13**, expanded (owner) to a fully **alphabet-agnostic UI**: the
|
geometry only. The durable journal / history / GCG stay decoded concrete characters (§9.1,
|
||||||
client caches the per-variant table (display only) behind an `include_alphabet` request
|
unchanged). The DAWG/solver build-time agreement (the original caveat, shared with TODO-2)
|
||||||
flag and exchanges indices both ways, word-check included; the durable journal stays
|
remains Stage 14.
|
||||||
concrete characters (§9.1). See Stage 13.
|
|
||||||
- **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9:*
|
- **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
|
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
|
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
|
// transition validates the actor and turn, applies op under the per-game lock and
|
||||||
// commits the result.
|
// commits the result.
|
||||||
func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) {
|
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)
|
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
|
// 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
|
// one game (active or finished). It backs the social package's "befriend an
|
||||||
// opponent" gate via a self-join on game_players.
|
// 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.
|
// TestCheckWordAndComplaint covers the word-check tool and complaint capture.
|
||||||
func TestCheckWordAndComplaint(t *testing.T) {
|
func TestCheckWordAndComplaint(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -101,13 +101,24 @@ type moveResultDTO struct {
|
|||||||
Game gameDTO `json:"game"`
|
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 {
|
type stateDTO struct {
|
||||||
Game gameDTO `json:"game"`
|
Game gameDTO `json:"game"`
|
||||||
Seat int `json:"seat"`
|
Seat int `json:"seat"`
|
||||||
Rack []string `json:"rack"`
|
Rack []int `json:"rack"`
|
||||||
BagLen int `json:"bag_len"`
|
BagLen int `json:"bag_len"`
|
||||||
HintsRemaining int `json:"hints_remaining"`
|
HintsRemaining int `json:"hints_remaining"`
|
||||||
|
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchDTO reports whether the caller has been paired into a game.
|
// 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)}
|
return moveResultDTO{Move: moveRecordDTOFrom(r.Move), Game: gameDTOFromGame(r.Game)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// stateDTOFrom projects a player's state view into its DTO.
|
// stateDTOFrom projects a player's state view into its DTO, encoding the rack as wire
|
||||||
func stateDTOFrom(v game.StateView) stateDTO {
|
// alphabet indices (Stage 13). When includeAlphabet is set it also embeds the variant's
|
||||||
return stateDTO{
|
// 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),
|
Game: gameDTOFromGame(v.Game),
|
||||||
Seat: v.Seat,
|
Seat: v.Seat,
|
||||||
Rack: v.Rack,
|
Rack: rack,
|
||||||
BagLen: v.BagLen,
|
BagLen: v.BagLen,
|
||||||
HintsRemaining: v.HintsRemaining,
|
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.
|
// matchDTOFrom projects an enqueue/poll result into its DTO.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -51,9 +52,10 @@ type chatListDTO struct {
|
|||||||
Messages []chatDTO `json:"messages"`
|
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 {
|
type exchangeRequest struct {
|
||||||
Tiles []string `json:"tiles"`
|
Tiles []int `json:"tiles"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// complaintRequest disputes a word-check result.
|
// complaintRequest disputes a word-check result.
|
||||||
@@ -125,7 +127,17 @@ func (s *Server) handleExchange(c *gin.Context) {
|
|||||||
abortBadRequest(c, "invalid request body")
|
abortBadRequest(c, "invalid request body")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
@@ -180,9 +192,15 @@ func (s *Server) handleEvaluate(c *gin.Context) {
|
|||||||
abortBadRequest(c, "dir must be H or V")
|
abortBadRequest(c, "dir must be H or V")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tiles := make([]engine.TileRecord, 0, len(req.Tiles))
|
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
|
||||||
for _, t := range req.Tiles {
|
if err != nil {
|
||||||
tiles = append(tiles, engine.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
|
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)
|
ev, err := s.games.EvaluatePlay(c.Request.Context(), gameID, uid, dir, tiles)
|
||||||
if err != nil {
|
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})
|
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) {
|
func (s *Server) handleCheckWord(c *gin.Context) {
|
||||||
_, gameID, ok := s.userGame(c)
|
_, gameID, ok := s.userGame(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
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)
|
legal, err := s.games.CheckWord(c.Request.Context(), gameID, word)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
@@ -207,6 +241,21 @@ func (s *Server) handleCheckWord(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, wordCheckDTO{Word: word, Legal: legal})
|
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.
|
// handleComplaint files a word-check complaint into the admin review queue.
|
||||||
func (s *Server) handleComplaint(c *gin.Context) {
|
func (s *Server) handleComplaint(c *gin.Context) {
|
||||||
uid, gameID, ok := s.userGame(c)
|
uid, gameID, ok := s.userGame(c)
|
||||||
|
|||||||
@@ -26,17 +26,33 @@ func (s *Server) handleProfile(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, profileResponseFor(acc))
|
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 {
|
type submitPlayRequest struct {
|
||||||
Dir string `json:"dir"`
|
Dir string `json:"dir"`
|
||||||
Tiles []struct {
|
Tiles []struct {
|
||||||
Row int `json:"row"`
|
Row int `json:"row"`
|
||||||
Col int `json:"col"`
|
Col int `json:"col"`
|
||||||
Letter string `json:"letter"`
|
Letter int `json:"letter"`
|
||||||
Blank bool `json:"blank"`
|
Blank bool `json:"blank"`
|
||||||
} `json:"tiles"`
|
} `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.
|
// handleSubmitPlay validates, scores and commits a placement.
|
||||||
func (s *Server) handleSubmitPlay(c *gin.Context) {
|
func (s *Server) handleSubmitPlay(c *gin.Context) {
|
||||||
uid, ok := userID(c)
|
uid, ok := userID(c)
|
||||||
@@ -59,9 +75,15 @@ func (s *Server) handleSubmitPlay(c *gin.Context) {
|
|||||||
abortBadRequest(c, "dir must be H or V")
|
abortBadRequest(c, "dir must be H or V")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tiles := make([]engine.TileRecord, 0, len(req.Tiles))
|
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
|
||||||
for _, t := range req.Tiles {
|
if err != nil {
|
||||||
tiles = append(tiles, engine.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
|
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)
|
res, err := s.games.SubmitPlay(c.Request.Context(), gameID, uid, dir, tiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -88,7 +110,11 @@ func (s *Server) handleGameState(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
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{})
|
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
|
||||||
c.JSON(http.StatusOK, dto)
|
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
|
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
||||||
200); only edge failures (rate limit, missing session, unknown type, internal)
|
200); only edge failures (rate limit, missing session, unknown type, internal)
|
||||||
surface as Connect error codes.
|
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
|
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
|
||||||
`X-User-ID` for authenticated requests; `backend` never re-derives identity
|
`X-User-ID` for authenticated requests; `backend` never re-derives identity
|
||||||
from the body.
|
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
|
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.
|
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
|
## 10. Notifications
|
||||||
|
|
||||||
Two channels: the **in-app live stream** (delivered from Stage 6) and
|
Two channels: the **in-app live stream** (delivered from Stage 6) and
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The structs below mirror the backend's JSON DTOs (backend/internal/server
|
// The structs below mirror the backend's JSON DTOs (backend/internal/server
|
||||||
@@ -47,7 +48,8 @@ type LinkResultResp struct {
|
|||||||
Profile *ProfileResp `json:"profile"`
|
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 {
|
type TileJSON struct {
|
||||||
Row int `json:"row"`
|
Row int `json:"row"`
|
||||||
Col int `json:"col"`
|
Col int `json:"col"`
|
||||||
@@ -55,6 +57,15 @@ type TileJSON struct {
|
|||||||
Blank bool `json:"blank"`
|
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.
|
// MoveRecordResp is a decoded move.
|
||||||
type MoveRecordResp struct {
|
type MoveRecordResp struct {
|
||||||
Player int `json:"player"`
|
Player int `json:"player"`
|
||||||
@@ -99,13 +110,23 @@ type MoveResultResp struct {
|
|||||||
Game GameResp `json:"game"`
|
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 {
|
type StateResp struct {
|
||||||
Game GameResp `json:"game"`
|
Game GameResp `json:"game"`
|
||||||
Seat int `json:"seat"`
|
Seat int `json:"seat"`
|
||||||
Rack []string `json:"rack"`
|
Rack []int `json:"rack"`
|
||||||
BagLen int `json:"bag_len"`
|
BagLen int `json:"bag_len"`
|
||||||
HintsRemaining int `json:"hints_remaining"`
|
HintsRemaining int `json:"hints_remaining"`
|
||||||
|
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchResp reports an auto-match outcome.
|
// MatchResp reports an auto-match outcome.
|
||||||
@@ -194,18 +215,25 @@ func (c *Client) Profile(ctx context.Context, userID string) (ProfileResp, error
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubmitPlay commits a placement on the player's turn.
|
// SubmitPlay commits a placement on the player's turn. The tiles are addressed by alphabet
|
||||||
func (c *Client) SubmitPlay(ctx context.Context, userID, gameID, dir string, tiles []TileJSON) (MoveResultResp, error) {
|
// index (Stage 13).
|
||||||
|
func (c *Client) SubmitPlay(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (MoveResultResp, error) {
|
||||||
var out MoveResultResp
|
var out MoveResultResp
|
||||||
body := map[string]any{"dir": dir, "tiles": tiles}
|
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)
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/games/"+url.PathEscape(gameID)+"/play", userID, "", body, &out)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GameState returns the player's view of a game.
|
// GameState returns the player's view of a game. When includeAlphabet is set the backend
|
||||||
func (c *Client) GameState(ctx context.Context, userID, gameID string) (StateResp, error) {
|
// 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
|
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
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,8 +306,9 @@ func (c *Client) Pass(ctx context.Context, userID, gameID string) (MoveResultRes
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exchange swaps the chosen rack tiles back into the bag.
|
// Exchange swaps the chosen rack tiles back into the bag. Tiles are wire alphabet indices
|
||||||
func (c *Client) Exchange(ctx context.Context, userID, gameID string, tiles []string) (MoveResultResp, error) {
|
// (Stage 13; a blank is engine.BlankIndex).
|
||||||
|
func (c *Client) Exchange(ctx context.Context, userID, gameID string, tiles []int) (MoveResultResp, error) {
|
||||||
var out MoveResultResp
|
var out MoveResultResp
|
||||||
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/exchange"), userID, "",
|
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/exchange"), userID, "",
|
||||||
map[string]any{"tiles": tiles}, &out)
|
map[string]any{"tiles": tiles}, &out)
|
||||||
@@ -300,18 +329,24 @@ func (c *Client) Hint(ctx context.Context, userID, gameID string) (HintResultRes
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evaluate previews a tentative play's legality and score.
|
// Evaluate previews a tentative play's legality and score. The tiles are addressed by
|
||||||
func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles []TileJSON) (EvalResultResp, error) {
|
// alphabet index (Stage 13).
|
||||||
|
func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (EvalResultResp, error) {
|
||||||
var out EvalResultResp
|
var out EvalResultResp
|
||||||
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/evaluate"), userID, "",
|
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/evaluate"), userID, "",
|
||||||
map[string]any{"dir": dir, "tiles": tiles}, &out)
|
map[string]any{"dir": dir, "tiles": tiles}, &out)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckWord looks a word up in the game's pinned dictionary.
|
// CheckWord looks a word up in the game's pinned dictionary. The word is carried as
|
||||||
func (c *Client) CheckWord(ctx context.Context, userID, gameID, word string) (WordCheckResp, error) {
|
// 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
|
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
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,21 +107,53 @@ func encodeMoveResult(r backendclient.MoveResultResp) []byte {
|
|||||||
return b.FinishedBytes()
|
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 {
|
func encodeState(s backendclient.StateResp) []byte {
|
||||||
b := flatbuffers.NewBuilder(512)
|
b := flatbuffers.NewBuilder(512)
|
||||||
game := buildGameView(b, s.Game)
|
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.StateViewStart(b)
|
||||||
fb.StateViewAddGame(b, game)
|
fb.StateViewAddGame(b, game)
|
||||||
fb.StateViewAddSeat(b, int32(s.Seat))
|
fb.StateViewAddSeat(b, int32(s.Seat))
|
||||||
fb.StateViewAddRack(b, rack)
|
fb.StateViewAddRack(b, rack)
|
||||||
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
||||||
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
||||||
|
if hasAlphabet {
|
||||||
|
fb.StateViewAddAlphabet(b, alphabet)
|
||||||
|
}
|
||||||
b.Finish(fb.StateViewEnd(b))
|
b.Finish(fb.StateViewEnd(b))
|
||||||
return b.FinishedBytes()
|
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.
|
// encodeMatch builds a MatchResult payload.
|
||||||
func encodeMatch(m backendclient.MatchResp) []byte {
|
func encodeMatch(m backendclient.MatchResp) []byte {
|
||||||
b := flatbuffers.NewBuilder(512)
|
b := flatbuffers.NewBuilder(512)
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ func submitPlayHandler(backend *backendclient.Client) Handler {
|
|||||||
func gameStateHandler(backend *backendclient.Client) Handler {
|
func gameStateHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsStateRequest(req.Payload, 0)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -238,17 +238,17 @@ func chatPostHandler(backend *backendclient.Client) Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeTiles reads the placed tiles from a SubmitPlayRequest.
|
// decodeTiles reads the index-addressed tiles to place from a SubmitPlayRequest (Stage 13).
|
||||||
func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.TileJSON {
|
func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.PlayTileJSON {
|
||||||
n := in.TilesLength()
|
n := in.TilesLength()
|
||||||
tiles := make([]backendclient.TileJSON, 0, n)
|
tiles := make([]backendclient.PlayTileJSON, 0, n)
|
||||||
var t fb.TileRecord
|
var t fb.PlayTile
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
if in.Tiles(&t, i) {
|
if in.Tiles(&t, i) {
|
||||||
tiles = append(tiles, backendclient.TileJSON{
|
tiles = append(tiles, backendclient.PlayTileJSON{
|
||||||
Row: int(t.Row()),
|
Row: int(t.Row()),
|
||||||
Col: int(t.Col()),
|
Col: int(t.Col()),
|
||||||
Letter: string(t.Letter()),
|
Letter: int(t.Letter()),
|
||||||
Blank: t.Blank(),
|
Blank: t.Blank(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -256,17 +256,17 @@ func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.TileJSON {
|
|||||||
return tiles
|
return tiles
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeEvalTiles reads the tentative tiles from an EvalRequest.
|
// decodeEvalTiles reads the index-addressed tentative tiles from an EvalRequest (Stage 13).
|
||||||
func decodeEvalTiles(in *fb.EvalRequest) []backendclient.TileJSON {
|
func decodeEvalTiles(in *fb.EvalRequest) []backendclient.PlayTileJSON {
|
||||||
n := in.TilesLength()
|
n := in.TilesLength()
|
||||||
tiles := make([]backendclient.TileJSON, 0, n)
|
tiles := make([]backendclient.PlayTileJSON, 0, n)
|
||||||
var t fb.TileRecord
|
var t fb.PlayTile
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
if in.Tiles(&t, i) {
|
if in.Tiles(&t, i) {
|
||||||
tiles = append(tiles, backendclient.TileJSON{
|
tiles = append(tiles, backendclient.PlayTileJSON{
|
||||||
Row: int(t.Row()),
|
Row: int(t.Row()),
|
||||||
Col: int(t.Col()),
|
Col: int(t.Col()),
|
||||||
Letter: string(t.Letter()),
|
Letter: int(t.Letter()),
|
||||||
Blank: t.Blank(),
|
Blank: t.Blank(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -274,12 +274,12 @@ func decodeEvalTiles(in *fb.EvalRequest) []backendclient.TileJSON {
|
|||||||
return tiles
|
return tiles
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeStringVector reads the exchange tiles from an ExchangeRequest.
|
// bytesToInts widens a FlatBuffers ubyte vector (an alphabet-index list) to []int for the
|
||||||
func decodeStringVector(in *fb.ExchangeRequest) []string {
|
// backend JSON edge (Stage 13: rack-exchange tiles and the word-check query).
|
||||||
n := in.TilesLength()
|
func bytesToInts(bs []byte) []int {
|
||||||
out := make([]string, 0, n)
|
out := make([]int, len(bs))
|
||||||
for i := 0; i < n; i++ {
|
for i, b := range bs {
|
||||||
out = append(out, string(in.Tiles(i)))
|
out[i] = int(b)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -319,7 +319,7 @@ func resignHandler(backend *backendclient.Client) Handler {
|
|||||||
func exchangeHandler(backend *backendclient.Client) Handler {
|
func exchangeHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsExchangeRequest(req.Payload, 0)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -352,7 +352,7 @@ func evaluateHandler(backend *backendclient.Client) Handler {
|
|||||||
func checkWordHandler(backend *backendclient.Client) Handler {
|
func checkWordHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsCheckWordRequest(req.Payload, 0)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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" {
|
if r.URL.Path != "/api/v1/user/games/g-1/state" {
|
||||||
t.Errorf("unexpected path %q", r.URL.Path)
|
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()
|
defer cleanup()
|
||||||
|
|
||||||
|
|||||||
+44
-14
@@ -14,8 +14,9 @@ namespace scrabblefb;
|
|||||||
|
|
||||||
// --- shared building blocks ---
|
// --- shared building blocks ---
|
||||||
|
|
||||||
// TileRecord is one placed (or to-place) tile: its board coordinate, the concrete
|
// TileRecord is one tile in a decoded move record (history, move result, hint): its
|
||||||
// letter ("?" when read from a hand for a blank) and whether it came from a blank.
|
// 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 {
|
table TileRecord {
|
||||||
row:int;
|
row:int;
|
||||||
col:int;
|
col:int;
|
||||||
@@ -23,6 +24,25 @@ table TileRecord {
|
|||||||
blank:bool;
|
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
|
// 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).
|
// backend from the account store (added trailing — backward-compatible).
|
||||||
table SeatView {
|
table SeatView {
|
||||||
@@ -123,11 +143,12 @@ table Profile {
|
|||||||
|
|
||||||
// --- game (authenticated) ---
|
// --- 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 {
|
table SubmitPlayRequest {
|
||||||
game_id:string;
|
game_id:string;
|
||||||
dir:string;
|
dir:string;
|
||||||
tiles:[TileRecord];
|
tiles:[PlayTile];
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveResult is the outcome of a committed move: the move and the post-move game.
|
// MoveResult is the outcome of a committed move: the move and the post-move game.
|
||||||
@@ -136,19 +157,25 @@ table MoveResult {
|
|||||||
game:GameView;
|
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 {
|
table StateRequest {
|
||||||
game_id:string;
|
game_id:string;
|
||||||
|
include_alphabet:bool = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateView is a player's view of a game: the shared summary plus their private
|
// StateView is a player's view of a game: the shared summary plus their private rack, the
|
||||||
// rack, the bag size and their remaining hint budget.
|
// 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 {
|
table StateView {
|
||||||
game:GameView;
|
game:GameView;
|
||||||
seat:int;
|
seat:int;
|
||||||
rack:[string];
|
rack:[ubyte];
|
||||||
bag_len:int;
|
bag_len:int;
|
||||||
hints_remaining:int;
|
hints_remaining:int;
|
||||||
|
alphabet:[AlphabetEntry];
|
||||||
}
|
}
|
||||||
|
|
||||||
// GameActionRequest carries just a game id (pass / resign / hint / history).
|
// GameActionRequest carries just a game id (pass / resign / hint / history).
|
||||||
@@ -156,17 +183,19 @@ table GameActionRequest {
|
|||||||
game_id:string;
|
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 {
|
table ExchangeRequest {
|
||||||
game_id:string;
|
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 {
|
table EvalRequest {
|
||||||
game_id:string;
|
game_id:string;
|
||||||
dir:string;
|
dir:string;
|
||||||
tiles:[TileRecord];
|
tiles:[PlayTile];
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvalResult is an unlimited move preview: legality, score and the words formed.
|
// EvalResult is an unlimited move preview: legality, score and the words formed.
|
||||||
@@ -176,10 +205,11 @@ table EvalResult {
|
|||||||
words:[string];
|
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 {
|
table CheckWordRequest {
|
||||||
game_id:string;
|
game_id:string;
|
||||||
word:string;
|
word:[ubyte];
|
||||||
}
|
}
|
||||||
|
|
||||||
// WordCheckResult is the dictionary lookup outcome.
|
// 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
|
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))
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
if o != 0 {
|
if o != 0 {
|
||||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
@@ -57,6 +74,15 @@ func (rcv *CheckWordRequest) Word() []byte {
|
|||||||
return nil
|
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) {
|
func CheckWordRequestStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(2)
|
builder.StartObject(2)
|
||||||
}
|
}
|
||||||
@@ -66,6 +92,9 @@ func CheckWordRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.
|
|||||||
func CheckWordRequestAddWord(builder *flatbuffers.Builder, word flatbuffers.UOffsetT) {
|
func CheckWordRequestAddWord(builder *flatbuffers.Builder, word flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(word), 0)
|
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 {
|
func CheckWordRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func (rcv *EvalRequest) Dir() []byte {
|
|||||||
return nil
|
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))
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
if o != 0 {
|
if o != 0 {
|
||||||
x := rcv._tab.Vector(o)
|
x := rcv._tab.Vector(o)
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ func (rcv *ExchangeRequest) GameId() []byte {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rcv *ExchangeRequest) Tiles(j int) []byte {
|
func (rcv *ExchangeRequest) Tiles(j int) byte {
|
||||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
if o != 0 {
|
if o != 0 {
|
||||||
a := rcv._tab.Vector(o)
|
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 {
|
func (rcv *ExchangeRequest) TilesLength() int {
|
||||||
@@ -66,6 +66,23 @@ func (rcv *ExchangeRequest) TilesLength() int {
|
|||||||
return 0
|
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) {
|
func ExchangeRequestStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(2)
|
builder.StartObject(2)
|
||||||
}
|
}
|
||||||
@@ -76,7 +93,7 @@ func ExchangeRequestAddTiles(builder *flatbuffers.Builder, tiles flatbuffers.UOf
|
|||||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(tiles), 0)
|
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(tiles), 0)
|
||||||
}
|
}
|
||||||
func ExchangeRequestStartTilesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
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 {
|
func ExchangeRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
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
|
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) {
|
func StateRequestStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(1)
|
builder.StartObject(2)
|
||||||
}
|
}
|
||||||
func StateRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
|
func StateRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
|
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 {
|
func StateRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,13 +66,13 @@ func (rcv *StateView) MutateSeat(n int32) bool {
|
|||||||
return rcv._tab.MutateInt32Slot(6, n)
|
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))
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
if o != 0 {
|
if o != 0 {
|
||||||
a := rcv._tab.Vector(o)
|
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 {
|
func (rcv *StateView) RackLength() int {
|
||||||
@@ -83,6 +83,23 @@ func (rcv *StateView) RackLength() int {
|
|||||||
return 0
|
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 {
|
func (rcv *StateView) BagLen() int32 {
|
||||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||||
if o != 0 {
|
if o != 0 {
|
||||||
@@ -107,8 +124,28 @@ func (rcv *StateView) MutateHintsRemaining(n int32) bool {
|
|||||||
return rcv._tab.MutateInt32Slot(12, n)
|
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) {
|
func StateViewStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(5)
|
builder.StartObject(6)
|
||||||
}
|
}
|
||||||
func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
|
func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(game), 0)
|
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)
|
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(rack), 0)
|
||||||
}
|
}
|
||||||
func StateViewStartRackVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
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) {
|
func StateViewAddBagLen(builder *flatbuffers.Builder, bagLen int32) {
|
||||||
builder.PrependInt32Slot(3, bagLen, 0)
|
builder.PrependInt32Slot(3, bagLen, 0)
|
||||||
@@ -128,6 +165,12 @@ func StateViewAddBagLen(builder *flatbuffers.Builder, bagLen int32) {
|
|||||||
func StateViewAddHintsRemaining(builder *flatbuffers.Builder, hintsRemaining int32) {
|
func StateViewAddHintsRemaining(builder *flatbuffers.Builder, hintsRemaining int32) {
|
||||||
builder.PrependInt32Slot(4, hintsRemaining, 0)
|
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 {
|
func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func (rcv *SubmitPlayRequest) Dir() []byte {
|
|||||||
return nil
|
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))
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
if o != 0 {
|
if o != 0 {
|
||||||
x := rcv._tab.Vector(o)
|
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
|
**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`).
|
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
|
**The play loop is alphabet-agnostic (Stage 13):** the rack and the play / exchange /
|
||||||
`scrabble-solver/rules/rules.go`** (pinned by a Vitest parity test). Board, tiles and
|
word-check requests carry **alphabet indices**, and the client caches each variant's
|
||||||
effects are pure CSS + Unicode — no image/font/SVG assets.
|
`(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
|
## Codegen
|
||||||
|
|
||||||
@@ -65,7 +70,8 @@ runtime; the Telegram SDK itself is wired in the Telegram stage.
|
|||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
lib/ model, client facade, transport (+ mock), codec, board replay,
|
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
|
components/ Header, Menu (+ badge), Modal, Toast, TabBar, Screen
|
||||||
screens/ Login, Lobby, NewGame, Profile, Settings, About, Friends, Stats
|
screens/ Login, Lobby, NewGame, Profile, Settings, About, Friends, Stats
|
||||||
game/ Game, Board, Rack, Controls, MakeMove, Chat
|
game/ Game, Board, Rack, Controls, MakeMove, Chat
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { BoardCell } from '../lib/board';
|
import type { BoardCell } from '../lib/board';
|
||||||
import type { Premium } from '../lib/premiums';
|
import type { Premium } from '../lib/premiums';
|
||||||
import { tileValue } from '../lib/premiums';
|
import { valueForLetter } from '../lib/alphabet';
|
||||||
import type { Variant } from '../lib/model';
|
import type { Variant } from '../lib/model';
|
||||||
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
|
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
|
||||||
import type { Locale } from '../lib/i18n/catalog';
|
import type { Locale } from '../lib/i18n/catalog';
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
>
|
>
|
||||||
{#if letter}
|
{#if letter}
|
||||||
<span class="letter">{letter}</span>
|
<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}
|
{:else if r === centre.row && c === centre.col}
|
||||||
<span class="star">★</span>
|
<span class="star">★</span>
|
||||||
{:else if bl?.kind === 'single'}
|
{:else if bl?.kind === 'single'}
|
||||||
|
|||||||
+19
-10
@@ -14,7 +14,8 @@
|
|||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
|
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
|
||||||
import { replay } from '../lib/board';
|
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 { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
|
||||||
import { shareOrDownloadGcg } from '../lib/share';
|
import { shareOrDownloadGcg } from '../lib/share';
|
||||||
import {
|
import {
|
||||||
@@ -84,7 +85,13 @@
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
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;
|
view = st;
|
||||||
moves = hist.moves;
|
moves = hist.moves;
|
||||||
placement = newPlacement(st.rack);
|
placement = newPlacement(st.rack);
|
||||||
@@ -206,7 +213,7 @@
|
|||||||
if (!sub) return;
|
if (!sub) return;
|
||||||
previewTimer = setTimeout(async () => {
|
previewTimer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
preview = await gateway.evaluate(id, sub.dir, sub.tiles);
|
preview = await gateway.evaluate(id, sub.dir, sub.tiles, variant);
|
||||||
} catch {
|
} catch {
|
||||||
/* best-effort */
|
/* best-effort */
|
||||||
}
|
}
|
||||||
@@ -218,7 +225,7 @@
|
|||||||
if (!sub) return;
|
if (!sub) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
await gateway.submitPlay(id, sub.dir, sub.tiles);
|
await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
|
||||||
zoomed = false;
|
zoomed = false;
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -298,7 +305,7 @@
|
|||||||
exchangeOpen = false;
|
exchangeOpen = false;
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
await gateway.exchange(id, tiles);
|
await gateway.exchange(id, tiles, variant);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
@@ -313,7 +320,7 @@
|
|||||||
checkOpen = true;
|
checkOpen = true;
|
||||||
}
|
}
|
||||||
function onCheckInput(e: Event) {
|
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
|
// 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.
|
// length. The input filter already restricts to the variant's alphabet.
|
||||||
@@ -326,9 +333,11 @@
|
|||||||
cooling = true;
|
cooling = true;
|
||||||
setTimeout(() => (cooling = false), 5000);
|
setTimeout(() => (cooling = false), 5000);
|
||||||
try {
|
try {
|
||||||
const r = await gateway.checkWord(id, w);
|
const r = await gateway.checkWord(id, w, variant);
|
||||||
checkedWords.set(r.word.toUpperCase(), r.legal);
|
// Key the cache and the displayed result on the upper-case word the player typed; the
|
||||||
checkResult = r;
|
// 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) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
}
|
}
|
||||||
@@ -535,7 +544,7 @@
|
|||||||
{#if blankPrompt}
|
{#if blankPrompt}
|
||||||
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
|
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
|
||||||
<div class="alpha">
|
<div class="alpha">
|
||||||
{#each alphabet(variant) as ch (ch)}
|
{#each alphabetLetters(variant) as ch (ch)}
|
||||||
<button onclick={() => chooseBlank(ch)}>{ch}</button>
|
<button onclick={() => chooseBlank(ch)}>{ch}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { RackSlot } from '../lib/placement';
|
import type { RackSlot } from '../lib/placement';
|
||||||
import { BLANK } from '../lib/placement';
|
import { BLANK } from '../lib/placement';
|
||||||
import { tileValue } from '../lib/premiums';
|
import { valueForLetter } from '../lib/alphabet';
|
||||||
import type { Variant } from '../lib/model';
|
import type { Variant } from '../lib/model';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
onpointerdown={(e) => ondown(e, slot.index)}
|
onpointerdown={(e) => ondown(e, slot.index)}
|
||||||
>
|
>
|
||||||
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
|
<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>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
export { AccountRef } from './scrabblefb/account-ref.js';
|
export { AccountRef } from './scrabblefb/account-ref.js';
|
||||||
export { Ack } from './scrabblefb/ack.js';
|
export { Ack } from './scrabblefb/ack.js';
|
||||||
|
export { AlphabetEntry } from './scrabblefb/alphabet-entry.js';
|
||||||
export { BlockList } from './scrabblefb/block-list.js';
|
export { BlockList } from './scrabblefb/block-list.js';
|
||||||
export { ChatList } from './scrabblefb/chat-list.js';
|
export { ChatList } from './scrabblefb/chat-list.js';
|
||||||
export { ChatMessage } from './scrabblefb/chat-message.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 { NotificationEvent } from './scrabblefb/notification-event.js';
|
||||||
export { NudgeEvent } from './scrabblefb/nudge-event.js';
|
export { NudgeEvent } from './scrabblefb/nudge-event.js';
|
||||||
export { OpponentMovedEvent } from './scrabblefb/opponent-moved-event.js';
|
export { OpponentMovedEvent } from './scrabblefb/opponent-moved-event.js';
|
||||||
|
export { PlayTile } from './scrabblefb/play-tile.js';
|
||||||
export { Profile } from './scrabblefb/profile.js';
|
export { Profile } from './scrabblefb/profile.js';
|
||||||
export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js';
|
export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js';
|
||||||
export { RedeemResult } from './scrabblefb/redeem-result.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;
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
word():string|null
|
word(index: number):number|null {
|
||||||
word(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
|
||||||
word(optionalEncoding?:any):string|Uint8Array|null {
|
|
||||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
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) {
|
static startCheckWordRequest(builder:flatbuffers.Builder) {
|
||||||
@@ -46,6 +54,18 @@ static addWord(builder:flatbuffers.Builder, wordOffset:flatbuffers.Offset) {
|
|||||||
builder.addFieldOffset(1, wordOffset, 0);
|
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 {
|
static endCheckWordRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as flatbuffers from 'flatbuffers';
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
import { TileRecord } from '../scrabblefb/tile-record.js';
|
import { PlayTile } from '../scrabblefb/play-tile.js';
|
||||||
|
|
||||||
|
|
||||||
export class EvalRequest {
|
export class EvalRequest {
|
||||||
@@ -37,9 +37,9 @@ dir(optionalEncoding?:any):string|Uint8Array|null {
|
|||||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : 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);
|
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 {
|
tilesLength():number {
|
||||||
|
|||||||
@@ -27,11 +27,9 @@ gameId(optionalEncoding?:any):string|Uint8Array|null {
|
|||||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
tiles(index: number):string
|
tiles(index: number):number|null {
|
||||||
tiles(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
|
|
||||||
tiles(index: number,optionalEncoding?:any):string|Uint8Array|null {
|
|
||||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
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 {
|
tilesLength():number {
|
||||||
@@ -39,6 +37,11 @@ tilesLength():number {
|
|||||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
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) {
|
static startExchangeRequest(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(2);
|
builder.startObject(2);
|
||||||
}
|
}
|
||||||
@@ -51,16 +54,16 @@ static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) {
|
|||||||
builder.addFieldOffset(1, tilesOffset, 0);
|
builder.addFieldOffset(1, tilesOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
static createTilesVector(builder:flatbuffers.Builder, data:number[]|Uint8Array):flatbuffers.Offset {
|
||||||
builder.startVector(4, data.length, 4);
|
builder.startVector(1, data.length, 1);
|
||||||
for (let i = data.length - 1; i >= 0; i--) {
|
for (let i = data.length - 1; i >= 0; i--) {
|
||||||
builder.addOffset(data[i]!);
|
builder.addInt8(data[i]!);
|
||||||
}
|
}
|
||||||
return builder.endVector();
|
return builder.endVector();
|
||||||
}
|
}
|
||||||
|
|
||||||
static startTilesVector(builder:flatbuffers.Builder, numElems:number) {
|
static startTilesVector(builder:flatbuffers.Builder, numElems:number) {
|
||||||
builder.startVector(4, numElems, 4);
|
builder.startVector(1, numElems, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
static endExchangeRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
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;
|
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) {
|
static startStateRequest(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(1);
|
builder.startObject(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
||||||
builder.addFieldOffset(0, gameIdOffset, 0);
|
builder.addFieldOffset(0, gameIdOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addIncludeAlphabet(builder:flatbuffers.Builder, includeAlphabet:boolean) {
|
||||||
|
builder.addFieldInt8(1, +includeAlphabet, +false);
|
||||||
|
}
|
||||||
|
|
||||||
static endStateRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endStateRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
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.startStateRequest(builder);
|
||||||
StateRequest.addGameId(builder, gameIdOffset);
|
StateRequest.addGameId(builder, gameIdOffset);
|
||||||
|
StateRequest.addIncludeAlphabet(builder, includeAlphabet);
|
||||||
return StateRequest.endStateRequest(builder);
|
return StateRequest.endStateRequest(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import * as flatbuffers from 'flatbuffers';
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
|
import { AlphabetEntry } from '../scrabblefb/alphabet-entry.js';
|
||||||
import { GameView } from '../scrabblefb/game-view.js';
|
import { GameView } from '../scrabblefb/game-view.js';
|
||||||
|
|
||||||
|
|
||||||
@@ -33,11 +34,9 @@ seat():number {
|
|||||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
rack(index: number):string
|
rack(index: number):number|null {
|
||||||
rack(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
|
|
||||||
rack(index: number,optionalEncoding?:any):string|Uint8Array|null {
|
|
||||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
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 {
|
rackLength():number {
|
||||||
@@ -45,6 +44,11 @@ rackLength():number {
|
|||||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
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 {
|
bagLen():number {
|
||||||
const offset = this.bb!.__offset(this.bb_pos, 10);
|
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
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;
|
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) {
|
static startStateView(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(5);
|
builder.startObject(6);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
|
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);
|
builder.addFieldOffset(2, rackOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
static createRackVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
static createRackVector(builder:flatbuffers.Builder, data:number[]|Uint8Array):flatbuffers.Offset {
|
||||||
builder.startVector(4, data.length, 4);
|
builder.startVector(1, data.length, 1);
|
||||||
for (let i = data.length - 1; i >= 0; i--) {
|
for (let i = data.length - 1; i >= 0; i--) {
|
||||||
builder.addOffset(data[i]!);
|
builder.addInt8(data[i]!);
|
||||||
}
|
}
|
||||||
return builder.endVector();
|
return builder.endVector();
|
||||||
}
|
}
|
||||||
|
|
||||||
static startRackVector(builder:flatbuffers.Builder, numElems:number) {
|
static startRackVector(builder:flatbuffers.Builder, numElems:number) {
|
||||||
builder.startVector(4, numElems, 4);
|
builder.startVector(1, numElems, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addBagLen(builder:flatbuffers.Builder, bagLen:number) {
|
static addBagLen(builder:flatbuffers.Builder, bagLen:number) {
|
||||||
@@ -91,18 +105,35 @@ static addHintsRemaining(builder:flatbuffers.Builder, hintsRemaining:number) {
|
|||||||
builder.addFieldInt32(4, hintsRemaining, 0);
|
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 {
|
static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
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.startStateView(builder);
|
||||||
StateView.addGame(builder, gameOffset);
|
StateView.addGame(builder, gameOffset);
|
||||||
StateView.addSeat(builder, seat);
|
StateView.addSeat(builder, seat);
|
||||||
StateView.addRack(builder, rackOffset);
|
StateView.addRack(builder, rackOffset);
|
||||||
StateView.addBagLen(builder, bagLen);
|
StateView.addBagLen(builder, bagLen);
|
||||||
StateView.addHintsRemaining(builder, hintsRemaining);
|
StateView.addHintsRemaining(builder, hintsRemaining);
|
||||||
|
StateView.addAlphabet(builder, alphabetOffset);
|
||||||
return StateView.endStateView(builder);
|
return StateView.endStateView(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as flatbuffers from 'flatbuffers';
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
import { TileRecord } from '../scrabblefb/tile-record.js';
|
import { PlayTile } from '../scrabblefb/play-tile.js';
|
||||||
|
|
||||||
|
|
||||||
export class SubmitPlayRequest {
|
export class SubmitPlayRequest {
|
||||||
@@ -37,9 +37,9 @@ dir(optionalEncoding?:any):string|Uint8Array|null {
|
|||||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : 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);
|
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 {
|
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>;
|
lobbyPoll(): Promise<MatchResult>;
|
||||||
|
|
||||||
// --- game ---
|
// --- 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>;
|
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>;
|
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>;
|
resign(gameId: string): Promise<MoveResult>;
|
||||||
hint(gameId: string): Promise<HintResult>;
|
hint(gameId: string): Promise<HintResult>;
|
||||||
evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise<EvalResult>;
|
evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise<EvalResult>;
|
||||||
checkWord(gameId: string, word: string): Promise<WordCheckResult>;
|
checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
|
||||||
complaint(gameId: string, word: string, note: string): Promise<void>;
|
complaint(gameId: string, word: string, note: string): Promise<void>;
|
||||||
|
|
||||||
// --- chat ---
|
// --- chat ---
|
||||||
|
|||||||
@@ -1,28 +1,44 @@
|
|||||||
import { Builder, ByteBuffer } from 'flatbuffers';
|
import { Builder, ByteBuffer } from 'flatbuffers';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import * as fb from '../gen/fbs/scrabblefb';
|
import * as fb from '../gen/fbs/scrabblefb';
|
||||||
|
import { BLANK_INDEX, setAlphabet } from './alphabet';
|
||||||
import {
|
import {
|
||||||
decodeFriendList,
|
decodeFriendList,
|
||||||
decodeGameList,
|
decodeGameList,
|
||||||
decodeInvitation,
|
decodeInvitation,
|
||||||
decodeLinkResult,
|
decodeLinkResult,
|
||||||
decodeSession,
|
decodeSession,
|
||||||
|
decodeStateView,
|
||||||
decodeStats,
|
decodeStats,
|
||||||
|
encodeCheckWord,
|
||||||
|
encodeExchange,
|
||||||
|
encodeStateRequest,
|
||||||
encodeSubmitPlay,
|
encodeSubmitPlay,
|
||||||
encodeTarget,
|
encodeTarget,
|
||||||
} from './codec';
|
} from './codec';
|
||||||
|
|
||||||
describe('codec', () => {
|
describe('codec', () => {
|
||||||
it('encodes a SubmitPlayRequest the gateway can read', () => {
|
it('encodes a SubmitPlayRequest with alphabet indices (Stage 13)', () => {
|
||||||
const buf = encodeSubmitPlay('g1', 'H', [
|
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: 7, letter: 'A', blank: false },
|
||||||
{ row: 7, col: 8, letter: 'B', blank: true },
|
{ row: 7, col: 8, letter: 'B', blank: true },
|
||||||
]);
|
],
|
||||||
|
'english',
|
||||||
|
);
|
||||||
const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf));
|
const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf));
|
||||||
expect(r.gameId()).toBe('g1');
|
expect(r.gameId()).toBe('g1');
|
||||||
expect(r.dir()).toBe('H');
|
expect(r.dir()).toBe('H');
|
||||||
expect(r.tilesLength()).toBe(2);
|
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);
|
expect(r.tiles(1)?.blank()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,3 +230,72 @@ describe('codec', () => {
|
|||||||
expect(inv.variant).toBe('english');
|
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 { Builder, ByteBuffer, type Offset } from 'flatbuffers';
|
||||||
import * as fb from '../gen/fbs/scrabblefb';
|
import * as fb from '../gen/fbs/scrabblefb';
|
||||||
|
import { indexForLetter, letterForIndex, setAlphabet, type AlphabetEntryWire } from './alphabet';
|
||||||
import type { PlacedTile } from './client';
|
import type { PlacedTile } from './client';
|
||||||
import type {
|
import type {
|
||||||
AccountRef,
|
AccountRef,
|
||||||
@@ -37,14 +38,15 @@ import type {
|
|||||||
|
|
||||||
// --- request encoders ---
|
// --- request encoders ---
|
||||||
|
|
||||||
function buildTile(b: Builder, t: PlacedTile): Offset {
|
// buildPlayTile encodes one to-place tile by its alphabet index (Stage 13); a placed blank
|
||||||
const letter = b.createString(t.letter);
|
// carries its designated letter's index with blank set.
|
||||||
fb.TileRecord.startTileRecord(b);
|
function buildPlayTile(b: Builder, t: PlacedTile, variant: Variant): Offset {
|
||||||
fb.TileRecord.addRow(b, t.row);
|
fb.PlayTile.startPlayTile(b);
|
||||||
fb.TileRecord.addCol(b, t.col);
|
fb.PlayTile.addRow(b, t.row);
|
||||||
fb.TileRecord.addLetter(b, letter);
|
fb.PlayTile.addCol(b, t.col);
|
||||||
fb.TileRecord.addBlank(b, t.blank);
|
fb.PlayTile.addLetter(b, indexForLetter(variant, t.letter));
|
||||||
return fb.TileRecord.endTileRecord(b);
|
fb.PlayTile.addBlank(b, t.blank);
|
||||||
|
return fb.PlayTile.endPlayTile(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
function finish(b: Builder, root: Offset): Uint8Array {
|
function finish(b: Builder, root: Offset): Uint8Array {
|
||||||
@@ -62,17 +64,23 @@ export function encodeGameAction(gameId: string): Uint8Array {
|
|||||||
return finish(b, fb.GameActionRequest.endGameActionRequest(b));
|
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 b = new Builder(64);
|
||||||
const gid = b.createString(gameId);
|
const gid = b.createString(gameId);
|
||||||
fb.StateRequest.startStateRequest(b);
|
fb.StateRequest.startStateRequest(b);
|
||||||
fb.StateRequest.addGameId(b, gid);
|
fb.StateRequest.addGameId(b, gid);
|
||||||
|
fb.StateRequest.addIncludeAlphabet(b, includeAlphabet);
|
||||||
return finish(b, fb.StateRequest.endStateRequest(b));
|
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 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 vec = fb.SubmitPlayRequest.createTilesVector(b, tileOffs);
|
||||||
const gid = b.createString(gameId);
|
const gid = b.createString(gameId);
|
||||||
const d = b.createString(dir);
|
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));
|
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 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 vec = fb.EvalRequest.createTilesVector(b, tileOffs);
|
||||||
const gid = b.createString(gameId);
|
const gid = b.createString(gameId);
|
||||||
const d = b.createString(dir);
|
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));
|
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 b = new Builder(128);
|
||||||
const offs = tiles.map((s) => b.createString(s));
|
const vec = fb.ExchangeRequest.createTilesVector(
|
||||||
const vec = fb.ExchangeRequest.createTilesVector(b, offs);
|
b,
|
||||||
|
tiles.map((l) => indexForLetter(variant, l)),
|
||||||
|
);
|
||||||
const gid = b.createString(gameId);
|
const gid = b.createString(gameId);
|
||||||
fb.ExchangeRequest.startExchangeRequest(b);
|
fb.ExchangeRequest.startExchangeRequest(b);
|
||||||
fb.ExchangeRequest.addGameId(b, gid);
|
fb.ExchangeRequest.addGameId(b, gid);
|
||||||
@@ -107,13 +122,16 @@ export function encodeExchange(gameId: string, tiles: string[]): Uint8Array {
|
|||||||
return finish(b, fb.ExchangeRequest.endExchangeRequest(b));
|
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 b = new Builder(128);
|
||||||
|
const vec = fb.CheckWordRequest.createWordVector(
|
||||||
|
b,
|
||||||
|
Array.from(word).map((ch) => indexForLetter(variant, ch)),
|
||||||
|
);
|
||||||
const gid = b.createString(gameId);
|
const gid = b.createString(gameId);
|
||||||
const w = b.createString(word);
|
|
||||||
fb.CheckWordRequest.startCheckWordRequest(b);
|
fb.CheckWordRequest.startCheckWordRequest(b);
|
||||||
fb.CheckWordRequest.addGameId(b, gid);
|
fb.CheckWordRequest.addGameId(b, gid);
|
||||||
fb.CheckWordRequest.addWord(b, w);
|
fb.CheckWordRequest.addWord(b, vec);
|
||||||
return finish(b, fb.CheckWordRequest.endCheckWordRequest(b));
|
return finish(b, fb.CheckWordRequest.endCheckWordRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +206,8 @@ function s(v: string | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function decodeTile(t: fb.TileRecord): Tile {
|
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 {
|
function decodeSeat(v: fb.SeatView): Seat {
|
||||||
@@ -229,7 +248,7 @@ function decodeMove(m: fb.MoveRecord): MoveRecord {
|
|||||||
if (t) tiles.push(decodeTile(t));
|
if (t) tiles.push(decodeTile(t));
|
||||||
}
|
}
|
||||||
const words: string[] = [];
|
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 {
|
return {
|
||||||
player: m.player(),
|
player: m.player(),
|
||||||
action: s(m.action()),
|
action: s(m.action()),
|
||||||
@@ -280,8 +299,19 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
|||||||
export function decodeStateView(buf: Uint8Array): StateView {
|
export function decodeStateView(buf: Uint8Array): StateView {
|
||||||
const v = fb.StateView.getRootAsStateView(new ByteBuffer(buf));
|
const v = fb.StateView.getRootAsStateView(new ByteBuffer(buf));
|
||||||
const g = v.game();
|
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[] = [];
|
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 {
|
return {
|
||||||
game: g ? decodeGameView(g) : emptyGame(),
|
game: g ? decodeGameView(g) : emptyGame(),
|
||||||
seat: v.seat(),
|
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,
|
Variant,
|
||||||
WordCheckResult,
|
WordCheckResult,
|
||||||
} from '../model';
|
} from '../model';
|
||||||
import { tileValue } from '../premiums';
|
import { valueForLetter } from '../alphabet';
|
||||||
|
import { seedMockAlphabets } from './alphabet';
|
||||||
import {
|
import {
|
||||||
ME,
|
ME,
|
||||||
MOCK_FRIENDS,
|
MOCK_FRIENDS,
|
||||||
@@ -93,6 +94,12 @@ export class MockGateway implements GatewayClient {
|
|||||||
private invitations: Invitation[] = mockInvitations();
|
private invitations: Invitation[] = mockInvitations();
|
||||||
private readonly stats: Stats = { ...MOCK_STATS };
|
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 {
|
setToken(_token: string | null): void {
|
||||||
// The mock needs no auth; the real transport stores the bearer token.
|
// The mock needs no auth; the real transport stores the bearer token.
|
||||||
}
|
}
|
||||||
@@ -174,7 +181,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- game ---
|
// --- game ---
|
||||||
async gameState(gameId: string): Promise<StateView> {
|
async gameState(gameId: string, _includeAlphabet: boolean): Promise<StateView> {
|
||||||
const g = this.game(gameId);
|
const g = this.game(gameId);
|
||||||
return {
|
return {
|
||||||
game: structuredClone(g.view),
|
game: structuredClone(g.view),
|
||||||
@@ -190,12 +197,12 @@ export class MockGateway implements GatewayClient {
|
|||||||
return { gameId, moves: structuredClone(g.moves) };
|
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 g = this.game(gameId);
|
||||||
const seat = this.mySeat(g);
|
const seat = this.mySeat(g);
|
||||||
if (g.view.toMove !== seat) throw new GatewayError('not_your_turn');
|
if (g.view.toMove !== seat) throw new GatewayError('not_your_turn');
|
||||||
const variant = g.view.variant;
|
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;
|
if (tiles.length === 7) score += 50;
|
||||||
const total = g.view.seats[seat].score + score;
|
const total = g.view.seats[seat].score + score;
|
||||||
const move = {
|
const move = {
|
||||||
@@ -265,7 +272,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
pass(gameId: string): Promise<MoveResult> {
|
pass(gameId: string): Promise<MoveResult> {
|
||||||
return this.simpleAction(gameId, 'pass');
|
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);
|
return this.simpleAction(gameId, 'exchange', tiles);
|
||||||
}
|
}
|
||||||
resign(gameId: string): Promise<MoveResult> {
|
resign(gameId: string): Promise<MoveResult> {
|
||||||
@@ -287,22 +294,22 @@ export class MockGateway implements GatewayClient {
|
|||||||
tiles: [{ row: 7, col: 7, letter, blank: false }],
|
tiles: [{ row: 7, col: 7, letter, blank: false }],
|
||||||
words: [letter],
|
words: [letter],
|
||||||
count: 1,
|
count: 1,
|
||||||
score: tileValue(g.view.variant, letter),
|
score: valueForLetter(g.view.variant, letter),
|
||||||
total: 0,
|
total: 0,
|
||||||
},
|
},
|
||||||
hintsRemaining: g.hintsRemaining,
|
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);
|
const g = this.game(gameId);
|
||||||
if (tiles.length === 0) return { legal: false, score: 0, words: [] };
|
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;
|
if (tiles.length === 7) score += 50;
|
||||||
return { legal: true, score, words: [tiles.map((t) => t.letter).join('')] };
|
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 };
|
return { word, legal: word.trim().length >= 2 };
|
||||||
}
|
}
|
||||||
async complaint(): Promise<void> {}
|
async complaint(): Promise<void> {}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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
|
// Premium-square geometry parity with scrabble-solver/rules/rules.go: english/russian
|
||||||
// (centre is a double word); erudit shares the geometry but a non-doubling centre.
|
// 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', () => {
|
describe('premium layout', () => {
|
||||||
it('is a 15x15 grid with TW corners', () => {
|
it('is a 15x15 grid with TW corners', () => {
|
||||||
const g = premiumGrid('english');
|
const g = premiumGrid('english');
|
||||||
@@ -35,19 +37,3 @@ describe('premium layout', () => {
|
|||||||
expect(count('DW')).toBe(17); // 16 double-word squares + the centre
|
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
|
// Board premium layout — the 15x15 premium-square geometry, ported from the engine source
|
||||||
// truth, scrabble-solver/rules/rules.go (standardBoard / eruditBoard, and the
|
// of truth, scrabble-solver/rules/rules.go (standardBoard / eruditBoard). The board is not
|
||||||
// per-variant value tables). These are NOT transmitted on the wire (StateView has
|
// transmitted on the wire (StateView has no board), so the client renders the premiums
|
||||||
// no board), so the client renders them locally. A Vitest parity test pins the
|
// locally; only the centre differs by variant. A Vitest parity test pins the geometry.
|
||||||
// layout against the known geometry. Keep this in lockstep with the solver.
|
// 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';
|
import type { Variant } from './model';
|
||||||
|
|
||||||
@@ -84,43 +85,5 @@ export function centre(variant: Variant): { row: number; col: number } {
|
|||||||
return { row: 7, col: 7 };
|
return { row: 7, col: 7 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- tile values (points shown on the tile face); blank scores 0 ---
|
// 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.
|
||||||
// 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()];
|
|
||||||
}
|
|
||||||
|
|||||||
+10
-10
@@ -81,20 +81,20 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
return codec.decodeMatchResult(await exec('lobby.poll', codec.empty()));
|
return codec.decodeMatchResult(await exec('lobby.poll', codec.empty()));
|
||||||
},
|
},
|
||||||
|
|
||||||
async gameState(id) {
|
async gameState(id, includeAlphabet) {
|
||||||
return codec.decodeStateView(await exec('game.state', codec.encodeStateRequest(id)));
|
return codec.decodeStateView(await exec('game.state', codec.encodeStateRequest(id, includeAlphabet)));
|
||||||
},
|
},
|
||||||
async gameHistory(id) {
|
async gameHistory(id) {
|
||||||
return codec.decodeHistory(await exec('game.history', codec.encodeGameAction(id)));
|
return codec.decodeHistory(await exec('game.history', codec.encodeGameAction(id)));
|
||||||
},
|
},
|
||||||
async submitPlay(id, dir, tiles) {
|
async submitPlay(id, dir, tiles, variant) {
|
||||||
return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, dir, tiles)));
|
return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, dir, tiles, variant)));
|
||||||
},
|
},
|
||||||
async pass(id) {
|
async pass(id) {
|
||||||
return codec.decodeMoveResult(await exec('game.pass', codec.encodeGameAction(id)));
|
return codec.decodeMoveResult(await exec('game.pass', codec.encodeGameAction(id)));
|
||||||
},
|
},
|
||||||
async exchange(id, tiles) {
|
async exchange(id, tiles, variant) {
|
||||||
return codec.decodeMoveResult(await exec('game.exchange', codec.encodeExchange(id, tiles)));
|
return codec.decodeMoveResult(await exec('game.exchange', codec.encodeExchange(id, tiles, variant)));
|
||||||
},
|
},
|
||||||
async resign(id) {
|
async resign(id) {
|
||||||
return codec.decodeMoveResult(await exec('game.resign', codec.encodeGameAction(id)));
|
return codec.decodeMoveResult(await exec('game.resign', codec.encodeGameAction(id)));
|
||||||
@@ -102,11 +102,11 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
async hint(id) {
|
async hint(id) {
|
||||||
return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id)));
|
return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id)));
|
||||||
},
|
},
|
||||||
async evaluate(id, dir, tiles) {
|
async evaluate(id, dir, tiles, variant) {
|
||||||
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, dir, tiles)));
|
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, dir, tiles, variant)));
|
||||||
},
|
},
|
||||||
async checkWord(id, word) {
|
async checkWord(id, word, variant) {
|
||||||
return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word)));
|
return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word, variant)));
|
||||||
},
|
},
|
||||||
async complaint(id, word, note) {
|
async complaint(id, word, note) {
|
||||||
await exec('game.complaint', codec.encodeComplaint(id, word, note));
|
await exec('game.complaint', codec.encodeComplaint(id, word, note));
|
||||||
|
|||||||
Reference in New Issue
Block a user