Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 780ff68ec2 | |||
| ca48b3e18e | |||
| a7f483792f | |||
| 80cc2a76e8 | |||
| 1e087be90a | |||
| 84d0385c95 | |||
| 0ed34c7720 | |||
| 52d0c559f9 | |||
| beda6ccd3d | |||
| c1ac77bc6a | |||
| cafe67e9c8 | |||
| 3adc4e32c9 | |||
| 903de7d41d | |||
| baa9961c3e | |||
| 66278e34b7 | |||
| 452a686e07 | |||
| 4ddc690c38 | |||
| 6216359c6f | |||
| 8c67d679d9 | |||
| 69673e7727 | |||
| d7f3d93c6c | |||
| 16cd3d0411 | |||
| ef8a32bb82 | |||
| 53311cbc95 | |||
| 7fc1301b31 | |||
| ee7ff06403 | |||
| 537a265409 | |||
| 8c991429c6 | |||
| 359af83c34 | |||
| ff486c80f8 | |||
| 42a6308261 | |||
| 08792dad3d | |||
| e9f4cb0178 | |||
| 8fbbb3c5ef | |||
| 30770a759b | |||
| 05c445e4da | |||
| d43a740eb6 | |||
| 350013acd9 | |||
| 3ad66f49c7 | |||
| 09c5a5e72e | |||
| fffc6030ce | |||
| fc8143758a | |||
| e0d28733ff | |||
| ccd65f61b8 | |||
| 5bc2ad3b6d | |||
| 2a4b0fb25e | |||
| 2a7c632840 | |||
| 3a72bc29ba | |||
| 020742fad3 | |||
| fd225564a3 | |||
| fdf14d5897 | |||
| 1a456c4847 | |||
| 2a045a5b37 | |||
| 19e7ea5da0 | |||
| 54d701fd8a | |||
| ec0c13bebc | |||
| 5643c8be10 | |||
| 2f70ef1b85 | |||
| ef832b823d | |||
| 99f0a545be | |||
| 1a95a5f2cb | |||
| 8abd8b2ae6 | |||
| 2f867b8e6c | |||
| a692024b4e | |||
| 8349e222fc | |||
| 68ecd881d9 | |||
| d80d28a402 | |||
| 1654131904 | |||
| 9ecbe480db | |||
| 32298595f2 | |||
| 2847412b5b | |||
| 4fa77bf82c | |||
| afa44d41b4 | |||
| e4cf143e9f | |||
| 543cbe56b9 | |||
| a9c8f1ecfe | |||
| d694ead7b6 | |||
| 8c5995c076 | |||
| cedc9ffae1 | |||
| c334a9d7b7 |
@@ -214,9 +214,21 @@ jobs:
|
|||||||
run: pnpm exec playwright install chromium webkit
|
run: pnpm exec playwright install chromium webkit
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
# The offline e2e plays a real local vs_ai game, so it needs the per-variant dawgs; fetch the
|
||||||
|
# release the same way the Go jobs do and point the mock preview's copy step (scripts/
|
||||||
|
# e2e-dict.mjs, via E2E_DICT_DIR below) at it.
|
||||||
|
- name: Fetch dictionary DAWGs
|
||||||
|
run: |
|
||||||
|
mkdir -p "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
|
||||||
|
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
ls -la "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
|
||||||
- name: E2E smoke (mock)
|
- name: E2E smoke (mock)
|
||||||
run: pnpm run test:e2e
|
run: pnpm run test:e2e
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
|
env:
|
||||||
|
E2E_DICT_DIR: ${{ github.workspace }}/dawg
|
||||||
|
|
||||||
# conformance proves the client's local move preview (the ported dawg reader +
|
# conformance proves the client's local move preview (the ported dawg reader +
|
||||||
# validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine:
|
# validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine:
|
||||||
@@ -252,6 +264,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold
|
go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold
|
||||||
go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold
|
go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold
|
||||||
|
go run ./backend/cmd/movegen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out "${GITHUB_WORKSPACE}/movegold"
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -271,6 +284,7 @@ jobs:
|
|||||||
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
|
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
|
||||||
DICT_GOLD_DIR: /tmp/dictgold
|
DICT_GOLD_DIR: /tmp/dictgold
|
||||||
DICT_VALID_DIR: /tmp/validgold
|
DICT_VALID_DIR: /tmp/validgold
|
||||||
|
DICT_MOVEGEN_DIR: ${{ github.workspace }}/movegold
|
||||||
run: pnpm exec vitest run src/lib/dict/
|
run: pnpm exec vitest run src/lib/dict/
|
||||||
|
|
||||||
# gate is the single branch-protection required check. It always runs and passes
|
# gate is the single branch-protection required check. It always runs and passes
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
// Command movegen emits golden conformance fixtures for the client-side move
|
||||||
|
// generator port (ui/src/lib/dict). It is a dev tool, run by hand; its output is
|
||||||
|
// committed so the TypeScript parity tests run without a Go toolchain.
|
||||||
|
//
|
||||||
|
// For each small sample dictionary (English and Russian — the latter reaches
|
||||||
|
// alphabet index 32, exercising the 33-letter cross-set boundary) it writes:
|
||||||
|
//
|
||||||
|
// - sample_<tag>.dawg the serialized dictionary (the reader/cursor fixture)
|
||||||
|
// - sample_<tag>.words.json the stored words + their alphabet indexes
|
||||||
|
// - sample_<tag>.gen.json ranked move-generation results from the real solver,
|
||||||
|
// for a handful of positions, plus the ruleset the TS
|
||||||
|
// side rebuilds to score identically
|
||||||
|
//
|
||||||
|
// Positions are built with only the solver's public API: an empty board, and
|
||||||
|
// two-ply positions reached by applying the solver's own top move (so no internal
|
||||||
|
// encoding is needed). Regenerate with:
|
||||||
|
//
|
||||||
|
// go run ./backend/cmd/movegen -out ui/src/lib/dict/testdata
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||||
|
dawg "github.com/iliadenisov/dafsa"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sampleWordsEN is the English sample dictionary, in strictly increasing
|
||||||
|
// alphabet-index order (the builder requires it). Shared prefixes (car/care/cars),
|
||||||
|
// shared suffixes (cats/dogs), internal-final nodes (do, an) and a one-letter word.
|
||||||
|
var sampleWordsEN = []string{
|
||||||
|
"a", "an", "and", "ant",
|
||||||
|
"car", "care", "cared", "cares", "cars", "cat", "cats",
|
||||||
|
"do", "doe", "does", "dog", "dogs", "done", "dot",
|
||||||
|
}
|
||||||
|
|
||||||
|
// sampleWordsRU is the Russian sample dictionary, in strictly increasing index
|
||||||
|
// order. It deliberately includes words starting with я (index 32) so the ported
|
||||||
|
// cross-set handles alphabet indexes past JS's 31-bit shift boundary.
|
||||||
|
var sampleWordsRU = []string{"ад", "ар", "оса", "я", "яд", "яр"}
|
||||||
|
|
||||||
|
// sampleFixture is the JSON committed with the .dawg so the TypeScript cursor test
|
||||||
|
// knows the exact word set (as alphabet indexes) to expect from enumeration.
|
||||||
|
type sampleFixture struct {
|
||||||
|
Alphabet string `json:"alphabet"`
|
||||||
|
NumAdded int `json:"numAdded"`
|
||||||
|
Words []string `json:"words"`
|
||||||
|
Indexes [][]int `json:"indexes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// genFixture is the move-generation golden set for one sample dictionary.
|
||||||
|
type genFixture struct {
|
||||||
|
Ruleset genRuleset `json:"ruleset"`
|
||||||
|
Cases []genCase `json:"cases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// genRuleset is the scoring data the TS side rebuilds so evaluate() matches the Go
|
||||||
|
// solver: letter values, premium multipliers per square, the centre, rack size and bonus.
|
||||||
|
type genRuleset struct {
|
||||||
|
Size int `json:"size"`
|
||||||
|
Cols int `json:"cols"`
|
||||||
|
Center int `json:"center"`
|
||||||
|
RackSize int `json:"rackSize"`
|
||||||
|
Bingo int `json:"bingo"`
|
||||||
|
Values []int `json:"values"`
|
||||||
|
LetterMult [][]int `json:"letterMult"`
|
||||||
|
WordMult [][]int `json:"wordMult"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// genTile is one placed tile (a board tile or a move placement).
|
||||||
|
type genTile struct {
|
||||||
|
Row int `json:"row"`
|
||||||
|
Col int `json:"col"`
|
||||||
|
Letter int `json:"letter"`
|
||||||
|
Blank bool `json:"blank"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// genRack is a rack as a multiset of letter indexes plus a blank count.
|
||||||
|
type genRack struct {
|
||||||
|
Letters []int `json:"letters"`
|
||||||
|
Blanks int `json:"blanks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// genMove is one ranked generated play: its orientation, placed tiles and total score.
|
||||||
|
type genMove struct {
|
||||||
|
Dir int `json:"dir"`
|
||||||
|
Tiles []genTile `json:"tiles"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// genCase is one generation position: the tiles already on the board (empty when
|
||||||
|
// none), the rack, the mode/rule and the ranked moves the solver returns.
|
||||||
|
type genCase struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Placed []genTile `json:"placed"`
|
||||||
|
Rack genRack `json:"rack"`
|
||||||
|
Mode int `json:"mode"`
|
||||||
|
IgnoreCrossWords bool `json:"ignoreCrossWords"`
|
||||||
|
Moves []genMove `json:"moves"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
out := flag.String("out", "ui/src/lib/dict/testdata", "output directory for fixtures")
|
||||||
|
dawgDir := flag.String("dawg-dir", "", "when set, emit real-dictionary move-gen golden from the .dawg files in this dir (conformance mode) instead of the committed samples")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(*out, 0o755); err != nil {
|
||||||
|
log.Fatalf("movegen: mkdir %s: %v", *out, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *dawgDir != "" {
|
||||||
|
buildReal(*dawgDir, *out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emitRulesets()
|
||||||
|
|
||||||
|
buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{
|
||||||
|
emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false),
|
||||||
|
emptyCase("empty-dogs", englishRack("dogsent", 0), scrabble.Both, false),
|
||||||
|
emptyCase("empty-blank", englishRack("caret", 1), scrabble.Both, false),
|
||||||
|
emptyCase("empty-single-word", englishRack("caredts", 0), scrabble.Both, true),
|
||||||
|
})
|
||||||
|
|
||||||
|
buildSample(*out, "ru", rules.RussianScrabble(), sampleWordsRU, []genCase{
|
||||||
|
emptyCase("empty-yad", russianRack("ядрасо", 0), scrabble.Both, false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSample writes the dawg, the word fixture and the generation golden set for
|
||||||
|
// one sample dictionary. Two-ply cases are appended: the solver's own top move from
|
||||||
|
// the first non-empty result is applied, then generation runs again on the new rack.
|
||||||
|
func buildSample(out, tag string, rs *rules.Ruleset, words []string, cases []genCase) {
|
||||||
|
idx := rs.Alphabet
|
||||||
|
b := dawg.New(idx)
|
||||||
|
indexes := make([][]int, 0, len(words))
|
||||||
|
for _, w := range words {
|
||||||
|
if err := b.Add(w); err != nil {
|
||||||
|
log.Fatalf("movegen[%s]: add %q: %v", tag, w, err)
|
||||||
|
}
|
||||||
|
enc, err := idx.Encode(w)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("movegen[%s]: encode %q: %v", tag, w, err)
|
||||||
|
}
|
||||||
|
ints := make([]int, len(enc))
|
||||||
|
for i, x := range enc {
|
||||||
|
ints[i] = int(x)
|
||||||
|
}
|
||||||
|
indexes = append(indexes, ints)
|
||||||
|
}
|
||||||
|
finder := b.Finish()
|
||||||
|
|
||||||
|
writeJSON(filepath.Join(out, "sample_"+tag+".words.json"), sampleFixture{
|
||||||
|
Alphabet: tag, NumAdded: finder.NumAdded(), Words: words, Indexes: indexes,
|
||||||
|
})
|
||||||
|
dawgPath := filepath.Join(out, "sample_"+tag+".dawg")
|
||||||
|
if _, err := finder.Save(dawgPath); err != nil {
|
||||||
|
log.Fatalf("movegen[%s]: save %s: %v", tag, dawgPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := scrabble.NewSolver(rs, finder)
|
||||||
|
for i := range cases {
|
||||||
|
runCase(s, rs, &cases[i], nil)
|
||||||
|
}
|
||||||
|
// A two-ply position from the first standard case that produced a move.
|
||||||
|
if two := twoPly(s, rs, cases); two != nil {
|
||||||
|
cases = append(cases, *two)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(filepath.Join(out, "sample_"+tag+".gen.json"), genFixture{
|
||||||
|
Ruleset: rulesetOf(rs), Cases: cases,
|
||||||
|
})
|
||||||
|
log.Printf("movegen[%s]: %d words, %d cases", tag, finder.NumAdded(), len(cases))
|
||||||
|
}
|
||||||
|
|
||||||
|
// realVariant maps a shipped dictionary file to the ruleset that scores it and the racks
|
||||||
|
// the conformance positions use. smallRack/blankRack keep the first-move (empty board)
|
||||||
|
// lists bounded on a dense dictionary; fullRack drives a deep 7-tile mid-game position,
|
||||||
|
// kept small by the anchors around the already-placed word.
|
||||||
|
type realVariant struct {
|
||||||
|
file, variant, smallRack, blankRack, fullRack string
|
||||||
|
rs *rules.Ruleset
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildReal emits move-generation golden from the real shipped dictionaries in dawgDir —
|
||||||
|
// the full alphabets and deep graphs the tiny samples cannot reach — one
|
||||||
|
// <variant>.movegen.json per variant. Like the dictgen/validategen vectors it is
|
||||||
|
// regenerated in CI and never committed, so it pins no dictionary version into the repo.
|
||||||
|
func buildReal(dawgDir, out string) {
|
||||||
|
reals := []realVariant{
|
||||||
|
{"en_sowpods", "scrabble_en", "aine", "ain", "aeinrst", rules.English()},
|
||||||
|
{"ru_scrabble", "scrabble_ru", "аеин", "аен", "аеиноср", rules.RussianScrabble()},
|
||||||
|
{"ru_erudit", "erudit_ru", "аеин", "аен", "аеиноср", rules.Erudit()},
|
||||||
|
}
|
||||||
|
for _, v := range reals {
|
||||||
|
data, err := os.ReadFile(filepath.Join(dawgDir, v.file+".dawg"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("movegen[%s]: read dawg: %v", v.variant, err)
|
||||||
|
}
|
||||||
|
finder, err := dawg.Read(bytes.NewReader(data), 0)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("movegen[%s]: parse dawg: %v", v.variant, err)
|
||||||
|
}
|
||||||
|
s := scrabble.NewSolver(v.rs, finder)
|
||||||
|
|
||||||
|
cases := []genCase{
|
||||||
|
emptyCase("first-move", encRack(v.rs, v.smallRack, 0), scrabble.Both, false),
|
||||||
|
emptyCase("first-move-blank", encRack(v.rs, v.blankRack, 1), scrabble.Both, false),
|
||||||
|
}
|
||||||
|
for i := range cases {
|
||||||
|
runCase(s, v.rs, &cases[i], nil)
|
||||||
|
}
|
||||||
|
// A deep 7-tile mid-game: place the top first move, then generate again. The
|
||||||
|
// anchors around the placed word bound the list while still exercising a full rack,
|
||||||
|
// deep left/right extension and wide cross-sets over the real graph.
|
||||||
|
full := encRack(v.rs, v.fullRack, 0)
|
||||||
|
b := board.New(v.rs.Rows, v.rs.Cols)
|
||||||
|
if m1 := s.GenerateMovesOpts(b, toRack(v.rs.Size(), full), scrabble.Both, scrabble.PlayOptions{}); len(m1) > 0 {
|
||||||
|
mid := genCase{Name: "mid-game", Rack: full, Mode: int(scrabble.Both)}
|
||||||
|
runCase(s, v.rs, &mid, tilesOf(m1[0].Tiles))
|
||||||
|
cases = append(cases, mid)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(filepath.Join(out, v.variant+".movegen.json"), genFixture{Ruleset: rulesetOf(v.rs), Cases: cases})
|
||||||
|
total := 0
|
||||||
|
for _, c := range cases {
|
||||||
|
total += len(c.Moves)
|
||||||
|
}
|
||||||
|
_ = finder.Close()
|
||||||
|
log.Printf("movegen[%s]: %d cases, %d golden moves", v.variant, len(cases), total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// encRack encodes a rack given as the variant's letters (plus a blank count) into the
|
||||||
|
// index-based genRack the fixtures carry.
|
||||||
|
func encRack(rs *rules.Ruleset, letters string, blanks int) genRack {
|
||||||
|
enc, err := rs.Alphabet.Encode(letters)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("movegen: encode rack %q: %v", letters, err)
|
||||||
|
}
|
||||||
|
idx := make([]int, len(enc))
|
||||||
|
for i, b := range enc {
|
||||||
|
idx[i] = int(b)
|
||||||
|
}
|
||||||
|
return genRack{Letters: idx, Blanks: blanks}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCase fills a case's Moves by generating on a board holding the given placed
|
||||||
|
// tiles (nil = empty board).
|
||||||
|
func runCase(s *scrabble.Solver, rs *rules.Ruleset, c *genCase, placed []genTile) {
|
||||||
|
bd := board.New(rs.Rows, rs.Cols)
|
||||||
|
for _, t := range placed {
|
||||||
|
bd.Set(t.Row, t.Col, cellByte(t.Letter, t.Blank))
|
||||||
|
}
|
||||||
|
c.Placed = placed
|
||||||
|
rk := toRack(rs.Size(), c.Rack)
|
||||||
|
moves := s.GenerateMovesOpts(bd, rk, scrabble.Mode(c.Mode), scrabble.PlayOptions{IgnoreCrossWords: c.IgnoreCrossWords})
|
||||||
|
c.Moves = movesOf(moves)
|
||||||
|
}
|
||||||
|
|
||||||
|
// twoPly reaches a mid-game position by applying the top move of the first standard
|
||||||
|
// case that generated one, then generates again with a fresh rack of the same tiles.
|
||||||
|
func twoPly(s *scrabble.Solver, rs *rules.Ruleset, cases []genCase) *genCase {
|
||||||
|
for _, c := range cases {
|
||||||
|
if c.IgnoreCrossWords || len(c.Moves) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
placed := c.Moves[0].Tiles
|
||||||
|
next := genCase{Name: "two-ply", Rack: c.Rack, Mode: int(scrabble.Both)}
|
||||||
|
runCase(s, rs, &next, placed)
|
||||||
|
return &next
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cellByte encodes a board cell the way internal/encoding.Cell does (bits 0-5 hold
|
||||||
|
// letter+1, bit 7 marks a blank). Duplicated here because that package is internal
|
||||||
|
// to the solver module and cannot be imported.
|
||||||
|
func cellByte(letter int, blank bool) byte {
|
||||||
|
v := byte(letter+1) & 0x3f
|
||||||
|
if blank {
|
||||||
|
v |= 0x80
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func toRack(size int, r genRack) rack.Rack {
|
||||||
|
rk := rack.New(size)
|
||||||
|
for _, l := range r.Letters {
|
||||||
|
rk.Add(byte(l))
|
||||||
|
}
|
||||||
|
for i := 0; i < r.Blanks; i++ {
|
||||||
|
rk.AddBlank()
|
||||||
|
}
|
||||||
|
return rk
|
||||||
|
}
|
||||||
|
|
||||||
|
func rulesetOf(rs *rules.Ruleset) genRuleset {
|
||||||
|
lm := make([][]int, rs.Rows)
|
||||||
|
wm := make([][]int, rs.Rows)
|
||||||
|
for r := 0; r < rs.Rows; r++ {
|
||||||
|
lm[r] = make([]int, rs.Cols)
|
||||||
|
wm[r] = make([]int, rs.Cols)
|
||||||
|
for c := 0; c < rs.Cols; c++ {
|
||||||
|
p := rs.Premium(r, c)
|
||||||
|
lm[r][c] = p.LetterMult()
|
||||||
|
wm[r][c] = p.WordMult()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return genRuleset{
|
||||||
|
Size: rs.Size(), Cols: rs.Cols, Center: rs.Center, RackSize: rs.RackSize,
|
||||||
|
Bingo: rs.Bingo, Values: rs.Values, LetterMult: lm, WordMult: wm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func movesOf(ms []scrabble.Move) []genMove {
|
||||||
|
out := make([]genMove, len(ms))
|
||||||
|
for i, m := range ms {
|
||||||
|
out[i] = genMove{Dir: int(m.Dir), Tiles: tilesOf(m.Tiles), Score: m.Score}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func tilesOf(ps []scrabble.Placement) []genTile {
|
||||||
|
out := make([]genTile, len(ps))
|
||||||
|
for i, p := range ps {
|
||||||
|
out[i] = genTile{Row: p.Row, Col: p.Col, Letter: int(p.Letter), Blank: p.Blank}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// emptyCase builds an empty-board case (Moves filled later by runCase).
|
||||||
|
func emptyCase(name string, r genRack, mode scrabble.Mode, ignoreCross bool) genCase {
|
||||||
|
return genCase{Name: name, Rack: r, Mode: int(mode), IgnoreCrossWords: ignoreCross}
|
||||||
|
}
|
||||||
|
|
||||||
|
// englishRack builds a rack from lowercase a-z letters (index = letter-'a').
|
||||||
|
func englishRack(letters string, blanks int) genRack {
|
||||||
|
idx := make([]int, 0, len(letters))
|
||||||
|
for _, ch := range letters {
|
||||||
|
idx = append(idx, int(ch-'a'))
|
||||||
|
}
|
||||||
|
return genRack{Letters: idx, Blanks: blanks}
|
||||||
|
}
|
||||||
|
|
||||||
|
// russianRack builds a rack from the Russian sample letters used above.
|
||||||
|
func russianRack(letters string, blanks int) genRack {
|
||||||
|
m := map[rune]int{'а': 0, 'д': 4, 'о': 15, 'р': 17, 'с': 18, 'я': 32}
|
||||||
|
idx := make([]int, 0, len([]rune(letters)))
|
||||||
|
for _, ch := range letters {
|
||||||
|
i, ok := m[ch]
|
||||||
|
if !ok {
|
||||||
|
log.Fatalf("movegen: russianRack: no index for %q", string(ch))
|
||||||
|
}
|
||||||
|
idx = append(idx, i)
|
||||||
|
}
|
||||||
|
return genRack{Letters: idx, Blanks: blanks}
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitRulesets writes the per-variant static ruleset data (tile values, bag counts, blanks,
|
||||||
|
// bingo, rack size) the offline engine mirrors in ui/src/lib/localgame/ruleset.ts, so a TS
|
||||||
|
// parity test can pin that hand-copied table to the Go rulesets (scrabble-solver/rules).
|
||||||
|
func emitRulesets() {
|
||||||
|
type rsFix struct {
|
||||||
|
Size int `json:"size"`
|
||||||
|
RackSize int `json:"rackSize"`
|
||||||
|
Bingo int `json:"bingo"`
|
||||||
|
Blanks int `json:"blanks"`
|
||||||
|
Values []int `json:"values"`
|
||||||
|
Counts []int `json:"counts"`
|
||||||
|
Letters []string `json:"letters"`
|
||||||
|
}
|
||||||
|
out := map[string]rsFix{}
|
||||||
|
for _, v := range []struct {
|
||||||
|
name string
|
||||||
|
rs *rules.Ruleset
|
||||||
|
}{
|
||||||
|
{"scrabble_en", rules.English()},
|
||||||
|
{"scrabble_ru", rules.RussianScrabble()},
|
||||||
|
{"erudit_ru", rules.Erudit()},
|
||||||
|
} {
|
||||||
|
letters := make([]string, v.rs.Size())
|
||||||
|
for i := range letters {
|
||||||
|
ch, err := v.rs.Alphabet.Character(byte(i))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("movegen: %s letter %d: %v", v.name, i, err)
|
||||||
|
}
|
||||||
|
letters[i] = strings.ToUpper(ch)
|
||||||
|
}
|
||||||
|
out[v.name] = rsFix{Size: v.rs.Size(), RackSize: v.rs.RackSize, Bingo: v.rs.Bingo, Blanks: v.rs.Blanks, Values: v.rs.Values, Counts: v.rs.Counts, Letters: letters}
|
||||||
|
}
|
||||||
|
dir := filepath.Join("ui", "src", "lib", "localgame", "testdata")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
log.Fatalf("movegen: mkdir %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
writeJSON(filepath.Join(dir, "rulesets.json"), out)
|
||||||
|
log.Printf("movegen: wrote %s (3 variants)", filepath.Join(dir, "rulesets.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(path string, v any) {
|
||||||
|
data, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("movegen: marshal %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||||
|
log.Fatalf("movegen: write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The offline engine (ui/src/lib/localgame) reproduces the end-of-game rack settlement and the
|
||||||
|
// winner rule so a local game finishes with the same scores as the server. These golden fixtures
|
||||||
|
// pin the ported pure functions (applyEndAdjustment / winner / rackValue) to the real Go engine.
|
||||||
|
// Being in-package, this emitter constructs Game values directly and drives the unexported
|
||||||
|
// end-game math on chosen positions.
|
||||||
|
|
||||||
|
type endCaseIn struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Hands [][]int `json:"hands"`
|
||||||
|
Scores []int `json:"scores"`
|
||||||
|
Resigned []bool `json:"resigned"`
|
||||||
|
ToMove int `json:"toMove"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type endCaseOut struct {
|
||||||
|
endCaseIn
|
||||||
|
ScoresAfter []int `json:"scoresAfter"`
|
||||||
|
Winner int `json:"winner"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func rulesetFor(variant string) *rules.Ruleset {
|
||||||
|
switch variant {
|
||||||
|
case "scrabble_ru":
|
||||||
|
return rules.RussianScrabble()
|
||||||
|
case "erudit_ru":
|
||||||
|
return rules.Erudit()
|
||||||
|
default:
|
||||||
|
return rules.English()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reasonFor(s string) EndReason {
|
||||||
|
switch s {
|
||||||
|
case "out_of_tiles":
|
||||||
|
return EndOutOfTiles
|
||||||
|
case "scoreless":
|
||||||
|
return EndScoreless
|
||||||
|
case "resign":
|
||||||
|
return EndResign
|
||||||
|
case "aborted":
|
||||||
|
return EndAborted
|
||||||
|
}
|
||||||
|
return EndNotOver
|
||||||
|
}
|
||||||
|
|
||||||
|
func handsBytes(hands [][]int) [][]byte {
|
||||||
|
out := make([][]byte, len(hands))
|
||||||
|
for i, h := range hands {
|
||||||
|
b := make([]byte, len(h))
|
||||||
|
for j, x := range h {
|
||||||
|
b[j] = byte(x)
|
||||||
|
}
|
||||||
|
out[i] = b
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmitEndgameFixtures regenerates ui/src/lib/localgame/testdata/endgame.json. Gated by
|
||||||
|
// EMIT_ENGINE_FIXTURES. Regenerate with:
|
||||||
|
//
|
||||||
|
// EMIT_ENGINE_FIXTURES=1 go test ./backend/internal/engine -run TestEmitEndgameFixtures
|
||||||
|
func TestEmitEndgameFixtures(t *testing.T) {
|
||||||
|
if os.Getenv("EMIT_ENGINE_FIXTURES") == "" {
|
||||||
|
t.Skip("set EMIT_ENGINE_FIXTURES=1 to regenerate ui/src/lib/localgame/testdata/endgame.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []endCaseIn{
|
||||||
|
{"out-basic", "scrabble_en", "out_of_tiles", [][]int{{}, {0, 1, 2}}, []int{50, 40}, []bool{false, false}, 0},
|
||||||
|
{"out-blank", "scrabble_en", "out_of_tiles", [][]int{{}, {255, 0}}, []int{30, 30}, []bool{false, false}, 0},
|
||||||
|
{"out-erudit-yo", "erudit_ru", "out_of_tiles", [][]int{{}, {6, 32}}, []int{10, 10}, []bool{false, false}, 0},
|
||||||
|
{"out-tie", "scrabble_en", "out_of_tiles", [][]int{{}, {}}, []int{30, 30}, []bool{false, false}, 0},
|
||||||
|
{"out-3p", "scrabble_ru", "out_of_tiles", [][]int{{}, {0, 1}, {2}}, []int{10, 10, 10}, []bool{false, false, false}, 0},
|
||||||
|
{"scoreless", "scrabble_en", "scoreless", [][]int{{0, 1}, {2, 3}}, []int{20, 20}, []bool{false, false}, 0},
|
||||||
|
{"resign-2p", "scrabble_en", "resign", [][]int{{}, {0}}, []int{100, 10}, []bool{true, false}, 1},
|
||||||
|
{"resign-3p", "scrabble_en", "resign", [][]int{{}, {}, {}}, []int{50, 60, 40}, []bool{false, true, false}, 0},
|
||||||
|
{"aborted", "scrabble_en", "aborted", [][]int{{0}, {1}}, []int{40, 30}, []bool{false, false}, 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]endCaseOut, 0, len(cases))
|
||||||
|
for _, c := range cases {
|
||||||
|
rs := rulesetFor(c.Variant)
|
||||||
|
scores := append([]int(nil), c.Scores...)
|
||||||
|
g := &Game{
|
||||||
|
rules: rs,
|
||||||
|
hands: handsBytes(c.Hands),
|
||||||
|
scores: scores,
|
||||||
|
resigned: c.Resigned,
|
||||||
|
toMove: c.ToMove,
|
||||||
|
}
|
||||||
|
reason := reasonFor(c.Reason)
|
||||||
|
g.over = true
|
||||||
|
g.reason = reason
|
||||||
|
g.applyEndAdjustment(reason)
|
||||||
|
out = append(out, endCaseOut{endCaseIn: c, ScoresAfter: g.scores, Winner: g.winner()})
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "localgame", "testdata")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(map[string]any{"cases": out}, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "endgame.json")
|
||||||
|
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
t.Logf("wrote %s (%d cases)", path, len(out))
|
||||||
|
}
|
||||||
@@ -69,6 +69,29 @@ func TestHintsRemaining(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHintUnlockLeftSeconds(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
// A gated vs_ai game on the caller's turn, the robot having moved 10 min ago (turn started then).
|
||||||
|
gated := Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
g Game
|
||||||
|
seat int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"non-vs_ai is open", Game{VsAI: false, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)}, 0, 0},
|
||||||
|
{"not the caller's turn is open", gated, 1, 0},
|
||||||
|
{"human first move (no robot move) is open", Game{VsAI: true, ToMove: 0, MoveCount: 0, TurnStartedAt: now}, 0, 0},
|
||||||
|
{"robot moved 10 min ago leaves 20 min", gated, 0, 20 * 60},
|
||||||
|
{"robot moved past the window is open", Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-40 * time.Minute)}, 0, 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := hintUnlockLeftSeconds(c.g, c.seat, now); got != c.want {
|
||||||
|
t.Errorf("%s: hintUnlockLeftSeconds = %d, want %d", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAllowedTimeout(t *testing.T) {
|
func TestAllowedTimeout(t *testing.T) {
|
||||||
if !allowedTimeout(24 * time.Hour) {
|
if !allowedTimeout(24 * time.Hour) {
|
||||||
t.Error("24h must be allowed")
|
t.Error("24h must be allowed")
|
||||||
|
|||||||
@@ -1058,10 +1058,31 @@ func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Varia
|
|||||||
return svc.store.MarkChangesApplied(ctx, variant.String(), version)
|
return svc.store.MarkChangesApplied(ctx, variant.String(), version)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hint reveals the top-scoring legal play for the requesting player on their
|
// hintIdleWindow is how long a vs_ai player must be stuck on a turn (since the robot's last move)
|
||||||
// turn, spending one hint from their per-game allowance and then their profile
|
// before the idle hint unlocks. Mirrors the offline client (lib/hints HINT_GATE_MS = 30 min).
|
||||||
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
|
const hintIdleWindow = 30 * time.Minute
|
||||||
// appropriate.
|
|
||||||
|
// hintUnlockLeftSeconds is the seconds until g's vs_ai idle hint unlocks for seat, measured from now:
|
||||||
|
// the robot's last move (the current turn's start, on the human's turn) plus the window, floored at
|
||||||
|
// 0 and ceiled to whole seconds. It is 0 for a non-vs_ai game, when it is not seat's turn, or on the
|
||||||
|
// human's first move (MoveCount 0, no robot move yet) — the gate is an anti-frustration aid, not a
|
||||||
|
// first-move tax. The client anchors a monotonic countdown to it, so a client clock cannot skew it.
|
||||||
|
func hintUnlockLeftSeconds(g Game, seat int, now time.Time) int {
|
||||||
|
if !g.VsAI || g.ToMove != seat || g.MoveCount < 1 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
left := g.TurnStartedAt.Add(hintIdleWindow).Sub(now)
|
||||||
|
if left <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int((left + time.Second - 1) / time.Second) // ceil to whole seconds (no math import)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hint reveals the top-scoring legal play for the requesting player on their turn. For a human game
|
||||||
|
// it spends one hint from the per-game allowance then the profile wallet (ErrHintsDisabled /
|
||||||
|
// ErrNoHintsLeft / ErrNoHintAvailable). For a vs_ai game the hint is unlimited and wallet-free but
|
||||||
|
// idle-gated from the server clock (ErrHintLocked until the window elapses) and counts toward no
|
||||||
|
// hint statistic.
|
||||||
func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
|
func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
|
||||||
pre, err := svc.store.GetGame(ctx, gameID)
|
pre, err := svc.store.GetGame(ctx, gameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1080,6 +1101,26 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
|||||||
if !pre.HintsAllowed {
|
if !pre.HintsAllowed {
|
||||||
return HintResult{}, ErrHintsDisabled
|
return HintResult{}, ErrHintsDisabled
|
||||||
}
|
}
|
||||||
|
if pre.VsAI {
|
||||||
|
// vs_ai: unlimited and wallet-free, but idle-gated from the server clock — and counted toward
|
||||||
|
// no hint statistic. Enforce the gate (the client normally pre-gates from the view's
|
||||||
|
// HintUnlockLeftSeconds; this is the authoritative backstop), then serve the top move without
|
||||||
|
// touching the allowance, the wallet or hints_used.
|
||||||
|
if hintUnlockLeftSeconds(pre, seat, svc.clock()) > 0 {
|
||||||
|
return HintResult{}, ErrHintLocked
|
||||||
|
}
|
||||||
|
unlock := svc.locks.lock(gameID)
|
||||||
|
defer unlock()
|
||||||
|
g, err := svc.liveGame(ctx, pre)
|
||||||
|
if err != nil {
|
||||||
|
return HintResult{}, err
|
||||||
|
}
|
||||||
|
move, ok := g.HintView()
|
||||||
|
if !ok {
|
||||||
|
return HintResult{}, ErrNoHintAvailable
|
||||||
|
}
|
||||||
|
return HintResult{Move: move}, nil
|
||||||
|
}
|
||||||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return HintResult{}, err
|
return HintResult{}, err
|
||||||
@@ -1208,6 +1249,8 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
|||||||
BagLen: g.BagLen(),
|
BagLen: g.BagLen(),
|
||||||
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
|
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
|
||||||
WalletBalance: acc.HintBalance,
|
WalletBalance: acc.HintBalance,
|
||||||
|
// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
|
||||||
|
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ var (
|
|||||||
ErrNoHintsLeft = errors.New("game: no hints remaining")
|
ErrNoHintsLeft = errors.New("game: no hints remaining")
|
||||||
// ErrNoHintAvailable is returned when the player has no legal play to reveal.
|
// ErrNoHintAvailable is returned when the player has no legal play to reveal.
|
||||||
ErrNoHintAvailable = errors.New("game: no legal move to suggest")
|
ErrNoHintAvailable = errors.New("game: no legal move to suggest")
|
||||||
|
// ErrHintLocked is returned when a vs_ai game's idle hint is still gated (the player has not yet
|
||||||
|
// been stuck the idle window since the robot's last move). The client normally pre-gates from
|
||||||
|
// StateView.HintUnlockLeftSeconds, so this is the authoritative server-clock backstop.
|
||||||
|
ErrHintLocked = errors.New("game: hint is not available yet")
|
||||||
// ErrGameLimitReached is returned when an account already holds MaxActiveQuickGames
|
// ErrGameLimitReached is returned when an account already holds MaxActiveQuickGames
|
||||||
// simultaneous quick games and tries to create another game (quick or by invitation).
|
// simultaneous quick games and tries to create another game (quick or by invitation).
|
||||||
ErrGameLimitReached = errors.New("game: simultaneous game limit reached")
|
ErrGameLimitReached = errors.New("game: simultaneous game limit reached")
|
||||||
@@ -240,6 +244,11 @@ type StateView struct {
|
|||||||
// WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds
|
// WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds
|
||||||
// it in with the per-game allowance), so the client keeps the wallet live across games.
|
// it in with the per-game allowance), so the client keeps the wallet live across games.
|
||||||
WalletBalance int
|
WalletBalance int
|
||||||
|
// HintUnlockLeftSeconds is, for a vs_ai game on the requesting player's turn, the seconds left
|
||||||
|
// until the idle hint unlocks (the robot's last move plus the idle window, from the server clock);
|
||||||
|
// 0 for the human's first move, when it is not their turn, or a non-vs_ai game. The vs_ai hint is
|
||||||
|
// unlimited and wallet-free; this idle gate replaces the allowance/wallet for it.
|
||||||
|
HintUnlockLeftSeconds int
|
||||||
}
|
}
|
||||||
|
|
||||||
// HistoryMove is one decoded journal row, independent of any dictionary.
|
// HistoryMove is one decoded journal row, independent of any dictionary.
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package robot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The client-side offline robot (ui/src/lib/robot) reproduces the robot's move choice
|
||||||
|
// so a local vs_ai game plays the same way as the server. These golden fixtures pin that
|
||||||
|
// TS port to the real Go strategy. Being in-package, this emitter can exercise the
|
||||||
|
// unexported mix / playToWin / deviates / selectMove that the port mirrors.
|
||||||
|
|
||||||
|
type mixCase struct {
|
||||||
|
Seed string `json:"seed"`
|
||||||
|
Salt string `json:"salt"`
|
||||||
|
Nums []int `json:"nums"`
|
||||||
|
Value string `json:"value"` // the mix() uint64, as a decimal string (exceeds JS safe ints)
|
||||||
|
}
|
||||||
|
|
||||||
|
type decisionCase struct {
|
||||||
|
Seed string `json:"seed"`
|
||||||
|
MoveCount int `json:"moveCount"`
|
||||||
|
MyScore int `json:"myScore"`
|
||||||
|
OppScore int `json:"oppScore"`
|
||||||
|
CandScores []int `json:"candScores"`
|
||||||
|
RackLen int `json:"rackLen"`
|
||||||
|
BagLen int `json:"bagLen"`
|
||||||
|
PlayToWin bool `json:"playToWin"`
|
||||||
|
Win bool `json:"win"` // the per-turn intent after the deviate flip
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Index int `json:"index"` // chosen candidate index for a play, else -1
|
||||||
|
}
|
||||||
|
|
||||||
|
type strategyFixture struct {
|
||||||
|
PlayToWinPercent int `json:"playToWinPercent"`
|
||||||
|
Mix []mixCase `json:"mix"`
|
||||||
|
Decisions []decisionCase `json:"decisions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// scenario is one selectMove situation exercised across several seeds.
|
||||||
|
type scenario struct {
|
||||||
|
moveCount, myScore, oppScore, rackLen, bagLen int
|
||||||
|
cands []int
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmitStrategyFixtures regenerates ui/src/lib/robot/testdata/strategy.json. It is
|
||||||
|
// gated by EMIT_STRATEGY_FIXTURES so a normal `go test` skips it; the committed output is
|
||||||
|
// what the TS parity test consumes. Regenerate with:
|
||||||
|
//
|
||||||
|
// EMIT_STRATEGY_FIXTURES=1 go test ./backend/internal/robot -run TestEmitStrategyFixtures
|
||||||
|
func TestEmitStrategyFixtures(t *testing.T) {
|
||||||
|
if os.Getenv("EMIT_STRATEGY_FIXTURES") == "" {
|
||||||
|
t.Skip("set EMIT_STRATEGY_FIXTURES=1 to regenerate ui/src/lib/robot/testdata/strategy.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
seeds := []int64{1, 42, -7, 1000003, 9999999999, 123456789, -123456789}
|
||||||
|
|
||||||
|
mixCases := []mixCase{}
|
||||||
|
addMix := func(seed int64, salt string, nums ...int) {
|
||||||
|
mixCases = append(mixCases, mixCase{
|
||||||
|
Seed: strconv.FormatInt(seed, 10),
|
||||||
|
Salt: salt,
|
||||||
|
Nums: append([]int{}, nums...),
|
||||||
|
Value: strconv.FormatUint(mix(seed, salt, nums...), 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, sd := range seeds {
|
||||||
|
addMix(sd, "win")
|
||||||
|
addMix(sd, "deviate", 0)
|
||||||
|
addMix(sd, "deviate", 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []scenario{
|
||||||
|
{3, 40, 55, 7, 20, []int{30, 12, 8}}, // behind, mid-game
|
||||||
|
{10, 120, 90, 7, 8, []int{25, 18, 5}}, // ahead, late
|
||||||
|
{1, 0, 0, 7, 30, []int{60, 30, 15, 7}}, // first move, big spread
|
||||||
|
{20, 200, 40, 5, 2, []int{50, 20}}, // big lead, bag near empty (no deviate)
|
||||||
|
{5, 30, 30, 7, 14, []int{20, 20, 20}}, // equal margins (tie-break)
|
||||||
|
{8, 50, 60, 7, 20, []int{}}, // no play, bag can refill -> exchange
|
||||||
|
{8, 50, 60, 3, 2, []int{}}, // no play, bag can't refill -> pass
|
||||||
|
{15, 300, 10, 7, 6, []int{80, 40, 10}}, // overshoots the band -> nearest to +30
|
||||||
|
}
|
||||||
|
|
||||||
|
decisions := []decisionCase{}
|
||||||
|
for _, sd := range seeds {
|
||||||
|
for _, sc := range scenarios {
|
||||||
|
cands := make([]engine.MoveRecord, len(sc.cands))
|
||||||
|
for i, s := range sc.cands {
|
||||||
|
cands[i] = engine.MoveRecord{Score: s, Player: i}
|
||||||
|
}
|
||||||
|
rack := make([]string, sc.rackLen)
|
||||||
|
for i := range rack {
|
||||||
|
rack[i] = "a"
|
||||||
|
}
|
||||||
|
|
||||||
|
ptw := playToWin(sd)
|
||||||
|
win := ptw
|
||||||
|
if deviates(sd, sc.moveCount, sc.bagLen) {
|
||||||
|
win = !win
|
||||||
|
}
|
||||||
|
d := selectMove(cands, sc.myScore, sc.oppScore, win, defaultBand, rack, sc.bagLen)
|
||||||
|
|
||||||
|
kind, index := "pass", -1
|
||||||
|
switch d.kind {
|
||||||
|
case decidePlay:
|
||||||
|
kind, index = "play", d.move.Player
|
||||||
|
case decideExchange:
|
||||||
|
kind = "exchange"
|
||||||
|
}
|
||||||
|
|
||||||
|
decisions = append(decisions, decisionCase{
|
||||||
|
Seed: strconv.FormatInt(sd, 10),
|
||||||
|
MoveCount: sc.moveCount,
|
||||||
|
MyScore: sc.myScore,
|
||||||
|
OppScore: sc.oppScore,
|
||||||
|
CandScores: append([]int{}, sc.cands...),
|
||||||
|
RackLen: sc.rackLen,
|
||||||
|
BagLen: sc.bagLen,
|
||||||
|
PlayToWin: ptw,
|
||||||
|
Win: win,
|
||||||
|
Kind: kind,
|
||||||
|
Index: index,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fx := strategyFixture{PlayToWinPercent: playToWinPercent, Mix: mixCases, Decisions: decisions}
|
||||||
|
dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "robot", "testdata")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(fx, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "strategy.json")
|
||||||
|
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
t.Logf("wrote %s (%d mix, %d decisions)", path, len(mixCases), len(decisions))
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/ads"
|
"scrabble/backend/internal/ads"
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,9 +57,34 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi
|
|||||||
r := profileResponseFor(acc)
|
r := profileResponseFor(acc)
|
||||||
r.Banner = s.bannerFor(ctx, acc)
|
r.Banner = s.bannerFor(ctx, acc)
|
||||||
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
||||||
|
r.DictVersions = s.currentDictVersions()
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currentDictVersions reports the current dictionary version of every game variant with a
|
||||||
|
// resident dictionary, as engine.Variant stable labels. It backs profileResponse.DictVersions
|
||||||
|
// so an offline-capable client preloads the matching dawg per variant off the cold-start
|
||||||
|
// profile. A variant without a loaded dictionary is omitted (Registry.Latest reports
|
||||||
|
// ErrUnknownVariant); the returned slice is nil only when no variant is resident.
|
||||||
|
func (s *Server) currentDictVersions() []dictVersion {
|
||||||
|
if s.registry == nil {
|
||||||
|
return nil // no dictionary registry wired (e.g. a minimal test server): advertise none
|
||||||
|
}
|
||||||
|
variants := []engine.Variant{engine.VariantEnglish, engine.VariantRussianScrabble, engine.VariantErudit}
|
||||||
|
out := make([]dictVersion, 0, len(variants))
|
||||||
|
for _, v := range variants {
|
||||||
|
version, _, err := s.registry.Latest(v)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, dictVersion{Variant: v.String(), Version: version})
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// fillLinkedIdentities sets the profile's confirmed email address and platform-linked
|
// fillLinkedIdentities sets the profile's confirmed email address and platform-linked
|
||||||
// flags from the account's identities, so the client offers the right link / unlink /
|
// flags from the account's identities, so the client offers the right link / unlink /
|
||||||
// change-email controls. A read failure leaves them zero (no controls), logged as a
|
// change-email controls. A read failure leaves them zero (no controls), logged as a
|
||||||
|
|||||||
@@ -63,6 +63,20 @@ type profileResponse struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
TelegramLinked bool `json:"telegram_linked"`
|
TelegramLinked bool `json:"telegram_linked"`
|
||||||
VkLinked bool `json:"vk_linked"`
|
VkLinked bool `json:"vk_linked"`
|
||||||
|
// DictVersions lists the current dictionary version per game variant (engine.Variant
|
||||||
|
// stable labels). An installed PWA preparing for offline play reads it to preload the
|
||||||
|
// right dawg per enabled variant off this cold-start response, without an extra request.
|
||||||
|
// Filled outside the pure projection (it reads the dictionary registry), so it is empty
|
||||||
|
// for callers that build the DTO without a Server. See Server.profileResponse.
|
||||||
|
DictVersions []dictVersion `json:"dict_versions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// dictVersion pairs a game variant's stable label (engine.Variant.String) with its current
|
||||||
|
// dictionary version. profileResponse.DictVersions carries the set so an offline-capable
|
||||||
|
// client preloads the matching dawg per variant.
|
||||||
|
type dictVersion struct {
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// tileDTO is one placed (or to-place) tile.
|
// tileDTO is one placed (or to-place) tile.
|
||||||
@@ -149,13 +163,16 @@ type alphabetEntryDTO struct {
|
|||||||
// stateDTO is a player's view of a game. Rack carries wire alphabet indices (a
|
// stateDTO is a player's view of a game. Rack carries wire alphabet indices (a
|
||||||
// blank is engine.BlankIndex). Alphabet is present only when the request asked for it.
|
// 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 []int `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"`
|
||||||
WalletBalance int `json:"wallet_balance"`
|
WalletBalance int `json:"wallet_balance"`
|
||||||
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
|
// HintUnlockLeftSeconds is the vs_ai idle-hint gate: seconds until the hint unlocks (0 for a human
|
||||||
|
// game / first move / not your turn). The client anchors a monotonic countdown to it.
|
||||||
|
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
||||||
|
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.
|
||||||
@@ -301,12 +318,13 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
|
|||||||
return stateDTO{}, err
|
return stateDTO{}, err
|
||||||
}
|
}
|
||||||
dto := stateDTO{
|
dto := stateDTO{
|
||||||
Game: gameDTOFromGame(v.Game),
|
Game: gameDTOFromGame(v.Game),
|
||||||
Seat: v.Seat,
|
Seat: v.Seat,
|
||||||
Rack: rack,
|
Rack: rack,
|
||||||
BagLen: v.BagLen,
|
BagLen: v.BagLen,
|
||||||
HintsRemaining: v.HintsRemaining,
|
HintsRemaining: v.HintsRemaining,
|
||||||
WalletBalance: v.WalletBalance,
|
WalletBalance: v.WalletBalance,
|
||||||
|
HintUnlockLeftSeconds: v.HintUnlockLeftSeconds,
|
||||||
}
|
}
|
||||||
if includeAlphabet {
|
if includeAlphabet {
|
||||||
tab, err := engine.AlphabetTable(v.Game.Variant)
|
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||||
|
|||||||
@@ -239,6 +239,9 @@ func statusForError(err error) (int, string) {
|
|||||||
return http.StatusConflict, "no_hint_available"
|
return http.StatusConflict, "no_hint_available"
|
||||||
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft):
|
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft):
|
||||||
return http.StatusConflict, "hint_unavailable"
|
return http.StatusConflict, "hint_unavailable"
|
||||||
|
case errors.Is(err, game.ErrHintLocked):
|
||||||
|
// A vs_ai idle hint is still gated (the server-clock backstop); the client shows the lock.
|
||||||
|
return http.StatusConflict, "hint_locked"
|
||||||
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
|
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
|
||||||
return http.StatusUnprocessableEntity, "illegal_play"
|
return http.StatusUnprocessableEntity, "illegal_play"
|
||||||
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
|
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
|
||||||
|
|||||||
+68
-6
@@ -1238,12 +1238,74 @@ separate site; the SPA shell is `noindex`, and the landing always boots in Russi
|
|||||||
browser-language detection — crawlers render with arbitrary languages). The favicon set,
|
browser-language detection — crawlers render with arbitrary languages). The favicon set,
|
||||||
`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by
|
`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by
|
||||||
`assets/icons/`, same tile design as the VK loader). On the web (`/app/`) the SPA is an installable **PWA**:
|
`assets/icons/`, same tile design as the VK loader). On the web (`/app/`) the SPA is an installable **PWA**:
|
||||||
`manifest.webmanifest` and an install-only **service worker** (`sw.js`) ship unhashed from
|
`manifest.webmanifest` ships unhashed from `ui/public/`, while the **service worker** (`sw.js`) is
|
||||||
`ui/public/`, and the client registers the worker **web-only** — never inside a Mini App or the
|
built from `ui/src/sw.ts` by **vite-plugin-pwa** (injectManifest strategy) — the client registers it
|
||||||
mock build. The worker intercepts only top-level navigations (network-first with a cached-shell
|
**web-only**, never inside a Mini App or the mock build (the plugin is disabled there entirely). It
|
||||||
fallback), leaving `/assets/*` and the Connect stream untouched; it exists to satisfy Chromium's
|
**precaches the app shell and the hashed assets** (Workbox) so an installed PWA cold-launches with
|
||||||
installability requirement (a registered SW, needed for install on Android) and is the single
|
no network. Shell **navigations are network-first** — the fresh `no-cache` shell is fetched from the
|
||||||
growth point for a future opt-in offline mode. The gateway registers the `.webmanifest` MIME type
|
server (so a new deploy is picked up on the very next launch, not the one after), falling back to the
|
||||||
|
precached shell only when the network is unreachable or too slow; the immutable hashed assets stay
|
||||||
|
cache-first, and the hash router resolves the route client-side. This navigation route is registered
|
||||||
|
**before** the precache route on purpose: Workbox matches in registration order, and the precache
|
||||||
|
route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve the shell
|
||||||
|
cache-first — the old build's version until a second load. The landing page and the conditional
|
||||||
|
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
|
||||||
|
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
|
||||||
|
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
|
||||||
|
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
|
||||||
|
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
|
||||||
|
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
|
||||||
|
creates one through the in-browser engine — the same game screen then drives it, the robot replying
|
||||||
|
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
|
||||||
|
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**.
|
||||||
|
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
|
||||||
|
per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host
|
||||||
|
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
|
||||||
|
current turn, **exclude** a player (a forced seat resign, `resignSeat`) or **terminate** the game
|
||||||
|
(deleted, no winner/loser); each seat may also carry its own **optional PIN** that withholds its rack
|
||||||
|
(the board stays visible — only the tray is gated) until it is entered, so players pass the device
|
||||||
|
around freely and lock a seat only against a distrusted tablemate. The unlock is **per turn**
|
||||||
|
(re-locks on advance). PINs are a **social lock, not cryptography** — a salted SHA-256 kept only in
|
||||||
|
the device record (`lib/pin`); a 4-digit PIN is trivially brute-forceable and the racks live in
|
||||||
|
client memory regardless, so they defeat a casual glance, not a determined peek. A hotseat game is
|
||||||
|
**not `vs_ai`**: hints (the freed control becomes the host button) and chat/self-resign are gone, and
|
||||||
|
its lobby card — active *and* finished — is master-PIN-gated to delete (so the last mover cannot
|
||||||
|
instantly wipe it); a naturally finished game is saved with its result like any local game. A `vs_ai`
|
||||||
|
hint (online and offline alike) is unlimited and wallet-free but idle-gated
|
||||||
|
(unlocked ~30 min into a stuck turn), and counts toward no hint statistic. The **source** reports the
|
||||||
|
**seconds left** (`hint_unlock_left_seconds` on the game view) — computed by the **backend from the
|
||||||
|
server clock** online (which also enforces the gate, returning `hint_locked` for an early request),
|
||||||
|
and by the offline source from the device clock (persisted, capped at the window). The **client
|
||||||
|
anchors a MONOTONIC countdown** (`performance.now()`, `lib/hints`) to that seconds-left when it lands
|
||||||
|
(on load, and to the full window when the robot moves), so a client clock change cannot skew it, and
|
||||||
|
a relaunch re-reads a fresh value so the wait is not forgotten. To have data ready before the switch, the **Profile advertises the current dictionary
|
||||||
|
version per variant** (`dict_versions`,
|
||||||
|
filled from the registry on the existing cold-start profile request — no extra round-trip), and an
|
||||||
|
eligible installed PWA (standalone web + confirmed email) **background-preloads** those dictionaries
|
||||||
|
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
|
||||||
|
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
|
||||||
|
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
|
||||||
|
the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch
|
||||||
|
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only
|
||||||
|
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the
|
||||||
|
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline
|
||||||
|
mode** boots from the persisted session and
|
||||||
|
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
|
||||||
|
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
|
||||||
|
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
|
||||||
|
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the
|
||||||
|
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient
|
||||||
|
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate
|
||||||
|
one (the toggle, or the cold-start dialog's *Switch*) persists. At cold start `navigator.onLine ===
|
||||||
|
false` enters offline for the session; otherwise a single bounded reachability probe (a `profile.get`,
|
||||||
|
~3 s) decides — success boots online, a timeout on an eligible install raises a *No connection* dialog
|
||||||
|
(*Switch* = sticky offline, *Wait for network* = boot online with the reachability watcher retrying).
|
||||||
|
Mid-session the `window` `online`/`offline` events drive it — `offline` auto-enters, `online`
|
||||||
|
re-verifies reachability before returning — backed by a `navigator.onLine` poll because those events
|
||||||
|
are unreliable on some platforms (notably iOS PWAs). Offline is a real **transport kill switch**
|
||||||
|
(every gateway call is refused, so no traffic leaks); the reachability probe is the one call exempt
|
||||||
|
from it (it is the return-to-online mechanism), and the transient reachability watcher is suppressed
|
||||||
|
while offline. The gateway registers the `.webmanifest` MIME type
|
||||||
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
|
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
|
||||||
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
||||||
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
||||||
|
|||||||
+48
-1
@@ -215,7 +215,16 @@ unlimited and offers a complaint on any result; for a word it finds, it also lin
|
|||||||
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
|
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
|
||||||
English) to look it up. Hints are governed per game —
|
English) to look it up. Hints are governed per game —
|
||||||
whether they are allowed and how many each player starts with — and draw on a
|
whether they are allowed and how many each player starts with — and draw on a
|
||||||
personal hint wallet once the per-game allowance is spent. The game ends when the
|
personal hint wallet once the per-game allowance is spent. Against the robot
|
||||||
|
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
|
||||||
|
anti-frustration aid: one unlocks only after the player has been **stuck ~30 minutes
|
||||||
|
on a turn** (timed from the robot's last move; the very first move, before the robot
|
||||||
|
has played, is exempt). While gated the hint button carries a small **🔒 lock** and a
|
||||||
|
tap shows how long remains; the lock lifts live at the mark. The same gate applies **online and
|
||||||
|
offline**: the countdown runs on a **steady in-app timer**, not the device clock, so changing the
|
||||||
|
clock cannot skew it, and a relaunch re-reads the remaining time so a stuck turn is not forgotten.
|
||||||
|
Online the **server enforces** it from its own clock (which the player cannot touch); a vs_ai hint
|
||||||
|
counts toward no hint statistic. The game ends when the
|
||||||
bag empties and a player clears their rack, after 6 consecutive scoreless turns,
|
bag empties and a player clears their rack, after 6 consecutive scoreless turns,
|
||||||
by resignation, or by the per-game move timeout (5 minutes to 24 hours, default
|
by resignation, or by the per-game move timeout (5 minutes to 24 hours, default
|
||||||
24 hours): a missed turn auto-resigns, except while the player is inside their
|
24 hours): a missed turn auto-resigns, except while the player is inside their
|
||||||
@@ -256,6 +265,44 @@ the same occasional move against its plan that fades out by the endgame), and ch
|
|||||||
add-friend are off. AI games are **practice** — they never count toward a player's
|
add-friend are off. AI games are **practice** — they never count toward a player's
|
||||||
statistics.
|
statistics.
|
||||||
|
|
||||||
|
### Offline mode
|
||||||
|
An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode**
|
||||||
|
(Settings → Play mode). Offline, the app never touches the network: the header turns blue with an
|
||||||
|
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are
|
||||||
|
disabled or hidden (the Stats tab; the *random opponent* option in New Game).
|
||||||
|
A New Game against the robot creates a device-local **vs_ai** game — it
|
||||||
|
plays entirely in the browser with no backend. Local games are kept on the device, visible only in
|
||||||
|
offline mode, and never sync to the account. So a game can be created and played with no connection,
|
||||||
|
the app quietly preloads the dictionaries for the player's enabled variants while still online, and
|
||||||
|
(once installed) launches from a precached shell even with no network. Switching **to** offline first
|
||||||
|
checks that the enabled variants' dictionaries are on the device — if any are missing it fetches them
|
||||||
|
and waits briefly, and if they cannot be readied it stays online with a short *needs internet* note
|
||||||
|
(the download keeps running in the background, so a later switch is instant). The mode is
|
||||||
|
device-scoped and sticky across launches.
|
||||||
|
|
||||||
|
Offline, New Game's **with friends** starts a **local pass-and-play** game for 2-4 people sharing one
|
||||||
|
device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
|
||||||
|
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
|
||||||
|
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
|
||||||
|
rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**.
|
||||||
|
During the game the board is always visible, but a PIN-locked seat shows an **Unlock** button in place
|
||||||
|
of its rack until its owner enters the PIN — so the device passes hand to hand and only a protected
|
||||||
|
seat hides its letters; the lock returns each turn. The **leader** (the host button in the game) can,
|
||||||
|
with the leader password, **skip** the current player's turn, **remove** a player, or **end the game
|
||||||
|
early** — an early end discards the game with no winner or loser. Hints and chat are off in a
|
||||||
|
pass-and-play game. A game that finishes normally is saved to the offline lobby like any other;
|
||||||
|
deleting any pass-and-play game — running or finished — from the lobby asks for the leader password,
|
||||||
|
so no one can wipe a game the moment they have moved.
|
||||||
|
|
||||||
|
The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch
|
||||||
|
with no connection it switches to offline for that session; when the device is online but the gateway
|
||||||
|
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with
|
||||||
|
**Switch** (go offline) or **Wait for network** (keep retrying online). While the app is open, losing
|
||||||
|
the network — airplane mode — switches to offline automatically, and restoring it returns online by
|
||||||
|
itself. A self-entered offline is temporary: it reverts to online the moment the network is back, and
|
||||||
|
the next launch re-checks. A **deliberate** offline — the Settings toggle, or **Switch** in that
|
||||||
|
dialog — is the player's choice: it is kept, and never undone automatically.
|
||||||
|
|
||||||
### Social: friends, block, chat, nudge
|
### Social: friends, block, chat, nudge
|
||||||
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
||||||
digits, valid for twelve hours), or send a **request to someone you have played
|
digits, valid for twelve hours), or send a **request to someone you have played
|
||||||
|
|||||||
+49
-1
@@ -222,7 +222,16 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
||||||
чтобы его посмотреть. Подсказки управляются настройками
|
чтобы его посмотреть. Подсказки управляются настройками
|
||||||
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
||||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия
|
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
|
||||||
|
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
|
||||||
|
анти-фрустрация: подсказка открывается, только когда игрок **застрял на ходу ~30 минут**
|
||||||
|
(отсчёт от последнего хода робота; самый первый ход, до хода робота, исключён). Пока гейт
|
||||||
|
закрыт, кнопка подсказки несёт маленький **🔒 замок**, а тап показывает, сколько осталось;
|
||||||
|
замок снимается вживую в нужный момент. Один и тот же гейт работает **онлайн и офлайн**: отсчёт
|
||||||
|
идёт по **ровному внутриигровому таймеру**, а не по системным часам, поэтому их перевод его не
|
||||||
|
собьёт, а повторный вход перечитывает остаток — застрявший ход не забывается. Онлайн гейт
|
||||||
|
**принуждает сервер** по своим часам (их игрок не тронет); vs_ai-подсказка не учитывается ни в
|
||||||
|
одной статистике подсказок. Партия
|
||||||
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
|
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
|
||||||
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
|
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
|
||||||
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
|
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
|
||||||
@@ -261,6 +270,45 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же
|
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же
|
||||||
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
||||||
|
|
||||||
|
### Офлайн-режим
|
||||||
|
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
|
||||||
|
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
|
||||||
|
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
|
||||||
|
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
|
||||||
|
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
|
||||||
|
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
|
||||||
|
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
|
||||||
|
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
|
||||||
|
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
|
||||||
|
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
|
||||||
|
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
|
||||||
|
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
|
||||||
|
сохраняется между запусками.
|
||||||
|
|
||||||
|
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 2–4 человек
|
||||||
|
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||||
|
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
|
||||||
|
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
|
||||||
|
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
|
||||||
|
дать любому месту **свой необязательный PIN**. Во время игры доска видна всегда, но место с PIN
|
||||||
|
вместо стойки показывает кнопку **«Разблокировать»**, пока владелец не введёт PIN, — так устройство
|
||||||
|
передаётся из рук в руки, и только защищённое место прячет свои буквы; на каждом ходу замок
|
||||||
|
возвращается. **Ведущий** (кнопка ведущего в игре) может по паролю ведущего **пропустить** ход
|
||||||
|
текущего игрока, **исключить** игрока или **завершить игру досрочно** — досрочное завершение стирает
|
||||||
|
партию без победителей и проигравших. Подсказки и чат в игре по очереди отключены. Нормально
|
||||||
|
завершённая партия сохраняется в офлайн-лобби как любая другая; удаление любой игры по очереди —
|
||||||
|
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
|
||||||
|
своего хода.
|
||||||
|
|
||||||
|
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
|
||||||
|
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
|
||||||
|
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
|
||||||
|
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети —
|
||||||
|
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн.
|
||||||
|
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
|
||||||
|
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
|
||||||
|
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.
|
||||||
|
|
||||||
### Социальное: друзья, блок, чат, nudge
|
### Социальное: друзья, блок, чат, nudge
|
||||||
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
||||||
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
|
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
|
||||||
|
|||||||
+12
-1
@@ -20,7 +20,18 @@ tests or touching CI.
|
|||||||
derivation and the GCG share/copy/download choice, plus Playwright specs against the
|
derivation and the GCG share/copy/download choice, plus Playwright specs against the
|
||||||
mock for the friends screen (code issue/redeem, accept a request), the lobby
|
mock for the friends screen (code issue/redeem, accept a request), the lobby
|
||||||
invitations section, the stats screen, profile editing, and the export chooser's
|
invitations section, the stats screen, profile editing, and the export chooser's
|
||||||
finished-only visibility + its signed-URL download flow (route-intercepted).
|
finished-only visibility + its signed-URL download flow (route-intercepted). The
|
||||||
|
**offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game
|
||||||
|
end-to-end: it forces the installed-PWA display mode, enters offline through the
|
||||||
|
Settings toggle (whose readiness check fetches the enabled variants' dawgs), then creates
|
||||||
|
and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
|
||||||
|
rack is deterministic and the human can tap out a precomputed opening), asserting the
|
||||||
|
robot's real reply and the IndexedDB replay after a reload. A local game needs a real
|
||||||
|
dictionary, so the mock's `fetchDict` serves the per-variant dawgs from the preview
|
||||||
|
build's `/e2edict/`, copied in by `scripts/e2e-dict.mjs` from `E2E_DICT_DIR` — the `ui`
|
||||||
|
CI job fetches the `scrabble-dictionary` release like the Go jobs; locally it defaults to
|
||||||
|
the sibling `scrabble-solver/dawg`. These dawgs are never committed and never enter the
|
||||||
|
production build.
|
||||||
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
|
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
|
||||||
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
|
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
|
||||||
real self-played 35-move game) must rasterize to a plausible PNG
|
real self-played 35-move game) must rasterize to a plausible PNG
|
||||||
|
|||||||
+4
-3
@@ -8,9 +8,10 @@ backend over REST/JSON, and bridges the backend's gRPC push stream to each
|
|||||||
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
|
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
|
||||||
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
||||||
**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model.
|
**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model.
|
||||||
The web SPA is an installable **PWA**: it serves `manifest.webmanifest` (with the `.webmanifest`
|
The web SPA is an installable **PWA**: it serves `manifest.webmanifest` (unhashed from `ui/public/`,
|
||||||
MIME type registered in-process — the distroless image has no `/etc/mime.types`) and the
|
with the `.webmanifest` MIME type registered in-process — the distroless image has no
|
||||||
install-only `sw.js`, both shipped unhashed from `ui/public/`.
|
`/etc/mime.types`) and the app-shell `sw.js` (built from `ui/src/sw.ts` by vite-plugin-pwa, which
|
||||||
|
precaches the shell + assets so an installed PWA cold-launches offline).
|
||||||
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
||||||
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
||||||
in the deployed contour the front caddy owns `/_gm` (see
|
in the deployed contour the front caddy owns `/_gm` (see
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ type ProfileResp struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
TelegramLinked bool `json:"telegram_linked"`
|
TelegramLinked bool `json:"telegram_linked"`
|
||||||
VkLinked bool `json:"vk_linked"`
|
VkLinked bool `json:"vk_linked"`
|
||||||
|
// DictVersions is the current dictionary version per game variant, forwarded verbatim
|
||||||
|
// into the Profile payload so an offline-capable client preloads the matching dawg.
|
||||||
|
DictVersions []DictVersion `json:"dict_versions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DictVersion pairs a game variant's stable label with its current dictionary version.
|
||||||
|
type DictVersion struct {
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BannerResp is the advertising-banner block of an eligible viewer's profile: the
|
// BannerResp is the advertising-banner block of an eligible viewer's profile: the
|
||||||
@@ -172,13 +181,14 @@ type AlphabetEntryJSON struct {
|
|||||||
// StateResp is a player's view of a game. Rack carries wire alphabet indices;
|
// StateResp is a player's view of a game. Rack carries wire alphabet indices;
|
||||||
// Alphabet is present only when the request asked for it.
|
// 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 []int `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"`
|
||||||
WalletBalance int `json:"wallet_balance"`
|
WalletBalance int `json:"wallet_balance"`
|
||||||
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
||||||
|
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchResp reports an auto-match outcome.
|
// MatchResp reports an auto-match outcome.
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
banner = encodeBanner(b, *p.Banner)
|
banner = encodeBanner(b, *p.Banner)
|
||||||
}
|
}
|
||||||
prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector)
|
prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector)
|
||||||
|
dictVersions := encodeDictVersions(b, p.DictVersions)
|
||||||
fb.ProfileStart(b)
|
fb.ProfileStart(b)
|
||||||
fb.ProfileAddUserId(b, uid)
|
fb.ProfileAddUserId(b, uid)
|
||||||
fb.ProfileAddDisplayName(b, name)
|
fb.ProfileAddDisplayName(b, name)
|
||||||
@@ -111,6 +112,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
fb.ProfileAddEmail(b, email)
|
fb.ProfileAddEmail(b, email)
|
||||||
fb.ProfileAddTelegramLinked(b, p.TelegramLinked)
|
fb.ProfileAddTelegramLinked(b, p.TelegramLinked)
|
||||||
fb.ProfileAddVkLinked(b, p.VkLinked)
|
fb.ProfileAddVkLinked(b, p.VkLinked)
|
||||||
|
if dictVersions != 0 {
|
||||||
|
fb.ProfileAddDictVersions(b, dictVersions)
|
||||||
|
}
|
||||||
if p.Banner != nil {
|
if p.Banner != nil {
|
||||||
fb.ProfileAddBanner(b, banner)
|
fb.ProfileAddBanner(b, banner)
|
||||||
}
|
}
|
||||||
@@ -118,6 +122,31 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// encodeDictVersions builds the Profile's dict_versions vector — one DictVersion table per
|
||||||
|
// variant/version pair — and returns its offset, or 0 when there are none (the field is then
|
||||||
|
// omitted). Every child table and its strings are created before the vector is opened, per the
|
||||||
|
// FlatBuffers rule against nesting a table under one still being built; the caller invokes it
|
||||||
|
// before ProfileStart for the same reason.
|
||||||
|
func encodeDictVersions(b *flatbuffers.Builder, dvs []backendclient.DictVersion) flatbuffers.UOffsetT {
|
||||||
|
if len(dvs) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
offsets := make([]flatbuffers.UOffsetT, len(dvs))
|
||||||
|
for i, dv := range dvs {
|
||||||
|
variant := b.CreateString(dv.Variant)
|
||||||
|
version := b.CreateString(dv.Version)
|
||||||
|
fb.DictVersionStart(b)
|
||||||
|
fb.DictVersionAddVariant(b, variant)
|
||||||
|
fb.DictVersionAddVersion(b, version)
|
||||||
|
offsets[i] = fb.DictVersionEnd(b)
|
||||||
|
}
|
||||||
|
fb.ProfileStartDictVersionsVector(b, len(offsets))
|
||||||
|
for i := len(offsets) - 1; i >= 0; i-- {
|
||||||
|
b.PrependUOffsetT(offsets[i])
|
||||||
|
}
|
||||||
|
return b.EndVector(len(offsets))
|
||||||
|
}
|
||||||
|
|
||||||
// optString creates a FlatBuffers string for a non-empty value, or 0 to omit the
|
// optString creates a FlatBuffers string for a non-empty value, or 0 to omit the
|
||||||
// optional field (the client then reads it as absent).
|
// optional field (the client then reads it as absent).
|
||||||
func optString(b *flatbuffers.Builder, s string) flatbuffers.UOffsetT {
|
func optString(b *flatbuffers.Builder, s string) flatbuffers.UOffsetT {
|
||||||
@@ -270,13 +299,14 @@ func toWireState(s backendclient.StateResp) wire.StateView {
|
|||||||
alphabet[i] = wire.AlphabetEntry{Index: e.Index, Letter: e.Letter, Value: e.Value}
|
alphabet[i] = wire.AlphabetEntry{Index: e.Index, Letter: e.Letter, Value: e.Value}
|
||||||
}
|
}
|
||||||
return wire.StateView{
|
return wire.StateView{
|
||||||
Game: toWireGame(s.Game),
|
Game: toWireGame(s.Game),
|
||||||
Seat: s.Seat,
|
Seat: s.Seat,
|
||||||
Rack: s.Rack,
|
Rack: s.Rack,
|
||||||
BagLen: s.BagLen,
|
BagLen: s.BagLen,
|
||||||
HintsRemaining: s.HintsRemaining,
|
HintsRemaining: s.HintsRemaining,
|
||||||
WalletBalance: s.WalletBalance,
|
WalletBalance: s.WalletBalance,
|
||||||
Alphabet: alphabet,
|
HintUnlockLeftSeconds: s.HintUnlockLeftSeconds,
|
||||||
|
Alphabet: alphabet,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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":"scrabble_en","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":4,"wallet_balance":3}`))
|
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","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":4,"wallet_balance":3,"hint_unlock_left_seconds":1200}`))
|
||||||
})
|
})
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
@@ -77,8 +77,8 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
|||||||
t.Fatalf("handler: %v", err)
|
t.Fatalf("handler: %v", err)
|
||||||
}
|
}
|
||||||
st := fb.GetRootAsStateView(payload, 0)
|
st := fb.GetRootAsStateView(payload, 0)
|
||||||
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 {
|
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 || st.HintUnlockLeftSeconds() != 1200 {
|
||||||
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance())
|
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d unlockLeft=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance(), st.HintUnlockLeftSeconds())
|
||||||
}
|
}
|
||||||
game := st.Game(nil)
|
game := st.Game(nil)
|
||||||
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
|
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ func handlerFor(content fs.FS, stripPrefix, indexName string) http.Handler {
|
|||||||
// app (notably a relaunched Telegram Mini App) is a cache hit, not a re-download.
|
// app (notably a relaunched Telegram Mini App) is a cache hit, not a re-download.
|
||||||
if strings.HasPrefix(name, "assets/") {
|
if strings.HasPrefix(name, "assets/") {
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
} else if name == "sw.js" {
|
||||||
|
// The service worker must be revalidated on every load so a new deploy's worker (and its
|
||||||
|
// fresh precache manifest) is picked up promptly; a cached sw.js would strand clients on
|
||||||
|
// the old build. The worker's network-first navigation then serves the fresh shell online.
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
}
|
}
|
||||||
files.ServeHTTP(w, r)
|
files.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ func TestHandlerServesServiceWorkerAsJavaScript(t *testing.T) {
|
|||||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
|
||||||
t.Errorf("Content-Type = %q, want text/javascript", ct)
|
t.Errorf("Content-Type = %q, want text/javascript", ct)
|
||||||
}
|
}
|
||||||
|
// Revalidated on every load so a new deploy's worker (and its fresh precache) is picked up.
|
||||||
|
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
|
||||||
|
t.Errorf("sw.js Cache-Control = %q, want no-cache", cc)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandlerFallsBackToShellForUnknownManifestPath guards the trap: a manifest/sw path NOT emitted
|
// TestHandlerFallsBackToShellForUnknownManifestPath guards the trap: a manifest/sw path NOT emitted
|
||||||
|
|||||||
@@ -223,6 +223,13 @@ table BannerInfo {
|
|||||||
// suppresses out-of-app platform push, leaving only the in-app live stream. banner
|
// suppresses out-of-app platform push, leaving only the in-app live stream. banner
|
||||||
// carries the advertising-banner block for an eligible viewer, absent otherwise
|
// carries the advertising-banner block for an eligible viewer, absent otherwise
|
||||||
// (all added trailing — backward-compatible).
|
// (all added trailing — backward-compatible).
|
||||||
|
// DictVersion is one variant's current dictionary version, carried on the profile so an offline
|
||||||
|
// client learns it from an existing cold-start request (no extra call for a rarely-used feature).
|
||||||
|
table DictVersion {
|
||||||
|
variant:string;
|
||||||
|
version:string;
|
||||||
|
}
|
||||||
|
|
||||||
table Profile {
|
table Profile {
|
||||||
user_id:string;
|
user_id:string;
|
||||||
display_name:string;
|
display_name:string;
|
||||||
@@ -245,6 +252,10 @@ table Profile {
|
|||||||
email:string;
|
email:string;
|
||||||
telegram_linked:bool;
|
telegram_linked:bool;
|
||||||
vk_linked:bool;
|
vk_linked:bool;
|
||||||
|
// dict_versions carries each variant's current dictionary version so an offline client can
|
||||||
|
// preload the right dictionary and pin a new local game without a separate request (added
|
||||||
|
// trailing — backward-compatible).
|
||||||
|
dict_versions:[DictVersion];
|
||||||
}
|
}
|
||||||
|
|
||||||
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation
|
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation
|
||||||
@@ -305,6 +316,13 @@ table StateView {
|
|||||||
// the wallet is a single global figure the client keeps live across games (added trailing —
|
// the wallet is a single global figure the client keeps live across games (added trailing —
|
||||||
// backward-compatible).
|
// backward-compatible).
|
||||||
wallet_balance:int;
|
wallet_balance:int;
|
||||||
|
// hint_unlock_left_seconds is, for a vs_ai game, how many seconds until the idle hint unlocks
|
||||||
|
// (the robot's last move plus the idle window, computed from the SERVER clock online / the device
|
||||||
|
// clock offline, capped at the window and floored at 0); 0 for a human's first move (no robot move
|
||||||
|
// yet) or a non-vs_ai game. The client anchors a MONOTONIC countdown (performance.now()) to it, so a
|
||||||
|
// client clock change cannot skew it — see lib/hints. Seconds granularity is enough; sent trailing
|
||||||
|
// (additive, backward-compatible).
|
||||||
|
hint_unlock_left_seconds:int;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GameActionRequest carries just a game id (pass / resign / hint / history).
|
// GameActionRequest carries just a game id (pass / resign / hint / history).
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package scrabblefb
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DictVersion struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsDictVersion(buf []byte, offset flatbuffers.UOffsetT) *DictVersion {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &DictVersion{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishDictVersionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.Finish(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsDictVersion(buf []byte, offset flatbuffers.UOffsetT) *DictVersion {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &DictVersion{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishSizePrefixedDictVersionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.FinishSizePrefixed(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *DictVersion) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *DictVersion) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *DictVersion) Variant() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *DictVersion) Version() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DictVersionStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(2)
|
||||||
|
}
|
||||||
|
func DictVersionAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(variant), 0)
|
||||||
|
}
|
||||||
|
func DictVersionAddVersion(builder *flatbuffers.Builder, version flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(version), 0)
|
||||||
|
}
|
||||||
|
func DictVersionEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
@@ -211,8 +211,28 @@ func (rcv *Profile) MutateVkLinked(n bool) bool {
|
|||||||
return rcv._tab.MutateBoolSlot(34, n)
|
return rcv._tab.MutateBoolSlot(34, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *Profile) DictVersions(obj *DictVersion, j int) bool {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(36))
|
||||||
|
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 *Profile) DictVersionsLength() int {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(36))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.VectorLen(o)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func ProfileStart(builder *flatbuffers.Builder) {
|
func ProfileStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(16)
|
builder.StartObject(17)
|
||||||
}
|
}
|
||||||
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
|
||||||
@@ -265,6 +285,12 @@ func ProfileAddTelegramLinked(builder *flatbuffers.Builder, telegramLinked bool)
|
|||||||
func ProfileAddVkLinked(builder *flatbuffers.Builder, vkLinked bool) {
|
func ProfileAddVkLinked(builder *flatbuffers.Builder, vkLinked bool) {
|
||||||
builder.PrependBoolSlot(15, vkLinked, false)
|
builder.PrependBoolSlot(15, vkLinked, false)
|
||||||
}
|
}
|
||||||
|
func ProfileAddDictVersions(builder *flatbuffers.Builder, dictVersions flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(16, flatbuffers.UOffsetT(dictVersions), 0)
|
||||||
|
}
|
||||||
|
func ProfileStartDictVersionsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
|
return builder.StartVector(4, numElems, 4)
|
||||||
|
}
|
||||||
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,8 +156,20 @@ func (rcv *StateView) MutateWalletBalance(n int32) bool {
|
|||||||
return rcv._tab.MutateInt32Slot(16, n)
|
return rcv._tab.MutateInt32Slot(16, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *StateView) HintUnlockLeftSeconds() int32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *StateView) MutateHintUnlockLeftSeconds(n int32) bool {
|
||||||
|
return rcv._tab.MutateInt32Slot(18, n)
|
||||||
|
}
|
||||||
|
|
||||||
func StateViewStart(builder *flatbuffers.Builder) {
|
func StateViewStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(7)
|
builder.StartObject(8)
|
||||||
}
|
}
|
||||||
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)
|
||||||
@@ -186,6 +198,9 @@ func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) fl
|
|||||||
func StateViewAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) {
|
func StateViewAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) {
|
||||||
builder.PrependInt32Slot(6, walletBalance, 0)
|
builder.PrependInt32Slot(6, walletBalance, 0)
|
||||||
}
|
}
|
||||||
|
func StateViewAddHintUnlockLeftSeconds(builder *flatbuffers.Builder, hintUnlockLeftSeconds int32) {
|
||||||
|
builder.PrependInt32Slot(7, hintUnlockLeftSeconds, 0)
|
||||||
|
}
|
||||||
func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-7
@@ -87,13 +87,14 @@ type AlphabetEntry struct {
|
|||||||
// (wire alphabet indices), bag size and hint budget. Alphabet is set only when the
|
// (wire alphabet indices), bag size and hint budget. Alphabet is set only when the
|
||||||
// recipient may not have cached the variant's display table yet.
|
// recipient may not have cached the variant's display table yet.
|
||||||
type StateView struct {
|
type StateView struct {
|
||||||
Game GameView
|
Game GameView
|
||||||
Seat int
|
Seat int
|
||||||
Rack []int
|
Rack []int
|
||||||
BagLen int
|
BagLen int
|
||||||
HintsRemaining int
|
HintsRemaining int
|
||||||
WalletBalance int
|
WalletBalance int
|
||||||
Alphabet []AlphabetEntry
|
HintUnlockLeftSeconds int
|
||||||
|
Alphabet []AlphabetEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccountRef is a referenced account with its display name resolved.
|
// AccountRef is a referenced account with its display name resolved.
|
||||||
@@ -256,6 +257,7 @@ func BuildStateView(b *flatbuffers.Builder, s StateView) flatbuffers.UOffsetT {
|
|||||||
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
||||||
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
||||||
fb.StateViewAddWalletBalance(b, int32(s.WalletBalance))
|
fb.StateViewAddWalletBalance(b, int32(s.WalletBalance))
|
||||||
|
fb.StateViewAddHintUnlockLeftSeconds(b, int32(s.HintUnlockLeftSeconds))
|
||||||
if hasAlphabet {
|
if hasAlphabet {
|
||||||
fb.StateViewAddAlphabet(b, alphabet)
|
fb.StateViewAddAlphabet(b, alphabet)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// Offline pass-and-play (hotseat) is offered only in offline mode, which is gated to an installed
|
||||||
|
// standalone PWA with a confirmed email. The mock account has an email; force standalone so the
|
||||||
|
// Settings offline toggle shows.
|
||||||
|
async function forceStandalone(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const orig = window.matchMedia.bind(window);
|
||||||
|
window.matchMedia = (q: string) =>
|
||||||
|
(q.includes('display-mode: standalone')
|
||||||
|
? { matches: true, media: q, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent: () => false }
|
||||||
|
: orig(q)) as MediaQueryList;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterLobby(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /guest|гост/i }).first().click();
|
||||||
|
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goOffline(page: Page): Promise<void> {
|
||||||
|
await page.locator('button.tab').nth(2).click(); // Settings
|
||||||
|
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
|
||||||
|
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
|
||||||
|
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||||
|
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action).
|
||||||
|
// It first waits for the dots to be empty, so a call made right after a previous entry does not lose
|
||||||
|
// digits during the 250 ms verdict pause (the 4th digit → pause → clear/advance).
|
||||||
|
async function typePin(page: Page, digits: string): Promise<void> {
|
||||||
|
await expect(page.locator('.pad .dot.on')).toHaveCount(0);
|
||||||
|
for (const d of digits) await page.locator('.pad .key', { hasText: d }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('offline hotseat (pass-and-play)', () => {
|
||||||
|
test('create with a locked seat, unlock, host-skip, terminate from the lobby', async ({ page }) => {
|
||||||
|
await forceStandalone(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await enterLobby(page);
|
||||||
|
await goOffline(page);
|
||||||
|
|
||||||
|
// A known seed: the bag deals a full rack deterministically, AND the seeded seating shuffle keeps
|
||||||
|
// the roster order (Ann first) so the locked-seat assertions below are stable. (Seat order is
|
||||||
|
// randomised at start; the shuffle is seed-driven — seed '1' would seat Bob first, '2' Ann first.)
|
||||||
|
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('2'));
|
||||||
|
|
||||||
|
// New game -> the offline mode selector now offers "with friends" (hotseat).
|
||||||
|
await page.locator('button.tab').nth(0).click();
|
||||||
|
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
|
||||||
|
|
||||||
|
// The player rows are disabled until the mandatory host (master) PIN is set.
|
||||||
|
await expect(page.locator('.pname').first()).toBeDisabled();
|
||||||
|
|
||||||
|
// Set the host PIN (enter + confirm), then decline taking a seat.
|
||||||
|
await page.locator('.hostpin .plink').click();
|
||||||
|
await typePin(page, '9999');
|
||||||
|
await typePin(page, '9999');
|
||||||
|
await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
|
||||||
|
|
||||||
|
// Pick the English variant (the plaques mirror Quick Match; "Scrabble" is the Latin English name).
|
||||||
|
await page.locator('.variant', { hasText: 'Scrabble' }).click();
|
||||||
|
|
||||||
|
// Two players: Ann (PIN-locked) and Bob (open).
|
||||||
|
await page.locator('.pname').nth(0).fill('Ann');
|
||||||
|
await page.locator('.pname').nth(1).fill('Bob');
|
||||||
|
|
||||||
|
// Row-delete: a 3rd player's kebab asks the host PIN, which ARMS a ❌ (not a silent delete);
|
||||||
|
// tapping the ❌ removes the row.
|
||||||
|
await page.getByRole('button', { name: /Add player|Добавить/i }).click();
|
||||||
|
await expect(page.locator('.prow')).toHaveCount(3);
|
||||||
|
await page.locator('.prow').nth(2).locator('.pkebab').click();
|
||||||
|
await typePin(page, '9999');
|
||||||
|
const rowDel = page.locator('.prow').nth(2).locator('.prow-del');
|
||||||
|
await expect(rowDel).toBeVisible();
|
||||||
|
await rowDel.click();
|
||||||
|
await expect(page.locator('.prow')).toHaveCount(2);
|
||||||
|
|
||||||
|
// Lock Ann's seat with a PIN.
|
||||||
|
await page.locator('.prow').nth(0).locator('.plink').click();
|
||||||
|
await typePin(page, '1234');
|
||||||
|
await typePin(page, '1234');
|
||||||
|
|
||||||
|
await page.locator('button.invite').click();
|
||||||
|
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Ann is to move and PIN-locked: the board is visible but her rack is withheld behind Unlock.
|
||||||
|
await expect(page.locator('.unlock')).toBeVisible();
|
||||||
|
await expect(page.locator('.rack .tile')).toHaveCount(0);
|
||||||
|
|
||||||
|
// A wrong PIN keeps it locked; the right PIN reveals the full rack.
|
||||||
|
await page.locator('.unlock').click();
|
||||||
|
await typePin(page, '0000');
|
||||||
|
await expect(page.locator('.pad')).toBeVisible(); // still open after a wrong PIN
|
||||||
|
await typePin(page, '1234');
|
||||||
|
await expect(page.locator('.rack .tile')).toHaveCount(7);
|
||||||
|
|
||||||
|
// Opening the history reveals NO social controls on the seat plaques (a local game is
|
||||||
|
// account-less), and the Dictionary entry (the comms button) is kept for the active game.
|
||||||
|
await page.locator('.scoreboard').click();
|
||||||
|
await expect(page.locator('.fico')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.chat-ico')).toBeVisible();
|
||||||
|
await page.locator('.scoreboard').click();
|
||||||
|
|
||||||
|
// Host override: 🔐 -> master PIN -> skip the current turn -> confirm. The turn passes to Bob,
|
||||||
|
// whose seat is open, so his rack shows without a lock.
|
||||||
|
await page.locator('button.tab', { hasText: /Host|Ведущий/ }).click();
|
||||||
|
await typePin(page, '9999');
|
||||||
|
await page.getByRole('button', { name: /Skip|Пропустить/ }).click();
|
||||||
|
await page.getByRole('button', { name: /^(OK|ОК)$/ }).click();
|
||||||
|
await expect(page.locator('.unlock')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.rack .tile')).toHaveCount(7);
|
||||||
|
|
||||||
|
// Back in the lobby the active hotseat game has a kebab; terminating it needs the master PIN.
|
||||||
|
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||||
|
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
|
||||||
|
await expect(page.locator('.rowwrap')).toHaveCount(1);
|
||||||
|
await page.locator('.kebab').first().click();
|
||||||
|
const del = page.locator('.rowwrap.revealed .del');
|
||||||
|
await expect(del).toBeVisible();
|
||||||
|
await del.click();
|
||||||
|
await typePin(page, '9999');
|
||||||
|
await expect(page.locator('.rowwrap')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// The offline mode is gated to an installed standalone PWA with a confirmed email. The mock account
|
||||||
|
// has an email; force the standalone display mode so the Settings offline toggle is offered. Only the
|
||||||
|
// `(display-mode: standalone)` query is overridden — theme/reduce-motion queries pass through.
|
||||||
|
async function forceStandalone(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const orig = window.matchMedia.bind(window);
|
||||||
|
window.matchMedia = (q: string) =>
|
||||||
|
(q.includes('display-mode: standalone')
|
||||||
|
? { matches: true, media: q, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent: () => false }
|
||||||
|
: orig(q)) as MediaQueryList;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a load, land in the lobby. The mock cold-starts on the login screen (no seeded session), so
|
||||||
|
// click through as guest. Waiting for the button (rather than a point-in-time count()) is what makes
|
||||||
|
// this deterministic: a count() sampled during the pre-login splash frame returned 0, skipped the
|
||||||
|
// click, and then hung — or latched a transient lobby tab-bar — instead of opening the real lobby.
|
||||||
|
async function enterLobby(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /guest|гост/i }).first().click();
|
||||||
|
// The lobby tab bar has three tabs in a fixed order: New (0), Stats (1), Settings (2). nth() is
|
||||||
|
// robust to locale, emoji variation selectors and the coachmark anchors (which the first-run
|
||||||
|
// onboarding strips after it completes).
|
||||||
|
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick a rack tile by its glyph and drop it on a board square (the rack of the pinned-seed game has
|
||||||
|
// all-distinct letters, so the glyph is unambiguous).
|
||||||
|
async function placeTile(page: Page, glyph: string, row: number, col: number): Promise<void> {
|
||||||
|
await page.locator('.rack .tile', { hasText: glyph }).first().click();
|
||||||
|
await page.locator(`[data-cell][data-row="${row}"][data-col="${col}"]`).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('offline mode', () => {
|
||||||
|
test('enter via the toggle, play a local vs_ai game, persist across a reload', async ({ page }) => {
|
||||||
|
await forceStandalone(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await enterLobby(page);
|
||||||
|
|
||||||
|
// Online: a seeded online game (vs Ann) is listed.
|
||||||
|
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Enter offline through the real Settings toggle (its readiness check fetches every enabled
|
||||||
|
// variant's dawg, served from /e2edict/ by the mock) — the header turns blue with the chip.
|
||||||
|
await page.locator('button.tab').nth(2).click();
|
||||||
|
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
|
||||||
|
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Back in the (now offline) lobby: the online games are hidden and the Stats tab is disabled.
|
||||||
|
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||||
|
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||||
|
await expect(page.getByText('Ann', { exact: false })).toHaveCount(0);
|
||||||
|
await expect(page.locator('button.tab').nth(1)).toBeDisabled();
|
||||||
|
|
||||||
|
// Create a device-local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO).
|
||||||
|
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
|
||||||
|
await page.locator('button.tab').nth(0).click();
|
||||||
|
// The English variant's display name is "Scrabble" (Latin) in both locales — the Russian
|
||||||
|
// variants are "Скрэббл"/"Эрудит"/"Erudite", so this uniquely selects the English game.
|
||||||
|
await page.locator('.variant', { hasText: 'Scrabble' }).click();
|
||||||
|
await page.locator('button.invite').click();
|
||||||
|
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||||
|
|
||||||
|
// The human's first move is not idle-gated: the hint is available at once (no 🔒 lock badge).
|
||||||
|
await expect(page.locator('.lock')).toHaveCount(0);
|
||||||
|
|
||||||
|
// The human plays WAY horizontally across the centre (7,5)-(7,7).
|
||||||
|
await placeTile(page, 'W', 7, 5);
|
||||||
|
await placeTile(page, 'A', 7, 6);
|
||||||
|
await placeTile(page, 'Y', 7, 7);
|
||||||
|
await expect(page.locator('[data-cell].pending')).toHaveCount(3);
|
||||||
|
await page.locator('.make').click();
|
||||||
|
|
||||||
|
// The play commits and the local robot replies with a real move, so the board carries more than
|
||||||
|
// WAY's three tiles.
|
||||||
|
await expect(async () => {
|
||||||
|
expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3);
|
||||||
|
}).toPass({ timeout: 15000 });
|
||||||
|
const filled = await page.locator('[data-cell].filled').count();
|
||||||
|
|
||||||
|
// Now it is the human's turn again after the robot moved: the idle hint gate arms, so the hint
|
||||||
|
// button shows the 🔒 lock (it lifts after 30 idle minutes on a monotonic clock — not waited here).
|
||||||
|
await expect(page.locator('.lock')).toBeVisible();
|
||||||
|
|
||||||
|
// Reload: the hash router restores the /game/<id> route and the local game replays from
|
||||||
|
// IndexedDB with every committed tile intact.
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||||
|
await expect(page.locator('[data-cell].filled')).toHaveCount(filled);
|
||||||
|
// The idle-hint gate is persisted (a wall-clock unlock time), so the 🔒 survives the reload —
|
||||||
|
// the wait was not reset by relaunching.
|
||||||
|
await expect(page.locator('.lock')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { expect, test } from './fixtures';
|
||||||
|
|
||||||
|
// The hash router must update its reactive `route` rune synchronously inside navigate(), not defer it
|
||||||
|
// to the asynchronous `hashchange` event. Bootstrap flips `app.ready` in the same tick right after
|
||||||
|
// `navigate('/login')` on an unauthenticated cold start; a route that trailed the hash for one frame
|
||||||
|
// let App.svelte render the stale route (the empty-hash lobby) under the new login screen — a visible
|
||||||
|
// lobby-shell flash plus a doomed games.list. It also made the offline e2e flaky: enterLobby could
|
||||||
|
// latch that transient lobby tab-bar instead of clicking through the login screen.
|
||||||
|
|
||||||
|
test('navigate updates the reactive route synchronously (no hashchange lag)', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// The bundle has loaded and installed the mock __router seam once the login screen is up.
|
||||||
|
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Read the route in the SAME microtask as the navigate: the fix makes it the new route at once;
|
||||||
|
// the pre-fix code returned the previous route (it waited for the async hashchange).
|
||||||
|
const routeAfterNavigate = await page.evaluate(() => {
|
||||||
|
const r = (window as unknown as { __router: { navigate(p: string): void; route(): string } }).__router;
|
||||||
|
r.navigate('/settings');
|
||||||
|
return r.route();
|
||||||
|
});
|
||||||
|
expect(routeAfterNavigate).toBe('settings');
|
||||||
|
|
||||||
|
// The hash was written too, so a reload/back still resolves the same route.
|
||||||
|
expect(new URL(page.url()).hash).toBe('#/settings');
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// Playwright's WebKit has no real soft keyboard, so emulate iOS's visual-viewport behaviour: replace
|
||||||
|
// window.visualViewport with a controllable fake BEFORE the app boots. The app's syncViewport
|
||||||
|
// (app.svelte.ts) reads visualViewport.height/offsetTop and mirrors them into --vvh / --vv-top, which
|
||||||
|
// position the pinned app-shell (app.css html.app-shell body). Driving the fake exercises the exact
|
||||||
|
// code path the real keyboard triggers on iOS — where the layout viewport does NOT shrink and the
|
||||||
|
// visual viewport instead offsets down toward the focused field.
|
||||||
|
async function installFakeViewport(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const fake = new EventTarget() as EventTarget & { height: number; offsetTop: number; width: number };
|
||||||
|
fake.height = window.innerHeight;
|
||||||
|
fake.offsetTop = 0;
|
||||||
|
fake.width = window.innerWidth;
|
||||||
|
Object.defineProperty(window, 'visualViewport', { configurable: true, value: fake });
|
||||||
|
(window as unknown as { __vv: typeof fake }).__vv = fake;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setViewport(page: Page, height: number, offsetTop: number): Promise<void> {
|
||||||
|
await page.evaluate(
|
||||||
|
([h, t]) => {
|
||||||
|
const vv = (window as unknown as { __vv: { height: number; offsetTop: number; dispatchEvent(e: Event): boolean } }).__vv;
|
||||||
|
vv.height = h;
|
||||||
|
vv.offsetTop = t;
|
||||||
|
vv.dispatchEvent(new Event('resize'));
|
||||||
|
vv.dispatchEvent(new Event('scroll'));
|
||||||
|
},
|
||||||
|
[height, offsetTop],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shell(page: Page): Promise<{ vvh: string; top: string; bodyTop: string }> {
|
||||||
|
return page.evaluate(() => ({
|
||||||
|
vvh: getComputedStyle(document.documentElement).getPropertyValue('--vvh').trim(),
|
||||||
|
top: getComputedStyle(document.documentElement).getPropertyValue('--vv-top').trim(),
|
||||||
|
bodyTop: getComputedStyle(document.body).top,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('visual-viewport shell (soft-keyboard alignment)', () => {
|
||||||
|
test('the pinned shell follows the visual viewport height AND offset', async ({ page }) => {
|
||||||
|
await installFakeViewport(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('html.app-shell')).toBeAttached();
|
||||||
|
const innerH = await page.evaluate(() => window.innerHeight);
|
||||||
|
|
||||||
|
// Keyboard closed: full height, no offset.
|
||||||
|
await setViewport(page, innerH, 0);
|
||||||
|
let s = await shell(page);
|
||||||
|
expect(s.top).toBe('0px');
|
||||||
|
expect(s.bodyTop).toBe('0px');
|
||||||
|
|
||||||
|
// Keyboard open + iOS offset: the visual viewport shrinks AND offsets down. The pinned shell must
|
||||||
|
// follow BOTH — the body's top must equal the offset (not stay at 0), else the top-anchored
|
||||||
|
// content shows empty space below (the iOS bug this fixes).
|
||||||
|
await setViewport(page, innerH - 300, 180);
|
||||||
|
s = await shell(page);
|
||||||
|
expect(s.vvh).toBe(`${innerH - 300}px`);
|
||||||
|
expect(s.top).toBe('180px');
|
||||||
|
expect(s.bodyTop).toBe('180px');
|
||||||
|
|
||||||
|
// Keyboard closed again: the offset reverts to 0 — no stale shift left behind.
|
||||||
|
await setViewport(page, innerH, 0);
|
||||||
|
s = await shell(page);
|
||||||
|
expect(s.top).toBe('0px');
|
||||||
|
expect(s.bodyTop).toBe('0px');
|
||||||
|
});
|
||||||
|
});
|
||||||
+5
-1
@@ -32,6 +32,10 @@
|
|||||||
"svelte-check": "^4.1.0",
|
"svelte-check": "^4.1.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
"vitest": "^3.0.0"
|
"vite-plugin-pwa": "^0.21.2",
|
||||||
|
"vitest": "^3.0.0",
|
||||||
|
"workbox-core": "^7.4.1",
|
||||||
|
"workbox-precaching": "^7.4.1",
|
||||||
|
"workbox-routing": "^7.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ export default defineConfig({
|
|||||||
// under /app/ and /telegram/): served from the preview root, an absolute base keeps assets at
|
// under /app/ and /telegram/): served from the preview root, an absolute base keeps assets at
|
||||||
// /assets/ so the SPA-fallback also boots a subpath like /telegram/. Base only prefixes asset
|
// /assets/ so the SPA-fallback also boots a subpath like /telegram/. Base only prefixes asset
|
||||||
// URLs, so the minified JS under test is identical to the contour's.
|
// URLs, so the minified JS under test is identical to the contour's.
|
||||||
|
// After the mock build, copy the real per-variant dawgs into the preview output (dist-e2e) so the
|
||||||
|
// offline spec can play a real local vs_ai game; the files are e2e-only (never committed, never in
|
||||||
|
// the production build). Source: E2E_DICT_DIR (CI: the fetched release; local: the sibling).
|
||||||
command:
|
command:
|
||||||
'pnpm exec vite build --mode mock --base / --outDir dist-e2e --emptyOutDir && pnpm exec vite preview --outDir dist-e2e --port 4173 --strictPort',
|
'pnpm exec vite build --mode mock --base / --outDir dist-e2e --emptyOutDir && node scripts/e2e-dict.mjs && pnpm exec vite preview --outDir dist-e2e --port 4173 --strictPort',
|
||||||
url: 'http://localhost:4173',
|
url: 'http://localhost:4173',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
|
|||||||
Generated
+3195
-21
File diff suppressed because it is too large
Load Diff
@@ -1,53 +0,0 @@
|
|||||||
// Install-only service worker.
|
|
||||||
//
|
|
||||||
// Its sole job today is to satisfy Chromium's PWA installability requirement (a registered
|
|
||||||
// service worker with a fetch handler) so the web app can be installed to the home screen /
|
|
||||||
// desktop — notably on Android, where the manifest alone is not enough. It deliberately does
|
|
||||||
// NOT cache assets or the Connect-RPC stream: only top-level navigations are handled,
|
|
||||||
// network-first with a cached-shell fallback, so the live app, its immutable hashed assets and
|
|
||||||
// the live event stream are never served stale.
|
|
||||||
//
|
|
||||||
// This is the single designated growth point for the planned offline mode (a Settings toggle,
|
|
||||||
// default online — see docs/ARCHITECTURE.md). Extend the fetch router here; keep the same
|
|
||||||
// /app/ scope; gate any real caching behind the toggle and coordinate updates with the boot
|
|
||||||
// version guard.
|
|
||||||
const SHELL = 'scrabble-shell-v1'; // bump only when the SW strategy itself changes.
|
|
||||||
|
|
||||||
self.addEventListener('install', () => {
|
|
||||||
// No cached content to migrate carefully, so activate the new worker immediately.
|
|
||||||
self.skipWaiting();
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
(async () => {
|
|
||||||
const keys = await caches.keys();
|
|
||||||
await Promise.all(keys.filter((k) => k !== SHELL).map((k) => caches.delete(k)));
|
|
||||||
await self.clients.claim();
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
|
||||||
const req = event.request;
|
|
||||||
// Only top-level navigations are handled. Hashed assets and the Connect-RPC requests are
|
|
||||||
// left untouched (no respondWith -> the browser performs its default fetch), so the worker
|
|
||||||
// can never break the live stream or serve a stale immutable asset.
|
|
||||||
if (req.mode !== 'navigate') return;
|
|
||||||
event.respondWith(
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(req);
|
|
||||||
const cache = await caches.open(SHELL);
|
|
||||||
// One shell entry regardless of the deep-link path: the hash router resolves the route
|
|
||||||
// client-side, so any offline navigation can be served the same cached shell.
|
|
||||||
cache.put('shell', res.clone());
|
|
||||||
return res;
|
|
||||||
} catch {
|
|
||||||
const cache = await caches.open(SHELL);
|
|
||||||
const cached = await cache.match('shell');
|
|
||||||
return cached ?? Response.error();
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -20,10 +20,16 @@ import { join } from 'node:path';
|
|||||||
const DIST = 'dist';
|
const DIST = 'dist';
|
||||||
|
|
||||||
// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview
|
// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview
|
||||||
// wiring, then to 112 for the PWA install feature (the install CTA + the pwa detection /
|
// wiring, to 112 for the PWA install feature, to 113 for the offline-mode wiring, to 114 for
|
||||||
// service-worker registration live in the app entry; the heavy dict subsystem stays in lazy
|
// the offline auto-detect (the cold-start reachability check and the "no connection" dialog live in
|
||||||
// chunks). Its scoped CSS lands in the CSS chunk, not this JS budget.
|
// the boot path), then to 115 for the vs_ai idle-hint gate — its monotonic clock, lock badge and
|
||||||
const BUDGET = { app: 112, shared: 30, landing: 5 };
|
// countdown toast live in the always-loaded game screen (Game.svelte) — and to 120 for the offline
|
||||||
|
// pass-and-play (hotseat) mode: the on-screen PIN pad, the creation roster and the in-game host menu
|
||||||
|
// live in the always-loaded New Game / Game / Lobby screens (the offline engine and the tiny PIN
|
||||||
|
// hashing stay in lazy chunks / are negligible). The heavy parts — the dict loader, the move
|
||||||
|
// generator and the preload orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS
|
||||||
|
// chunk, not this JS budget.
|
||||||
|
const BUDGET = { app: 120, shared: 30, landing: 5 };
|
||||||
|
|
||||||
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
||||||
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Copies the real per-variant dictionary DAWGs into the mock e2e preview output (dist-e2e/e2edict/)
|
||||||
|
// so the offline spec can create and play a real local vs_ai game (the mock's fetchDict serves these
|
||||||
|
// files — see lib/mock/client.ts). The dawgs are NEVER committed and never enter the production
|
||||||
|
// build; they live only in the throwaway dist-e2e/ that `vite preview` serves for Playwright.
|
||||||
|
//
|
||||||
|
// Source directory: E2E_DICT_DIR — in CI the fetched scrabble-dictionary release
|
||||||
|
// ($GITHUB_WORKSPACE/dawg); locally it defaults to the sibling scrabble-solver/dawg checkout. A
|
||||||
|
// missing source only warns (so the other, dawg-free specs still run); the offline spec then fails
|
||||||
|
// with an obvious HTTP 404 from fetchDict.
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const srcDir = process.env.E2E_DICT_DIR ?? '../../scrabble-solver/dawg';
|
||||||
|
const outDir = 'dist-e2e/e2edict';
|
||||||
|
|
||||||
|
// The app's Variant enum value -> the release dawg file name (matches the movegen/parity mapping).
|
||||||
|
const dawgFor = {
|
||||||
|
scrabble_en: 'en_sowpods',
|
||||||
|
scrabble_ru: 'ru_scrabble',
|
||||||
|
erudit_ru: 'ru_erudit',
|
||||||
|
};
|
||||||
|
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
let copied = 0;
|
||||||
|
for (const [variant, file] of Object.entries(dawgFor)) {
|
||||||
|
const src = join(srcDir, `${file}.dawg`);
|
||||||
|
if (!existsSync(src)) {
|
||||||
|
console.warn(`e2e-dict: missing ${src} — the offline spec will 404 (set E2E_DICT_DIR)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
copyFileSync(src, join(outDir, `${variant}.dawg`));
|
||||||
|
copied++;
|
||||||
|
}
|
||||||
|
console.log(`e2e-dict: copied ${copied}/${Object.keys(dawgFor).length} dawgs from ${srcDir} -> ${outDir}`);
|
||||||
+59
-1
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
import { app, bootstrap } from './lib/app.svelte';
|
import { app, bootstrap, resolveOfflinePrompt } from './lib/app.svelte';
|
||||||
import { router, type RouteName } from './lib/router.svelte';
|
import { router, type RouteName } from './lib/router.svelte';
|
||||||
import { t } from './lib/i18n/index.svelte';
|
import { t } from './lib/i18n/index.svelte';
|
||||||
import Toast from './components/Toast.svelte';
|
import Toast from './components/Toast.svelte';
|
||||||
@@ -136,6 +136,21 @@
|
|||||||
<Coachmark />
|
<Coachmark />
|
||||||
<MaintenanceOverlay />
|
<MaintenanceOverlay />
|
||||||
|
|
||||||
|
<!-- Cold-start "no connection" dialog: the reachability check timed out with the network interface
|
||||||
|
reportedly online, so it is ambiguous. The player chooses to go offline (play local vs_ai) or to
|
||||||
|
keep trying to connect. bootstrap awaits this choice (app.offlinePrompt). -->
|
||||||
|
{#if app.offlinePrompt}
|
||||||
|
<div class="offprompt" role="dialog" aria-modal="true">
|
||||||
|
<div class="card">
|
||||||
|
<p class="msg">{t('offline.promptTitle')}</p>
|
||||||
|
<div class="acts">
|
||||||
|
<button class="opt primary" onclick={() => resolveOfflinePrompt(true)}>{t('offline.promptYes')}</button>
|
||||||
|
<button class="opt" onclick={() => resolveOfflinePrompt(false)}>{t('offline.promptNo')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
||||||
<Splash />
|
<Splash />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -145,6 +160,49 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* The cold-start "no connection" dialog — a centred card over a scrim, above the splash. */
|
||||||
|
.offprompt {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
padding: var(--pad);
|
||||||
|
}
|
||||||
|
.offprompt .card {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 320px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.offprompt .msg {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.offprompt .acts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.offprompt .opt {
|
||||||
|
padding: 11px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.offprompt .opt.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
.splash {
|
.splash {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
+9
-1
@@ -162,7 +162,15 @@ html.app-shell {
|
|||||||
}
|
}
|
||||||
html.app-shell body {
|
html.app-shell body {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
/* Follow the visual viewport (top + height), not merely fill the layout viewport: iOS Safari /
|
||||||
|
WKWebView does not shrink the layout viewport for the soft keyboard — it offsets the visual
|
||||||
|
viewport down toward the focused field — so a top-anchored (inset:0) shell would misalign, showing
|
||||||
|
empty space below the content (worst on a repeat focus). --vv-top / --vvh are kept in sync from
|
||||||
|
app.svelte.ts (syncViewport). The fallbacks equal inset:0 before the first sync / on the landing. */
|
||||||
|
top: var(--vv-top, 0px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: var(--vvh, 100%);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
|
import { offlineMode, dictPreloadWarning } from '../lib/offline.svelte';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import { app, openDebug } from '../lib/app.svelte';
|
import { app, openDebug } from '../lib/app.svelte';
|
||||||
import Spinner from './Spinner.svelte';
|
import Spinner from './Spinner.svelte';
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="nav" class:grow>
|
<header class="nav" class:grow class:offline={offlineMode.active}>
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
{#if showBack}
|
{#if showBack}
|
||||||
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
|
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
|
||||||
@@ -36,15 +37,20 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if connection.online}
|
{#if connection.online || offlineMode.active}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
<h1 onclick={onTitleTap}>{title}</h1>
|
<h1 onclick={onTitleTap}>{title}</h1>
|
||||||
{:else}
|
{:else}
|
||||||
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
|
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- A right-hand spacer balances the back button so the title stays centred. -->
|
<!-- A right-hand spacer balances the back button so the title stays centred; in offline mode it
|
||||||
<span class="spacer"></span>
|
carries the "Offline" chip so the deliberate mode is always visible (not just the blue tint). -->
|
||||||
|
{#if offlineMode.active}
|
||||||
|
<span class="chip">{t('settings.offline')}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="spacer"></span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<!-- The ad banner lives inside the nav, directly under the title bar, so it sits in the
|
<!-- The ad banner lives inside the nav, directly under the title bar, so it sits in the
|
||||||
same place on every screen — and in the game (grown nav) the spare height falls below
|
same place on every screen — and in the game (grown nav) the spare height falls below
|
||||||
@@ -52,7 +58,11 @@
|
|||||||
coachmark overlay is up (app.coachActive) so the scrolling strip does not run behind the
|
coachmark overlay is up (app.coachActive) so the scrolling strip does not run behind the
|
||||||
dimmed onboarding layer; the engine keeps rotating (module scope) and the strip reappears,
|
dimmed onboarding layer; the engine keeps rotating (module scope) and the strip reappears,
|
||||||
per this same condition, once onboarding closes. -->
|
per this same condition, once onboarding closes. -->
|
||||||
{#if app.profile?.banner && app.profile.banner.campaigns.length && !app.coachActive}
|
{#if dictPreloadWarning.active}
|
||||||
|
<!-- A background dictionary preload for offline readiness failed (poor connection): a soft
|
||||||
|
notice takes the ad banner's slot until a later preload succeeds and clears it. -->
|
||||||
|
<p class="preload-warn" role="alert">{t('offline.preloadWarning')}</p>
|
||||||
|
{:else if app.profile?.banner && app.profile.banner.campaigns.length && !app.coachActive}
|
||||||
<AdBanner
|
<AdBanner
|
||||||
campaigns={app.profile.banner.campaigns}
|
campaigns={app.profile.banner.campaigns}
|
||||||
timings={app.profile.banner.timings}
|
timings={app.profile.banner.timings}
|
||||||
@@ -74,6 +84,12 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
/* Deliberate offline mode: a blue-tinted nav, mixed from the accent so it tracks the light/dark
|
||||||
|
theme (and any Telegram theme override) — the offline state is unmistakable at a glance. */
|
||||||
|
.nav.offline {
|
||||||
|
background: color-mix(in srgb, var(--accent) 20%, var(--bg-elev));
|
||||||
|
border-bottom-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||||
|
}
|
||||||
.nav.grow {
|
.nav.grow {
|
||||||
/* Grow into spare height (banner under the title, the board pinned to the bottom), but
|
/* Grow into spare height (banner under the title, the board pinned to the bottom), but
|
||||||
never shrink: on a short viewport the banner keeps its height and the board's own
|
never shrink: on a short viewport the banner keeps its height and the board's own
|
||||||
@@ -114,6 +130,34 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
/* The offline chip: a compact accent-tinted pill in the header's right slot. */
|
||||||
|
.chip {
|
||||||
|
min-width: 40px;
|
||||||
|
height: 24px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* The offline-readiness preload warning: a soft, muted strip in the ad banner's slot, sized like
|
||||||
|
the banner region so the bar does not jump when it appears. */
|
||||||
|
.preload-warn {
|
||||||
|
margin: 0;
|
||||||
|
padding: 7px var(--pad);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
.back {
|
.back {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Apple-lock-screen-style 4-digit PIN pad for offline pass-and-play (hotseat) games.
|
||||||
|
// Modes:
|
||||||
|
// set — enter a PIN, then repeat it to confirm; emits { kind: 'set', lock }.
|
||||||
|
// verify — enter a PIN; runs `verify(pin)` on the 4th digit; emits { kind: 'verified' }
|
||||||
|
// or shakes + clears on a wrong PIN.
|
||||||
|
// change — run `verify` on the current PIN, then offer "set a new PIN" (the set flow)
|
||||||
|
// or, when allowRemove, "remove PIN" ({ kind: 'removed' }).
|
||||||
|
// Verification is delegated to the caller's `verify` callback: at creation time it checks a
|
||||||
|
// UI-held lock (lib/pin verifyPin); in-game it delegates to the local source (which holds the
|
||||||
|
// stored lock). The pad never shows an OK button: entering the 4th digit is the action. PIN
|
||||||
|
// storage is a social lock, not cryptography — see lib/pin.ts.
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
import { newLock, type PinLock } from '../lib/pin';
|
||||||
|
|
||||||
|
/** PinResult is the outcome handed to onresult when the pad completes. */
|
||||||
|
export type PinResult =
|
||||||
|
| { kind: 'set'; lock: PinLock }
|
||||||
|
| { kind: 'verified' }
|
||||||
|
| { kind: 'removed' };
|
||||||
|
|
||||||
|
let {
|
||||||
|
mode,
|
||||||
|
title = '',
|
||||||
|
verify,
|
||||||
|
allowRemove = false,
|
||||||
|
onclose,
|
||||||
|
onresult,
|
||||||
|
}: {
|
||||||
|
mode: 'set' | 'verify' | 'change';
|
||||||
|
title?: string;
|
||||||
|
/** Verifies an entered PIN (required for 'verify' and 'change'). */
|
||||||
|
verify?: (pin: string) => Promise<boolean>;
|
||||||
|
allowRemove?: boolean;
|
||||||
|
onclose?: () => void;
|
||||||
|
onresult: (r: PinResult) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const LEN = 4;
|
||||||
|
|
||||||
|
// 'old' verifies the existing PIN (change); 'menu' offers set-new / remove (change);
|
||||||
|
// 'enter' collects a PIN; 'confirm' re-enters it (set / change→new). `mode` is fixed
|
||||||
|
// for the pad's lifetime (callers mount a fresh pad per operation), so capturing its
|
||||||
|
// initial value here is intentional.
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let phase = $state<'old' | 'menu' | 'enter' | 'confirm'>(mode === 'change' ? 'old' : 'enter');
|
||||||
|
let buffer = $state('');
|
||||||
|
let first = $state(''); // the first entry, awaiting confirmation
|
||||||
|
let error = $state(false); // drives the error prompt + shake
|
||||||
|
let shakeSeq = $state(0); // bumped on each error to replay the shake animation
|
||||||
|
let busy = $state(false); // guards the async check between the 4th digit and the outcome
|
||||||
|
|
||||||
|
function promptText(): string {
|
||||||
|
if (error) return phase === 'confirm' ? t('pin.mismatch') : t('pin.wrong');
|
||||||
|
switch (phase) {
|
||||||
|
case 'old':
|
||||||
|
return t('pin.enterCurrent');
|
||||||
|
case 'confirm':
|
||||||
|
return t('pin.repeat');
|
||||||
|
default:
|
||||||
|
return mode === 'verify' ? t('pin.enter') : t('pin.create');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(): void {
|
||||||
|
buffer = '';
|
||||||
|
error = true;
|
||||||
|
shakeSeq += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit(): Promise<void> {
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
// Let the 4th dot register before the verdict: a beat so the fill (and, on failure, the shake)
|
||||||
|
// reads as a deliberate response rather than an instant flicker.
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
if (phase === 'old') {
|
||||||
|
if (verify && (await verify(buffer))) {
|
||||||
|
buffer = '';
|
||||||
|
error = false;
|
||||||
|
phase = 'menu';
|
||||||
|
} else fail();
|
||||||
|
} else if (phase === 'enter' && mode === 'verify') {
|
||||||
|
if (verify && (await verify(buffer))) onresult({ kind: 'verified' });
|
||||||
|
else fail();
|
||||||
|
} else if (phase === 'enter') {
|
||||||
|
first = buffer;
|
||||||
|
buffer = '';
|
||||||
|
error = false;
|
||||||
|
phase = 'confirm';
|
||||||
|
} else if (phase === 'confirm') {
|
||||||
|
if (buffer === first) onresult({ kind: 'set', lock: await newLock(buffer) });
|
||||||
|
else {
|
||||||
|
first = '';
|
||||||
|
phase = 'enter';
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function press(d: string): void {
|
||||||
|
if (busy || phase === 'menu' || buffer.length >= LEN) return;
|
||||||
|
error = false;
|
||||||
|
buffer += d;
|
||||||
|
if (buffer.length === LEN) void commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function back(): void {
|
||||||
|
if (busy || phase === 'menu') return;
|
||||||
|
buffer = buffer.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNew(): void {
|
||||||
|
error = false;
|
||||||
|
first = '';
|
||||||
|
buffer = '';
|
||||||
|
phase = 'enter';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkey(e: KeyboardEvent): void {
|
||||||
|
if (e.key >= '0' && e.key <= '9') {
|
||||||
|
press(e.key);
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'Backspace') {
|
||||||
|
back();
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
onclose?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||||
|
const slots = Array.from({ length: LEN }, (_, i) => i);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onkey} />
|
||||||
|
|
||||||
|
<Modal {title} {onclose} overlayKeyboard>
|
||||||
|
<div class="pad">
|
||||||
|
{#if phase === 'menu'}
|
||||||
|
<div class="menu">
|
||||||
|
<button class="opt" onclick={startNew}>{t('pin.setNew')}</button>
|
||||||
|
{#if allowRemove}
|
||||||
|
<button class="opt danger" onclick={() => onresult({ kind: 'removed' })}>
|
||||||
|
{t('pin.remove')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="prompt" class:err={error}>{promptText()}</p>
|
||||||
|
{#key shakeSeq}
|
||||||
|
<div class="dots" class:shake={error} aria-hidden="true">
|
||||||
|
{#each slots as i}
|
||||||
|
<span class="dot" class:on={i < buffer.length}></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
<div class="grid">
|
||||||
|
{#each keys as k}
|
||||||
|
<button class="key" onclick={() => press(k)} aria-label={k}>{k}</button>
|
||||||
|
{/each}
|
||||||
|
<span class="key spacer"></span>
|
||||||
|
<button class="key" onclick={() => press('0')} aria-label="0">0</button>
|
||||||
|
<button class="key back" onclick={back} aria-label="⌫" disabled={buffer.length === 0}>⌫</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pad {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
.prompt {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--muted, var(--text));
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
.prompt.err {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--text);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.dot.on {
|
||||||
|
background: var(--text);
|
||||||
|
}
|
||||||
|
.shake {
|
||||||
|
animation: shake 0.32s ease;
|
||||||
|
}
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
20% { transform: translateX(-7px); }
|
||||||
|
40% { transform: translateX(7px); }
|
||||||
|
60% { transform: translateX(-5px); }
|
||||||
|
80% { transform: translateX(5px); }
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.key {
|
||||||
|
width: 62px;
|
||||||
|
height: 62px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.key:active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
.key.back {
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
.key.back:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
.key.spacer {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.opt {
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.opt.danger {
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
|
import { gameSource, isLocalGameId } from '../lib/gamesource';
|
||||||
import { handleError, showToast } from '../lib/app.svelte';
|
import { handleError, showToast } from '../lib/app.svelte';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import { alphabetLetters } from '../lib/alphabet';
|
import { alphabetLetters } from '../lib/alphabet';
|
||||||
@@ -20,8 +21,10 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
// Include the alphabet so input sanitising + the check accept the variant's letters.
|
// Include the alphabet so input sanitising + the check accept the variant's letters. Routed
|
||||||
const st = await gateway.gameState(id, true);
|
// through gameSource so an offline (local) game resolves its state from the device, not the
|
||||||
|
// network.
|
||||||
|
const st = await gameSource(id).gameState(id, true);
|
||||||
variant = st.game.variant;
|
variant = st.game.variant;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
@@ -41,7 +44,7 @@
|
|||||||
cooling = true;
|
cooling = true;
|
||||||
setTimeout(() => (cooling = false), 5000);
|
setTimeout(() => (cooling = false), 5000);
|
||||||
try {
|
try {
|
||||||
const r = await gateway.checkWord(id, w, variant);
|
const r = await gameSource(id).checkWord(id, w, variant);
|
||||||
checked.set(w, r.legal);
|
checked.set(w, r.legal);
|
||||||
result = { word: w, legal: r.legal };
|
result = { word: w, legal: r.legal };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -77,7 +80,11 @@
|
|||||||
: t('game.wordIllegal', { word: result.word })}
|
: t('game.wordIllegal', { word: result.word })}
|
||||||
</p>
|
</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="complain" onclick={complain}>{t('game.complain')}</button>
|
<!-- Complaints go to the admin over the network; a local (offline) game has no backend to
|
||||||
|
receive them, so the control is dropped there. -->
|
||||||
|
{#if !isLocalGameId(id)}
|
||||||
|
<button class="complain" onclick={complain}>{t('game.complain')}</button>
|
||||||
|
{/if}
|
||||||
{#if result.legal}
|
{#if result.legal}
|
||||||
<a
|
<a
|
||||||
class="lookup"
|
class="lookup"
|
||||||
|
|||||||
@@ -16,15 +16,21 @@
|
|||||||
// The game is rendered (and cached) before its comms open, so the cache tells us whether
|
// The game is rendered (and cached) before its comms open, so the cache tells us whether
|
||||||
// it is still active without another fetch; an unknown game keeps the Dictionary offered.
|
// it is still active without another fetch; an unknown game keeps the Dictionary offered.
|
||||||
const active = $derived(getCachedGame(id)?.view.game.status !== 'finished');
|
const active = $derived(getCachedGame(id)?.view.game.status !== 'finished');
|
||||||
// An honest-AI game has no chat at all, so its hub is Dictionary-only.
|
// A local game has NO chat: an honest-AI game (vs_ai) and an offline pass-and-play (hotseat) game.
|
||||||
const vsAi = $derived(getCachedGame(id)?.view.game.vsAi ?? false);
|
// Its hub is Dictionary-only.
|
||||||
|
const chatless = $derived(
|
||||||
|
(getCachedGame(id)?.view.game.vsAi ?? false) || (getCachedGame(id)?.view.game.hotseat ?? false),
|
||||||
|
);
|
||||||
// Seeded once from the entry route's tab, then owned locally. The effect keeps the tab valid:
|
// Seeded once from the entry route's tab, then owned locally. The effect keeps the tab valid:
|
||||||
// an AI game has only the Dictionary; a finished non-AI game has only Chat (a stale Dictionary
|
// a chatless game has only the Dictionary; a finished non-AI game has only Chat (a stale Dictionary
|
||||||
// deep-link falls back to Chat).
|
// deep-link falls back to Chat).
|
||||||
|
// A chatless game starts on the Dictionary regardless of the entry route — this keeps ChatScreen
|
||||||
|
// (which fetches chat over the network) from mounting even for a beat, which would otherwise raise
|
||||||
|
// an error toast in offline mode.
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let tab = $state<CommsTab>(initialTab);
|
let tab = $state<CommsTab>(chatless ? 'dictionary' : initialTab);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (vsAi) tab = 'dictionary';
|
if (chatless) tab = 'dictionary';
|
||||||
else if (tab === 'dictionary' && !active) tab = 'chat';
|
else if (tab === 'dictionary' && !active) tab = 'chat';
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -43,7 +49,7 @@
|
|||||||
|
|
||||||
{#snippet tabbar()}
|
{#snippet tabbar()}
|
||||||
<TabBar>
|
<TabBar>
|
||||||
{#if !vsAi}
|
{#if !chatless}
|
||||||
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
|
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
|
||||||
<span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span>
|
<span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+392
-49
@@ -4,24 +4,28 @@
|
|||||||
import TabBar from '../components/TabBar.svelte';
|
import TabBar from '../components/TabBar.svelte';
|
||||||
import TapConfirm from '../components/TapConfirm.svelte';
|
import TapConfirm from '../components/TapConfirm.svelte';
|
||||||
import Modal from '../components/Modal.svelte';
|
import Modal from '../components/Modal.svelte';
|
||||||
|
import PinPad from '../components/PinPad.svelte';
|
||||||
import DictWarmup from '../components/DictWarmup.svelte';
|
import DictWarmup from '../components/DictWarmup.svelte';
|
||||||
import Board from './Board.svelte';
|
import Board from './Board.svelte';
|
||||||
import Rack from './Rack.svelte';
|
import Rack from './Rack.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
|
import { gameSource, localSource, isLocalGameId } from '../lib/gamesource';
|
||||||
import { notePreviewLocal, notePreviewNetwork } from '../lib/localeval-metrics';
|
import { notePreviewLocal, notePreviewNetwork } from '../lib/localeval-metrics';
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
|
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
|
import { offlineMode } from '../lib/offline.svelte';
|
||||||
import { GatewayError } from '../lib/client';
|
import { GatewayError } from '../lib/client';
|
||||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
||||||
import { lastMoveCells, replay } from '../lib/board';
|
import { lastMoveCells, replay } from '../lib/board';
|
||||||
import { badgeKind } from '../lib/unread';
|
import { badgeKind } from '../lib/unread';
|
||||||
|
import { seatMedal } from '../lib/result';
|
||||||
import { historyGrid } from '../lib/history';
|
import { historyGrid } from '../lib/history';
|
||||||
import { centre, premiumGrid } from '../lib/premiums';
|
import { centre, premiumGrid } from '../lib/premiums';
|
||||||
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
|
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
|
||||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||||
import { hintsLeft } from '../lib/hints';
|
import { hintsLeft, hintGateRemainingMs, hintLockMinutes, HINT_GATE_MS } from '../lib/hints';
|
||||||
import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
|
import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
|
||||||
import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk';
|
import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk';
|
||||||
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
||||||
@@ -47,6 +51,18 @@
|
|||||||
|
|
||||||
let { id }: { id: string } = $props();
|
let { id }: { id: string } = $props();
|
||||||
|
|
||||||
|
// The game's source: the local engine for an offline game id, otherwise the network gateway. The
|
||||||
|
// screen calls the game-loop methods (state/history/submit/pass/exchange/resign/hint/evaluate/
|
||||||
|
// draft) through it, so the same UI drives a local vs_ai game and an online one alike.
|
||||||
|
const source = $derived(gameSource(id));
|
||||||
|
// A local (offline) game plays entirely on-device, so it is always ready; an online game needs a
|
||||||
|
// live connection — and offline mode's kill switch refuses its calls — so its network actions are
|
||||||
|
// disabled while disconnected or in offline mode (which also stops the "something went wrong"
|
||||||
|
// toasts a blocked call would otherwise raise).
|
||||||
|
const netReady = $derived(isLocalGameId(id) || (connection.online && !offlineMode.active));
|
||||||
|
// Unsubscribes from a local game's robot-reply events (offline only; null for a network game).
|
||||||
|
let localUnsub: (() => void) | null = null;
|
||||||
|
|
||||||
let view = $state<StateView | null>(null);
|
let view = $state<StateView | null>(null);
|
||||||
let moves = $state<MoveRecord[]>([]);
|
let moves = $state<MoveRecord[]>([]);
|
||||||
let placement = $state<Placement>(newPlacement([]));
|
let placement = $state<Placement>(newPlacement([]));
|
||||||
@@ -149,10 +165,42 @@
|
|||||||
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
|
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
|
||||||
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
|
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
|
||||||
const gameOver = $derived(!!view && view.game.status === 'finished');
|
const gameOver = $derived(!!view && view.game.status === 'finished');
|
||||||
|
// Offline hotseat: the seat to move is PIN-locked until its owner enters the seat PIN this turn.
|
||||||
|
// While locked the board stays visible but the rack is withheld and the move controls disabled;
|
||||||
|
// canMove folds the lock into isMyTurn (locked is always false for a network / vs_ai game).
|
||||||
|
const locked = $derived(!!view?.locked);
|
||||||
|
const canMove = $derived(isMyTurn && !locked);
|
||||||
// The hint badge: this game's allowance remaining plus the LIVE global wallet. Reading the
|
// The hint badge: this game's allowance remaining plus the LIVE global wallet. Reading the
|
||||||
// wallet from the profile (not the per-game view snapshot) keeps it correct after a wallet
|
// wallet from the profile (not the per-game view snapshot) keeps it correct after a wallet
|
||||||
// hint was spent in another game (see lib/hints).
|
// hint was spent in another game (see lib/hints).
|
||||||
const hintCount = $derived(hintsLeft(view, app.profile?.hintBalance ?? 0));
|
const hintCount = $derived(hintsLeft(view, app.profile?.hintBalance ?? 0));
|
||||||
|
// A vs_ai game's hint is unlimited and wallet-free, but idle-gated (an anti-frustration aid: it
|
||||||
|
// unlocks only after the player has been stuck ~30 min on a turn). The source (the SERVER clock
|
||||||
|
// online, the device clock offline) tells us the SECONDS LEFT (view.hintUnlockLeftSeconds); we
|
||||||
|
// anchor a MONOTONIC countdown (performance.now()) to it, so a client clock change cannot skew it.
|
||||||
|
// A fresh value arrives on load (armHintGate below); when the robot moves the wait is the full
|
||||||
|
// window. `monoNow` ticks so the 🔒 lifts live at the mark.
|
||||||
|
let hintGateStart = $state<number | null>(null); // performance.now() at the last anchor; null = open
|
||||||
|
let gateLeftMs = $state(0);
|
||||||
|
let monoNow = $state(performance.now());
|
||||||
|
const hintRemaining = $derived(hintGateRemainingMs(hintGateStart, gateLeftMs, monoNow));
|
||||||
|
const hintGated = $derived(hintRemaining > 0);
|
||||||
|
// Anchor the countdown to a freshly received seconds-left (or open the gate). Called on every state
|
||||||
|
// load from the view, and on the robot's move to the full window (the robot just moved).
|
||||||
|
function armHintGate(leftSeconds: number): void {
|
||||||
|
if (leftSeconds > 0) {
|
||||||
|
hintGateStart = performance.now();
|
||||||
|
gateLeftMs = leftSeconds * 1000;
|
||||||
|
} else {
|
||||||
|
hintGateStart = null;
|
||||||
|
gateLeftMs = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$effect(() => {
|
||||||
|
if (!view?.game.vsAi) return;
|
||||||
|
const iv = setInterval(() => (monoNow = performance.now()), 10_000);
|
||||||
|
return () => clearInterval(iv);
|
||||||
|
});
|
||||||
// RACK_SIZE mirrors the engine's rules.RackSize (7 for every current variant). The exchange
|
// RACK_SIZE mirrors the engine's rules.RackSize (7 for every current variant). The exchange
|
||||||
// gate is only a UX guard: the backend stays the source of truth and rejects an under-supplied
|
// gate is only a UX guard: the backend stays the source of truth and rejects an under-supplied
|
||||||
// exchange regardless (engine rejects when bag.Len() < rules.RackSize).
|
// exchange regardless (engine rejects when bag.Len() < rules.RackSize).
|
||||||
@@ -190,11 +238,13 @@
|
|||||||
// Fetch the saved draft alongside state and history (best-effort) so the composition is
|
// Fetch the saved draft alongside state and history (best-effort) so the composition is
|
||||||
// applied in the same tick the board appears — never as a second, visible rack→board step.
|
// applied in the same tick the board appears — never as a second, visible rack→board step.
|
||||||
const [st, hist, draft] = await Promise.all([
|
const [st, hist, draft] = await Promise.all([
|
||||||
gateway.gameState(id, includeAlphabet),
|
source.gameState(id, includeAlphabet),
|
||||||
gateway.gameHistory(id),
|
source.gameHistory(id),
|
||||||
gateway.draftGet(id).catch(() => ''),
|
source.draftGet(id).catch(() => ''),
|
||||||
]);
|
]);
|
||||||
view = st;
|
view = st;
|
||||||
|
// Anchor the vs_ai idle-hint countdown to the freshly fetched seconds-left (0 = open / non-vs_ai).
|
||||||
|
armHintGate(st.game.vsAi ? (st.hintUnlockLeftSeconds ?? 0) : 0);
|
||||||
syncWallet(st.walletBalance);
|
syncWallet(st.walletBalance);
|
||||||
// Seed the unread flag from the authoritative state (the live stream only raises it).
|
// Seed the unread flag from the authoritative state (the live stream only raises it).
|
||||||
seedChatUnread(id, st.game.unreadChat, st.game.unreadMessages);
|
seedChatUnread(id, st.game.unreadChat, st.game.unreadMessages);
|
||||||
@@ -220,7 +270,7 @@
|
|||||||
setCachedDraft(id, json);
|
setCachedDraft(id, json);
|
||||||
if (draftSaveTimer) clearTimeout(draftSaveTimer);
|
if (draftSaveTimer) clearTimeout(draftSaveTimer);
|
||||||
draftSaveTimer = setTimeout(() => {
|
draftSaveTimer = setTimeout(() => {
|
||||||
void gateway.draftSave(id, json).catch(() => {});
|
void source.draftSave(id, json).catch(() => {});
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
// applyDraft restores the player's saved composition over a freshly loaded state: the rack
|
// applyDraft restores the player's saved composition over a freshly loaded state: the rack
|
||||||
@@ -247,6 +297,9 @@
|
|||||||
if (cached) {
|
if (cached) {
|
||||||
view = cached.view;
|
view = cached.view;
|
||||||
moves = cached.moves;
|
moves = cached.moves;
|
||||||
|
// Arm the vs_ai idle-hint countdown from the cached seconds-left so the 🔒 shows at once on a
|
||||||
|
// warm open (no wait for load()); the cached value is a snapshot, refreshed by load() below.
|
||||||
|
armHintGate(cached.view.game.vsAi ? (cached.view.hintUnlockLeftSeconds ?? 0) : 0);
|
||||||
// Apply the cached draft synchronously so a warm/preloaded open paints the pending tiles
|
// Apply the cached draft synchronously so a warm/preloaded open paints the pending tiles
|
||||||
// already on the board (no full-rack-then-jump). load() then refreshes in the background.
|
// already on the board (no full-rack-then-jump). load() then refreshes in the background.
|
||||||
applyDraft(cached.view, cached.draft ?? '');
|
applyDraft(cached.view, cached.draft ?? '');
|
||||||
@@ -264,6 +317,12 @@
|
|||||||
dict = m;
|
dict = m;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// A local (offline) game has no live stream: route the source's robot-reply events through the
|
||||||
|
// same app event hub the network stream feeds, so the event effect above reacts to
|
||||||
|
// opponent_moved / game_over identically.
|
||||||
|
if (isLocalGameId(id)) {
|
||||||
|
localUnsub = localSource.events(id, (e) => (app.lastEvent = e));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Warm the game's dictionary for the local move preview once, when both the game and the
|
// Warm the game's dictionary for the local move preview once, when both the game and the
|
||||||
@@ -309,7 +368,11 @@
|
|||||||
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
|
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
|
||||||
// the move as a delta with no fetch.
|
// the move as a delta with no fetch.
|
||||||
if (placement.pending.length > 0) void load();
|
if (placement.pending.length > 0) void load();
|
||||||
else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen }));
|
else {
|
||||||
|
applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen }));
|
||||||
|
// The robot just moved (vs_ai) → the human's turn begins with the full idle window.
|
||||||
|
if (view?.game.vsAi) armHintGate(HINT_GATE_MS / 1000);
|
||||||
|
}
|
||||||
} else if (e.kind === 'your_turn' && e.gameId === id) {
|
} else if (e.kind === 'your_turn' && e.gameId === id) {
|
||||||
// The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch
|
// The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch
|
||||||
// only if we missed the move (our cached count trails the event's).
|
// only if we missed the move (our cached count trails the event's).
|
||||||
@@ -611,8 +674,13 @@
|
|||||||
// Flush a pending draft save so leaving mid-composition still persists it.
|
// Flush a pending draft save so leaving mid-composition still persists it.
|
||||||
if (draftSaveTimer) {
|
if (draftSaveTimer) {
|
||||||
clearTimeout(draftSaveTimer);
|
clearTimeout(draftSaveTimer);
|
||||||
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
|
void source.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
|
||||||
}
|
}
|
||||||
|
localUnsub?.();
|
||||||
|
// Leaving a hotseat game re-locks the current seat, so returning from the lobby re-prompts its
|
||||||
|
// PIN. Read the id from the loaded view, NOT the `id` prop: a $props() value reads back undefined
|
||||||
|
// during Svelte teardown, and isLocalGameId(undefined) would throw and abort this cleanup.
|
||||||
|
if (view?.game.hotseat) localSource.relock(view.game.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
function onCell(row: number, col: number) {
|
function onCell(row: number, col: number) {
|
||||||
@@ -717,8 +785,8 @@
|
|||||||
// rapid placements do not pile up requests on a slow link.
|
// rapid placements do not pile up requests on a slow link.
|
||||||
evalCtrl?.abort();
|
evalCtrl?.abort();
|
||||||
evalCtrl = null;
|
evalCtrl = null;
|
||||||
// Off-turn the composition is position-only: no score preview or evaluate.
|
// Off-turn (or a locked hotseat seat) the composition is position-only: no score preview.
|
||||||
if (!isMyTurn) return;
|
if (!canMove) return;
|
||||||
const sub = toSubmit(placement);
|
const sub = toSubmit(placement);
|
||||||
if (!sub) return;
|
if (!sub) return;
|
||||||
// Instant on-device preview when the game's dictionary is warm; the network otherwise.
|
// Instant on-device preview when the game's dictionary is warm; the network otherwise.
|
||||||
@@ -740,7 +808,7 @@
|
|||||||
evalCtrl = ctrl;
|
evalCtrl = ctrl;
|
||||||
previewTimer = setTimeout(async () => {
|
previewTimer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
preview = await gateway.evaluate(id, sub.tiles, variant, ctrl.signal);
|
preview = await source.evaluate(id, sub.tiles, variant, ctrl.signal);
|
||||||
notePreviewNetwork();
|
notePreviewNetwork();
|
||||||
} catch {
|
} catch {
|
||||||
/* best-effort (or aborted) */
|
/* best-effort (or aborted) */
|
||||||
@@ -776,12 +844,106 @@
|
|||||||
refreshRecent();
|
refreshRecent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- offline hotseat: seat unlock + host (referee) overrides -------------------
|
||||||
|
let unlockOpen = $state(false); // the current locked seat's owner enters their PIN to reveal the rack
|
||||||
|
// The host menu: 'pin' collects the master PIN, then 'menu' lists the overrides. hostPinEntered is
|
||||||
|
// the verified master PIN, reused to authorise the chosen action (the source re-checks it).
|
||||||
|
let hostMenuStep = $state<'closed' | 'pin' | 'menu'>('closed');
|
||||||
|
let hostPinEntered = $state('');
|
||||||
|
// The pending host action awaiting its confirm ("Skip X's turn?" etc.); null shows the menu list.
|
||||||
|
let hostConfirm = $state<{ action: 'skip' | 'resign' | 'terminate'; seat?: number; name?: string } | null>(null);
|
||||||
|
let hostDone = $state(false); // the transient success ✅ shown after an override
|
||||||
|
|
||||||
|
// hotseatName is the plain display name of a seat by index (hotseat seats are account-less local
|
||||||
|
// players, so no "you"/🤖 resolution is needed — unlike the seat-object seatName used elsewhere).
|
||||||
|
function hotseatName(i: number): string {
|
||||||
|
return view?.game.seats[i]?.displayName ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSkip(): void {
|
||||||
|
if (!view) return;
|
||||||
|
hostConfirm = { action: 'skip', seat: view.game.toMove, name: hotseatName(view.game.toMove) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashDone(): void {
|
||||||
|
hostDone = true;
|
||||||
|
setTimeout(() => (hostDone = false), 1100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceHotseat re-points the screen at the next seat after a hotseat move: the source has advanced
|
||||||
|
// the turn and re-locked, so a fresh state gives the next seat's rack (empty while it is locked).
|
||||||
|
async function advanceHotseat(): Promise<void> {
|
||||||
|
const st = await source.gameState(id, false);
|
||||||
|
view = st;
|
||||||
|
rackIds = st.rack.map((_, i) => i);
|
||||||
|
placement = newPlacement(st.rack);
|
||||||
|
selected = null;
|
||||||
|
preview = null;
|
||||||
|
setCachedGame(id, st, moves, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// onUnlock verifies the current seat's PIN through the source (which reveals the rack on success)
|
||||||
|
// and applies the unlocked state; handed to the PIN pad as its verifier.
|
||||||
|
async function onUnlock(pin: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const st = await localSource.unlockSeat(id, pin);
|
||||||
|
view = st;
|
||||||
|
rackIds = st.rack.map((_, i) => i);
|
||||||
|
placement = newPlacement(st.rack);
|
||||||
|
selected = null;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyHost captures the entered master PIN (reused to authorise the chosen action) and checks it.
|
||||||
|
async function verifyHost(pin: string): Promise<boolean> {
|
||||||
|
hostPinEntered = pin;
|
||||||
|
return localSource.verifyHostPin(id, pin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostConfirmText(c: { action: 'skip' | 'resign' | 'terminate'; name?: string }): string {
|
||||||
|
if (c.action === 'skip') return t('hotseat.askSkip', { name: c.name ?? '' });
|
||||||
|
if (c.action === 'resign') return t('hotseat.askExclude', { name: c.name ?? '' });
|
||||||
|
return t('hotseat.askTerminate');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runHostAction(): Promise<void> {
|
||||||
|
if (!hostConfirm) return;
|
||||||
|
const { action, seat } = hostConfirm;
|
||||||
|
hostConfirm = null;
|
||||||
|
hostMenuStep = 'closed';
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const st = await localSource.hostAction(id, hostPinEntered, action, seat);
|
||||||
|
if (st === null) {
|
||||||
|
navigate('/'); // terminated: the game is deleted, so return to the lobby
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
view = st;
|
||||||
|
rackIds = st.rack.map((_, i) => i);
|
||||||
|
placement = newPlacement(st.rack);
|
||||||
|
selected = null;
|
||||||
|
preview = null;
|
||||||
|
moves = (await source.gameHistory(id)).moves; // a skip / resign added a journal move
|
||||||
|
setCachedGame(id, st, moves, '');
|
||||||
|
flashDone();
|
||||||
|
} catch (e) {
|
||||||
|
handleError(e);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
hostPinEntered = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function commit() {
|
async function commit() {
|
||||||
const sub = toSubmit(placement);
|
const sub = toSubmit(placement);
|
||||||
if (!sub) return;
|
if (!sub) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant));
|
applyMoveResult(await source.submitPlay(id, sub.tiles, variant));
|
||||||
|
if (view?.game.hotseat) await advanceHotseat();
|
||||||
haptic('success');
|
haptic('success');
|
||||||
zoomed = false;
|
zoomed = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -800,7 +962,8 @@
|
|||||||
async function doPass() {
|
async function doPass() {
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
applyMoveResult(await gateway.pass(id));
|
applyMoveResult(await source.pass(id));
|
||||||
|
if (view?.game.hotseat) await advanceHotseat();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -821,7 +984,7 @@
|
|||||||
resignOpen = false;
|
resignOpen = false;
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
applyMoveResult(await gateway.resign(id));
|
applyMoveResult(await source.resign(id));
|
||||||
// Reveal the final board once the game is resigned: close the move-history drawer
|
// Reveal the final board once the game is resigned: close the move-history drawer
|
||||||
// (portrait — the landscape dock is unaffected) and zoom out if it was magnified.
|
// (portrait — the landscape dock is unaffected) and zoom out if it was magnified.
|
||||||
historyOpen = false;
|
historyOpen = false;
|
||||||
@@ -833,8 +996,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function doHint() {
|
async function doHint() {
|
||||||
|
// vs_ai: the idle gate replaces the wallet. While it is still closed, a tap only explains when the
|
||||||
|
// hint unlocks (no wallet is ever spent) — matching the 🔒 badge. Measured fresh at tap time.
|
||||||
|
if (view?.game.vsAi) {
|
||||||
|
const remaining = hintGateRemainingMs(hintGateStart, gateLeftMs, performance.now());
|
||||||
|
if (remaining > 0) {
|
||||||
|
showToast(t('game.hintLockedIn', { n: hintLockMinutes(remaining) }), 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const h = await gateway.hint(id);
|
const h = await source.hint(id);
|
||||||
if (h.move.tiles.length && view) {
|
if (h.move.tiles.length && view) {
|
||||||
placement = placementFromHint(h.move.tiles, view.rack);
|
placement = placementFromHint(h.move.tiles, view.rack);
|
||||||
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
|
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
|
||||||
@@ -869,6 +1041,12 @@
|
|||||||
// The backend does not spend a hint when there is no move.
|
// The backend does not spend a hint when there is no move.
|
||||||
if (e instanceof GatewayError && e.code === 'no_hint_available') {
|
if (e instanceof GatewayError && e.code === 'no_hint_available') {
|
||||||
showToast(t('game.noHintOptions'), 'info');
|
showToast(t('game.noHintOptions'), 'info');
|
||||||
|
} else if (e instanceof GatewayError && e.code === 'hint_locked') {
|
||||||
|
// The server-clock backstop refused a vs_ai hint the client thought was open (a rare client
|
||||||
|
// clock desync): re-sync the countdown from a fresh fetch and explain the gate.
|
||||||
|
await load();
|
||||||
|
const left = hintGateRemainingMs(hintGateStart, gateLeftMs, performance.now());
|
||||||
|
showToast(t('game.hintLockedIn', { n: hintLockMinutes(left || 60_000) }), 'info');
|
||||||
} else {
|
} else {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
}
|
}
|
||||||
@@ -906,7 +1084,8 @@
|
|||||||
exchangeOpen = false;
|
exchangeOpen = false;
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
applyMoveResult(await gateway.exchange(id, tiles, variant));
|
applyMoveResult(await source.exchange(id, tiles, variant));
|
||||||
|
if (view?.game.hotseat) await advanceHotseat();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -928,7 +1107,15 @@
|
|||||||
if (!view) return '';
|
if (!view) return '';
|
||||||
const me = view.game.seats[view.seat];
|
const me = view.game.seats[view.seat];
|
||||||
if (me?.isWinner) return t('game.won');
|
if (me?.isWinner) return t('game.won');
|
||||||
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
|
if (view.game.endReason === 'aborted') return t('game.tied'); // an abort is a draw for everyone
|
||||||
|
const myScore = me?.score ?? 0;
|
||||||
|
// A declared winner (incl. by resignation) that is not me, or anyone who scored higher → a loss.
|
||||||
|
if (view.game.seats.some((s) => s.isWinner) || view.game.seats.some((s) => !s.resigned && s.score > myScore)) {
|
||||||
|
return t('game.lost');
|
||||||
|
}
|
||||||
|
// No one above me and no declared winner: a tie for the lead is a (shared) win; only an
|
||||||
|
// all-level finish is a draw.
|
||||||
|
return view.game.seats.filter((s) => !s.resigned).every((s) => s.score === myScore) ? t('game.tied') : t('game.won');
|
||||||
}
|
}
|
||||||
|
|
||||||
// The finished-game export offers two formats — the GCG file and the server-rendered PNG
|
// The finished-game export offers two formats — the GCG file and the server-rendered PNG
|
||||||
@@ -1244,8 +1431,9 @@
|
|||||||
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested
|
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested
|
||||||
// opponent still shows it, but disabled).
|
// opponent still shows it, but disabled).
|
||||||
function canAddFriend(s: { accountId: string; seat: number }): boolean {
|
function canAddFriend(s: { accountId: string; seat: number }): boolean {
|
||||||
// Never offer add-friend against an AI opponent, an existing friend, or a blocked player.
|
// Never offer add-friend against an AI opponent, an existing friend, or a blocked player — nor in
|
||||||
if (view?.game.vsAi) return false;
|
// a local (offline) game, whose seats are account-less: vs_ai, and hotseat's synthetic seat ids.
|
||||||
|
if (view?.game.vsAi || isLocalGameId(id)) return false;
|
||||||
return (
|
return (
|
||||||
!!s.accountId &&
|
!!s.accountId &&
|
||||||
!app.profile?.isGuest &&
|
!app.profile?.isGuest &&
|
||||||
@@ -1259,7 +1447,7 @@
|
|||||||
// may still be blocked (the block overrides the friendship), so it omits the friend exclusion.
|
// may still be blocked (the block overrides the friendship), so it omits the friend exclusion.
|
||||||
// An already-blocked opponent hides it (both controls go, and the name is struck).
|
// An already-blocked opponent hides it (both controls go, and the name is struck).
|
||||||
function canBlock(s: { accountId: string; seat: number }): boolean {
|
function canBlock(s: { accountId: string; seat: number }): boolean {
|
||||||
if (view?.game.vsAi) return false;
|
if (view?.game.vsAi || isLocalGameId(id)) return false;
|
||||||
return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
|
return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1317,7 +1505,7 @@
|
|||||||
{#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if}
|
{#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if}
|
||||||
{#each view.game.seats as s (s.seat)}
|
{#each view.game.seats as s (s.seat)}
|
||||||
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
||||||
<div class="nm" class:struck={seatBlocked(s)}>{seatName(s)}</div>
|
<div class="nm" class:struck={seatBlocked(s)}>{#if gameOver && view.game.hotseat}<span class="medal" aria-hidden="true">{seatMedal(view.game, s.seat)}</span>{/if}{seatName(s)}</div>
|
||||||
<div class="sc" class:blockprompt={blockConfirm[s.seat]}>
|
<div class="sc" class:blockprompt={blockConfirm[s.seat]}>
|
||||||
{#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if}
|
{#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -1361,13 +1549,18 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<button class="hicon" onclick={onExportClick} aria-label={t('game.exportGcg')}>📤</button>
|
<button class="hicon" onclick={onExportClick} aria-label={t('game.exportGcg')}>📤</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if view.game.hotseat}
|
||||||
|
<!-- Hotseat resign is a host (referee) action, not self-serve; keep the slot empty. -->
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
||||||
<!-- A finished AI game has no comms at all (no chat, and the dictionary closes with the
|
<!-- The comms entry opens Chat + the word Dictionary. A local game (vs_ai or hotseat) has no
|
||||||
game), so the entry is dropped; an active AI game keeps it (it opens the dictionary). -->
|
chat, so its hub is Dictionary-only (CommsHub), and once finished the dictionary closes too
|
||||||
{#if !(gameOver && view.game.vsAi)}
|
— so drop the entry only when a local game is finished; an active local game keeps it for
|
||||||
|
the Dictionary. An online game always keeps it (chat outlives the game). -->
|
||||||
|
{#if !(gameOver && (view.game.vsAi || view.game.hotseat))}
|
||||||
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
||||||
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
|
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
|
||||||
</button>
|
</button>
|
||||||
@@ -1438,9 +1631,12 @@
|
|||||||
<div class="status">
|
<div class="status">
|
||||||
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
||||||
{#if gameOver}
|
{#if gameOver}
|
||||||
<strong class="over">{resultText()}</strong>
|
<!-- A hotseat game shows its result as per-seat medals on the plaques (below), not a
|
||||||
|
viewer-centric "you won/lost" — with 2-4 local players there is no single "you". A vs_ai
|
||||||
|
game keeps the "you won/lost" text (one human). -->
|
||||||
|
{#if !view.game.hotseat}<strong class="over">{resultText()}</strong>{/if}
|
||||||
{:else if placement.pending.length === 0}
|
{:else if placement.pending.length === 0}
|
||||||
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
<span class="turn-ind">{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="scores">
|
<span class="scores">
|
||||||
{#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
|
{#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
|
||||||
@@ -1454,36 +1650,58 @@
|
|||||||
a finished game shows the final rack greyed out and the controls disabled. -->
|
a finished game shows the final rack greyed out and the controls disabled. -->
|
||||||
<div class="rack-row" class:inert={gameOver} data-coach="game-rack">
|
<div class="rack-row" class:inert={gameOver} data-coach="game-rack">
|
||||||
<div class="rack-wrap">
|
<div class="rack-wrap">
|
||||||
<Rack
|
{#if locked}
|
||||||
slots={rackSlots}
|
<!-- A PIN-locked hotseat seat: the rack is withheld until its owner unlocks it. The board
|
||||||
{variant}
|
above stays visible; only this tray is gated. -->
|
||||||
{selected}
|
<button class="unlock" onclick={() => (unlockOpen = true)}>🔒 {t('hotseat.unlock')}</button>
|
||||||
{landscape}
|
{:else}
|
||||||
shuffling={shuffling && !app.reduceMotion}
|
<Rack
|
||||||
draggingId={reorderDragId}
|
slots={rackSlots}
|
||||||
dropIndex={reorderTo}
|
{variant}
|
||||||
ondown={onRackDown}
|
{selected}
|
||||||
/>
|
{landscape}
|
||||||
|
shuffling={shuffling && !app.reduceMotion}
|
||||||
|
draggingId={reorderDragId}
|
||||||
|
dropIndex={reorderTo}
|
||||||
|
ondown={onRackDown}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
|
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
|
||||||
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
<button class="make" onclick={commit} disabled={busy || !canMove || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet controlButtons()}
|
{#snippet controlButtons()}
|
||||||
<button class="tab" disabled={busy || !isMyTurn || !connection.online} onclick={openExchange}>
|
<button class="tab" disabled={busy || !canMove || !netReady} onclick={openExchange}>
|
||||||
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
|
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
|
||||||
</button>
|
</button>
|
||||||
<TapConfirm
|
{#if view?.game.hotseat}
|
||||||
triggerClass="tab"
|
<!-- Hotseat: no hints. The freed slot becomes the host (referee) button — always available
|
||||||
label={t('game.hint')}
|
(even on a locked seat: the host may skip a player who forgot their PIN). -->
|
||||||
disabled={busy || !isMyTurn || !connection.online || hintCount <= 0}
|
<button class="tab" disabled={busy || gameOver} onclick={() => (hostMenuStep = 'pin')}>
|
||||||
onconfirm={doHint}
|
<span class="sq">🔐</span><span class="lbl">{t('hotseat.host')}</span>
|
||||||
>
|
</button>
|
||||||
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
|
{:else if view?.game.vsAi}
|
||||||
<span class="lbl">{t('game.hint')}</span>
|
<!-- vs_ai hints are unlimited and wallet-free, so no confirm and no count. A 🔒 badge marks the
|
||||||
</TapConfirm>
|
idle gate while it is closed; tapping then only explains when it opens (doHint), and the lock
|
||||||
|
lifts live at the unlock moment. -->
|
||||||
|
<button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={doHint}>
|
||||||
|
<span class="sq" data-coach="game-hints">🛟{#if hintGated}<span class="lock" aria-hidden="true">🔒</span>{/if}</span>
|
||||||
|
<span class="lbl">{t('game.hint')}</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<TapConfirm
|
||||||
|
triggerClass="tab"
|
||||||
|
label={t('game.hint')}
|
||||||
|
disabled={busy || !isMyTurn || !netReady || hintCount <= 0}
|
||||||
|
onconfirm={doHint}
|
||||||
|
>
|
||||||
|
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
|
||||||
|
<span class="lbl">{t('game.hint')}</span>
|
||||||
|
</TapConfirm>
|
||||||
|
{/if}
|
||||||
{#if placement.pending.length > 0}
|
{#if placement.pending.length > 0}
|
||||||
<button class="tab" disabled={busy} onclick={resetPlacement}>
|
<button class="tab" disabled={busy} onclick={resetPlacement}>
|
||||||
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
|
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
|
||||||
@@ -1536,11 +1754,60 @@
|
|||||||
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
|
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
|
||||||
<div class="confirm-row">
|
<div class="confirm-row">
|
||||||
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
|
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
|
||||||
<button class="danger" onclick={doResign} disabled={!connection.online}>{t('game.dropGame')}</button>
|
<button class="danger" onclick={doResign} disabled={!netReady}>{t('game.dropGame')}</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Offline hotseat: unlock the current seat's rack. -->
|
||||||
|
{#if unlockOpen && view}
|
||||||
|
<PinPad
|
||||||
|
mode="verify"
|
||||||
|
title={t('hotseat.unlockTitle', { name: hotseatName(view.game.toMove) })}
|
||||||
|
verify={onUnlock}
|
||||||
|
onclose={() => (unlockOpen = false)}
|
||||||
|
onresult={() => (unlockOpen = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Offline hotseat: the host (referee) enters the master PIN, then picks an override. -->
|
||||||
|
{#if hostMenuStep === 'pin'}
|
||||||
|
<PinPad
|
||||||
|
mode="verify"
|
||||||
|
title={t('hotseat.host')}
|
||||||
|
verify={verifyHost}
|
||||||
|
onclose={() => (hostMenuStep = 'closed')}
|
||||||
|
onresult={() => (hostMenuStep = 'menu')}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if hostMenuStep === 'menu' && view}
|
||||||
|
<Modal title={t('hotseat.host')} onclose={() => { hostMenuStep = 'closed'; hostConfirm = null; }}>
|
||||||
|
{#if hostConfirm}
|
||||||
|
<p class="host-q">{hostConfirmText(hostConfirm)}</p>
|
||||||
|
<div class="confirm-row">
|
||||||
|
<button class="cancel" onclick={() => (hostConfirm = null)}>{t('common.cancel')}</button>
|
||||||
|
<button class="danger" onclick={runHostAction}>{t('common.ok')}</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="host-menu">
|
||||||
|
<button class="host-act" onclick={startSkip}>
|
||||||
|
{t('hotseat.skip')}
|
||||||
|
</button>
|
||||||
|
<div class="host-sub">{t('hotseat.exclude')}</div>
|
||||||
|
{#each view.game.seats as s (s.seat)}
|
||||||
|
<button class="host-seat" onclick={() => (hostConfirm = { action: 'resign', seat: s.seat, name: s.displayName })}>
|
||||||
|
{s.displayName}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<button class="host-act danger" onclick={() => (hostConfirm = { action: 'terminate' })}>{t('hotseat.terminate')}</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
{#if hostDone}
|
||||||
|
<div class="host-done" aria-hidden="true">✅</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if exportOpen}
|
{#if exportOpen}
|
||||||
<Modal title={t('game.exportGcg')} onclose={() => (exportOpen = false)}>
|
<Modal title={t('game.exportGcg')} onclose={() => (exportOpen = false)}>
|
||||||
<div class="export-opts">
|
<div class="export-opts">
|
||||||
@@ -1548,7 +1815,7 @@
|
|||||||
<button
|
<button
|
||||||
class="confirm"
|
class="confirm"
|
||||||
onclick={() => { exportOpen = false; void exportArtifact('png'); }}
|
onclick={() => { exportOpen = false; void exportArtifact('png'); }}
|
||||||
disabled={!connection.online}
|
disabled={!netReady}
|
||||||
>
|
>
|
||||||
{t('game.exportImageOpt')}
|
{t('game.exportImageOpt')}
|
||||||
</button>
|
</button>
|
||||||
@@ -1556,7 +1823,7 @@
|
|||||||
<button
|
<button
|
||||||
class={exportImageAvailable ? 'export-alt' : 'confirm'}
|
class={exportImageAvailable ? 'export-alt' : 'confirm'}
|
||||||
onclick={() => { exportOpen = false; void exportArtifact('gcg'); }}
|
onclick={() => { exportOpen = false; void exportArtifact('gcg'); }}
|
||||||
disabled={!connection.online}
|
disabled={!netReady}
|
||||||
>
|
>
|
||||||
{t('game.exportGcgOpt')}
|
{t('game.exportGcgOpt')}
|
||||||
</button>
|
</button>
|
||||||
@@ -1610,6 +1877,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
/* The finished-game place medal on a local (offline) seat plaque, left of the name. */
|
||||||
|
.medal {
|
||||||
|
margin-right: 3px;
|
||||||
|
}
|
||||||
.sc {
|
.sc {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
@@ -2031,4 +2302,76 @@
|
|||||||
.scoreboard.flat {
|
.scoreboard.flat {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
/* The vs_ai hint's idle lock: a small 🔒 at the icon-square corner (the .sq is positioned by the
|
||||||
|
shared TabBar rule). No count pill — the hint is unlimited; the lock alone marks the gate. */
|
||||||
|
.sq .lock {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
/* --- offline hotseat --- */
|
||||||
|
/* The locked-seat rack overlay: fills the rack tray with an "unlock" button, the board above
|
||||||
|
stays visible. */
|
||||||
|
.unlock {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.host-menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.host-act,
|
||||||
|
.host-seat {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.host-act.danger {
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.host-sub {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.host-q {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
/* The transient success tick after a host override — a centred ✅ that fades out on a timer. */
|
||||||
|
.host-done {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 4rem;
|
||||||
|
z-index: 50;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: host-done-fade 1.1s ease forwards;
|
||||||
|
}
|
||||||
|
@keyframes host-done-fade {
|
||||||
|
0% { opacity: 0; transform: scale(0.7); }
|
||||||
|
25% { opacity: 1; transform: scale(1); }
|
||||||
|
75% { opacity: 1; transform: scale(1); }
|
||||||
|
100% { opacity: 0; transform: scale(1); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export { ChatPostRequest } from './scrabblefb/chat-post-request.js';
|
|||||||
export { CheckWordRequest } from './scrabblefb/check-word-request.js';
|
export { CheckWordRequest } from './scrabblefb/check-word-request.js';
|
||||||
export { ComplaintRequest } from './scrabblefb/complaint-request.js';
|
export { ComplaintRequest } from './scrabblefb/complaint-request.js';
|
||||||
export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js';
|
export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js';
|
||||||
|
export { DictVersion } from './scrabblefb/dict-version.js';
|
||||||
export { DraftRequest } from './scrabblefb/draft-request.js';
|
export { DraftRequest } from './scrabblefb/draft-request.js';
|
||||||
export { DraftView } from './scrabblefb/draft-view.js';
|
export { DraftView } from './scrabblefb/draft-view.js';
|
||||||
export { EmailConfirmLinkRequest } from './scrabblefb/email-confirm-link-request.js';
|
export { EmailConfirmLinkRequest } from './scrabblefb/email-confirm-link-request.js';
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// automatically generated by the FlatBuffers compiler, do not modify
|
||||||
|
|
||||||
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
|
export class DictVersion {
|
||||||
|
bb: flatbuffers.ByteBuffer|null = null;
|
||||||
|
bb_pos = 0;
|
||||||
|
__init(i:number, bb:flatbuffers.ByteBuffer):DictVersion {
|
||||||
|
this.bb_pos = i;
|
||||||
|
this.bb = bb;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRootAsDictVersion(bb:flatbuffers.ByteBuffer, obj?:DictVersion):DictVersion {
|
||||||
|
return (obj || new DictVersion()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSizePrefixedRootAsDictVersion(bb:flatbuffers.ByteBuffer, obj?:DictVersion):DictVersion {
|
||||||
|
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||||
|
return (obj || new DictVersion()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
variant():string|null
|
||||||
|
variant(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
variant(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
version():string|null
|
||||||
|
version(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
version(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
static startDictVersion(builder:flatbuffers.Builder) {
|
||||||
|
builder.startObject(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(0, variantOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addVersion(builder:flatbuffers.Builder, versionOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(1, versionOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static endDictVersion(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
|
const offset = builder.endObject();
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
static createDictVersion(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset, versionOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
|
DictVersion.startDictVersion(builder);
|
||||||
|
DictVersion.addVariant(builder, variantOffset);
|
||||||
|
DictVersion.addVersion(builder, versionOffset);
|
||||||
|
return DictVersion.endDictVersion(builder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import * as flatbuffers from 'flatbuffers';
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
import { BannerInfo } from '../scrabblefb/banner-info.js';
|
import { BannerInfo } from '../scrabblefb/banner-info.js';
|
||||||
|
import { DictVersion } from '../scrabblefb/dict-version.js';
|
||||||
|
|
||||||
|
|
||||||
export class Profile {
|
export class Profile {
|
||||||
@@ -124,8 +125,18 @@ vkLinked():boolean {
|
|||||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dictVersions(index: number, obj?:DictVersion):DictVersion|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 36);
|
||||||
|
return offset ? (obj || new DictVersion()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dictVersionsLength():number {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 36);
|
||||||
|
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
static startProfile(builder:flatbuffers.Builder) {
|
static startProfile(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(16);
|
builder.startObject(17);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
|
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
|
||||||
@@ -204,6 +215,22 @@ static addVkLinked(builder:flatbuffers.Builder, vkLinked:boolean) {
|
|||||||
builder.addFieldInt8(15, +vkLinked, +false);
|
builder.addFieldInt8(15, +vkLinked, +false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addDictVersions(builder:flatbuffers.Builder, dictVersionsOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(16, dictVersionsOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static createDictVersionsVector(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 startDictVersionsVector(builder:flatbuffers.Builder, numElems:number) {
|
||||||
|
builder.startVector(4, numElems, 4);
|
||||||
|
}
|
||||||
|
|
||||||
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
|
|||||||
@@ -74,8 +74,13 @@ walletBalance():number {
|
|||||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hintUnlockLeftSeconds():number {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 18);
|
||||||
|
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
static startStateView(builder:flatbuffers.Builder) {
|
static startStateView(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(7);
|
builder.startObject(8);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
|
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
|
||||||
@@ -130,12 +135,16 @@ static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) {
|
|||||||
builder.addFieldInt32(6, walletBalance, 0);
|
builder.addFieldInt32(6, walletBalance, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addHintUnlockLeftSeconds(builder:flatbuffers.Builder, hintUnlockLeftSeconds:number) {
|
||||||
|
builder.addFieldInt32(7, hintUnlockLeftSeconds, 0);
|
||||||
|
}
|
||||||
|
|
||||||
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, alphabetOffset:flatbuffers.Offset, walletBalance:number):flatbuffers.Offset {
|
static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset, walletBalance:number, hintUnlockLeftSeconds:number):flatbuffers.Offset {
|
||||||
StateView.startStateView(builder);
|
StateView.startStateView(builder);
|
||||||
StateView.addGame(builder, gameOffset);
|
StateView.addGame(builder, gameOffset);
|
||||||
StateView.addSeat(builder, seat);
|
StateView.addSeat(builder, seat);
|
||||||
@@ -144,6 +153,7 @@ static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offse
|
|||||||
StateView.addHintsRemaining(builder, hintsRemaining);
|
StateView.addHintsRemaining(builder, hintsRemaining);
|
||||||
StateView.addAlphabet(builder, alphabetOffset);
|
StateView.addAlphabet(builder, alphabetOffset);
|
||||||
StateView.addWalletBalance(builder, walletBalance);
|
StateView.addWalletBalance(builder, walletBalance);
|
||||||
|
StateView.addHintUnlockLeftSeconds(builder, hintUnlockLeftSeconds);
|
||||||
return StateView.endStateView(builder);
|
return StateView.endStateView(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+188
-13
@@ -43,6 +43,9 @@ import { parseStartParam } from './deeplink';
|
|||||||
import {
|
import {
|
||||||
clearOnboarding,
|
clearOnboarding,
|
||||||
clearSession,
|
clearSession,
|
||||||
|
clearProfile,
|
||||||
|
loadProfile,
|
||||||
|
saveProfile,
|
||||||
loadOnboarding,
|
loadOnboarding,
|
||||||
loadPrefs,
|
loadPrefs,
|
||||||
loadSession,
|
loadSession,
|
||||||
@@ -52,7 +55,9 @@ import {
|
|||||||
savePrefs,
|
savePrefs,
|
||||||
} from './session';
|
} from './session';
|
||||||
import type { CoachSeries } from './coachmark';
|
import type { CoachSeries } from './coachmark';
|
||||||
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
import { connection, reportOffline, reportOnline, resetConnection, checkReachable } from './connection.svelte';
|
||||||
|
import { offlineMode, setOfflineMode } from './offline.svelte';
|
||||||
|
import { shouldBootOffline } from './offline';
|
||||||
import { isConnectionCode } from './retry';
|
import { isConnectionCode } from './retry';
|
||||||
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
|
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
|
||||||
import { advanceCached } from './gamedelta';
|
import { advanceCached } from './gamedelta';
|
||||||
@@ -73,6 +78,10 @@ export const app = $state<{
|
|||||||
* backend was down during a deploy). App.svelte then renders the boot-error retry screen
|
* backend was down during a deploy). App.svelte then renders the boot-error retry screen
|
||||||
* instead of the web login — a Mini App has no manual sign-in to fall back to. */
|
* instead of the web login — a Mini App has no manual sign-in to fall back to. */
|
||||||
bootError: boolean;
|
bootError: boolean;
|
||||||
|
/** True while the cold-start "no connection — go offline?" dialog is up (the reachability check
|
||||||
|
* timed out with the network interface reportedly online). App.svelte renders it over the splash;
|
||||||
|
* bootstrap awaits the choice via resolveOfflinePrompt. */
|
||||||
|
offlinePrompt: boolean;
|
||||||
/** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App
|
/** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App
|
||||||
* launch carried no sign-in data (empty initData). App.svelte then renders the compact
|
* launch carried no sign-in data (empty initData). App.svelte then renders the compact
|
||||||
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
|
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
|
||||||
@@ -143,6 +152,7 @@ export const app = $state<{
|
|||||||
}>({
|
}>({
|
||||||
ready: false,
|
ready: false,
|
||||||
bootError: false,
|
bootError: false,
|
||||||
|
offlinePrompt: false,
|
||||||
launchError: null,
|
launchError: null,
|
||||||
debugOpen: false,
|
debugOpen: false,
|
||||||
lobbyReady: false,
|
lobbyReady: false,
|
||||||
@@ -294,7 +304,9 @@ export function handleError(err: unknown): void {
|
|||||||
void enterBlocked();
|
void enterBlocked();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isConnectionCode(code) || !connection.online) return;
|
// A blocked call in offline mode ('offline', the transport kill switch) is expected, not an error
|
||||||
|
// to surface — the offline chrome already signals the state; never a red toast.
|
||||||
|
if (isConnectionCode(code) || code === 'offline' || !connection.online) return;
|
||||||
haptic('error');
|
haptic('error');
|
||||||
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
|
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
|
||||||
}
|
}
|
||||||
@@ -460,6 +472,7 @@ export async function refreshProfile(): Promise<void> {
|
|||||||
if (!app.session) return;
|
if (!app.session) return;
|
||||||
try {
|
try {
|
||||||
app.profile = await gateway.profileGet();
|
app.profile = await gateway.profileGet();
|
||||||
|
void saveProfile($state.snapshot(app.profile)); // keep the offline-boot cache fresh (plain snapshot)
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort; the banner just stays as it was until the next fetch.
|
// Best-effort; the banner just stays as it was until the next fetch.
|
||||||
}
|
}
|
||||||
@@ -517,6 +530,11 @@ async function adoptSession(s: Session): Promise<void> {
|
|||||||
await saveSession(s);
|
await saveSession(s);
|
||||||
try {
|
try {
|
||||||
app.profile = await gateway.profileGet();
|
app.profile = await gateway.profileGet();
|
||||||
|
// Persist the profile so a later offline cold start can launch from it (its variant
|
||||||
|
// preferences, dictionary versions and display name) without a network fetch. Snapshot to a
|
||||||
|
// plain object first — the reactive $state proxy is not structured-cloneable, so it would fail
|
||||||
|
// the IndexedDB write and fall back to a localStorage entry that loadProfile never reads.
|
||||||
|
void saveProfile($state.snapshot(app.profile));
|
||||||
// The live interface language follows the device — the explicit local choice (saved in
|
// The live interface language follows the device — the explicit local choice (saved in
|
||||||
// prefs) or the system guess made at bootstrap — and is no longer overridden from the
|
// prefs) or the system guess made at bootstrap — and is no longer overridden from the
|
||||||
// account here: the Telegram bot a user signs in through must not dictate the UI, so a
|
// account here: the Telegram bot a user signs in through must not dictate the UI, so a
|
||||||
@@ -540,6 +558,74 @@ async function adoptSession(s: Session): Promise<void> {
|
|||||||
void refreshNotifications();
|
void refreshNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The cold-start "no connection — go offline?" dialog. bootstrap raises it and awaits the choice
|
||||||
|
// here; App.svelte's dialog buttons call resolveOfflinePrompt.
|
||||||
|
const BOOT_REACHABILITY_TIMEOUT_MS = 3000;
|
||||||
|
let offlinePromptResolve: ((goOffline: boolean) => void) | null = null;
|
||||||
|
|
||||||
|
/** resolveOfflinePrompt answers the cold-start "no connection — go offline?" dialog (App.svelte). */
|
||||||
|
export function resolveOfflinePrompt(goOffline: boolean): void {
|
||||||
|
app.offlinePrompt = false;
|
||||||
|
const resolve = offlinePromptResolve;
|
||||||
|
offlinePromptResolve = null;
|
||||||
|
resolve?.(goOffline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** promptOfflineChoice raises the dialog and resolves to the player's choice (true = go offline). */
|
||||||
|
function promptOfflineChoice(): Promise<boolean> {
|
||||||
|
app.offlinePrompt = true;
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
offlinePromptResolve = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* initNetworkReactivity reacts to mid-session network changes (e.g. the player toggling flight mode):
|
||||||
|
* losing the network interface enters offline mode automatically (session-only); regaining it
|
||||||
|
* verifies the gateway is really reachable (an interface being up does not guarantee it) and returns
|
||||||
|
* to online — but only if the offline was auto. A deliberate offline (the Settings toggle or the
|
||||||
|
* cold-start dialog) is left as the player's choice. These are passive OS events — no polling, no
|
||||||
|
* battery cost. Web-only and skipped in the mock (the e2e drives connectivity directly).
|
||||||
|
*/
|
||||||
|
// While in auto-offline, poll for the network to return so the app comes back online — robust to the
|
||||||
|
// unreliable `online` event (which often does not fire in an installed PWA). Each tick is cheap: it
|
||||||
|
// reads navigator.onLine (no radio) and only hits the network (a reachability check) when the
|
||||||
|
// interface is actually up, so while flight mode is on it costs nothing. The poll runs ONLY in
|
||||||
|
// auto-offline (a deliberate offline is the player's choice) and stops on returning online.
|
||||||
|
let recoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
export function scheduleRecovery(delayMs: number): void {
|
||||||
|
if (recoveryTimer) {
|
||||||
|
clearTimeout(recoveryTimer);
|
||||||
|
recoveryTimer = null;
|
||||||
|
}
|
||||||
|
if (typeof window === 'undefined' || !offlineMode.active || !offlineMode.auto) return;
|
||||||
|
recoveryTimer = setTimeout(async () => {
|
||||||
|
recoveryTimer = null;
|
||||||
|
if (!offlineMode.active || !offlineMode.auto) return;
|
||||||
|
const interfaceUp = typeof navigator === 'undefined' || navigator.onLine;
|
||||||
|
if (interfaceUp && (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS))) {
|
||||||
|
if (offlineMode.active && offlineMode.auto) setOfflineMode(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleRecovery(4000);
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initNetworkReactivity(): void {
|
||||||
|
if (typeof window === 'undefined' || import.meta.env.MODE === 'mock') return;
|
||||||
|
window.addEventListener('offline', () => {
|
||||||
|
if (!offlineMode.active) {
|
||||||
|
setOfflineMode(true, false); // auto (session)
|
||||||
|
scheduleRecovery(4000); // poll for the network to return
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// The online event, when it fires, just kicks an immediate reachability check; the poll above is
|
||||||
|
// the reliable fallback for platforms where it does not fire.
|
||||||
|
window.addEventListener('online', () => {
|
||||||
|
if (offlineMode.active && offlineMode.auto) scheduleRecovery(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* applyLinkResult applies a completed account link or merge: it adopts a
|
* applyLinkResult applies a completed account link or merge: it adopts a
|
||||||
* switched session (a guest initiator whose durable counterpart won, so the active
|
* switched session (a guest initiator whose durable counterpart won, so the active
|
||||||
@@ -639,17 +725,37 @@ function syncTelegramSafeArea(): void {
|
|||||||
root.classList.toggle('tg-fullscreen', top > 0);
|
root.classList.toggle('tg-fullscreen', top > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The soft-keyboard height threshold (px) that distinguishes an open keyboard from browser chrome
|
||||||
|
// (an address bar shrinks the visual viewport by far less), used to gate the focus-into-view scroll.
|
||||||
|
const KEYBOARD_MIN_PX = 120;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* syncViewportHeight mirrors the visual-viewport height into the --vvh CSS var so a screen can
|
* syncViewport mirrors the visual viewport into CSS vars so the pinned app-shell (app.css
|
||||||
* fit the visible area above an open soft keyboard (iOS does not shrink dvh for the keyboard).
|
* `html.app-shell body`) both FITS (`--vvh`) and FOLLOWS (`--vv-top`) the visible area above the soft
|
||||||
* On a screen whose input sits at the bottom (chat, word-check) this keeps the input visible
|
* keyboard. iOS Safari / WKWebView does not shrink the layout viewport for the keyboard (Android
|
||||||
* without the page scrolling, so the layout no longer jumps when the keyboard appears.
|
* Chrome does); instead the visual viewport shrinks AND offsets down to reveal the focused field, and
|
||||||
|
* those values do not always cleanly revert. Tracking only the height left the top-anchored shell
|
||||||
|
* misaligned on iOS — empty space below the content, worst on a repeat focus; tracking offsetTop too
|
||||||
|
* keeps the shell on the visible area on every platform. On the keyboard-OPEN transition it also
|
||||||
|
* scrolls the focused field into view, since iOS does not reliably scroll a pinned document to it.
|
||||||
*/
|
*/
|
||||||
function syncViewportHeight(): void {
|
let keyboardOpen = false;
|
||||||
|
function syncViewport(): void {
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
const win = typeof window !== 'undefined' ? window : null;
|
||||||
const h = vv ? vv.height : typeof window !== 'undefined' ? window.innerHeight : 0;
|
const vv = win ? win.visualViewport : null;
|
||||||
|
const h = vv ? vv.height : win ? win.innerHeight : 0;
|
||||||
|
const top = vv ? vv.offsetTop : 0;
|
||||||
if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`);
|
if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`);
|
||||||
|
document.documentElement.style.setProperty('--vv-top', `${top}px`);
|
||||||
|
const open = !!vv && !!win && win.innerHeight - h > KEYBOARD_MIN_PX;
|
||||||
|
if (open && !keyboardOpen) {
|
||||||
|
const el = document.activeElement;
|
||||||
|
if (el instanceof HTMLElement && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) {
|
||||||
|
requestAnimationFrame(() => el.scrollIntoView({ block: 'center' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keyboardOpen = open;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -709,11 +815,23 @@ export async function bootstrap(): Promise<void> {
|
|||||||
setLocale(guess);
|
setLocale(guess);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track the visual-viewport height so screens fit above an open soft keyboard (--vvh).
|
// Track the visual viewport (height + offset) so screens fit and follow the visible area above an
|
||||||
syncViewportHeight();
|
// open soft keyboard (--vvh / --vv-top). Both resize AND scroll fire on iOS when the keyboard moves
|
||||||
|
// the visual viewport.
|
||||||
|
syncViewport();
|
||||||
if (typeof window !== 'undefined' && window.visualViewport) {
|
if (typeof window !== 'undefined' && window.visualViewport) {
|
||||||
window.visualViewport.addEventListener('resize', syncViewportHeight);
|
window.visualViewport.addEventListener('resize', syncViewport);
|
||||||
window.visualViewport.addEventListener('scroll', syncViewportHeight);
|
window.visualViewport.addEventListener('scroll', syncViewport);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter on a single-line <input> dismisses the soft keyboard (blur): the keyboard's default
|
||||||
|
// return/"Go" key otherwise does nothing on a bare input, which reads as stuck. A <textarea> (the
|
||||||
|
// feedback form) is excluded so Enter still inserts a newline; a form's own Enter→submit still
|
||||||
|
// fires first (this listener is on the document, so it runs after the field's own handler).
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && e.target instanceof HTMLInputElement) e.target.blur();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no
|
// Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no
|
||||||
@@ -789,12 +907,61 @@ export async function bootstrap(): Promise<void> {
|
|||||||
// worker so the app is installable — Chromium needs a registered SW, notably on Android. Web-only
|
// worker so the app is installable — Chromium needs a registered SW, notably on Android. Web-only
|
||||||
// and skipped in the mock build; see lib/pwa.svelte.
|
// and skipped in the mock build; see lib/pwa.svelte.
|
||||||
registerServiceWorker();
|
registerServiceWorker();
|
||||||
|
// React to mid-session network changes (e.g. flight mode) for the rest of the session.
|
||||||
|
initNetworkReactivity();
|
||||||
|
|
||||||
|
// Deliberate offline mode carried over from a prior session: with no network the session adoption
|
||||||
|
// + profile fetch below would hang the splash, so skip them and launch straight from the cached
|
||||||
|
// session + profile into the offline lobby. Without a cached profile (e.g. storage cleared) fall
|
||||||
|
// through to the normal online flow but KEEP the sticky flag — the mode is the player's choice, and
|
||||||
|
// an online boot re-persists the profile so the next launch goes offline. (A truly offline launch
|
||||||
|
// with no cached profile is unreachable: enabling offline requires a prior online session.)
|
||||||
|
if (offlineMode.active) {
|
||||||
|
const [offlineSession, offlineProfile] = await Promise.all([loadSession(), loadProfile()]);
|
||||||
|
if (shouldBootOffline({ offlineActive: true, hasSession: !!offlineSession, hasProfile: !!offlineProfile })) {
|
||||||
|
gateway.setToken(offlineSession!.token);
|
||||||
|
app.session = offlineSession!;
|
||||||
|
app.profile = offlineProfile!;
|
||||||
|
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
|
||||||
|
app.ready = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const saved = await loadSession();
|
const saved = await loadSession();
|
||||||
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
|
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
|
||||||
// from VK; capture and clear it here (it needs the restored session to link against).
|
// from VK; capture and clear it here (it needs the restored session to link against).
|
||||||
const vkcb = pendingVKLink();
|
const vkcb = pendingVKLink();
|
||||||
if (saved) {
|
if (saved) {
|
||||||
|
// Cold-start network auto-detect (deliberate offline is handled above). With a cached profile to
|
||||||
|
// fall back on and no pending VK-link, do not hang on adoptSession's retrying profile fetch when
|
||||||
|
// the network is down: a device with no network interface goes offline for the session (no
|
||||||
|
// dialog); an interface that is up but cannot reach the gateway within a short window asks the
|
||||||
|
// player. Web-only reaches here (the Mini-App branches returned above), so isStandalone gates it.
|
||||||
|
const cachedProfile = await loadProfile();
|
||||||
|
const canOffline = !!cachedProfile && !vkcb && isStandalone() && !!cachedProfile.email;
|
||||||
|
if (canOffline) {
|
||||||
|
const interfaceOffline = typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||||
|
gateway.setToken(saved.token);
|
||||||
|
const reachable = interfaceOffline ? false : await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS);
|
||||||
|
if (!reachable) {
|
||||||
|
// No network interface → go offline for the session (no dialog); interface up but the
|
||||||
|
// gateway is unreachable → ambiguous, so ask.
|
||||||
|
const goOffline = interfaceOffline ? true : await promptOfflineChoice();
|
||||||
|
if (goOffline) {
|
||||||
|
// A deliberate dialog choice is sticky; an auto (no-interface) offline is session-only.
|
||||||
|
setOfflineMode(true, !interfaceOffline);
|
||||||
|
if (interfaceOffline) scheduleRecovery(4000); // auto-offline: poll for the network to return
|
||||||
|
app.session = saved;
|
||||||
|
app.profile = cachedProfile;
|
||||||
|
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
|
||||||
|
app.ready = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// "Нет" — keep trying online: fall through to adoptSession (it retries; the connection
|
||||||
|
// watcher shows "Connecting…").
|
||||||
|
}
|
||||||
|
}
|
||||||
await adoptSession(saved);
|
await adoptSession(saved);
|
||||||
if (vkcb) {
|
if (vkcb) {
|
||||||
// The full-page redirect lost the in-app route, so hand the callback to Profile, which
|
// The full-page redirect lost the in-app route, so hand the callback to Profile, which
|
||||||
@@ -1023,6 +1190,7 @@ export async function logout(): Promise<void> {
|
|||||||
app.splashDone = false;
|
app.splashDone = false;
|
||||||
gateway.setToken(null);
|
gateway.setToken(null);
|
||||||
await clearSession();
|
await clearSession();
|
||||||
|
await clearProfile();
|
||||||
app.session = null;
|
app.session = null;
|
||||||
app.profile = null;
|
app.profile = null;
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
@@ -1174,4 +1342,11 @@ if (import.meta.env.MODE === 'mock' && typeof window !== 'undefined') {
|
|||||||
closeStream();
|
closeStream();
|
||||||
app.blocked = { blocked: true, permanent: true, until: '', reason: '', ...b };
|
app.blocked = { blocked: true, permanent: true, until: '', reason: '', ...b };
|
||||||
};
|
};
|
||||||
|
// Expose the router so the e2e can assert navigate() updates the route synchronously (no
|
||||||
|
// hashchange lag): a route that trailed the hash for a frame let bootstrap's `app.ready` flip
|
||||||
|
// render the previous route under the new screen (the empty-hash lobby flashing under login).
|
||||||
|
(window as unknown as { __router?: { navigate(p: string): void; route(): string } }).__router = {
|
||||||
|
navigate,
|
||||||
|
route: () => router.route.name,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -712,6 +712,28 @@ describe('codec', () => {
|
|||||||
expect(decodeProfile(b.asUint8Array()).variantPreferences).toEqual(['erudit_ru', 'scrabble_en']);
|
expect(decodeProfile(b.asUint8Array()).variantPreferences).toEqual(['erudit_ru', 'scrabble_en']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('decodes the profile dictionary versions into a per-variant map', () => {
|
||||||
|
const b = new Builder(128);
|
||||||
|
const uid = b.createString('u-1');
|
||||||
|
const en = fb.DictVersion.createDictVersion(b, b.createString('scrabble_en'), b.createString('v1.3.0'));
|
||||||
|
const er = fb.DictVersion.createDictVersion(b, b.createString('erudit_ru'), b.createString('v1.2.0'));
|
||||||
|
const vec = fb.Profile.createDictVersionsVector(b, [en, er]);
|
||||||
|
fb.Profile.startProfile(b);
|
||||||
|
fb.Profile.addUserId(b, uid);
|
||||||
|
fb.Profile.addDictVersions(b, vec);
|
||||||
|
b.finish(fb.Profile.endProfile(b));
|
||||||
|
expect(decodeProfile(b.asUint8Array()).dictVersions).toEqual({ scrabble_en: 'v1.3.0', erudit_ru: 'v1.2.0' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes an absent dict_versions vector to an empty map', () => {
|
||||||
|
const b = new Builder(64);
|
||||||
|
const uid = b.createString('u-1');
|
||||||
|
fb.Profile.startProfile(b);
|
||||||
|
fb.Profile.addUserId(b, uid);
|
||||||
|
b.finish(fb.Profile.endProfile(b));
|
||||||
|
expect(decodeProfile(b.asUint8Array()).dictVersions).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
it('encodes the update-profile variant preferences', () => {
|
it('encodes the update-profile variant preferences', () => {
|
||||||
const buf = encodeUpdateProfile({
|
const buf = encodeUpdateProfile({
|
||||||
displayName: 'Kaya',
|
displayName: 'Kaya',
|
||||||
|
|||||||
@@ -364,9 +364,25 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
|||||||
email: s(p.email()),
|
email: s(p.email()),
|
||||||
telegramLinked: p.telegramLinked(),
|
telegramLinked: p.telegramLinked(),
|
||||||
vkLinked: p.vkLinked(),
|
vkLinked: p.vkLinked(),
|
||||||
|
dictVersions: decodeDictVersions(p),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decodeDictVersions reads the profile's per-variant current dictionary versions into a
|
||||||
|
// variant-keyed map, so the offline preloader can look up the version for each enabled
|
||||||
|
// variant. An entry with an unknown variant or empty version is skipped.
|
||||||
|
function decodeDictVersions(p: fb.Profile): Partial<Record<Variant, string>> {
|
||||||
|
const out: Partial<Record<Variant, string>> = {};
|
||||||
|
for (let i = 0; i < p.dictVersionsLength(); i++) {
|
||||||
|
const dv = p.dictVersions(i);
|
||||||
|
if (!dv) continue;
|
||||||
|
const variant = s(dv.variant()) as Variant;
|
||||||
|
const version = s(dv.version());
|
||||||
|
if (variant && version) out[variant] = version;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// decodeVariantPreferences reads the Profile.variant_preferences vector (the variants
|
// decodeVariantPreferences reads the Profile.variant_preferences vector (the variants
|
||||||
// the player enabled in Settings).
|
// the player enabled in Settings).
|
||||||
function decodeVariantPreferences(p: fb.Profile): Variant[] {
|
function decodeVariantPreferences(p: fb.Profile): Variant[] {
|
||||||
@@ -445,6 +461,7 @@ function decodeStateViewTable(v: fb.StateView): StateView {
|
|||||||
bagLen: v.bagLen(),
|
bagLen: v.bagLen(),
|
||||||
hintsRemaining: v.hintsRemaining(),
|
hintsRemaining: v.hintsRemaining(),
|
||||||
walletBalance: v.walletBalance(),
|
walletBalance: v.walletBalance(),
|
||||||
|
hintUnlockLeftSeconds: v.hintUnlockLeftSeconds(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
// other traffic is in flight.
|
// other traffic is in flight.
|
||||||
|
|
||||||
import { backoffMs } from './retry';
|
import { backoffMs } from './retry';
|
||||||
|
import { offlineMode } from './offline.svelte';
|
||||||
|
|
||||||
let online = $state(true);
|
let online = $state(true);
|
||||||
let watchTimer: ReturnType<typeof setTimeout> | null = null;
|
let watchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -27,6 +28,24 @@ export function registerProbe(fn: () => Promise<void>): void {
|
|||||||
probe = fn;
|
probe = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* checkReachable runs the reachability probe once, bounded by timeoutMs, and reports whether the
|
||||||
|
* gateway answered — a single attempt (no retry loop), for the cold-start network decision — while
|
||||||
|
* updating the online signal. It reports false when no probe is registered or the timeout wins.
|
||||||
|
*/
|
||||||
|
export async function checkReachable(timeoutMs: number): Promise<boolean> {
|
||||||
|
if (!probe) return false;
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
probe(),
|
||||||
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('reachability timeout')), timeoutMs)),
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** reportOnline marks the gateway reachable and stops the watcher. */
|
/** reportOnline marks the gateway reachable and stops the watcher. */
|
||||||
export function reportOnline(): void {
|
export function reportOnline(): void {
|
||||||
online = true;
|
online = true;
|
||||||
@@ -51,7 +70,9 @@ function scheduleProbe(attempt: number): void {
|
|||||||
watchTimer = setTimeout(
|
watchTimer = setTimeout(
|
||||||
() => {
|
() => {
|
||||||
watchTimer = null;
|
watchTimer = null;
|
||||||
if (online || !probe) return;
|
// Never probe the network in offline mode (the kill switch); the online/offline events drive
|
||||||
|
// recovery there instead.
|
||||||
|
if (online || !probe || offlineMode.active) return;
|
||||||
probe().then(reportOnline, () => scheduleProbe(Math.min(attempt + 1, 6)));
|
probe().then(reportOnline, () => scheduleProbe(Math.min(attempt + 1, 6)));
|
||||||
},
|
},
|
||||||
backoffMs(attempt),
|
backoffMs(attempt),
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { Dawg } from './dawg';
|
||||||
|
|
||||||
|
// The step-by-step DAWG cursor (root/final/next/arcs) is the primitive the move
|
||||||
|
// generator walks. These fast unit tests pin it against a small committed sample
|
||||||
|
// dictionary (backend/cmd/movegen); the full parity vs the Go solver lands with
|
||||||
|
// the generator's conformance fixtures.
|
||||||
|
const bytes = new Uint8Array(readFileSync(new URL('./testdata/sample_en.dawg', import.meta.url)));
|
||||||
|
const fixture = JSON.parse(
|
||||||
|
readFileSync(new URL('./testdata/sample_en.words.json', import.meta.url), 'utf8'),
|
||||||
|
) as { numAdded: number; words: string[]; indexes: number[][] };
|
||||||
|
|
||||||
|
const key = (w: number[]): string => w.join(',');
|
||||||
|
|
||||||
|
// enumerateWords walks the whole automaton depth-first, collecting the index path
|
||||||
|
// at every accepting node — i.e. every stored word.
|
||||||
|
function enumerateWords(d: Dawg): number[][] {
|
||||||
|
const out: number[][] = [];
|
||||||
|
const path: number[] = [];
|
||||||
|
const visit = (node: number): void => {
|
||||||
|
d.arcs(node, (label, dest, final) => {
|
||||||
|
path.push(label);
|
||||||
|
if (final) out.push(path.slice());
|
||||||
|
visit(dest);
|
||||||
|
path.pop();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (d.final(d.root())) out.push([]);
|
||||||
|
visit(d.root());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dawg cursor', () => {
|
||||||
|
it('parses the sample fixture', () => {
|
||||||
|
const d = new Dawg(bytes);
|
||||||
|
expect(d.numAdded).toBe(fixture.numAdded);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('root is not an accepting state (the sample has no empty word)', () => {
|
||||||
|
const d = new Dawg(bytes);
|
||||||
|
expect(d.final(d.root())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enumerates exactly the stored words', () => {
|
||||||
|
const d = new Dawg(bytes);
|
||||||
|
const got = enumerateWords(d).map(key).sort();
|
||||||
|
const want = fixture.indexes.map(key).sort();
|
||||||
|
expect(got).toEqual(want);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('next walks a stored word to an accepting node and rejects a non-edge', () => {
|
||||||
|
const d = new Dawg(bytes);
|
||||||
|
let node = d.root();
|
||||||
|
for (const ch of [2, 0, 17, 4, 3]) {
|
||||||
|
// "cared"
|
||||||
|
node = d.next(node, ch);
|
||||||
|
expect(node).toBeGreaterThanOrEqual(0);
|
||||||
|
}
|
||||||
|
expect(d.final(node)).toBe(true);
|
||||||
|
// "care" (index [2,0,17,4]) is an internal accepting node on the way to "cared".
|
||||||
|
const care = [2, 0, 17, 4].reduce((n, ch) => d.next(n, ch), d.root());
|
||||||
|
expect(d.final(care)).toBe(true);
|
||||||
|
// No stored word starts with 'z' (index 25).
|
||||||
|
expect(d.next(d.root(), 25)).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -95,6 +95,85 @@ export class Dawg {
|
|||||||
return this.indexOf(word) >= 0;
|
return this.indexOf(word) >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Step-by-step traversal (the move generator's primitive) ---------------
|
||||||
|
//
|
||||||
|
// A `Node` is a bit offset into the graph; 0 denotes the root (which resolves
|
||||||
|
// to firstNodeOffset). These mirror dafsa's traverse.go Cursor (Root/Final/
|
||||||
|
// Next/Arcs) over the same bitstream this reader already decodes, so the ported
|
||||||
|
// generator can drive the automaton one transition at a time. Single-threaded
|
||||||
|
// JS shares this reader's position across calls; every method re-seeks to its
|
||||||
|
// node on entry, and arcs brackets the callback with a save/restore, so nested
|
||||||
|
// use during a walk is safe. Mirrors dafsa (*Cursor).
|
||||||
|
|
||||||
|
/** root returns the start state of the automaton. */
|
||||||
|
root(): number {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** final reports whether node is an accepting state (a stored word ends there). */
|
||||||
|
final(node: number): boolean {
|
||||||
|
if (this.numEdges <= 0) {
|
||||||
|
return this.hasEmptyWord && node === 0;
|
||||||
|
}
|
||||||
|
this.p = node === 0 ? this.firstNodeOffset : node;
|
||||||
|
return this.readBits(1) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* next follows the edge labelled ch (an alphabet index) from node, returning the
|
||||||
|
* destination node, or -1 when no such edge exists.
|
||||||
|
*/
|
||||||
|
next(node: number, ch: number): number {
|
||||||
|
return this.getEdge(node, ch) ? this.eNode : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* arcs calls fn for each out-edge of node in ascending label order, passing the
|
||||||
|
* edge's label, its destination node and whether that destination is accepting.
|
||||||
|
* It stops early if fn returns false. Mirrors dafsa (*Cursor).Arcs.
|
||||||
|
*/
|
||||||
|
arcs(node: number, fn: (label: number, dest: number, final: boolean) => boolean): void {
|
||||||
|
if (this.numEdges <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.p = node === 0 ? this.firstNodeOffset : node;
|
||||||
|
this.readBits(1); // node final flag — not needed here
|
||||||
|
const fallthrough = this.readBits(1);
|
||||||
|
|
||||||
|
if (fallthrough === 1) {
|
||||||
|
const label = this.readBits(this.cbits);
|
||||||
|
// The reader now sits at the destination node, whose first bit is its final flag.
|
||||||
|
const dest = this.p;
|
||||||
|
const final = this.readBits(1) === 1;
|
||||||
|
fn(label, dest, final);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nskiplen = bitsLen(this.wbits);
|
||||||
|
let nskip = 0;
|
||||||
|
let numEdges = 1;
|
||||||
|
if (this.readBits(1) !== 1) {
|
||||||
|
// not a single edge
|
||||||
|
numEdges = this.readUnsigned();
|
||||||
|
nskip = this.readBits(nskiplen);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < numEdges; i++) {
|
||||||
|
const label = this.readBits(this.cbits);
|
||||||
|
if (i > 0) {
|
||||||
|
this.readBits(nskip); // per-edge skip count, unused for traversal
|
||||||
|
}
|
||||||
|
const dest = this.readBits(this.abits);
|
||||||
|
const resume = this.p;
|
||||||
|
this.p = dest;
|
||||||
|
const final = this.readBits(1) === 1;
|
||||||
|
if (!fn(label, dest, final)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.p = resume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// getEdge resolves the outgoing edge for ch from the node at the given bit
|
// getEdge resolves the outgoing edge for ch from the node at the given bit
|
||||||
// offset. On success it fills eNode/eCount/eFinal and returns true. Mirrors
|
// offset. On success it fills eNode/eCount/eFinal and returns true. Mirrors
|
||||||
// dafsa (*dawg).getEdge.
|
// dafsa (*dawg).getEdge.
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { Dawg } from './dawg';
|
||||||
|
import { generateMoves, GenRack, type GenBoard, type Mode } from './generate';
|
||||||
|
import type { Ruleset } from './validate';
|
||||||
|
|
||||||
|
// Conformance gate for the ported move generator: for each committed position it must
|
||||||
|
// return exactly the ranked play list the real Go solver returns (backend/cmd/movegen).
|
||||||
|
// The Russian sample reaches alphabet index 32, exercising the 33-letter cross-set.
|
||||||
|
|
||||||
|
interface Tile {
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
letter: number;
|
||||||
|
blank: boolean;
|
||||||
|
}
|
||||||
|
interface GenMove {
|
||||||
|
dir: number;
|
||||||
|
tiles: Tile[];
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
interface Fixture {
|
||||||
|
ruleset: {
|
||||||
|
size: number;
|
||||||
|
cols: number;
|
||||||
|
center: number;
|
||||||
|
rackSize: number;
|
||||||
|
bingo: number;
|
||||||
|
values: number[];
|
||||||
|
letterMult: number[][];
|
||||||
|
wordMult: number[][];
|
||||||
|
};
|
||||||
|
cases: {
|
||||||
|
name: string;
|
||||||
|
placed: Tile[] | null;
|
||||||
|
rack: { letters: number[]; blanks: number };
|
||||||
|
mode: number;
|
||||||
|
ignoreCrossWords: boolean;
|
||||||
|
moves: GenMove[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(tag: string): { dawg: Dawg; fx: Fixture } {
|
||||||
|
const bytes = new Uint8Array(readFileSync(new URL(`./testdata/sample_${tag}.dawg`, import.meta.url)));
|
||||||
|
const fx = JSON.parse(
|
||||||
|
readFileSync(new URL(`./testdata/sample_${tag}.gen.json`, import.meta.url), 'utf8'),
|
||||||
|
) as Fixture;
|
||||||
|
return { dawg: new Dawg(bytes), fx };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBoard(placed: Tile[], cols: number): GenBoard {
|
||||||
|
const grid: ({ letter: number; blank: boolean } | null)[] = new Array(cols * cols).fill(null);
|
||||||
|
for (const t of placed) grid[t.row * cols + t.col] = { letter: t.letter, blank: t.blank };
|
||||||
|
const inBounds = (r: number, c: number): boolean => r >= 0 && r < cols && c >= 0 && c < cols;
|
||||||
|
return {
|
||||||
|
rows: cols,
|
||||||
|
cols,
|
||||||
|
inBounds,
|
||||||
|
filled: (r, c) => inBounds(r, c) && grid[r * cols + c] !== null,
|
||||||
|
cellAt: (r, c) => grid[r * cols + c]!,
|
||||||
|
isEmpty: () => placed.length === 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rulesetFor(fx: Fixture, ignoreCrossWords: boolean): Ruleset {
|
||||||
|
const r = fx.ruleset;
|
||||||
|
return {
|
||||||
|
cols: r.cols,
|
||||||
|
center: r.center,
|
||||||
|
rackSize: r.rackSize,
|
||||||
|
bingo: r.bingo,
|
||||||
|
values: r.values,
|
||||||
|
letterMult: (row, col) => r.letterMult[row][col],
|
||||||
|
wordMult: (row, col) => r.wordMult[row][col],
|
||||||
|
ignoreCrossWords,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// sig is an order-stable signature of a move: orientation, score and its placed tiles
|
||||||
|
// sorted by square — so two lists compare equal iff they rank the same plays the same way.
|
||||||
|
function sig(m: GenMove): string {
|
||||||
|
const ts = m.tiles.slice().sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col));
|
||||||
|
return `${m.dir}#${m.score}#` + ts.map((t) => `${t.row},${t.col},${t.letter}${t.blank ? '*' : ''}`).join(';');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tag of ['en', 'ru']) {
|
||||||
|
describe(`move generator parity vs Go solver (${tag})`, () => {
|
||||||
|
const { dawg, fx } = load(tag);
|
||||||
|
for (const c of fx.cases) {
|
||||||
|
it(c.name, () => {
|
||||||
|
const board = buildBoard(c.placed ?? [], fx.ruleset.cols);
|
||||||
|
const rack = GenRack.from(fx.ruleset.size, c.rack.letters, c.rack.blanks);
|
||||||
|
const rs = rulesetFor(fx, c.ignoreCrossWords);
|
||||||
|
const got = generateMoves(dawg, board, rack, rs, c.mode as Mode);
|
||||||
|
expect(got.map(sig)).toEqual(c.moves.map(sig));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Dawg } from './dawg';
|
||||||
|
import { generateMoves, GenRack, type GenBoard, type Mode } from './generate';
|
||||||
|
import type { Ruleset } from './validate';
|
||||||
|
|
||||||
|
// Full-dictionary conformance for the ported move generator: for positions over the real
|
||||||
|
// shipped dictionaries (deep graphs and full 26/33-letter alphabets the tiny committed
|
||||||
|
// samples cannot reach) it must return exactly the ranked list the Go solver does. The
|
||||||
|
// golden vectors come from `go run ./backend/cmd/movegen -dawg-dir <dawg> -out <dir>` and
|
||||||
|
// the CI conformance job wires the two directories in; the suite skips when they are unset.
|
||||||
|
const dawgDir = process.env.DICT_DAWG_DIR;
|
||||||
|
const goldDir = process.env.DICT_MOVEGEN_DIR;
|
||||||
|
const ready = !!dawgDir && !!goldDir && existsSync(dawgDir) && existsSync(goldDir);
|
||||||
|
|
||||||
|
// variant -> the release dawg file name (matches dawg.parity.test.ts / the movegen tool).
|
||||||
|
const variants = [
|
||||||
|
{ variant: 'scrabble_en', dawg: 'en_sowpods' },
|
||||||
|
{ variant: 'scrabble_ru', dawg: 'ru_scrabble' },
|
||||||
|
{ variant: 'erudit_ru', dawg: 'ru_erudit' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Tile {
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
letter: number;
|
||||||
|
blank: boolean;
|
||||||
|
}
|
||||||
|
interface GenMove {
|
||||||
|
dir: number;
|
||||||
|
tiles: Tile[];
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
interface Fixture {
|
||||||
|
ruleset: {
|
||||||
|
size: number;
|
||||||
|
cols: number;
|
||||||
|
center: number;
|
||||||
|
rackSize: number;
|
||||||
|
bingo: number;
|
||||||
|
values: number[];
|
||||||
|
letterMult: number[][];
|
||||||
|
wordMult: number[][];
|
||||||
|
};
|
||||||
|
cases: {
|
||||||
|
name: string;
|
||||||
|
placed: Tile[] | null;
|
||||||
|
rack: { letters: number[]; blanks: number };
|
||||||
|
mode: number;
|
||||||
|
ignoreCrossWords: boolean;
|
||||||
|
moves: GenMove[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBoard(placed: Tile[], cols: number): GenBoard {
|
||||||
|
const grid: ({ letter: number; blank: boolean } | null)[] = new Array(cols * cols).fill(null);
|
||||||
|
for (const t of placed) grid[t.row * cols + t.col] = { letter: t.letter, blank: t.blank };
|
||||||
|
const inBounds = (r: number, c: number): boolean => r >= 0 && r < cols && c >= 0 && c < cols;
|
||||||
|
return {
|
||||||
|
rows: cols,
|
||||||
|
cols,
|
||||||
|
inBounds,
|
||||||
|
filled: (r, c) => inBounds(r, c) && grid[r * cols + c] !== null,
|
||||||
|
cellAt: (r, c) => grid[r * cols + c]!,
|
||||||
|
isEmpty: () => placed.length === 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rulesetFor(fx: Fixture, ignoreCrossWords: boolean): Ruleset {
|
||||||
|
const r = fx.ruleset;
|
||||||
|
return {
|
||||||
|
cols: r.cols,
|
||||||
|
center: r.center,
|
||||||
|
rackSize: r.rackSize,
|
||||||
|
bingo: r.bingo,
|
||||||
|
values: r.values,
|
||||||
|
letterMult: (row, col) => r.letterMult[row][col],
|
||||||
|
wordMult: (row, col) => r.wordMult[row][col],
|
||||||
|
ignoreCrossWords,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sig(m: GenMove): string {
|
||||||
|
const ts = m.tiles.slice().sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col));
|
||||||
|
return `${m.dir}#${m.score}#` + ts.map((t) => `${t.row},${t.col},${t.letter}${t.blank ? '*' : ''}`).join(';');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!ready)('move generator parity vs Go on the real dictionaries', () => {
|
||||||
|
for (const v of variants) {
|
||||||
|
describe(v.variant, () => {
|
||||||
|
// Guard the collection-time reads: the block is skipped when the dirs are unset, but
|
||||||
|
// the describe callback still runs to register tests, so touch the filesystem only
|
||||||
|
// when ready (otherwise join(undefined, …) would throw during a normal unit run).
|
||||||
|
if (!ready) return;
|
||||||
|
const fx = JSON.parse(readFileSync(join(goldDir!, `${v.variant}.movegen.json`), 'utf8')) as Fixture;
|
||||||
|
const dawg = new Dawg(new Uint8Array(readFileSync(join(dawgDir!, `${v.dawg}.dawg`))));
|
||||||
|
for (const c of fx.cases) {
|
||||||
|
it(
|
||||||
|
c.name,
|
||||||
|
() => {
|
||||||
|
const board = buildBoard(c.placed ?? [], fx.ruleset.cols);
|
||||||
|
const rack = GenRack.from(fx.ruleset.size, c.rack.letters, c.rack.blanks);
|
||||||
|
const rs = rulesetFor(fx, c.ignoreCrossWords);
|
||||||
|
const got = generateMoves(dawg, board, rack, rs, c.mode as Mode);
|
||||||
|
expect(got.map(sig)).toEqual(c.moves.map(sig));
|
||||||
|
},
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
// Local move generator: every legal play for a rack on a board, ranked by
|
||||||
|
// descending score. Ported from the scrabble-solver engine — the Appel-Jacobson
|
||||||
|
// two-phase algorithm (LeftPart then ExtendRight) over a plain left-to-right DAWG
|
||||||
|
// (scrabble/gen_dawg.go, gen.go, crossset.go, solver.go, key.go). It walks the DAWG
|
||||||
|
// with the cursor from dawg.ts and scores each play with evaluate() from validate.ts,
|
||||||
|
// so the whole robot brain runs on-device. Faithfulness to the Go solver is pinned by
|
||||||
|
// generate.parity.test.ts against golden fixtures (backend/cmd/movegen).
|
||||||
|
//
|
||||||
|
// Everything works in alphabet-index space, mirroring the Go engine. A letterSet is
|
||||||
|
// a per-square cross-set (the letters that form a legal perpendicular word there);
|
||||||
|
// it is a boolean membership array rather than a uint64, so alphabet indexes past
|
||||||
|
// JS's 31-bit shift boundary (Russian has 33 letters) are handled exactly.
|
||||||
|
|
||||||
|
import { Dawg } from './dawg';
|
||||||
|
import {
|
||||||
|
evaluate,
|
||||||
|
connected,
|
||||||
|
Horizontal,
|
||||||
|
Vertical,
|
||||||
|
type Board,
|
||||||
|
type Ruleset,
|
||||||
|
type Move,
|
||||||
|
type Placement,
|
||||||
|
type Direction,
|
||||||
|
} from './validate';
|
||||||
|
|
||||||
|
/** Both generates across plays (on the board) and down plays (on its transpose). */
|
||||||
|
export const Both = 0;
|
||||||
|
/** OnlyHorizontal generates across plays only. */
|
||||||
|
export const OnlyHorizontal = 1;
|
||||||
|
/** OnlyVertical generates down plays only (Эрудит plays a single orientation per turn). */
|
||||||
|
export const OnlyVertical = 2;
|
||||||
|
export type Mode = typeof Both | typeof OnlyHorizontal | typeof OnlyVertical;
|
||||||
|
|
||||||
|
function modeIncludes(mode: Mode, dir: Direction): boolean {
|
||||||
|
if (mode === Both) return true;
|
||||||
|
if (mode === OnlyHorizontal) return dir === Horizontal;
|
||||||
|
return dir === Vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GenBoard is the board view the generator needs: the validator's read view plus the
|
||||||
|
* dimensions it iterates over. The whole board is square, so a transposed view (below)
|
||||||
|
* satisfies the same shape.
|
||||||
|
*/
|
||||||
|
export interface GenBoard extends Board {
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GenRack is a rack as per-letter tile counts plus a blank slot, mirroring
|
||||||
|
* scrabble-solver/rack. The generator mutates a single rack in place — removing a
|
||||||
|
* tile, recursing, putting it back — so the operations are O(1) and allocation-free.
|
||||||
|
*/
|
||||||
|
export class GenRack {
|
||||||
|
private readonly counts: Int32Array;
|
||||||
|
|
||||||
|
/** Construct an empty rack for an alphabet of the given size (a trailing blank slot). */
|
||||||
|
constructor(size: number) {
|
||||||
|
this.counts = new Int32Array(size + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** from builds a rack from a multiset of letter indexes plus a blank count. */
|
||||||
|
static from(size: number, letters: readonly number[], blanks: number): GenRack {
|
||||||
|
const r = new GenRack(size);
|
||||||
|
for (const l of letters) r.counts[l]++;
|
||||||
|
r.counts[size] += blanks;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** has reports whether at least one tile of the letter index is on the rack. */
|
||||||
|
has(letter: number): boolean {
|
||||||
|
return this.counts[letter] > 0;
|
||||||
|
}
|
||||||
|
/** blanks returns how many blank tiles are on the rack. */
|
||||||
|
blanks(): number {
|
||||||
|
return this.counts[this.counts.length - 1];
|
||||||
|
}
|
||||||
|
remove(letter: number): void {
|
||||||
|
this.counts[letter]--;
|
||||||
|
}
|
||||||
|
add(letter: number): void {
|
||||||
|
this.counts[letter]++;
|
||||||
|
}
|
||||||
|
removeBlank(): void {
|
||||||
|
this.counts[this.counts.length - 1]--;
|
||||||
|
}
|
||||||
|
addBlank(): void {
|
||||||
|
this.counts[this.counts.length - 1]++;
|
||||||
|
}
|
||||||
|
/** clone returns an independent copy. */
|
||||||
|
clone(): GenRack {
|
||||||
|
const c = new GenRack(this.counts.length - 1);
|
||||||
|
c.counts.set(this.counts);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- cross-sets ------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// A LetterSet is membership over alphabet letter indexes. Mirrors scrabble.letterSet
|
||||||
|
// (a uint64) but as a Uint8Array so index 32 (Russian) is exact under JS bit ops.
|
||||||
|
type LetterSet = Uint8Array;
|
||||||
|
|
||||||
|
function fullSet(size: number): LetterSet {
|
||||||
|
return new Uint8Array(size).fill(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// walk follows word (alphabet indexes) left to right from the root; -1 if it derails.
|
||||||
|
function walk(dawg: Dawg, word: readonly number[]): number {
|
||||||
|
let n = dawg.root();
|
||||||
|
for (const l of word) {
|
||||||
|
n = dawg.next(n, l);
|
||||||
|
if (n < 0) return -1;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// completers returns the letters X (< size) whose arc from state leads directly to an
|
||||||
|
// accepting node — the deterministic cross-set primitive. Mirrors scrabble.completers.
|
||||||
|
function completers(dawg: Dawg, state: number, size: number): LetterSet {
|
||||||
|
const set = new Uint8Array(size);
|
||||||
|
dawg.arcs(state, (label, _dest, final) => {
|
||||||
|
if (final && label < size) set[label] = 1;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
// dawgCrossSet returns the letters X for which above·X·below is a stored word. Mirrors
|
||||||
|
// scrabble.dawgCrossSet: a right extension (no tiles below) just completes the prefix
|
||||||
|
// above; a left extension (tiles below) probes each X. above/below are letter indexes.
|
||||||
|
function dawgCrossSet(dawg: Dawg, above: number[], below: number[], size: number): LetterSet {
|
||||||
|
if (above.length === 0 && below.length === 0) return fullSet(size);
|
||||||
|
if (below.length === 0) {
|
||||||
|
const node = walk(dawg, above);
|
||||||
|
if (node < 0) return new Uint8Array(size);
|
||||||
|
return completers(dawg, node, size);
|
||||||
|
}
|
||||||
|
let node = dawg.root();
|
||||||
|
if (above.length > 0) {
|
||||||
|
node = walk(dawg, above);
|
||||||
|
if (node < 0) return new Uint8Array(size);
|
||||||
|
}
|
||||||
|
const set = new Uint8Array(size);
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
let m = dawg.next(node, x);
|
||||||
|
if (m < 0) continue;
|
||||||
|
let ok = true;
|
||||||
|
for (const l of below) {
|
||||||
|
m = dawg.next(m, l);
|
||||||
|
if (m < 0) {
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ok && dawg.final(m)) set[x] = 1;
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
// columnContext returns the contiguous run of filled cells immediately above and below
|
||||||
|
// the empty square (r, c), each top to bottom, as letter indexes — the tiles a
|
||||||
|
// perpendicular word through (r, c) would include. Mirrors scrabble.columnContext.
|
||||||
|
function columnContext(b: GenBoard, r: number, c: number): { above: number[]; below: number[] } {
|
||||||
|
const above: number[] = [];
|
||||||
|
let start = r;
|
||||||
|
while (start - 1 >= 0 && b.filled(start - 1, c)) start--;
|
||||||
|
for (let rr = start; rr < r; rr++) above.push(b.cellAt(rr, c).letter);
|
||||||
|
|
||||||
|
const below: number[] = [];
|
||||||
|
let end = r;
|
||||||
|
while (end + 1 < b.rows && b.filled(end + 1, c)) end++;
|
||||||
|
for (let rr = r + 1; rr <= end; rr++) below.push(b.cellAt(rr, c).letter);
|
||||||
|
return { above, below };
|
||||||
|
}
|
||||||
|
|
||||||
|
// transpose returns a view of b with rows and columns swapped, so down-play generation
|
||||||
|
// runs as across generation. Mirrors board.Transpose (as a lazy view).
|
||||||
|
function transpose(b: GenBoard): GenBoard {
|
||||||
|
return {
|
||||||
|
rows: b.cols,
|
||||||
|
cols: b.rows,
|
||||||
|
inBounds: (r, c) => b.inBounds(c, r),
|
||||||
|
filled: (r, c) => b.filled(c, r),
|
||||||
|
cellAt: (r, c) => b.cellAt(c, r),
|
||||||
|
isEmpty: () => b.isEmpty(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tentatively placed left-part tile; its column is fixed only at record time.
|
||||||
|
interface TileInfo {
|
||||||
|
letter: number;
|
||||||
|
blank: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcrossGen carries one across-generation pass over a board. Mirrors scrabble.acrossGen.
|
||||||
|
class AcrossGen {
|
||||||
|
private row = 0;
|
||||||
|
private readonly left: TileInfo[] = [];
|
||||||
|
private readonly right: Placement[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly dawg: Dawg,
|
||||||
|
private readonly bd: GenBoard,
|
||||||
|
private readonly rk: GenRack,
|
||||||
|
private readonly cross: (r: number, c: number) => LetterSet,
|
||||||
|
private readonly emit: (placements: Placement[]) => void,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
generateRow(row: number, firstMove: boolean, centerRow: number, centerCol: number): void {
|
||||||
|
this.row = row;
|
||||||
|
let limit = 0;
|
||||||
|
for (let col = 0; col < this.bd.cols; col++) {
|
||||||
|
if (this.bd.filled(row, col)) {
|
||||||
|
limit = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const anchor = firstMove ? row === centerRow && col === centerCol : this.hasFilledNeighbor(row, col);
|
||||||
|
if (!anchor) {
|
||||||
|
limit++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.left.length = 0;
|
||||||
|
this.right.length = 0;
|
||||||
|
if (col > 0 && this.bd.filled(row, col - 1)) {
|
||||||
|
const pre = this.walkPrefix(row, col);
|
||||||
|
if (pre.ok) this.extendRight(pre.node, col, col);
|
||||||
|
} else {
|
||||||
|
this.leftPart(this.dawg.root(), col, limit);
|
||||||
|
}
|
||||||
|
limit = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasFilledNeighbor(r: number, c: number): boolean {
|
||||||
|
return this.bd.filled(r - 1, c) || this.bd.filled(r + 1, c) || this.bd.filled(r, c - 1) || this.bd.filled(r, c + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// walkPrefix walks the DAWG through the filled run ending at col-1, returning the
|
||||||
|
// node reached and whether that prefix exists. Mirrors scrabble.acrossGen.walkPrefix.
|
||||||
|
private walkPrefix(row: number, col: number): { node: number; ok: boolean } {
|
||||||
|
let start = col - 1;
|
||||||
|
while (start - 1 >= 0 && this.bd.filled(row, start - 1)) start--;
|
||||||
|
let node = this.dawg.root();
|
||||||
|
for (let c = start; c < col; c++) {
|
||||||
|
node = this.dawg.next(node, this.bd.cellAt(row, c).letter);
|
||||||
|
if (node < 0) return { node, ok: false };
|
||||||
|
}
|
||||||
|
return { node, ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// leftPart places left-part tiles from the rack (up to limit), calling extendRight
|
||||||
|
// after each prefix. Mirrors scrabble.acrossGen.leftPart.
|
||||||
|
private leftPart(node: number, anchorCol: number, limit: number): void {
|
||||||
|
this.extendRight(node, anchorCol, anchorCol);
|
||||||
|
if (limit === 0) return;
|
||||||
|
this.dawg.arcs(node, (label, dest) => {
|
||||||
|
if (this.rk.has(label)) {
|
||||||
|
this.rk.remove(label);
|
||||||
|
this.left.push({ letter: label, blank: false });
|
||||||
|
this.leftPart(dest, anchorCol, limit - 1);
|
||||||
|
this.left.pop();
|
||||||
|
this.rk.add(label);
|
||||||
|
}
|
||||||
|
if (this.rk.blanks() > 0) {
|
||||||
|
this.rk.removeBlank();
|
||||||
|
this.left.push({ letter: label, blank: true });
|
||||||
|
this.leftPart(dest, anchorCol, limit - 1);
|
||||||
|
this.left.pop();
|
||||||
|
this.rk.addBlank();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// extendRight extends the word rightward from col, placing rack tiles on empty
|
||||||
|
// squares (constrained by cross-sets) and following board tiles. A word is recorded
|
||||||
|
// only past the anchor. Mirrors scrabble.acrossGen.extendRight.
|
||||||
|
private extendRight(node: number, col: number, anchorCol: number): void {
|
||||||
|
if (col >= this.bd.cols) {
|
||||||
|
if (col > anchorCol && this.dawg.final(node)) this.record(anchorCol);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.bd.filled(this.row, col)) {
|
||||||
|
const dest = this.dawg.next(node, this.bd.cellAt(this.row, col).letter);
|
||||||
|
if (dest >= 0) this.extendRight(dest, col + 1, anchorCol);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (col > anchorCol && this.dawg.final(node)) this.record(anchorCol);
|
||||||
|
const cross = this.cross(this.row, col);
|
||||||
|
this.dawg.arcs(node, (label, dest) => {
|
||||||
|
if (cross[label] !== 1) return true;
|
||||||
|
if (this.rk.has(label)) {
|
||||||
|
this.rk.remove(label);
|
||||||
|
this.right.push({ row: this.row, col, letter: label, blank: false });
|
||||||
|
this.extendRight(dest, col + 1, anchorCol);
|
||||||
|
this.right.pop();
|
||||||
|
this.rk.add(label);
|
||||||
|
}
|
||||||
|
if (this.rk.blanks() > 0) {
|
||||||
|
this.rk.removeBlank();
|
||||||
|
this.right.push({ row: this.row, col, letter: label, blank: true });
|
||||||
|
this.extendRight(dest, col + 1, anchorCol);
|
||||||
|
this.right.pop();
|
||||||
|
this.rk.addBlank();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// record assembles the play (left part at fixed columns, then the right part) and
|
||||||
|
// reports it, skipping plays that lay no new tile. Mirrors scrabble.acrossGen.record.
|
||||||
|
private record(anchorCol: number): void {
|
||||||
|
if (this.left.length + this.right.length === 0) return;
|
||||||
|
const placements: Placement[] = [];
|
||||||
|
const leftStart = anchorCol - this.left.length;
|
||||||
|
for (let i = 0; i < this.left.length; i++) {
|
||||||
|
placements.push({ row: this.row, col: leftStart + i, letter: this.left[i].letter, blank: this.left[i].blank });
|
||||||
|
}
|
||||||
|
for (const p of this.right) placements.push(p);
|
||||||
|
this.emit(placements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAcross generates all across plays on bd and reports each via emit in bd's
|
||||||
|
// coordinates. Cross-sets are computed lazily (vertical words on bd) and cached.
|
||||||
|
// Mirrors DAWGGenerator.runAcross.
|
||||||
|
function runAcross(
|
||||||
|
dawg: Dawg,
|
||||||
|
bd: GenBoard,
|
||||||
|
rk: GenRack,
|
||||||
|
size: number,
|
||||||
|
rs: Ruleset,
|
||||||
|
centerRow: number,
|
||||||
|
centerCol: number,
|
||||||
|
emit: (placements: Placement[]) => void,
|
||||||
|
): void {
|
||||||
|
let crossFn: (r: number, c: number) => LetterSet;
|
||||||
|
if (rs.ignoreCrossWords) {
|
||||||
|
const full = fullSet(size);
|
||||||
|
crossFn = () => full;
|
||||||
|
} else {
|
||||||
|
const cache = new Array<LetterSet | undefined>(bd.rows * bd.cols);
|
||||||
|
crossFn = (r, c) => {
|
||||||
|
const i = r * bd.cols + c;
|
||||||
|
let s = cache[i];
|
||||||
|
if (!s) {
|
||||||
|
const { above, below } = columnContext(bd, r, c);
|
||||||
|
s = dawgCrossSet(dawg, above, below, size);
|
||||||
|
cache[i] = s;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ag = new AcrossGen(dawg, bd, rk, crossFn, emit);
|
||||||
|
const firstMove = bd.isEmpty();
|
||||||
|
for (let row = 0; row < bd.rows; row++) {
|
||||||
|
ag.generateRow(row, firstMove, centerRow, centerCol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveKey is a canonical string identifying a play (direction plus its placed tiles),
|
||||||
|
// used to de-duplicate and rank generated moves. Mirrors scrabble.moveKey.
|
||||||
|
export function moveKey(dir: Direction, placements: readonly Placement[]): string {
|
||||||
|
const ps = placements.slice().sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col));
|
||||||
|
let s = String(dir);
|
||||||
|
for (const p of ps) s += `;${p.row},${p.col},${p.letter}${p.blank ? '*' : ''}`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* generateMoves returns every legal play for rack on board in the mode's orientations,
|
||||||
|
* ranked by descending score (ties broken by the canonical move key). It walks the DAWG
|
||||||
|
* with the cursor and scores each play with evaluate(); the alphabet size is taken from
|
||||||
|
* the ruleset's value table. Mirrors (*Solver).GenerateMovesOpts (the ruleset carries
|
||||||
|
* ignoreCrossWords for the single-word rule).
|
||||||
|
*/
|
||||||
|
export function generateMoves(dawg: Dawg, board: GenBoard, rack: GenRack, rs: Ruleset, mode: Mode = Both): Move[] {
|
||||||
|
const size = rs.values.length;
|
||||||
|
const rk = rack.clone(); // generation mutates the rack in place and restores it
|
||||||
|
const centerRow = Math.floor(rs.center / rs.cols);
|
||||||
|
const centerCol = rs.center % rs.cols;
|
||||||
|
|
||||||
|
const moves: Move[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const emit = (dir: Direction, placements: Placement[]): void => {
|
||||||
|
const key = moveKey(dir, placements);
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
const res = evaluate(board, rs, dir, placements);
|
||||||
|
if (res.err || !res.move) return;
|
||||||
|
seen.add(key);
|
||||||
|
moves.push(res.move);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (modeIncludes(mode, Horizontal)) {
|
||||||
|
runAcross(dawg, board, rk, size, rs, centerRow, centerCol, (p) => emit(Horizontal, p));
|
||||||
|
}
|
||||||
|
if (modeIncludes(mode, Vertical)) {
|
||||||
|
const tb = transpose(board);
|
||||||
|
runAcross(dawg, tb, rk, size, rs, centerCol, centerRow, (p) => {
|
||||||
|
const rp = p.map((pl) => ({ row: pl.col, col: pl.row, letter: pl.letter, blank: pl.blank }));
|
||||||
|
emit(Vertical, rp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const kept = moves.filter((m) => connected(board, rs, m));
|
||||||
|
kept.sort((a, b) => {
|
||||||
|
if (a.score !== b.score) return b.score - a.score;
|
||||||
|
const ka = moveKey(a.dir, a.tiles);
|
||||||
|
const kb = moveKey(b.dir, b.tiles);
|
||||||
|
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
||||||
|
});
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Browser orchestration for the offline-toggle readiness wait. Lazily imported by
|
||||||
|
// offline.svelte.ts's requestOffline so the dict loader/generator it pulls in stays out of the main
|
||||||
|
// bundle. It runs the same cache-first preload as the background warmup (preload.ts), but bounded by
|
||||||
|
// a UI wait: it tells the toggle whether flipping to offline can succeed right now, and leaves the
|
||||||
|
// fetch running on a timeout so a later flip is instant.
|
||||||
|
|
||||||
|
import type { Profile } from '../model';
|
||||||
|
import { preloadDicts } from './preload';
|
||||||
|
import { getDawg, dictLoadingDisabled } from '../dict';
|
||||||
|
import { raceOfflineReady } from '../offline';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ensureOfflineDicts fetches the profile's enabled variants' dictionaries cache-first (instant when
|
||||||
|
* already warm, a network fetch otherwise) and reports, within budgetMs, whether every one is
|
||||||
|
* available — the offline toggle's readiness gate. With no enabled variant there is nothing to play
|
||||||
|
* offline, so it is never ready. The fetch is not aborted on a timeout; it keeps warming the
|
||||||
|
* on-device cache in the background so a later flip to offline succeeds immediately.
|
||||||
|
*/
|
||||||
|
export async function ensureOfflineDicts(prof: Profile, budgetMs: number): Promise<boolean> {
|
||||||
|
if (prof.variantPreferences.length === 0) return false;
|
||||||
|
const run = preloadDicts(prof.dictVersions, prof.variantPreferences, {
|
||||||
|
getDawg,
|
||||||
|
disabled: dictLoadingDisabled,
|
||||||
|
retries: 1,
|
||||||
|
});
|
||||||
|
return raceOfflineReady(run, budgetMs);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { preloadDicts } from './preload';
|
||||||
|
import type { Variant } from '../model';
|
||||||
|
import type { Dawg } from './dawg';
|
||||||
|
|
||||||
|
// A non-null stand-in for a loaded reader — preloadDicts only checks getDawg's result for null.
|
||||||
|
const DAWG = {} as Dawg;
|
||||||
|
const noSleep = (): Promise<void> => Promise.resolve();
|
||||||
|
|
||||||
|
describe('preloadDicts', () => {
|
||||||
|
it('fetches every enabled variant that has a known version', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const res = await preloadDicts({ scrabble_en: 'v1', scrabble_ru: 'v2', erudit_ru: 'v3' }, ['scrabble_en', 'erudit_ru'], {
|
||||||
|
getDawg: async (v: Variant, ver: string) => {
|
||||||
|
calls.push(`${v}@${ver}`);
|
||||||
|
return DAWG;
|
||||||
|
},
|
||||||
|
disabled: () => false,
|
||||||
|
sleep: noSleep,
|
||||||
|
});
|
||||||
|
expect(res.ok).toEqual(['scrabble_en', 'erudit_ru']);
|
||||||
|
expect(res.failed).toEqual([]);
|
||||||
|
expect(calls).toEqual(['scrabble_en@v1', 'erudit_ru@v3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a variant with no known version as failed without fetching it', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en', 'scrabble_ru'], {
|
||||||
|
getDawg: async (v: Variant) => {
|
||||||
|
calls.push(v);
|
||||||
|
return DAWG;
|
||||||
|
},
|
||||||
|
disabled: () => false,
|
||||||
|
sleep: noSleep,
|
||||||
|
});
|
||||||
|
expect(res.ok).toEqual(['scrabble_en']);
|
||||||
|
expect(res.failed).toEqual(['scrabble_ru']);
|
||||||
|
expect(calls).toEqual(['scrabble_en']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries a transient failure with linear backoff, then succeeds', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const waits: number[] = [];
|
||||||
|
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en'], {
|
||||||
|
getDawg: async () => (++attempts >= 3 ? DAWG : null),
|
||||||
|
disabled: () => false,
|
||||||
|
sleep: async (ms: number) => void waits.push(ms),
|
||||||
|
retries: 3,
|
||||||
|
backoffMs: 100,
|
||||||
|
});
|
||||||
|
expect(res.ok).toEqual(['scrabble_en']);
|
||||||
|
expect(attempts).toBe(3);
|
||||||
|
expect(waits).toEqual([100, 200]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives up a persistent failure after the retry budget', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en'], {
|
||||||
|
getDawg: async () => {
|
||||||
|
attempts++;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
disabled: () => false,
|
||||||
|
sleep: noSleep,
|
||||||
|
retries: 2,
|
||||||
|
});
|
||||||
|
expect(res.failed).toEqual(['scrabble_en']);
|
||||||
|
expect(res.ok).toEqual([]);
|
||||||
|
expect(attempts).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops retrying once the session miss-breaker trips', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en'], {
|
||||||
|
getDawg: async () => {
|
||||||
|
attempts++;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
disabled: () => true,
|
||||||
|
sleep: noSleep,
|
||||||
|
retries: 5,
|
||||||
|
});
|
||||||
|
expect(res.failed).toEqual(['scrabble_en']);
|
||||||
|
expect(attempts).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Background dictionary preload for offline readiness. An installed PWA with a confirmed email
|
||||||
|
// warms the dictionaries for the player's enabled variants while online, so a later switch to
|
||||||
|
// deliberate offline mode has the data it needs. The pure preloadDicts here takes its side effects
|
||||||
|
// (getDawg, the session miss-breaker, sleep) as dependencies, so it unit-tests in the node env. The
|
||||||
|
// eligibility and once/online guard live in offline.svelte.ts (kickDictPreload); the browser
|
||||||
|
// orchestration that supplies the real side effects and raises the in-lobby warning lives in
|
||||||
|
// preloadrun.ts, which offline.svelte.ts imports dynamically so neither the loader nor the
|
||||||
|
// generator is pulled into the main bundle.
|
||||||
|
|
||||||
|
import type { Variant } from '../model';
|
||||||
|
import type { Dawg } from './dawg';
|
||||||
|
|
||||||
|
/** PreloadDeps injects preloadDicts's side effects so the logic stays pure and testable. */
|
||||||
|
export interface PreloadDeps {
|
||||||
|
/** getDawg resolves the (variant, version) reader, serving memory/IndexedDB before the network,
|
||||||
|
* or null on any miss — mirrors the in-game loader. */
|
||||||
|
getDawg: (variant: Variant, version: string) => Promise<Dawg | null>;
|
||||||
|
/** disabled reports whether the session dictionary miss-breaker has tripped (too many network
|
||||||
|
* misses this session); when it has, a network fetch will not recover, so retries stop. */
|
||||||
|
disabled: () => boolean;
|
||||||
|
/** sleep waits between retries; defaults to a real timer. */
|
||||||
|
sleep?: (ms: number) => Promise<void>;
|
||||||
|
/** retries is the number of extra attempts after the first (default 2). */
|
||||||
|
retries?: number;
|
||||||
|
/** backoffMs is the base linear backoff between attempts (default 800). */
|
||||||
|
backoffMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PreloadResult reports which enabled variants ended up available (ok) and which are still
|
||||||
|
* missing (failed) after the preload — the caller surfaces a warning when failed is non-empty. */
|
||||||
|
export interface PreloadResult {
|
||||||
|
ok: Variant[];
|
||||||
|
failed: Variant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* preloadDicts fetches, via getDawg, the dictionary for each enabled variant that has a known
|
||||||
|
* version, retrying transient misses with linear backoff. A variant with no known version, or one
|
||||||
|
* still missing after the retry budget, lands in failed; the rest in ok. It never throws and stops
|
||||||
|
* retrying a variant once the session miss-breaker (disabled) trips, since the network will not
|
||||||
|
* recover this session — a cached dictionary is still served by getDawg regardless.
|
||||||
|
*/
|
||||||
|
export async function preloadDicts(
|
||||||
|
versions: Partial<Record<Variant, string>>,
|
||||||
|
enabled: readonly Variant[],
|
||||||
|
deps: PreloadDeps,
|
||||||
|
): Promise<PreloadResult> {
|
||||||
|
const sleep = deps.sleep ?? ((ms) => new Promise<void>((r) => setTimeout(r, ms)));
|
||||||
|
const retries = deps.retries ?? 2;
|
||||||
|
const backoffMs = deps.backoffMs ?? 800;
|
||||||
|
const ok: Variant[] = [];
|
||||||
|
const failed: Variant[] = [];
|
||||||
|
for (const variant of enabled) {
|
||||||
|
const version = versions[variant];
|
||||||
|
if (!version) {
|
||||||
|
failed.push(variant);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dawg: Dawg | null = null;
|
||||||
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||||
|
dawg = await deps.getDawg(variant, version);
|
||||||
|
if (dawg || deps.disabled()) break;
|
||||||
|
if (attempt < retries) await sleep(backoffMs * (attempt + 1));
|
||||||
|
}
|
||||||
|
(dawg ? ok : failed).push(variant);
|
||||||
|
}
|
||||||
|
return { ok, failed };
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Browser-only orchestration for the offline dictionary preload. Kept apart from the pure
|
||||||
|
// preloadDicts (preload.ts) — which unit-tests in the node env — and lazily imported by
|
||||||
|
// offline.svelte.ts's kickDictPreload, so the dict loader/generator it pulls in stays out of the
|
||||||
|
// main bundle. It supplies the real side effects (getDawg, the session miss-breaker) and raises the
|
||||||
|
// in-lobby notice when a first-lobby preload cannot fetch every enabled variant's dictionary.
|
||||||
|
|
||||||
|
import type { Profile } from '../model';
|
||||||
|
import { preloadDicts } from './preload';
|
||||||
|
import { getDawg, dictLoadingDisabled } from '../dict';
|
||||||
|
import { setDictPreloadWarning } from '../offline.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* runPreload warms the dictionaries for the profile's enabled variants (using the versions the
|
||||||
|
* profile advertises) via the real dict loader, so a later switch to offline mode has the data.
|
||||||
|
* When warnOnFail is set (the first lobby entry), it raises the in-lobby notice if a variant is
|
||||||
|
* still missing afterwards, and clears it on a run where every variant is available.
|
||||||
|
*/
|
||||||
|
export async function runPreload(prof: Profile, warnOnFail: boolean): Promise<void> {
|
||||||
|
const res = await preloadDicts(prof.dictVersions, prof.variantPreferences, {
|
||||||
|
getDawg,
|
||||||
|
disabled: dictLoadingDisabled,
|
||||||
|
});
|
||||||
|
if (warnOnFail) setDictPreloadWarning(res.failed.length > 0);
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
+9802
File diff suppressed because it is too large
Load Diff
+122
@@ -0,0 +1,122 @@
|
|||||||
|
{
|
||||||
|
"alphabet": "en",
|
||||||
|
"numAdded": 18,
|
||||||
|
"words": [
|
||||||
|
"a",
|
||||||
|
"an",
|
||||||
|
"and",
|
||||||
|
"ant",
|
||||||
|
"car",
|
||||||
|
"care",
|
||||||
|
"cared",
|
||||||
|
"cares",
|
||||||
|
"cars",
|
||||||
|
"cat",
|
||||||
|
"cats",
|
||||||
|
"do",
|
||||||
|
"doe",
|
||||||
|
"does",
|
||||||
|
"dog",
|
||||||
|
"dogs",
|
||||||
|
"done",
|
||||||
|
"dot"
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
[
|
||||||
|
0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
13
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
13,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
13,
|
||||||
|
19
|
||||||
|
],
|
||||||
|
[
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
17
|
||||||
|
],
|
||||||
|
[
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
17,
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
17,
|
||||||
|
4,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
17,
|
||||||
|
4,
|
||||||
|
18
|
||||||
|
],
|
||||||
|
[
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
17,
|
||||||
|
18
|
||||||
|
],
|
||||||
|
[
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
19
|
||||||
|
],
|
||||||
|
[
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
19,
|
||||||
|
18
|
||||||
|
],
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
14
|
||||||
|
],
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
14,
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
14,
|
||||||
|
4,
|
||||||
|
18
|
||||||
|
],
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
14,
|
||||||
|
6
|
||||||
|
],
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
14,
|
||||||
|
6,
|
||||||
|
18
|
||||||
|
],
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
14,
|
||||||
|
13,
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
14,
|
||||||
|
19
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
+1271
File diff suppressed because it is too large
Load Diff
+38
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"alphabet": "ru",
|
||||||
|
"numAdded": 6,
|
||||||
|
"words": [
|
||||||
|
"ад",
|
||||||
|
"ар",
|
||||||
|
"оса",
|
||||||
|
"я",
|
||||||
|
"яд",
|
||||||
|
"яр"
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
17
|
||||||
|
],
|
||||||
|
[
|
||||||
|
15,
|
||||||
|
18,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
32
|
||||||
|
],
|
||||||
|
[
|
||||||
|
32,
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
32,
|
||||||
|
17
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -297,8 +297,9 @@ export function validatePlay(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// connected reports whether the play connects to the position (or covers the
|
// connected reports whether the play connects to the position (or covers the
|
||||||
// centre on the first move). Mirrors (*Solver).connected.
|
// centre on the first move). Mirrors (*Solver).connected. Exported so the move
|
||||||
function connected(b: Board, rs: Ruleset, m: Move): boolean {
|
// generator can apply the same post-generation connectivity filter.
|
||||||
|
export function connected(b: Board, rs: Ruleset, m: Move): boolean {
|
||||||
if (b.isEmpty()) {
|
if (b.isEmpty()) {
|
||||||
const cr = Math.floor(rs.center / rs.cols);
|
const cr = Math.floor(rs.center / rs.cols);
|
||||||
const cc = rs.center % rs.cols;
|
const cc = rs.center % rs.cols;
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Dispatch a game id to its source: the local engine (an offline vs_ai game) or the network
|
||||||
|
// gateway. The same game screen (game/Game.svelte) drives both through the shared game-loop subset
|
||||||
|
// (GameLoopSource); a game id decides which. A network game's calls are unchanged — gameSource
|
||||||
|
// returns the gateway itself for it — so the seam adds the local path without touching online play.
|
||||||
|
//
|
||||||
|
// The offline engine is kept OUT of the app entry bundle: this module statically imports only the
|
||||||
|
// gateway, the tiny id helper and types; the LocalSource is dynamically imported on first use of a
|
||||||
|
// local game, so it loads as a separate chunk only when someone plays offline.
|
||||||
|
|
||||||
|
import { gateway } from './gateway';
|
||||||
|
import { isLocalGameId } from './localgame/id';
|
||||||
|
import type { GameLoopSource, LocalSource } from './localgame/source';
|
||||||
|
|
||||||
|
let loaded: Promise<LocalSource> | null = null;
|
||||||
|
function load(): Promise<LocalSource> {
|
||||||
|
if (!loaded) loaded = import('./localgame/source').then((m) => new m.LocalSource());
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* localSource is a lazy handle on the single offline LocalSource. It is obtainable synchronously,
|
||||||
|
* but each method dynamically imports the engine on first use, so the offline code stays out of the
|
||||||
|
* app entry bundle until a local game is actually played. It exposes the game-loop methods plus the
|
||||||
|
* local-only create() and the robot-reply event subscription events().
|
||||||
|
*/
|
||||||
|
export const localSource = {
|
||||||
|
// Offline scoring is self-contained, so the local source ignores includeAlphabet, the evaluate
|
||||||
|
// abort signal, and the draft (drafts are not persisted offline); the proxy accepts the full
|
||||||
|
// gateway signatures but forwards only what the local source uses.
|
||||||
|
gameState: (id, _includeAlphabet) => load().then((s) => s.gameState(id)),
|
||||||
|
gameHistory: (id) => load().then((s) => s.gameHistory(id)),
|
||||||
|
submitPlay: (id, tiles, variant) => load().then((s) => s.submitPlay(id, tiles, variant)),
|
||||||
|
pass: (id) => load().then((s) => s.pass(id)),
|
||||||
|
exchange: (id, tiles, variant) => load().then((s) => s.exchange(id, tiles, variant)),
|
||||||
|
resign: (id) => load().then((s) => s.resign(id)),
|
||||||
|
hint: (id) => load().then((s) => s.hint(id)),
|
||||||
|
evaluate: (id, tiles, variant, _signal) => load().then((s) => s.evaluate(id, tiles, variant)),
|
||||||
|
checkWord: (id, word, variant) => load().then((s) => s.checkWord(id, word, variant)),
|
||||||
|
draftGet: (_id) => load().then((s) => s.draftGet()),
|
||||||
|
draftSave: (_id, _json) => load().then((s) => s.draftSave()),
|
||||||
|
create: (opts) => load().then((s) => s.create(opts)),
|
||||||
|
list: () => load().then((s) => s.list()),
|
||||||
|
delete: (id) => load().then((s) => s.delete(id)),
|
||||||
|
// Offline hotseat controls (no network equivalent): reveal a PIN-locked seat, and the host
|
||||||
|
// (referee) overrides gated by the master PIN.
|
||||||
|
unlockSeat: (id, pin) => load().then((s) => s.unlockSeat(id, pin)),
|
||||||
|
verifyHostPin: (id, pin) => load().then((s) => s.verifyHostPin(id, pin)),
|
||||||
|
hostAction: (id, pin, action, targetSeat) => load().then((s) => s.hostAction(id, pin, action, targetSeat)),
|
||||||
|
// relock is fire-and-forget: the engine is already loaded (the game was open), and re-locking a
|
||||||
|
// cached game before the screen is left needs no result.
|
||||||
|
relock: (id) => void load().then((s) => s.relock(id)),
|
||||||
|
// events must return the unsubscribe synchronously (the screen subscribes in onMount), so it wires
|
||||||
|
// the real subscription once the engine loads and, until then, cancels a pending subscribe.
|
||||||
|
events: (id, onEvent) => {
|
||||||
|
let unsub = (): void => {};
|
||||||
|
let cancelled = false;
|
||||||
|
void load().then((s) => {
|
||||||
|
if (!cancelled) unsub = s.events(id, onEvent);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unsub();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} satisfies GameLoopSource &
|
||||||
|
Pick<LocalSource, 'events' | 'create' | 'list' | 'delete' | 'unlockSeat' | 'verifyHostPin' | 'hostAction' | 'relock'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gameSource returns the source that runs the game with the given id: the local engine for a local
|
||||||
|
* id, otherwise the network gateway (which satisfies the same game-loop interface).
|
||||||
|
*/
|
||||||
|
export function gameSource(id: string): GameLoopSource {
|
||||||
|
return isLocalGameId(id) ? localSource : gateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isLocalGameId } from './localgame/id';
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import type { GatewayClient } from './client';
|
import type { GatewayClient } from './client';
|
||||||
import { MockGateway } from './mock/client';
|
import { MockGateway } from './mock/client';
|
||||||
import { createTransport } from './transport';
|
import { createTransport } from './transport';
|
||||||
|
import { setForcedSeed } from './localgame/id';
|
||||||
import { reportOffline, reportOnline } from './connection.svelte';
|
import { reportOffline, reportOnline } from './connection.svelte';
|
||||||
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
|
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ if (isMock && typeof window !== 'undefined') {
|
|||||||
setGameLimit(v: boolean): void;
|
setGameLimit(v: boolean): void;
|
||||||
clearEmail(): void;
|
clearEmail(): void;
|
||||||
confirmEmailOutOfBand(email: string): void;
|
confirmEmailOutOfBand(email: string): void;
|
||||||
|
setLocalSeed(seed: string): void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
).__mock = {
|
).__mock = {
|
||||||
@@ -50,5 +52,8 @@ if (isMock && typeof window !== 'undefined') {
|
|||||||
setGameLimit: (v: boolean) => (gateway as MockGateway).setGameLimit(v),
|
setGameLimit: (v: boolean) => (gateway as MockGateway).setGameLimit(v),
|
||||||
clearEmail: () => (gateway as MockGateway).mockClearEmail(),
|
clearEmail: () => (gateway as MockGateway).mockClearEmail(),
|
||||||
confirmEmailOutOfBand: (email: string) => (gateway as MockGateway).mockConfirmEmailOutOfBand(email),
|
confirmEmailOutOfBand: (email: string) => (gateway as MockGateway).mockConfirmEmailOutOfBand(email),
|
||||||
|
// Pin the next local game's bag seed, so the offline e2e deals a known rack (a bigint as a
|
||||||
|
// string — window hooks marshal strings cleanly).
|
||||||
|
setLocalSeed: (seed: string) => setForcedSeed(BigInt(seed)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { hintsLeft } from './hints';
|
import { hintsLeft, hintGateRemainingMs, hintLockMinutes } from './hints';
|
||||||
|
|
||||||
// view carries only the two fields hintsLeft reads.
|
// view carries only the two fields hintsLeft reads.
|
||||||
const view = (hintsRemaining: number, walletBalance: number) => ({ hintsRemaining, walletBalance });
|
const view = (hintsRemaining: number, walletBalance: number) => ({ hintsRemaining, walletBalance });
|
||||||
@@ -30,3 +30,32 @@ describe('hintsLeft', () => {
|
|||||||
expect(hintsLeft(view(1, 0), -5)).toBe(1);
|
expect(hintsLeft(view(1, 0), -5)).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('hintGateRemainingMs (the vs_ai idle-hint gate, monotonic)', () => {
|
||||||
|
it('is 0 when the gate is open — a null anchor (the human first move, or a non-gated game)', () => {
|
||||||
|
expect(hintGateRemainingMs(null, 60_000, 5000)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is the window remaining minus the monotonic time elapsed since the anchor', () => {
|
||||||
|
// anchored at mono 1000 with 60s left; 20s of monotonic time later -> 40s left.
|
||||||
|
expect(hintGateRemainingMs(1000, 60_000, 1000 + 20_000)).toBe(40_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is 0 once the elapsed monotonic time reaches (or passes) the window', () => {
|
||||||
|
expect(hintGateRemainingMs(1000, 60_000, 1000 + 60_000)).toBe(0);
|
||||||
|
expect(hintGateRemainingMs(1000, 60_000, 1000 + 90_000)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hintLockMinutes (the toast N, rounded up)', () => {
|
||||||
|
it('rounds up to whole minutes, so a still-closed gate never reads 0', () => {
|
||||||
|
expect(hintLockMinutes(1)).toBe(1);
|
||||||
|
expect(hintLockMinutes(60_000)).toBe(1);
|
||||||
|
expect(hintLockMinutes(61_000)).toBe(2);
|
||||||
|
expect(hintLockMinutes(29 * 60_000)).toBe(29);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is 0 when nothing remains', () => {
|
||||||
|
expect(hintLockMinutes(0)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,3 +24,27 @@ export function hintsLeft(
|
|||||||
const allowance = Math.max(0, view.hintsRemaining - view.walletBalance);
|
const allowance = Math.max(0, view.hintsRemaining - view.walletBalance);
|
||||||
return allowance + Math.max(0, walletBalance);
|
return allowance + Math.max(0, walletBalance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** HINT_GATE_MS is the idle time a vs_ai player must be stuck on a turn before a hint unlocks. */
|
||||||
|
export const HINT_GATE_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hintGateRemainingMs returns how long (in milliseconds) until a vs_ai game's idle hint unlocks — 0
|
||||||
|
* once it is available. It counts down from a MONOTONIC clock (performance.now()), so a client clock
|
||||||
|
* change cannot skew it: gateStartMono is performance.now() captured when the source's "seconds left"
|
||||||
|
* was received, gateLeftMs is that seconds-left in ms, and monoNow is performance.now() now; the
|
||||||
|
* remaining is gateLeftMs minus the monotonic time elapsed since the anchor. gateStartMono null means
|
||||||
|
* the gate is open (the human's first move, or a non-gated game). The duration comes from the source
|
||||||
|
* (the SERVER clock online, the device clock offline) and is re-fetched on every load, so the wait
|
||||||
|
* both persists across a relaunch and stays immune to a live clock change.
|
||||||
|
*/
|
||||||
|
export function hintGateRemainingMs(gateStartMono: number | null, gateLeftMs: number, monoNow: number): number {
|
||||||
|
if (gateStartMono === null) return 0;
|
||||||
|
return Math.max(0, gateLeftMs - (monoNow - gateStartMono));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** hintLockMinutes rounds a remaining-milliseconds gap up to whole minutes for the "available in N
|
||||||
|
* min." toast, so a gate that is still closed never reads 0. */
|
||||||
|
export function hintLockMinutes(remainingMs: number): number {
|
||||||
|
return Math.ceil(remainingMs / 60000);
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export const en = {
|
|||||||
'game.draw': 'Exchange/Pass',
|
'game.draw': 'Exchange/Pass',
|
||||||
'game.shuffle': 'Shuffle',
|
'game.shuffle': 'Shuffle',
|
||||||
'game.hint': 'Hint',
|
'game.hint': 'Hint',
|
||||||
|
'game.hintLockedIn': 'Available in {n} min.',
|
||||||
'game.chat': 'Chat',
|
'game.chat': 'Chat',
|
||||||
'game.checkWord': 'Check word',
|
'game.checkWord': 'Check word',
|
||||||
'game.dictionary': 'Dictionary',
|
'game.dictionary': 'Dictionary',
|
||||||
@@ -214,6 +215,15 @@ export const en = {
|
|||||||
'settings.labelsClassic': 'Classic',
|
'settings.labelsClassic': 'Classic',
|
||||||
'settings.labelsNone': 'None',
|
'settings.labelsNone': 'None',
|
||||||
'settings.reduceMotion': 'Reduce motion',
|
'settings.reduceMotion': 'Reduce motion',
|
||||||
|
'settings.offlineMode': 'Play mode',
|
||||||
|
'settings.online': 'Online',
|
||||||
|
'settings.offline': 'Offline',
|
||||||
|
'settings.offlineChecking': 'Loading dictionaries…',
|
||||||
|
'settings.offlineNeedsData': 'Not enough data on the device. Internet access is needed to download it.',
|
||||||
|
'offline.preloadWarning': 'Poor internet connection. Some features may be unavailable.',
|
||||||
|
'offline.promptTitle': 'No connection. Enable offline mode?',
|
||||||
|
'offline.promptYes': 'Enable',
|
||||||
|
'offline.promptNo': 'Keep trying',
|
||||||
|
|
||||||
'about.title': 'About',
|
'about.title': 'About',
|
||||||
'about.tab': 'Info',
|
'about.tab': 'Info',
|
||||||
@@ -379,6 +389,35 @@ export const en = {
|
|||||||
'error.game_active': 'Available only after the game is finished.',
|
'error.game_active': 'Available only after the game is finished.',
|
||||||
'error.invalid_profile': 'Some profile fields are invalid.',
|
'error.invalid_profile': 'Some profile fields are invalid.',
|
||||||
'error.already_confirmed': 'This email is already confirmed.',
|
'error.already_confirmed': 'This email is already confirmed.',
|
||||||
|
|
||||||
|
'pin.enter': 'Enter PIN',
|
||||||
|
'pin.create': 'Create a PIN',
|
||||||
|
'pin.repeat': 'Repeat the PIN',
|
||||||
|
'pin.enterCurrent': 'Enter current PIN',
|
||||||
|
'pin.mismatch': "PINs don't match",
|
||||||
|
'pin.wrong': 'Wrong PIN',
|
||||||
|
'pin.setNew': 'Set a new PIN',
|
||||||
|
'pin.remove': 'Remove PIN',
|
||||||
|
|
||||||
|
'hotseat.hostPin': 'Host password',
|
||||||
|
'hotseat.setPin': 'Password',
|
||||||
|
'hotseat.changePin': 'Change',
|
||||||
|
'hotseat.playerName': 'Player name',
|
||||||
|
'hotseat.addPlayer': 'Add player',
|
||||||
|
'hotseat.removePlayer': 'Remove player',
|
||||||
|
'hotseat.hostPlaysTitle': 'Are you playing too?',
|
||||||
|
'hotseat.hostPlaysYes': 'Yes, I play',
|
||||||
|
'hotseat.hostPlaysNo': 'No',
|
||||||
|
'hotseat.unlock': 'Unlock',
|
||||||
|
'hotseat.unlockTitle': 'Unlock: {name}',
|
||||||
|
'hotseat.turnOf': 'Turn: {name}',
|
||||||
|
'hotseat.host': 'Host',
|
||||||
|
'hotseat.skip': 'Skip the current turn',
|
||||||
|
'hotseat.exclude': 'Exclude a player:',
|
||||||
|
'hotseat.terminate': 'End the game',
|
||||||
|
'hotseat.askSkip': "Skip {name}'s turn?",
|
||||||
|
'hotseat.askExclude': 'Remove {name} from the game?',
|
||||||
|
'hotseat.askTerminate': 'End the game with no result?',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type MessageKey = keyof typeof en;
|
export type MessageKey = keyof typeof en;
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'game.draw': 'Обмен/Пас',
|
'game.draw': 'Обмен/Пас',
|
||||||
'game.shuffle': 'Перемешать',
|
'game.shuffle': 'Перемешать',
|
||||||
'game.hint': 'Подсказка',
|
'game.hint': 'Подсказка',
|
||||||
|
'game.hintLockedIn': 'Будет доступно через {n} мин.',
|
||||||
'game.chat': 'Чат',
|
'game.chat': 'Чат',
|
||||||
'game.checkWord': 'Проверить слово',
|
'game.checkWord': 'Проверить слово',
|
||||||
'game.dictionary': 'Словарь',
|
'game.dictionary': 'Словарь',
|
||||||
@@ -214,6 +215,15 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'settings.labelsClassic': 'Классика',
|
'settings.labelsClassic': 'Классика',
|
||||||
'settings.labelsNone': 'Без текста',
|
'settings.labelsNone': 'Без текста',
|
||||||
'settings.reduceMotion': 'Меньше анимаций',
|
'settings.reduceMotion': 'Меньше анимаций',
|
||||||
|
'settings.offlineMode': 'Режим игры',
|
||||||
|
'settings.online': 'Онлайн',
|
||||||
|
'settings.offline': 'Оффлайн',
|
||||||
|
'settings.offlineChecking': 'Загрузка словарей…',
|
||||||
|
'settings.offlineNeedsData': 'Недостаточно данных. Необходим доступ в интернет для загрузки.',
|
||||||
|
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
|
||||||
|
'offline.promptTitle': 'Нет связи. Включить офлайн-режим?',
|
||||||
|
'offline.promptYes': 'Включить',
|
||||||
|
'offline.promptNo': 'Ждать сеть',
|
||||||
|
|
||||||
'about.title': 'О программе',
|
'about.title': 'О программе',
|
||||||
'about.tab': 'Инфо',
|
'about.tab': 'Инфо',
|
||||||
@@ -379,4 +389,33 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'error.game_active': 'Доступно только после завершения игры.',
|
'error.game_active': 'Доступно только после завершения игры.',
|
||||||
'error.invalid_profile': 'Некоторые поля профиля некорректны.',
|
'error.invalid_profile': 'Некоторые поля профиля некорректны.',
|
||||||
'error.already_confirmed': 'Эта почта уже подтверждена.',
|
'error.already_confirmed': 'Эта почта уже подтверждена.',
|
||||||
|
|
||||||
|
'pin.enter': 'Введите PIN',
|
||||||
|
'pin.create': 'Придумайте PIN',
|
||||||
|
'pin.repeat': 'Повторите PIN',
|
||||||
|
'pin.enterCurrent': 'Введите текущий PIN',
|
||||||
|
'pin.mismatch': 'PIN не совпадает',
|
||||||
|
'pin.wrong': 'Неверный PIN',
|
||||||
|
'pin.setNew': 'Задать новый PIN',
|
||||||
|
'pin.remove': 'Удалить пароль',
|
||||||
|
|
||||||
|
'hotseat.hostPin': 'Пароль ведущего',
|
||||||
|
'hotseat.setPin': 'Пароль',
|
||||||
|
'hotseat.changePin': 'Изменить',
|
||||||
|
'hotseat.playerName': 'Имя игрока',
|
||||||
|
'hotseat.addPlayer': 'Добавить игрока',
|
||||||
|
'hotseat.removePlayer': 'Удалить игрока',
|
||||||
|
'hotseat.hostPlaysTitle': 'Принимаете участие в игре?',
|
||||||
|
'hotseat.hostPlaysYes': 'Да, играю',
|
||||||
|
'hotseat.hostPlaysNo': 'Нет',
|
||||||
|
'hotseat.unlock': 'Разблокировать',
|
||||||
|
'hotseat.unlockTitle': 'Разблокировать: {name}',
|
||||||
|
'hotseat.turnOf': 'Ход: {name}',
|
||||||
|
'hotseat.host': 'Ведущий',
|
||||||
|
'hotseat.skip': 'Пропустить ход',
|
||||||
|
'hotseat.exclude': 'Исключить игрока:',
|
||||||
|
'hotseat.terminate': 'Отменить партию',
|
||||||
|
'hotseat.askSkip': 'Пропустить ход игрока {name}?',
|
||||||
|
'hotseat.askExclude': 'Удалить {name} из игры?',
|
||||||
|
'hotseat.askTerminate': 'Отменить партию без результата?',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ beforeEach(() => clearLobby());
|
|||||||
|
|
||||||
describe('patchLobbyGame', () => {
|
describe('patchLobbyGame', () => {
|
||||||
it('replaces the matching game by id and leaves the others untouched', () => {
|
it('replaces the matching game by id and leaves the others untouched', () => {
|
||||||
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [], atGameLimit: false });
|
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [], atGameLimit: false, offline: false });
|
||||||
// The player's own move flipped game "a" to the opponent's turn.
|
// The player's own move flipped game "a" to the opponent's turn.
|
||||||
patchLobbyGame(gameView('a', 'active', 1));
|
patchLobbyGame(gameView('a', 'active', 1));
|
||||||
const snap = getLobby();
|
const snap = getLobby(false);
|
||||||
expect(snap?.games.map((g) => g.id)).toEqual(['a', 'b']);
|
expect(snap?.games.map((g) => g.id)).toEqual(['a', 'b']);
|
||||||
expect(snap?.games.find((g) => g.id === 'a')?.toMove).toBe(1);
|
expect(snap?.games.find((g) => g.id === 'a')?.toMove).toBe(1);
|
||||||
expect(snap?.games.find((g) => g.id === 'b')?.toMove).toBe(0);
|
expect(snap?.games.find((g) => g.id === 'b')?.toMove).toBe(0);
|
||||||
@@ -54,27 +54,27 @@ describe('patchLobbyGame', () => {
|
|||||||
|
|
||||||
it('preserves invitations and incoming when patching a game', () => {
|
it('preserves invitations and incoming when patching a game', () => {
|
||||||
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
||||||
setLobby({ games: [gameView('a')], invitations: [], incoming, atGameLimit: false });
|
setLobby({ games: [gameView('a')], invitations: [], incoming, atGameLimit: false, offline: false });
|
||||||
patchLobbyGame(gameView('a', 'finished'));
|
patchLobbyGame(gameView('a', 'finished'));
|
||||||
expect(getLobby()?.incoming).toEqual(incoming);
|
expect(getLobby(false)?.incoming).toEqual(incoming);
|
||||||
expect(getLobby()?.games[0].status).toBe('finished');
|
expect(getLobby(false)?.games[0].status).toBe('finished');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => {
|
it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => {
|
||||||
setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: false });
|
setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: false, offline: false });
|
||||||
patchLobbyGame(gameView('z', 'active'));
|
patchLobbyGame(gameView('z', 'active'));
|
||||||
// Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership.
|
// Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership.
|
||||||
expect(getLobby()?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
|
expect(getLobby(false)?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is a no-op when there is no cached lobby yet', () => {
|
it('is a no-op when there is no cached lobby yet', () => {
|
||||||
patchLobbyGame(gameView('a'));
|
patchLobbyGame(gameView('a'));
|
||||||
expect(getLobby()).toBeNull();
|
expect(getLobby(false)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not mutate the previous snapshot array', () => {
|
it('does not mutate the previous snapshot array', () => {
|
||||||
const games = [gameView('a', 'active', 0)];
|
const games = [gameView('a', 'active', 0)];
|
||||||
setLobby({ games, invitations: [], incoming: [], atGameLimit: false });
|
setLobby({ games, invitations: [], incoming: [], atGameLimit: false, offline: false });
|
||||||
patchLobbyGame(gameView('a', 'active', 1));
|
patchLobbyGame(gameView('a', 'active', 1));
|
||||||
expect(games[0].toMove).toBe(0); // the original array/object is left intact
|
expect(games[0].toMove).toBe(0); // the original array/object is left intact
|
||||||
});
|
});
|
||||||
@@ -82,51 +82,67 @@ describe('patchLobbyGame', () => {
|
|||||||
it('preserves the at-game-limit flag across a game patch', () => {
|
it('preserves the at-game-limit flag across a game patch', () => {
|
||||||
// A finished-game patch arriving while the lobby is unmounted must not drop the flag —
|
// A finished-game patch arriving while the lobby is unmounted must not drop the flag —
|
||||||
// the lobby renders the "New Game" button from the cached snapshot before the refresh.
|
// the lobby renders the "New Game" button from the cached snapshot before the refresh.
|
||||||
setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: true });
|
setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: true, offline: false });
|
||||||
patchLobbyGame(gameView('a', 'finished'));
|
patchLobbyGame(gameView('a', 'finished'));
|
||||||
expect(getLobby()?.atGameLimit).toBe(true);
|
expect(getLobby(false)?.atGameLimit).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('patchLobbyInvitation', () => {
|
describe('patchLobbyInvitation', () => {
|
||||||
it('adds a new pending invitation to the cached lobby', () => {
|
it('adds a new pending invitation to the cached lobby', () => {
|
||||||
setLobby({ games: [], invitations: [], incoming: [], atGameLimit: false });
|
setLobby({ games: [], invitations: [], incoming: [], atGameLimit: false, offline: false });
|
||||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1']);
|
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replaces a pending invitation already in the list (e.g. an updated response)', () => {
|
it('replaces a pending invitation already in the list (e.g. an updated response)', () => {
|
||||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], atGameLimit: false });
|
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], atGameLimit: false, offline: false });
|
||||||
const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 };
|
const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 };
|
||||||
patchLobbyInvitation(updated);
|
patchLobbyInvitation(updated);
|
||||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
|
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
|
||||||
expect(getLobby()?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999);
|
expect(getLobby(false)?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes an invitation that reached a terminal status', () => {
|
it('removes an invitation that reached a terminal status', () => {
|
||||||
for (const terminal of ['declined', 'cancelled', 'started', 'expired']) {
|
for (const terminal of ['declined', 'cancelled', 'started', 'expired']) {
|
||||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], atGameLimit: false });
|
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], atGameLimit: false, offline: false });
|
||||||
patchLobbyInvitation(invitation('i1', terminal));
|
patchLobbyInvitation(invitation('i1', terminal));
|
||||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
|
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is a no-op for a terminal invitation that is not in the list', () => {
|
it('is a no-op for a terminal invitation that is not in the list', () => {
|
||||||
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [], atGameLimit: false });
|
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [], atGameLimit: false, offline: false });
|
||||||
patchLobbyInvitation(invitation('i1', 'declined'));
|
patchLobbyInvitation(invitation('i1', 'declined'));
|
||||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
|
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is a no-op when there is no cached lobby yet', () => {
|
it('is a no-op when there is no cached lobby yet', () => {
|
||||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||||
expect(getLobby()).toBeNull();
|
expect(getLobby(false)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves games and incoming when patching an invitation', () => {
|
it('preserves games and incoming when patching an invitation', () => {
|
||||||
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
||||||
setLobby({ games: [gameView('g1')], invitations: [], incoming, atGameLimit: false });
|
setLobby({ games: [gameView('g1')], invitations: [], incoming, atGameLimit: false, offline: false });
|
||||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||||
expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']);
|
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['g1']);
|
||||||
expect(getLobby()?.incoming).toEqual(incoming);
|
expect(getLobby(false)?.incoming).toEqual(incoming);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLobby is mode-aware (a mode flip must not render the other mode’s games)', () => {
|
||||||
|
it('returns the snapshot only for the mode it was stored under', () => {
|
||||||
|
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], atGameLimit: false, offline: false });
|
||||||
|
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['online1']);
|
||||||
|
// Reading it as the OTHER (offline) mode must not surface the online snapshot.
|
||||||
|
expect(getLobby(true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces the snapshot when the mode changes, so the stale mode is dropped', () => {
|
||||||
|
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], atGameLimit: false, offline: false });
|
||||||
|
setLobby({ games: [gameView('local1')], invitations: [], incoming: [], atGameLimit: false, offline: true });
|
||||||
|
expect(getLobby(false)).toBeNull();
|
||||||
|
expect(getLobby(true)?.games.map((g) => g.id)).toEqual(['local1']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,13 +13,18 @@ interface LobbySnapshot {
|
|||||||
// atGameLimit rides the snapshot so the lobby renders the "New Game" button in its last
|
// atGameLimit rides the snapshot so the lobby renders the "New Game" button in its last
|
||||||
// known enabled/disabled state instantly (no flicker), then refreshes it in the background.
|
// known enabled/disabled state instantly (no flicker), then refreshes it in the background.
|
||||||
atGameLimit: boolean;
|
atGameLimit: boolean;
|
||||||
|
// Which mode the snapshot belongs to (offline = device-local games, online = server games). The
|
||||||
|
// lobby renders it instantly only when it matches the current mode, so a mode flip never flashes —
|
||||||
|
// or lets the player open — the other mode's games before the background refresh replaces them.
|
||||||
|
offline: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let snapshot: LobbySnapshot | null = null;
|
let snapshot: LobbySnapshot | null = null;
|
||||||
|
|
||||||
/** getLobby returns the last lobby lists, or null before the first load. */
|
/** getLobby returns the last lobby lists for the given mode, or null before the first load or when
|
||||||
export function getLobby(): LobbySnapshot | null {
|
* the cached snapshot belongs to the other mode (so the lobby cold-renders after a mode flip). */
|
||||||
return snapshot;
|
export function getLobby(offline: boolean): LobbySnapshot | null {
|
||||||
|
return snapshot && snapshot.offline === offline ? snapshot : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** setLobby stores the latest lobby lists. */
|
/** setLobby stores the latest lobby lists. */
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Bag } from './bag';
|
||||||
|
import { RULESETS } from './ruleset';
|
||||||
|
import { BLANK_INDEX } from '../alphabet';
|
||||||
|
|
||||||
|
describe('offline bag', () => {
|
||||||
|
it('holds exactly the variant tile distribution', () => {
|
||||||
|
const rs = RULESETS.scrabble_en;
|
||||||
|
const total = rs.counts.reduce((a, b) => a + b, 0) + rs.blanks;
|
||||||
|
const bag = new Bag('scrabble_en', 42);
|
||||||
|
expect(bag.length).toBe(total);
|
||||||
|
|
||||||
|
const drawn = bag.draw(total);
|
||||||
|
expect(drawn.length).toBe(total);
|
||||||
|
expect(bag.length).toBe(0);
|
||||||
|
|
||||||
|
const tally = new Map<number, number>();
|
||||||
|
for (const t of drawn) tally.set(t, (tally.get(t) ?? 0) + 1);
|
||||||
|
for (let i = 0; i < rs.counts.length; i++) expect(tally.get(i) ?? 0).toBe(rs.counts[i]);
|
||||||
|
expect(tally.get(BLANK_INDEX) ?? 0).toBe(rs.blanks);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws beyond the remaining, returning all and emptying', () => {
|
||||||
|
const bag = new Bag('scrabble_ru', 7);
|
||||||
|
const total = bag.length;
|
||||||
|
const all = bag.draw(total + 5);
|
||||||
|
expect(all.length).toBe(total);
|
||||||
|
expect(bag.length).toBe(0);
|
||||||
|
expect(bag.draw(3)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns tiles back to the bag', () => {
|
||||||
|
const bag = new Bag('erudit_ru', 1);
|
||||||
|
const before = bag.length;
|
||||||
|
const seven = bag.draw(7);
|
||||||
|
expect(bag.length).toBe(before - 7);
|
||||||
|
bag.return(seven);
|
||||||
|
expect(bag.length).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic for a given seed and operation sequence', () => {
|
||||||
|
const a = new Bag('scrabble_en', 123);
|
||||||
|
const b = new Bag('scrabble_en', 123);
|
||||||
|
expect(a.draw(30)).toEqual(b.draw(30));
|
||||||
|
// A return reshuffles both identically, so subsequent draws still agree.
|
||||||
|
a.return([0, 1, 2]);
|
||||||
|
b.return([0, 1, 2]);
|
||||||
|
expect(a.draw(10)).toEqual(b.draw(10));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// The offline tile bag — the shuffled draw pile for one local game. Structurally a port of
|
||||||
|
// backend/internal/engine/bag.go (fill from the variant's counts + blanks, draw from the end,
|
||||||
|
// return-and-reshuffle for an exchange), but shuffled with a small DETERMINISTIC in-house PRNG
|
||||||
|
// rather than Go's math/rand: a local game only needs to be reproducible from its own seed and
|
||||||
|
// sequence of operations (for replay from the stored journal), not bit-identical to a server
|
||||||
|
// game (docs plan). Blanks ride as BLANK_INDEX, matching lib/alphabet.ts (and the engine's
|
||||||
|
// blankTile = 0xff = 255).
|
||||||
|
|
||||||
|
import { RULESETS } from './ruleset';
|
||||||
|
import { BLANK_INDEX } from '../alphabet';
|
||||||
|
import type { Variant } from '../model';
|
||||||
|
|
||||||
|
// mulberry32 is a compact deterministic PRNG returning a float in [0, 1). Seeded from the game
|
||||||
|
// seed so the shuffle sequence — and thus the draws — replay identically for the same seed and
|
||||||
|
// the same sequence of returns.
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a = (a + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bag is one local game's draw pile. Construct it with the variant and a numeric seed; draw()
|
||||||
|
* and return() mutate it in place. It is reproducible: the same seed and the same sequence of
|
||||||
|
* operations yield the same draws.
|
||||||
|
*/
|
||||||
|
export class Bag {
|
||||||
|
private tiles: number[];
|
||||||
|
private readonly rand: () => number;
|
||||||
|
|
||||||
|
constructor(variant: Variant, seed: number) {
|
||||||
|
const rs = RULESETS[variant];
|
||||||
|
const tiles: number[] = [];
|
||||||
|
for (let i = 0; i < rs.counts.length; i++) {
|
||||||
|
for (let n = 0; n < rs.counts[i]; n++) tiles.push(i);
|
||||||
|
}
|
||||||
|
for (let n = 0; n < rs.blanks; n++) tiles.push(BLANK_INDEX);
|
||||||
|
this.tiles = tiles;
|
||||||
|
this.rand = mulberry32(seed);
|
||||||
|
this.shuffle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** length is the number of tiles left in the bag. */
|
||||||
|
get length(): number {
|
||||||
|
return this.tiles.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* draw removes up to n tiles from the end of the bag and returns them. Drawing more than
|
||||||
|
* remain returns all of them; drawing from an empty bag returns an empty array.
|
||||||
|
*/
|
||||||
|
draw(n: number): number[] {
|
||||||
|
const take = Math.min(n, this.tiles.length);
|
||||||
|
return this.tiles.splice(this.tiles.length - take, take);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** return puts tiles back into the bag and reshuffles, as when a player exchanges tiles. */
|
||||||
|
return(tiles: readonly number[]): void {
|
||||||
|
for (const t of tiles) this.tiles.push(t);
|
||||||
|
this.shuffle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// shuffle randomises the remaining tiles in place with the bag's own PRNG (Fisher–Yates).
|
||||||
|
private shuffle(): void {
|
||||||
|
for (let i = this.tiles.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(this.rand() * (i + 1));
|
||||||
|
const tmp = this.tiles[i];
|
||||||
|
this.tiles[i] = this.tiles[j];
|
||||||
|
this.tiles[j] = tmp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// The mutable local-game board: a 15x15 row-major grid of placed tiles (alphabet-index letter
|
||||||
|
// plus a blank flag). It satisfies the read view the validator and generator need (GenBoard,
|
||||||
|
// which extends validate.ts's Board) and adds set() so the engine can apply a play. The board is
|
||||||
|
// alphabet-agnostic — a cell's letter is a variant index, meaningful with the variant's ruleset.
|
||||||
|
|
||||||
|
import { BOARD_SIZE } from '../premiums';
|
||||||
|
import type { Cell } from '../dict/validate';
|
||||||
|
import type { GenBoard } from '../dict/generate';
|
||||||
|
|
||||||
|
/** LocalBoard is the engine's in-memory board; it reads as a GenBoard and applies plays via set. */
|
||||||
|
export class LocalBoard implements GenBoard {
|
||||||
|
readonly rows = BOARD_SIZE;
|
||||||
|
readonly cols = BOARD_SIZE;
|
||||||
|
private readonly grid: (Cell | null)[];
|
||||||
|
private count = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.grid = new Array<Cell | null>(BOARD_SIZE * BOARD_SIZE).fill(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
inBounds(row: number, col: number): boolean {
|
||||||
|
return row >= 0 && row < this.rows && col >= 0 && col < this.cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
filled(row: number, col: number): boolean {
|
||||||
|
return this.inBounds(row, col) && this.grid[row * this.cols + col] !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
cellAt(row: number, col: number): Cell {
|
||||||
|
return this.grid[row * this.cols + col]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
isEmpty(): boolean {
|
||||||
|
return this.count === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** set places a tile at (row, col). It assumes the square was empty (a play only lays tiles
|
||||||
|
* on empty squares), keeping the filled count exact for isEmpty. */
|
||||||
|
set(row: number, col: number, letter: number, blank: boolean): void {
|
||||||
|
const i = row * this.cols + col;
|
||||||
|
if (this.grid[i] === null) this.count++;
|
||||||
|
this.grid[i] = { letter, blank };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { applyEndAdjustment, winner, type EndReason } from './engine';
|
||||||
|
import { RULESETS } from './ruleset';
|
||||||
|
import type { Variant } from '../model';
|
||||||
|
|
||||||
|
// The offline engine must settle unplayed racks and decide the winner exactly as the Go engine
|
||||||
|
// does, so a local game finishes with the same scores. Golden from the in-package emitter
|
||||||
|
// (backend/internal/engine/endfixture_test.go).
|
||||||
|
interface EndCase {
|
||||||
|
name: string;
|
||||||
|
variant: Variant;
|
||||||
|
reason: EndReason;
|
||||||
|
hands: number[][];
|
||||||
|
scores: number[];
|
||||||
|
resigned: boolean[];
|
||||||
|
toMove: number;
|
||||||
|
scoresAfter: number[];
|
||||||
|
winner: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fx = JSON.parse(
|
||||||
|
readFileSync(new URL('./testdata/endgame.json', import.meta.url), 'utf8'),
|
||||||
|
) as { cases: EndCase[] };
|
||||||
|
|
||||||
|
describe('offline engine end-game parity vs Go', () => {
|
||||||
|
for (const c of fx.cases) {
|
||||||
|
it(c.name, () => {
|
||||||
|
const values = RULESETS[c.variant].values;
|
||||||
|
const after = applyEndAdjustment(c.reason, c.hands, c.scores, c.toMove, values);
|
||||||
|
expect(after).toEqual(c.scoresAfter);
|
||||||
|
expect(winner(true, c.reason, after, c.resigned)).toBe(c.winner);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { Dawg } from '../dict/dawg';
|
||||||
|
import { RACK_SIZE } from '../premiums';
|
||||||
|
import { LocalGame } from './engine';
|
||||||
|
import { decide } from '../robot/strategy';
|
||||||
|
import type { Move } from '../dict/validate';
|
||||||
|
|
||||||
|
// A full-loop smoke: two robots play a whole local vs_ai game to completion over the committed
|
||||||
|
// sample dictionary, exercising deal / play / refill / exchange / pass / end detection / winner.
|
||||||
|
// (The rich full-dictionary robot behaviour is pinned elsewhere by the generator + strategy parity
|
||||||
|
// suites; this test proves the engine drives to a valid finish.)
|
||||||
|
const dawg = new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
|
||||||
|
|
||||||
|
function bestOpponentScore(game: LocalGame, seat: number): number {
|
||||||
|
let best = 0;
|
||||||
|
for (let i = 0; i < game.playerCount; i++) if (i !== seat) best = Math.max(best, game.scoreOf(i));
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// robotTurn plays the seat to move: it picks the robot's action from the ranked legal plays and
|
||||||
|
// dispatches it. The sample dict has a one-letter word ("a") the generator emits but the validator
|
||||||
|
// rejects (len < 2) — real dictionaries have none, so filtering to main length >= 2 is a no-op in
|
||||||
|
// production; an exchange the bag is too small to satisfy falls back to a pass.
|
||||||
|
function robotTurn(game: LocalGame, seed: bigint): void {
|
||||||
|
const seat = game.currentPlayer;
|
||||||
|
const cands: Move[] = game.generateMoves().filter((m) => m.main.letters.length >= 2);
|
||||||
|
const dec = decide(seed, game.moveCount, cands, game.scoreOf(seat), bestOpponentScore(game, seat), game.handOf(seat), game.bagLength);
|
||||||
|
if (dec.kind === 'play') {
|
||||||
|
game.play(dec.move.dir, dec.move.tiles);
|
||||||
|
} else if (dec.kind === 'exchange' && game.bagLength >= RACK_SIZE) {
|
||||||
|
game.exchange(dec.exchange);
|
||||||
|
} else {
|
||||||
|
game.pass();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('offline engine full-game smoke', () => {
|
||||||
|
it('plays a whole 2-player vs_ai game to completion', () => {
|
||||||
|
const game = new LocalGame({
|
||||||
|
variant: 'scrabble_en',
|
||||||
|
version: 'sample',
|
||||||
|
seed: 123456789n,
|
||||||
|
players: 2,
|
||||||
|
dawg,
|
||||||
|
multipleWords: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let turns = 0;
|
||||||
|
while (!game.isOver && turns < 2000) {
|
||||||
|
robotTurn(game, game.seed);
|
||||||
|
turns++;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(game.isOver).toBe(true);
|
||||||
|
expect(turns).toBeLessThan(2000);
|
||||||
|
expect(['out_of_tiles', 'scoreless', 'resign']).toContain(game.endReason);
|
||||||
|
|
||||||
|
const w = game.winnerIndex;
|
||||||
|
expect(w === -1 || (w >= 0 && w < game.playerCount)).toBe(true);
|
||||||
|
for (let i = 0; i < game.playerCount; i++) expect(Number.isInteger(game.scoreOf(i))).toBe(true);
|
||||||
|
// The move log recorded every turn.
|
||||||
|
expect(game.history.length).toBe(turns);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is reproducible from the seed', () => {
|
||||||
|
const play = (): { reason: string; scores: number[]; turns: number } => {
|
||||||
|
const g = new LocalGame({ variant: 'scrabble_en', version: 'sample', seed: 42n, players: 2, dawg, multipleWords: true });
|
||||||
|
let t = 0;
|
||||||
|
while (!g.isOver && t < 2000) {
|
||||||
|
robotTurn(g, g.seed);
|
||||||
|
t++;
|
||||||
|
}
|
||||||
|
return { reason: g.endReason, scores: [g.scoreOf(0), g.scoreOf(1)], turns: t };
|
||||||
|
};
|
||||||
|
expect(play()).toEqual(play());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
// The offline game engine — a faithful port of backend/internal/engine/game.go. It owns the
|
||||||
|
// board, the bag, each seat's hand, the scores, whose turn it is and the move log, applies plays
|
||||||
|
// (reusing the already-ported validator lib/dict/validate.ts and generator lib/dict/generate.ts),
|
||||||
|
// and detects the end of a game (out-of-tiles / scoreless / resign) with the standard end-of-game
|
||||||
|
// rack adjustment. It is pure in-memory logic with no I/O; a local vs_ai game is driven by feeding
|
||||||
|
// it the robot's choice (lib/robot/strategy.ts) each turn. The bag RNG is our own deterministic
|
||||||
|
// PRNG (see bag.ts), so a game replays from its seed but is not bit-identical to a server game.
|
||||||
|
//
|
||||||
|
// End-of-game scoring, the winner rule and the rack value are exported as pure functions so they
|
||||||
|
// can be pinned against the Go engine (engine.parity.test.ts) on constructed positions.
|
||||||
|
|
||||||
|
import { LocalBoard } from './board';
|
||||||
|
import { Bag } from './bag';
|
||||||
|
import { RULESETS } from './ruleset';
|
||||||
|
import { generateMoves, GenRack, Both } from '../dict/generate';
|
||||||
|
import { validatePlay, type Direction, type Placement, type Ruleset, type Move } from '../dict/validate';
|
||||||
|
import { playDirection } from '../dict/direction';
|
||||||
|
import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums';
|
||||||
|
import { BLANK_INDEX } from '../alphabet';
|
||||||
|
import type { Dawg } from '../dict/dawg';
|
||||||
|
import type { Variant } from '../model';
|
||||||
|
|
||||||
|
/** scorelessLimit is the number of consecutive scoreless turns (passes and exchanges) that ends
|
||||||
|
* a game, mirroring engine.scorelessLimit. */
|
||||||
|
export const scorelessLimit = 6;
|
||||||
|
|
||||||
|
/** EndReason explains why a game finished, using the engine's stable labels. */
|
||||||
|
export type EndReason = 'not_over' | 'out_of_tiles' | 'scoreless' | 'resign' | 'aborted';
|
||||||
|
|
||||||
|
/** Disposition of a dropped-out (resigned/timed-out) seat's tiles in a 3-4 player game. */
|
||||||
|
export type DropoutTiles = 'remove' | 'return';
|
||||||
|
|
||||||
|
/** LocalMove is one recorded turn. tiles/dir are set on a play, count on an exchange. */
|
||||||
|
export interface LocalMove {
|
||||||
|
player: number;
|
||||||
|
action: 'play' | 'pass' | 'exchange' | 'resign';
|
||||||
|
dir?: Direction;
|
||||||
|
tiles?: Placement[];
|
||||||
|
/** ActionPlay only: the main word's first-letter coordinate. */
|
||||||
|
mainRow?: number;
|
||||||
|
mainCol?: number;
|
||||||
|
/** ActionPlay only: the words formed as alphabet-index letter arrays — the main word first, then
|
||||||
|
* the cross words. Decoded to glyphs when building the history's MoveRecord. */
|
||||||
|
words?: number[][];
|
||||||
|
/** ActionExchange only: number of tiles swapped. */
|
||||||
|
count?: number;
|
||||||
|
/** ActionExchange only: the swapped tiles (alphabet-index bytes, BLANK_INDEX for a blank),
|
||||||
|
* recorded so the game can be reconstructed by replaying the journal (see localgame/serialize). */
|
||||||
|
exchanged?: number[];
|
||||||
|
score: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GameError carries a stable code for the engine's rejection reasons. */
|
||||||
|
export class GameError extends Error {
|
||||||
|
constructor(public readonly code: string) {
|
||||||
|
super(code);
|
||||||
|
this.name = 'GameError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function letterMultOf(p: Premium): number {
|
||||||
|
return p === 'DL' ? 2 : p === 'TL' ? 3 : 1;
|
||||||
|
}
|
||||||
|
function wordMultOf(p: Premium): number {
|
||||||
|
return p === 'DW' ? 2 : p === 'TW' ? 3 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* buildRuleset assembles the validator/generator ruleset for a variant from the static offline
|
||||||
|
* tile values (ruleset.ts) and the board geometry (premiums.ts). multipleWords false selects the
|
||||||
|
* single-word-per-turn rule (perpendicular cross-words ignored). Mirrors the server engine's
|
||||||
|
* ruleset for the same variant; online scoring instead reads the server-sent alphabet values.
|
||||||
|
*/
|
||||||
|
export function buildRuleset(variant: Variant, multipleWords: boolean): Ruleset {
|
||||||
|
const prem = premiumGrid(variant);
|
||||||
|
const ctr = centre(variant);
|
||||||
|
return {
|
||||||
|
cols: BOARD_SIZE,
|
||||||
|
center: ctr.row * BOARD_SIZE + ctr.col,
|
||||||
|
rackSize: RACK_SIZE,
|
||||||
|
bingo: BINGO[variant],
|
||||||
|
values: RULESETS[variant].values,
|
||||||
|
letterMult: (r, c) => letterMultOf(prem[r][c]),
|
||||||
|
wordMult: (r, c) => wordMultOf(prem[r][c]),
|
||||||
|
ignoreCrossWords: !multipleWords,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** rackValue sums the tile values left on a hand; blanks (BLANK_INDEX) count zero. Mirrors
|
||||||
|
* engine (*Game).rackValue. */
|
||||||
|
export function rackValue(hand: readonly number[], values: readonly number[]): number {
|
||||||
|
let v = 0;
|
||||||
|
for (const t of hand) if (t !== BLANK_INDEX) v += values[t];
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* applyEndAdjustment settles the unplayed racks and returns the adjusted scores. Out-of-tiles: the
|
||||||
|
* player who went out (toMove) gains the sum of every opponent's rack value and each opponent loses
|
||||||
|
* their own. Scoreless: everyone loses their own rack value. Resign/aborted: no adjustment.
|
||||||
|
* Mirrors engine (*Game).applyEndAdjustment.
|
||||||
|
*/
|
||||||
|
export function applyEndAdjustment(
|
||||||
|
reason: EndReason,
|
||||||
|
hands: readonly (readonly number[])[],
|
||||||
|
scores: readonly number[],
|
||||||
|
toMove: number,
|
||||||
|
values: readonly number[],
|
||||||
|
): number[] {
|
||||||
|
const out = [...scores];
|
||||||
|
if (reason === 'out_of_tiles') {
|
||||||
|
let bonus = 0;
|
||||||
|
for (let i = 0; i < hands.length; i++) {
|
||||||
|
if (i === toMove) continue;
|
||||||
|
const v = rackValue(hands[i], values);
|
||||||
|
out[i] -= v;
|
||||||
|
bonus += v;
|
||||||
|
}
|
||||||
|
out[toMove] += bonus;
|
||||||
|
} else if (reason === 'scoreless') {
|
||||||
|
for (let i = 0; i < hands.length; i++) out[i] -= rackValue(hands[i], values);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* winner returns the index of the single highest-scoring seat, or -1 on a tie for the lead, an
|
||||||
|
* unfinished game or an aborted (draw) game. Resigned seats are excluded, so a two-player game
|
||||||
|
* returns the remaining player even when the resigner led. Mirrors engine (*Game).winner.
|
||||||
|
*/
|
||||||
|
export function winner(over: boolean, reason: EndReason, scores: readonly number[], resigned: readonly boolean[]): number {
|
||||||
|
if (!over || reason === 'aborted') return -1;
|
||||||
|
let best = -1;
|
||||||
|
let tie = false;
|
||||||
|
for (let i = 0; i < scores.length; i++) {
|
||||||
|
if (resigned[i]) continue;
|
||||||
|
if (best === -1 || scores[i] > scores[best]) {
|
||||||
|
best = i;
|
||||||
|
tie = false;
|
||||||
|
} else if (scores[i] === scores[best]) {
|
||||||
|
tie = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tie ? -1 : best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for a new local game. */
|
||||||
|
export interface LocalGameOptions {
|
||||||
|
variant: Variant;
|
||||||
|
version: string;
|
||||||
|
/** Seeds the bag; the whole game replays from it. Also the strategy seed the caller uses. */
|
||||||
|
seed: bigint;
|
||||||
|
players: number;
|
||||||
|
dawg: Dawg;
|
||||||
|
multipleWords: boolean;
|
||||||
|
dropoutTiles?: DropoutTiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// placementTiles maps placements to the tiles they consume (BLANK_INDEX for a blank).
|
||||||
|
function placementTiles(tiles: readonly Placement[]): number[] {
|
||||||
|
return tiles.map((p) => (p.blank ? BLANK_INDEX : p.letter));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LocalGame is the in-memory state of one local match and the rules engine over it. Construct it
|
||||||
|
* with a loaded dictionary; drive it with play/pass/exchange/resign. It performs no I/O.
|
||||||
|
*/
|
||||||
|
export class LocalGame {
|
||||||
|
readonly variant: Variant;
|
||||||
|
readonly version: string;
|
||||||
|
readonly seed: bigint;
|
||||||
|
private readonly vrs: Ruleset;
|
||||||
|
private readonly values: readonly number[];
|
||||||
|
private readonly rackSize: number;
|
||||||
|
private readonly dawg: Dawg;
|
||||||
|
private readonly multipleWords: boolean;
|
||||||
|
private readonly dropoutTiles: DropoutTiles;
|
||||||
|
|
||||||
|
private readonly board: LocalBoard;
|
||||||
|
private readonly bag: Bag;
|
||||||
|
private readonly hands: number[][];
|
||||||
|
private readonly scores: number[];
|
||||||
|
private readonly resigned: boolean[];
|
||||||
|
private toMove = 0;
|
||||||
|
private scorelessRun = 0;
|
||||||
|
private over = false;
|
||||||
|
private reason: EndReason = 'not_over';
|
||||||
|
private readonly log: LocalMove[] = [];
|
||||||
|
|
||||||
|
constructor(opts: LocalGameOptions) {
|
||||||
|
if (opts.players < 2 || opts.players > 4) {
|
||||||
|
throw new GameError('players_out_of_range');
|
||||||
|
}
|
||||||
|
this.variant = opts.variant;
|
||||||
|
this.version = opts.version;
|
||||||
|
this.seed = opts.seed;
|
||||||
|
this.dawg = opts.dawg;
|
||||||
|
this.multipleWords = opts.multipleWords;
|
||||||
|
this.dropoutTiles = opts.dropoutTiles ?? 'remove';
|
||||||
|
this.vrs = buildRuleset(opts.variant, opts.multipleWords);
|
||||||
|
this.values = RULESETS[opts.variant].values;
|
||||||
|
this.rackSize = RULESETS[opts.variant].rackSize;
|
||||||
|
|
||||||
|
this.board = new LocalBoard();
|
||||||
|
this.bag = new Bag(opts.variant, Number(BigInt.asUintN(32, opts.seed)));
|
||||||
|
this.hands = [];
|
||||||
|
this.scores = [];
|
||||||
|
this.resigned = [];
|
||||||
|
for (let i = 0; i < opts.players; i++) {
|
||||||
|
this.hands.push(this.bag.draw(this.rackSize));
|
||||||
|
this.scores.push(0);
|
||||||
|
this.resigned.push(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- queries ---------------------------------------------------------------
|
||||||
|
get playerCount(): number {
|
||||||
|
return this.hands.length;
|
||||||
|
}
|
||||||
|
get currentPlayer(): number {
|
||||||
|
return this.toMove;
|
||||||
|
}
|
||||||
|
get isOver(): boolean {
|
||||||
|
return this.over;
|
||||||
|
}
|
||||||
|
get endReason(): EndReason {
|
||||||
|
return this.reason;
|
||||||
|
}
|
||||||
|
get bagLength(): number {
|
||||||
|
return this.bag.length;
|
||||||
|
}
|
||||||
|
get moveCount(): number {
|
||||||
|
return this.log.length;
|
||||||
|
}
|
||||||
|
scoreOf(player: number): number {
|
||||||
|
return this.scores[player];
|
||||||
|
}
|
||||||
|
/** rackOf returns a copy of a seat's hand (alphabet-index bytes, BLANK_INDEX for blanks). */
|
||||||
|
handOf(player: number): number[] {
|
||||||
|
return [...this.hands[player]];
|
||||||
|
}
|
||||||
|
/** resignedOf reports whether a seat has resigned or been excluded by the host. */
|
||||||
|
resignedOf(player: number): boolean {
|
||||||
|
return this.resigned[player] ?? false;
|
||||||
|
}
|
||||||
|
/** winnerIndex is the finished game's winner, or -1 (tie / in progress / aborted). */
|
||||||
|
get winnerIndex(): number {
|
||||||
|
return winner(this.over, this.reason, this.scores, this.resigned);
|
||||||
|
}
|
||||||
|
/** config returns the rule settings the game was created with — the bits, alongside the seed and
|
||||||
|
* journal, needed to reconstruct it (see localgame/serialize). */
|
||||||
|
get config(): { multipleWords: boolean; dropoutTiles: DropoutTiles } {
|
||||||
|
return { multipleWords: this.multipleWords, dropoutTiles: this.dropoutTiles };
|
||||||
|
}
|
||||||
|
/** history returns a copy of the move log. */
|
||||||
|
get history(): LocalMove[] {
|
||||||
|
return this.log.map((m) => ({ ...m }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** generateMoves returns every legal play for the current player, ranked by descending score. */
|
||||||
|
generateMoves(): Move[] {
|
||||||
|
return generateMoves(this.dawg, this.board, this.rackOf(this.toMove), this.vrs, Both);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** evaluatePlay scores a candidate placement for the current position without committing it,
|
||||||
|
* returning its legality (dictionary + connectivity), score, the words it forms (as alphabet-index
|
||||||
|
* arrays, main first) and the inferred direction. Backs the local move preview. */
|
||||||
|
evaluatePlay(tiles: Placement[]): { legal: boolean; score: number; words: number[][]; dir: Direction } {
|
||||||
|
const dir = playDirection(this.board, this.vrs, this.dawg, tiles);
|
||||||
|
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
|
||||||
|
if (!res.legal || !res.move) return { legal: false, score: 0, words: [], dir };
|
||||||
|
const m = res.move;
|
||||||
|
return { legal: true, score: m.score, words: [m.main.letters, ...m.cross.map((w) => w.letters)], dir };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** dictionaryHas reports whether the word (alphabet-index letters) is in the game's dictionary. */
|
||||||
|
dictionaryHas(word: readonly number[]): boolean {
|
||||||
|
return this.dawg.indexOf(word) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- turns -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/** submitPlay infers the play's orientation from the placement (like the server's SubmitPlay),
|
||||||
|
* then plays it — the client submits tiles without a direction and the engine resolves it. */
|
||||||
|
submitPlay(tiles: Placement[]): LocalMove {
|
||||||
|
const dir = playDirection(this.board, this.vrs, this.dawg, tiles);
|
||||||
|
return this.play(dir, tiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** play validates and applies the current player's placement, scores it, refills the rack and
|
||||||
|
* advances the turn (or ends the game). Throws GameError on an illegal play. */
|
||||||
|
play(dir: Direction, tiles: Placement[]): LocalMove {
|
||||||
|
if (this.over) throw new GameError('game_over');
|
||||||
|
const player = this.toMove;
|
||||||
|
const used = placementTiles(tiles);
|
||||||
|
if (!this.holds(player, used)) throw new GameError('tiles_not_on_rack');
|
||||||
|
|
||||||
|
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
|
||||||
|
if (!res.legal || !res.move) throw new GameError('illegal_play');
|
||||||
|
const move = res.move;
|
||||||
|
|
||||||
|
for (const t of tiles) this.board.set(t.row, t.col, t.letter, t.blank);
|
||||||
|
this.removeFromHand(player, used);
|
||||||
|
this.scores[player] += move.score;
|
||||||
|
this.refill(player);
|
||||||
|
this.scorelessRun = 0;
|
||||||
|
|
||||||
|
const rec: LocalMove = {
|
||||||
|
player,
|
||||||
|
action: 'play',
|
||||||
|
dir,
|
||||||
|
tiles: tiles.map((t) => ({ ...t })),
|
||||||
|
mainRow: move.main.row,
|
||||||
|
mainCol: move.main.col,
|
||||||
|
words: [move.main.letters, ...move.cross.map((w) => w.letters)],
|
||||||
|
score: move.score,
|
||||||
|
total: this.scores[player],
|
||||||
|
};
|
||||||
|
this.log.push(rec);
|
||||||
|
|
||||||
|
if (this.hands[player].length === 0 && this.bag.length === 0) this.finish('out_of_tiles');
|
||||||
|
else this.advance();
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pass forfeits the current turn, extending the scoreless run (which may end the game). */
|
||||||
|
pass(): LocalMove {
|
||||||
|
if (this.over) throw new GameError('game_over');
|
||||||
|
const player = this.toMove;
|
||||||
|
this.scorelessRun++;
|
||||||
|
const rec: LocalMove = { player, action: 'pass', score: 0, total: this.scores[player] };
|
||||||
|
this.log.push(rec);
|
||||||
|
this.endTurnAfterScoreless();
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** exchange swaps the given tiles (alphabet-index bytes, BLANK_INDEX for blanks) for fresh ones.
|
||||||
|
* Legal only while the bag holds at least a full rack; the fresh tiles are drawn before the
|
||||||
|
* swapped ones return, so a player cannot draw back their own. Extends the scoreless run. */
|
||||||
|
exchange(tiles: number[]): LocalMove {
|
||||||
|
if (this.over) throw new GameError('game_over');
|
||||||
|
if (tiles.length === 0) throw new GameError('nothing_to_exchange');
|
||||||
|
if (this.bag.length < this.rackSize) throw new GameError('not_enough_tiles_to_exchange');
|
||||||
|
const player = this.toMove;
|
||||||
|
if (!this.holds(player, tiles)) throw new GameError('tiles_not_on_rack');
|
||||||
|
|
||||||
|
this.removeFromHand(player, tiles);
|
||||||
|
const drawn = this.bag.draw(tiles.length);
|
||||||
|
for (const t of drawn) this.hands[player].push(t);
|
||||||
|
this.bag.return(tiles);
|
||||||
|
this.scorelessRun++;
|
||||||
|
|
||||||
|
const rec: LocalMove = { player, action: 'exchange', count: tiles.length, exchanged: [...tiles], score: 0, total: this.scores[player] };
|
||||||
|
this.log.push(rec);
|
||||||
|
this.endTurnAfterScoreless();
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** resign drops the current player out of the game (they forfeit the win, keep their score). */
|
||||||
|
resign(): LocalMove {
|
||||||
|
return this.resignSeat(this.toMove);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** resignSeat resigns a specific seat regardless of whose turn it is. */
|
||||||
|
resignSeat(seat: number): LocalMove {
|
||||||
|
if (this.over) throw new GameError('game_over');
|
||||||
|
if (seat < 0 || seat >= this.hands.length || this.resigned[seat]) throw new GameError('game_over');
|
||||||
|
this.resigned[seat] = true;
|
||||||
|
this.disposeHand(seat);
|
||||||
|
const rec: LocalMove = { player: seat, action: 'resign', score: 0, total: this.scores[seat] };
|
||||||
|
this.log.push(rec);
|
||||||
|
if (this.activeCount() <= 1) this.finish('resign');
|
||||||
|
else if (seat === this.toMove) this.advance();
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internals -------------------------------------------------------------
|
||||||
|
|
||||||
|
private rackOf(player: number): GenRack {
|
||||||
|
const letters: number[] = [];
|
||||||
|
let blanks = 0;
|
||||||
|
for (const t of this.hands[player]) {
|
||||||
|
if (t === BLANK_INDEX) blanks++;
|
||||||
|
else letters.push(t);
|
||||||
|
}
|
||||||
|
return GenRack.from(this.values.length, letters, blanks);
|
||||||
|
}
|
||||||
|
|
||||||
|
private finish(reason: EndReason): void {
|
||||||
|
this.over = true;
|
||||||
|
this.reason = reason;
|
||||||
|
const adjusted = applyEndAdjustment(reason, this.hands, this.scores, this.toMove, this.values);
|
||||||
|
for (let i = 0; i < adjusted.length; i++) this.scores[i] = adjusted[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
private endTurnAfterScoreless(): void {
|
||||||
|
if (this.scorelessRun >= scorelessLimit) this.finish('scoreless');
|
||||||
|
else this.advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
private advance(): void {
|
||||||
|
const n = this.hands.length;
|
||||||
|
for (let i = 1; i <= n; i++) {
|
||||||
|
const next = (this.toMove + i) % n;
|
||||||
|
if (!this.resigned[next]) {
|
||||||
|
this.toMove = next;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private activeCount(): number {
|
||||||
|
return this.resigned.reduce((n, r) => (r ? n : n + 1), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private disposeHand(player: number): void {
|
||||||
|
if (this.dropoutTiles === 'return') this.bag.return(this.hands[player]);
|
||||||
|
this.hands[player] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private holds(player: number, want: readonly number[]): boolean {
|
||||||
|
const avail = new Map<number, number>();
|
||||||
|
for (const t of this.hands[player]) avail.set(t, (avail.get(t) ?? 0) + 1);
|
||||||
|
const need = new Map<number, number>();
|
||||||
|
for (const t of want) need.set(t, (need.get(t) ?? 0) + 1);
|
||||||
|
for (const [t, n] of need) if ((avail.get(t) ?? 0) < n) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeFromHand(player: number, used: readonly number[]): void {
|
||||||
|
const hand = this.hands[player];
|
||||||
|
for (const t of used) {
|
||||||
|
const i = hand.indexOf(t);
|
||||||
|
if (i >= 0) hand.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private refill(player: number): void {
|
||||||
|
const need = this.rackSize - this.hands[player].length;
|
||||||
|
if (need > 0) for (const t of this.bag.draw(need)) this.hands[player].push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { LOCAL_ID_PREFIX, isLocalGameId, newLocalGameId, randomSeed } from './id';
|
||||||
|
|
||||||
|
describe('local game id helpers', () => {
|
||||||
|
it('recognises local ids by prefix', () => {
|
||||||
|
expect(isLocalGameId(LOCAL_ID_PREFIX + 'x')).toBe(true);
|
||||||
|
expect(isLocalGameId('deadbeef')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mints unique local ids', () => {
|
||||||
|
const a = newLocalGameId();
|
||||||
|
const b = newLocalGameId();
|
||||||
|
expect(isLocalGameId(a)).toBe(true);
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a bigint seed in the 32-bit range', () => {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const s = randomSeed();
|
||||||
|
expect(typeof s).toBe('bigint');
|
||||||
|
expect(s >= 0n && s <= 0xffffffffn).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Local game id helpers, in a tiny standalone module with no engine/generator imports, so the
|
||||||
|
// dispatcher (lib/gamesource) can recognise a local game id — and the game screen can branch on it
|
||||||
|
// — without eagerly bundling the whole offline engine. The engine is dynamically imported only when
|
||||||
|
// a local game is actually played (keeping the app entry bundle within its size budget).
|
||||||
|
|
||||||
|
/** LOCAL_ID_PREFIX marks a game id as a local (offline) game. */
|
||||||
|
export const LOCAL_ID_PREFIX = 'local:';
|
||||||
|
|
||||||
|
/** isLocalGameId reports whether an id belongs to a local game. */
|
||||||
|
export function isLocalGameId(id: string): boolean {
|
||||||
|
return id.startsWith(LOCAL_ID_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** newLocalGameId mints a fresh, device-unique local game id (time + random suffix — no crypto API,
|
||||||
|
* so it also works on the older engines the SPA still supports). */
|
||||||
|
export function newLocalGameId(): string {
|
||||||
|
return `${LOCAL_ID_PREFIX}${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A test seam for the mock e2e: pinning the seed makes a local game deal a known rack, so a spec
|
||||||
|
// can play a precomputed opening word. Only the mock build's window.__mock wires setForcedSeed
|
||||||
|
// (see lib/gateway.ts); a production build never calls it, so randomSeed stays truly random.
|
||||||
|
let forcedSeed: bigint | null = null;
|
||||||
|
|
||||||
|
/** setForcedSeed pins the value randomSeed returns (the mock-e2e seam); pass null to restore
|
||||||
|
* randomness. */
|
||||||
|
export function setForcedSeed(seed: bigint | null): void {
|
||||||
|
forcedSeed = seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** randomSeed returns a random 32-bit seed for a new local game's deterministic bag (or the pinned
|
||||||
|
* seed when a test has set one via setForcedSeed). */
|
||||||
|
export function randomSeed(): bigint {
|
||||||
|
if (forcedSeed !== null) return forcedSeed;
|
||||||
|
return BigInt(Math.floor(Math.random() * 0xffffffff));
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { RULESETS } from './ruleset';
|
||||||
|
import type { Variant } from '../model';
|
||||||
|
|
||||||
|
// The offline engine is self-contained: it carries each variant's tile values, bag counts and
|
||||||
|
// blank count in ruleset.ts (these are not otherwise on the client — online scoring uses the
|
||||||
|
// server-sent alphabet). This pins that hand-copied table to the Go rulesets
|
||||||
|
// (scrabble-solver/rules), whose values the movegen tool dumps to testdata/rulesets.json.
|
||||||
|
interface RulesetFix {
|
||||||
|
size: number;
|
||||||
|
rackSize: number;
|
||||||
|
bingo: number;
|
||||||
|
blanks: number;
|
||||||
|
values: number[];
|
||||||
|
counts: number[];
|
||||||
|
letters: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const fx = JSON.parse(
|
||||||
|
readFileSync(new URL('./testdata/rulesets.json', import.meta.url), 'utf8'),
|
||||||
|
) as Record<Variant, RulesetFix>;
|
||||||
|
|
||||||
|
describe('offline ruleset table parity vs Go rules', () => {
|
||||||
|
for (const variant of Object.keys(fx) as Variant[]) {
|
||||||
|
it(variant, () => {
|
||||||
|
const rs = RULESETS[variant];
|
||||||
|
expect({
|
||||||
|
size: rs.size,
|
||||||
|
rackSize: rs.rackSize,
|
||||||
|
bingo: rs.bingo,
|
||||||
|
blanks: rs.blanks,
|
||||||
|
values: [...rs.values],
|
||||||
|
counts: [...rs.counts],
|
||||||
|
letters: [...rs.letters],
|
||||||
|
}).toEqual(fx[variant]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Static per-variant ruleset data for the offline engine — tile point values, bag tile counts
|
||||||
|
// and blank count, indexed by alphabet letter index. Mirrored from scrabble-solver/rules/rules.go
|
||||||
|
// (English / RussianScrabble / Erudit) so a local game is fully self-contained: online scoring
|
||||||
|
// reads the server-sent alphabet (lib/alphabet.ts), but offline there is no server, so the values
|
||||||
|
// live here. Board geometry, the centre and RACK_SIZE/BINGO already live in lib/premiums.ts; this
|
||||||
|
// table adds the values (offline copy), the bag distribution and the blank count. Pinned to the Go
|
||||||
|
// rulesets by ruleset.parity.test.ts.
|
||||||
|
|
||||||
|
import type { Variant } from '../model';
|
||||||
|
|
||||||
|
/** VariantRuleset is the offline scoring + bag data for one variant. */
|
||||||
|
export interface VariantRuleset {
|
||||||
|
/** Number of letters in the alphabet (bag distribution and values are indexed 0..size-1). */
|
||||||
|
size: number;
|
||||||
|
/** Tiles drawn to a full rack (7 for every variant). */
|
||||||
|
rackSize: number;
|
||||||
|
/** All-tiles (bingo) bonus. */
|
||||||
|
bingo: number;
|
||||||
|
/** Number of blank tiles in the bag. */
|
||||||
|
blanks: number;
|
||||||
|
/** Tile point value per letter index; a blank scores 0 (handled by the caller). */
|
||||||
|
values: readonly number[];
|
||||||
|
/** Bag tile count per letter index (how many of each letter the bag holds). */
|
||||||
|
counts: readonly number[];
|
||||||
|
/** Display glyph per letter index, upper-cased (matching lib/alphabet.ts). Offline needs these
|
||||||
|
* to translate the engine's index space to/from the glyphs the game UI shows, with no server. */
|
||||||
|
letters: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RULESETS is the static ruleset table, one entry per variant, mirrored from rules.go. */
|
||||||
|
export const RULESETS: Record<Variant, VariantRuleset> = {
|
||||||
|
scrabble_en: {
|
||||||
|
size: 26,
|
||||||
|
rackSize: 7,
|
||||||
|
bingo: 50,
|
||||||
|
blanks: 2,
|
||||||
|
// a b c d e f g h i j k l m n o p q r s t u v w x y z
|
||||||
|
values: [1, 3, 3, 2, 1, 4, 2, 4, 1, 8, 5, 1, 3, 1, 1, 3, 10, 1, 1, 1, 1, 4, 4, 8, 4, 10],
|
||||||
|
counts: [9, 2, 2, 4, 12, 2, 3, 2, 9, 1, 1, 4, 2, 6, 8, 2, 1, 6, 4, 6, 4, 2, 2, 1, 2, 1],
|
||||||
|
letters: [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'],
|
||||||
|
},
|
||||||
|
scrabble_ru: {
|
||||||
|
size: 33,
|
||||||
|
rackSize: 7,
|
||||||
|
bingo: 50,
|
||||||
|
blanks: 2,
|
||||||
|
// а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я
|
||||||
|
values: [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],
|
||||||
|
counts: [8, 2, 4, 2, 4, 8, 1, 1, 2, 5, 1, 4, 4, 3, 5, 10, 4, 5, 5, 5, 4, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 2],
|
||||||
|
letters: [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ'],
|
||||||
|
},
|
||||||
|
erudit_ru: {
|
||||||
|
size: 33,
|
||||||
|
rackSize: 7,
|
||||||
|
bingo: 15,
|
||||||
|
blanks: 3,
|
||||||
|
// а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я
|
||||||
|
values: [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],
|
||||||
|
counts: [10, 3, 5, 3, 5, 9, 0, 2, 2, 8, 4, 6, 4, 5, 8, 10, 6, 6, 6, 5, 3, 1, 2, 1, 2, 1, 1, 1, 2, 2, 1, 1, 3],
|
||||||
|
letters: [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ'],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { Dawg } from '../dict/dawg';
|
||||||
|
import { RACK_SIZE } from '../premiums';
|
||||||
|
import { LocalGame } from './engine';
|
||||||
|
import { serializeGame, replayGame, type Seat } from './serialize';
|
||||||
|
import { decide } from '../robot/strategy';
|
||||||
|
import type { Move } from '../dict/validate';
|
||||||
|
|
||||||
|
// A game reconstructed from its record (seed + journal) must be identical to the original — same
|
||||||
|
// board, racks, bag, scores, turn and log — since the bag is deterministic from the seed and the
|
||||||
|
// replayed sequence of operations. Uses the committed sample dictionary.
|
||||||
|
const dawg = new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
|
||||||
|
const seats: Seat[] = [
|
||||||
|
{ kind: 'human', name: 'You' },
|
||||||
|
{ kind: 'robot', name: 'Robot' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function bestOpponentScore(game: LocalGame, seat: number): number {
|
||||||
|
let best = 0;
|
||||||
|
for (let i = 0; i < game.playerCount; i++) if (i !== seat) best = Math.max(best, game.scoreOf(i));
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// drive plays one turn with the robot's choice (legal plays only — see engine.test.ts).
|
||||||
|
function drive(game: LocalGame, seed: bigint): void {
|
||||||
|
const seat = game.currentPlayer;
|
||||||
|
const cands: Move[] = game.generateMoves().filter((m) => m.main.letters.length >= 2);
|
||||||
|
const dec = decide(seed, game.moveCount, cands, game.scoreOf(seat), bestOpponentScore(game, seat), game.handOf(seat), game.bagLength);
|
||||||
|
if (dec.kind === 'play') game.play(dec.move.dir, dec.move.tiles);
|
||||||
|
else if (dec.kind === 'exchange' && game.bagLength >= RACK_SIZE) game.exchange(dec.exchange);
|
||||||
|
else game.pass();
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(game: LocalGame): unknown {
|
||||||
|
const scores: number[] = [];
|
||||||
|
const hands: number[][] = [];
|
||||||
|
for (let i = 0; i < game.playerCount; i++) {
|
||||||
|
scores.push(game.scoreOf(i));
|
||||||
|
hands.push(game.handOf(i).slice().sort((a, b) => a - b));
|
||||||
|
}
|
||||||
|
return { scores, hands, toMove: game.currentPlayer, bagLen: game.bagLength, over: game.isOver, reason: game.endReason, history: game.history };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGame(seed: bigint): LocalGame {
|
||||||
|
return new LocalGame({ variant: 'scrabble_en', version: 'sample', seed, players: 2, dawg, multipleWords: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = { id: 'g1', seats, createdAtUnix: 1, updatedAtUnix: 2, hintUnlockAtMs: 0 };
|
||||||
|
|
||||||
|
describe('local game serialize + replay', () => {
|
||||||
|
it('reconstructs a mid-game position by replay', () => {
|
||||||
|
const g = makeGame(777n);
|
||||||
|
for (let i = 0; i < 15 && !g.isOver; i++) drive(g, g.seed);
|
||||||
|
const rec = serializeGame(g, meta);
|
||||||
|
const r = replayGame(rec, dawg);
|
||||||
|
expect(snapshot(r)).toEqual(snapshot(g));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconstructs a finished game by replay', () => {
|
||||||
|
const g = makeGame(2024n);
|
||||||
|
let t = 0;
|
||||||
|
while (!g.isOver && t < 2000) {
|
||||||
|
drive(g, g.seed);
|
||||||
|
t++;
|
||||||
|
}
|
||||||
|
expect(g.isOver).toBe(true);
|
||||||
|
const rec = serializeGame(g, meta);
|
||||||
|
expect(rec.status).toBe('finished');
|
||||||
|
const r = replayGame(rec, dawg);
|
||||||
|
expect(snapshot(r)).toEqual(snapshot(g));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serialises the seed, rules and journal', () => {
|
||||||
|
const g = makeGame(9999999999n);
|
||||||
|
for (let i = 0; i < 5 && !g.isOver; i++) drive(g, g.seed);
|
||||||
|
const rec = serializeGame(g, meta);
|
||||||
|
expect(rec.seed).toBe('9999999999');
|
||||||
|
expect(rec.variant).toBe('scrabble_en');
|
||||||
|
expect(rec.multipleWords).toBe(true);
|
||||||
|
expect(rec.players).toBe(2);
|
||||||
|
expect(rec.seats).toEqual(seats);
|
||||||
|
expect(rec.journal.length).toBe(g.moveCount);
|
||||||
|
expect(replayGame(rec, dawg).seed).toBe(9999999999n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serialises hotseat metadata: the mode, master PIN and per-seat PINs', async () => {
|
||||||
|
const { newLock } = await import('../pin');
|
||||||
|
const hostPin = await newLock('9999');
|
||||||
|
const seatPin = await newLock('1234');
|
||||||
|
const hotseatSeats: Seat[] = [
|
||||||
|
{ kind: 'human', name: 'Ann', pin: seatPin },
|
||||||
|
{ kind: 'human', name: 'Bob' },
|
||||||
|
];
|
||||||
|
const g = makeGame(5n);
|
||||||
|
const rec = serializeGame(g, { id: 'h1', seats: hotseatSeats, hotseat: true, hostPin, createdAtUnix: 1, updatedAtUnix: 2, hintUnlockAtMs: 0 });
|
||||||
|
expect(rec.hotseat).toBe(true);
|
||||||
|
expect(rec.hostPin).toEqual(hostPin);
|
||||||
|
expect(rec.seats[0].pin).toEqual(seatPin);
|
||||||
|
expect(rec.seats[1].pin).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Serialising a local game to a durable record and reconstructing it by replay — the offline
|
||||||
|
// counterpart of the server's game rehydration (backend replay: engine.New(seed) + replayMove per
|
||||||
|
// journal row). A record carries the seed, the rules, the seat metadata and the move journal; the
|
||||||
|
// board, bag and racks are NOT stored — they are rebuilt by replaying the journal on a fresh engine
|
||||||
|
// seeded the same way (the bag is deterministic from the seed and the sequence of operations).
|
||||||
|
//
|
||||||
|
// The journal is in alphabet-index space, which is dictionary-independent: a variant's alphabet
|
||||||
|
// (Latin 26 / embedded Russian 33) is fixed, so the same journal replays against any dictionary
|
||||||
|
// version of that variant (only the word list changes, not the indices). The dictionary version is
|
||||||
|
// pinned in the record, and replay applies each recorded move against that dawg.
|
||||||
|
|
||||||
|
import { LocalGame, type LocalMove, type DropoutTiles, type LocalGameOptions } from './engine';
|
||||||
|
import type { Dawg } from '../dict/dawg';
|
||||||
|
import type { Variant } from '../model';
|
||||||
|
import type { PinLock } from '../pin';
|
||||||
|
|
||||||
|
/** Seat describes one player of a local game (forward-compatible with a 2-4 hotseat). */
|
||||||
|
export interface Seat {
|
||||||
|
kind: 'human' | 'robot';
|
||||||
|
name: string;
|
||||||
|
/** The player's real account id for a human seat, so the client identifies "you" and the correct
|
||||||
|
* turn exactly as in an online game; absent for the robot (a synthetic id is derived). */
|
||||||
|
accountId?: string;
|
||||||
|
/** Offline hotseat only: the seat's optional PIN lock. When set, the seat's rack stays hidden
|
||||||
|
* until the PIN is entered (a social lock for a shared device — see lib/pin). */
|
||||||
|
pin?: PinLock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LocalGameRecord is the durable form of one local game — everything needed to reconstruct it. */
|
||||||
|
export interface LocalGameRecord {
|
||||||
|
id: string;
|
||||||
|
variant: Variant;
|
||||||
|
dictVersion: string;
|
||||||
|
/** The bag seed, serialised as a decimal string (it may exceed JS safe integers). */
|
||||||
|
seed: string;
|
||||||
|
players: number;
|
||||||
|
multipleWords: boolean;
|
||||||
|
dropoutTiles: DropoutTiles;
|
||||||
|
seats: Seat[];
|
||||||
|
/** The dictionary-independent, alphabet-index-space move journal. */
|
||||||
|
journal: LocalMove[];
|
||||||
|
status: 'active' | 'finished';
|
||||||
|
/** Offline pass-and-play (hotseat): 2-4 humans share one device. Absent on vs_ai records (and on
|
||||||
|
* older records predating the feature), which the source reads as false. */
|
||||||
|
hotseat?: boolean;
|
||||||
|
/** Offline hotseat only: the mandatory master (host/referee) PIN lock, needed to skip/resign a
|
||||||
|
* seat, exclude a player or terminate the game (see lib/pin). Absent on vs_ai records. */
|
||||||
|
hostPin?: PinLock;
|
||||||
|
createdAtUnix: number;
|
||||||
|
updatedAtUnix: number;
|
||||||
|
/** Wall-clock ms at which the vs_ai idle hint unlocks (the robot's last move + the window), or 0
|
||||||
|
* while open (no robot move yet). Persisted so the wait survives leaving and reopening the app;
|
||||||
|
* read through a sanitiser (cap at now + window) so a device clock set back cannot freeze it —
|
||||||
|
* see lib/hints. */
|
||||||
|
hintUnlockAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The record fields the caller owns (the engine supplies the rest). */
|
||||||
|
export interface RecordMeta {
|
||||||
|
id: string;
|
||||||
|
seats: Seat[];
|
||||||
|
hotseat?: boolean;
|
||||||
|
hostPin?: PinLock;
|
||||||
|
createdAtUnix: number;
|
||||||
|
updatedAtUnix: number;
|
||||||
|
hintUnlockAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* serializeGame builds the durable record for game, taking the caller-owned metadata (id, seats,
|
||||||
|
* timestamps) and deriving the rest — the seed, rules, journal and status — from the game itself.
|
||||||
|
*/
|
||||||
|
export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecord {
|
||||||
|
const cfg = game.config;
|
||||||
|
return {
|
||||||
|
id: meta.id,
|
||||||
|
variant: game.variant,
|
||||||
|
dictVersion: game.version,
|
||||||
|
seed: game.seed.toString(),
|
||||||
|
players: game.playerCount,
|
||||||
|
multipleWords: cfg.multipleWords,
|
||||||
|
dropoutTiles: cfg.dropoutTiles,
|
||||||
|
seats: meta.seats,
|
||||||
|
journal: game.history,
|
||||||
|
status: game.isOver ? 'finished' : 'active',
|
||||||
|
hotseat: meta.hotseat,
|
||||||
|
hostPin: meta.hostPin,
|
||||||
|
createdAtUnix: meta.createdAtUnix,
|
||||||
|
updatedAtUnix: meta.updatedAtUnix,
|
||||||
|
hintUnlockAtMs: meta.hintUnlockAtMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* replayGame reconstructs the live LocalGame from a record and the loaded dictionary, by seeding a
|
||||||
|
* fresh engine identically and replaying every journal move in order. The recorded plays stay legal
|
||||||
|
* against the pinned dictionary, so replay reproduces the exact board, racks, bag and scores.
|
||||||
|
*/
|
||||||
|
export function replayGame(record: LocalGameRecord, dawg: Dawg): LocalGame {
|
||||||
|
const opts: LocalGameOptions = {
|
||||||
|
variant: record.variant,
|
||||||
|
version: record.dictVersion,
|
||||||
|
seed: BigInt(record.seed),
|
||||||
|
players: record.players,
|
||||||
|
dawg,
|
||||||
|
multipleWords: record.multipleWords,
|
||||||
|
dropoutTiles: record.dropoutTiles,
|
||||||
|
};
|
||||||
|
const game = new LocalGame(opts);
|
||||||
|
for (const m of record.journal) {
|
||||||
|
switch (m.action) {
|
||||||
|
case 'play':
|
||||||
|
game.play(m.dir!, m.tiles!);
|
||||||
|
break;
|
||||||
|
case 'pass':
|
||||||
|
game.pass();
|
||||||
|
break;
|
||||||
|
case 'exchange':
|
||||||
|
game.exchange(m.exchanged!);
|
||||||
|
break;
|
||||||
|
case 'resign':
|
||||||
|
game.resignSeat(m.player);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return game;
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import type { StateView } from '../model';
|
||||||
|
|
||||||
|
// getDawg is mocked to the committed sample dictionary (the store's IndexedDB is absent under node,
|
||||||
|
// so games live in the source's in-memory cache — created once, then driven without reload).
|
||||||
|
vi.mock('../dict', () => ({
|
||||||
|
getDawg: async () => {
|
||||||
|
const { Dawg } = await import('../dict/dawg');
|
||||||
|
const { readFileSync } = await import('node:fs');
|
||||||
|
return new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { LocalSource } from './source';
|
||||||
|
import type { Seat } from './serialize';
|
||||||
|
import { newLock } from '../pin';
|
||||||
|
|
||||||
|
async function hotseat(id: string, seats: Seat[], hostDigits = '9999'): Promise<LocalSource> {
|
||||||
|
const src = new LocalSource();
|
||||||
|
await src.create({
|
||||||
|
id,
|
||||||
|
variant: 'scrabble_en',
|
||||||
|
dictVersion: 'sample',
|
||||||
|
seed: 3n,
|
||||||
|
multipleWords: true,
|
||||||
|
seats,
|
||||||
|
hotseat: true,
|
||||||
|
hostPin: await newLock(hostDigits),
|
||||||
|
});
|
||||||
|
return src;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LocalSource hotseat', () => {
|
||||||
|
it('creates a pass-and-play game: not vs_ai, board visible, first seat to move', async () => {
|
||||||
|
const src = await hotseat('local:h1', [
|
||||||
|
{ kind: 'human', name: 'Ann' },
|
||||||
|
{ kind: 'human', name: 'Bob' },
|
||||||
|
]);
|
||||||
|
const st = await src.gameState('local:h1');
|
||||||
|
expect(st.game.vsAi).toBe(false);
|
||||||
|
expect(st.game.hotseat).toBe(true);
|
||||||
|
expect(st.game.toMove).toBe(0);
|
||||||
|
expect(st.locked).toBeFalsy(); // seat 0 has no PIN
|
||||||
|
expect(st.rack.length).toBe(7);
|
||||||
|
expect(st.game.seats.map((s) => s.displayName)).toEqual(['Ann', 'Bob']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the rack of a PIN-locked seat until the right PIN unlocks it', async () => {
|
||||||
|
const src = await hotseat('local:h2', [
|
||||||
|
{ kind: 'human', name: 'Ann', pin: await newLock('1234') },
|
||||||
|
{ kind: 'human', name: 'Bob' },
|
||||||
|
]);
|
||||||
|
const st = await src.gameState('local:h2');
|
||||||
|
expect(st.locked).toBe(true);
|
||||||
|
expect(st.rack).toEqual([]);
|
||||||
|
|
||||||
|
await expect(src.unlockSeat('local:h2', '0000')).rejects.toMatchObject({ code: 'wrong_pin' });
|
||||||
|
|
||||||
|
const opened = await src.unlockSeat('local:h2', '1234');
|
||||||
|
expect(opened.locked).toBe(false);
|
||||||
|
expect(opened.rack.length).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-locks the seat when the turn advances and again when it comes back', async () => {
|
||||||
|
const src = await hotseat('local:h3', [
|
||||||
|
{ kind: 'human', name: 'A', pin: await newLock('1111') },
|
||||||
|
{ kind: 'human', name: 'B' },
|
||||||
|
{ kind: 'human', name: 'C' },
|
||||||
|
]);
|
||||||
|
await src.unlockSeat('local:h3', '1111');
|
||||||
|
await src.pass('local:h3'); // seat 0 -> 1
|
||||||
|
let st = await src.gameState('local:h3');
|
||||||
|
expect(st.game.toMove).toBe(1);
|
||||||
|
expect(st.locked).toBeFalsy(); // seat 1 has no PIN
|
||||||
|
|
||||||
|
await src.pass('local:h3'); // 1 -> 2
|
||||||
|
await src.pass('local:h3'); // 2 -> 0
|
||||||
|
st = await src.gameState('local:h3');
|
||||||
|
expect(st.game.toMove).toBe(0);
|
||||||
|
expect(st.locked).toBe(true); // seat 0 needs re-unlocking
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the host skip the current turn, gated by the master PIN', async () => {
|
||||||
|
const src = await hotseat('local:h4', [
|
||||||
|
{ kind: 'human', name: 'A' },
|
||||||
|
{ kind: 'human', name: 'B' },
|
||||||
|
], '4321');
|
||||||
|
expect((await src.gameState('local:h4')).game.toMove).toBe(0);
|
||||||
|
await expect(src.hostAction('local:h4', '0000', 'skip')).rejects.toMatchObject({ code: 'wrong_pin' });
|
||||||
|
const st = await src.hostAction('local:h4', '4321', 'skip');
|
||||||
|
expect(st).not.toBeNull();
|
||||||
|
expect((st as StateView).game.toMove).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the host resign a seat (ending a two-player game)', async () => {
|
||||||
|
const src = await hotseat('local:h5', [
|
||||||
|
{ kind: 'human', name: 'A' },
|
||||||
|
{ kind: 'human', name: 'B' },
|
||||||
|
], '4321');
|
||||||
|
const st = await src.hostAction('local:h5', '4321', 'resign', 1);
|
||||||
|
expect((st as StateView).game.status).toBe('finished');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a host-excluded seat as resigned in the view (for the medal ranking)', async () => {
|
||||||
|
const src = await hotseat('local:hR', [
|
||||||
|
{ kind: 'human', name: 'A' },
|
||||||
|
{ kind: 'human', name: 'B' },
|
||||||
|
{ kind: 'human', name: 'C' },
|
||||||
|
], '4321');
|
||||||
|
await src.hostAction('local:hR', '4321', 'resign', 1); // exclude seat 1 (game continues: 2 active)
|
||||||
|
const st = await src.gameState('local:hR');
|
||||||
|
expect(st.game.seats[1].resigned).toBe(true);
|
||||||
|
expect(st.game.seats[0].resigned).toBe(false);
|
||||||
|
expect(st.game.seats[2].resigned).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the host terminate the game, deleting it', async () => {
|
||||||
|
const src = await hotseat('local:h6', [
|
||||||
|
{ kind: 'human', name: 'A' },
|
||||||
|
{ kind: 'human', name: 'B' },
|
||||||
|
], '4321');
|
||||||
|
const r = await src.hostAction('local:h6', '4321', 'terminate');
|
||||||
|
expect(r).toBeNull();
|
||||||
|
await expect(src.gameState('local:h6')).rejects.toMatchObject({ code: 'game_not_found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-locks an unlocked seat on relock (returning to the lobby)', async () => {
|
||||||
|
const src = await hotseat('local:h9', [
|
||||||
|
{ kind: 'human', name: 'Ann', pin: await newLock('1234') },
|
||||||
|
{ kind: 'human', name: 'Bob' },
|
||||||
|
]);
|
||||||
|
const opened = await src.unlockSeat('local:h9', '1234');
|
||||||
|
expect(opened.locked).toBe(false);
|
||||||
|
src.relock('local:h9');
|
||||||
|
const st = await src.gameState('local:h9');
|
||||||
|
expect(st.locked).toBe(true); // returning to the game re-prompts the seat PIN
|
||||||
|
expect(st.rack).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies the master PIN', async () => {
|
||||||
|
const src = await hotseat('local:h7', [
|
||||||
|
{ kind: 'human', name: 'A' },
|
||||||
|
{ kind: 'human', name: 'B' },
|
||||||
|
], '4321');
|
||||||
|
expect(await src.verifyHostPin('local:h7', '4321')).toBe(true);
|
||||||
|
expect(await src.verifyHostPin('local:h7', '0000')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists a naturally finished game (scoreless run) as finished, and unlocked', async () => {
|
||||||
|
const src = await hotseat('local:h8', [
|
||||||
|
{ kind: 'human', name: 'A', pin: await newLock('1111') },
|
||||||
|
{ kind: 'human', name: 'B', pin: await newLock('2222') },
|
||||||
|
]);
|
||||||
|
for (let i = 0; i < 6; i++) await src.pass('local:h8');
|
||||||
|
const st = await src.gameState('local:h8');
|
||||||
|
expect(st.game.status).toBe('finished');
|
||||||
|
expect(st.locked).toBeFalsy(); // a finished game shows its final rack, never an unlock button
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import type { LocalGameRecord } from './serialize';
|
||||||
|
|
||||||
|
// An in-memory stand-in for the IndexedDB store, so list() — which reads the *persisted* records,
|
||||||
|
// not the source's in-memory cache — can be exercised in the node env (the real store is a
|
||||||
|
// best-effort no-op without IndexedDB). Declared before the vi.mock factory that closes over it.
|
||||||
|
const store = new Map<string, LocalGameRecord>();
|
||||||
|
vi.mock('./store', () => ({
|
||||||
|
saveLocalGame: async (r: LocalGameRecord) => void store.set(r.id, r),
|
||||||
|
getLocalGame: async (id: string) => store.get(id) ?? null,
|
||||||
|
listLocalGames: async () => [...store.values()],
|
||||||
|
deleteLocalGame: async (id: string) => void store.delete(id),
|
||||||
|
}));
|
||||||
|
vi.mock('../dict', () => ({
|
||||||
|
getDawg: async () => {
|
||||||
|
const { Dawg } = await import('../dict/dawg');
|
||||||
|
const { readFileSync } = await import('node:fs');
|
||||||
|
return new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { LocalSource } from './source';
|
||||||
|
import type { Seat } from './serialize';
|
||||||
|
|
||||||
|
const seats: Seat[] = [
|
||||||
|
{ kind: 'human', name: 'You' },
|
||||||
|
{ kind: 'robot', name: 'Robot' },
|
||||||
|
];
|
||||||
|
const opts = (id: string, seed: bigint) => ({
|
||||||
|
id,
|
||||||
|
variant: 'scrabble_en' as const,
|
||||||
|
dictVersion: 'sample',
|
||||||
|
seed,
|
||||||
|
multipleWords: true,
|
||||||
|
seats,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => store.clear());
|
||||||
|
|
||||||
|
describe('LocalSource.list', () => {
|
||||||
|
it('is empty when no games are stored', async () => {
|
||||||
|
expect(await new LocalSource().list()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a lobby GameView for every stored local game', async () => {
|
||||||
|
const src = new LocalSource();
|
||||||
|
await src.create(opts('local:a', 1n));
|
||||||
|
await src.create(opts('local:b', 2n));
|
||||||
|
const list = await src.list();
|
||||||
|
expect(new Set(list.map((g) => g.id))).toEqual(new Set(['local:a', 'local:b']));
|
||||||
|
expect(list.every((g) => g.vsAi && g.status === 'active')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the human seat account id into the GameView so the client identifies "you"', async () => {
|
||||||
|
const src = new LocalSource();
|
||||||
|
await src.create({
|
||||||
|
...opts('local:me', 3n),
|
||||||
|
seats: [
|
||||||
|
{ kind: 'human', name: 'You', accountId: 'acct-42' },
|
||||||
|
{ kind: 'robot', name: 'Robot' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const [g] = await src.list();
|
||||||
|
expect(g.seats[0].accountId).toBe('acct-42'); // the human's real id, matched against the session
|
||||||
|
expect(g.seats[1].accountId).not.toBe('acct-42'); // the robot keeps a synthetic id
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delete removes a stored local game from the list', async () => {
|
||||||
|
const src = new LocalSource();
|
||||||
|
await src.create(opts('local:x', 5n));
|
||||||
|
expect((await src.list()).map((g) => g.id)).toContain('local:x');
|
||||||
|
await src.delete('local:x');
|
||||||
|
expect((await src.list()).map((g) => g.id)).not.toContain('local:x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconstructs a game persisted by a previous session (not in the source cache)', async () => {
|
||||||
|
// Seed the store via one source, then list from a fresh source whose in-memory cache is empty.
|
||||||
|
await new LocalSource().create(opts('local:old', 7n));
|
||||||
|
const list = await new LocalSource().list();
|
||||||
|
expect(list.map((g) => g.id)).toEqual(['local:old']);
|
||||||
|
expect(list[0].variant).toBe('scrabble_en');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import type { PushEvent } from '../model';
|
||||||
|
|
||||||
|
// getDawg is mocked to the committed sample dictionary (the store's IndexedDB is absent under node,
|
||||||
|
// so games live in the source's in-memory cache — created once, then driven without reload).
|
||||||
|
vi.mock('../dict', () => ({
|
||||||
|
getDawg: async () => {
|
||||||
|
const { Dawg } = await import('../dict/dawg');
|
||||||
|
const { readFileSync } = await import('node:fs');
|
||||||
|
return new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { LocalSource, isLocalGameId } from './source';
|
||||||
|
import type { Seat } from './serialize';
|
||||||
|
import { hasAlphabet, alphabetValues } from '../alphabet';
|
||||||
|
|
||||||
|
const seats: Seat[] = [
|
||||||
|
{ kind: 'human', name: 'You' },
|
||||||
|
{ kind: 'robot', name: 'Robot' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function newSource(id: string, seed: bigint): Promise<LocalSource> {
|
||||||
|
const src = new LocalSource();
|
||||||
|
await src.create({ id, variant: 'scrabble_en', dictVersion: 'sample', seed, multipleWords: true, seats });
|
||||||
|
return src;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LocalSource', () => {
|
||||||
|
it('detects local game ids', () => {
|
||||||
|
expect(isLocalGameId('local:abc')).toBe(true);
|
||||||
|
expect(isLocalGameId('deadbeef')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a vs_ai game with the human to move and a full rack', async () => {
|
||||||
|
const src = await newSource('local:g1', 42n);
|
||||||
|
const st = await src.gameState('local:g1');
|
||||||
|
expect(st.game.vsAi).toBe(true);
|
||||||
|
expect(st.game.status).toBe('active');
|
||||||
|
expect(st.seat).toBe(0);
|
||||||
|
expect(st.rack.length).toBe(7);
|
||||||
|
expect(st.game.seats.map((s) => s.displayName)).toEqual(['You', 'Robot']);
|
||||||
|
expect(st.game.toMove).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds the per-variant alphabet from the ruleset, so offline tiles show real weights not zeros', async () => {
|
||||||
|
// Offline there is no server to send the alphabet table the rack/board render from; opening a
|
||||||
|
// local game must seed it from the static ruleset. Without this a cold offline boot draws zeros.
|
||||||
|
const src = await newSource('local:gAlpha', 42n);
|
||||||
|
await src.gameState('local:gAlpha');
|
||||||
|
expect(hasAlphabet('scrabble_en')).toBe(true);
|
||||||
|
const values = alphabetValues('scrabble_en');
|
||||||
|
expect(values.length).toBe(26);
|
||||||
|
expect(values[0]).toBe(1); // A = 1 (pinned to rules.go / ruleset.ts), not 0
|
||||||
|
expect(values.every((v) => v > 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replies to a human pass with a synchronous robot move, delivered via the event', async () => {
|
||||||
|
const src = await newSource('local:g2', 999n);
|
||||||
|
const events: PushEvent[] = [];
|
||||||
|
src.events('local:g2', (e) => events.push(e));
|
||||||
|
|
||||||
|
const res = await src.pass('local:g2');
|
||||||
|
expect(res.move.action).toBe('pass');
|
||||||
|
expect(res.move.player).toBe(0);
|
||||||
|
|
||||||
|
// The robot has already played (synchronously) — the state reflects both moves...
|
||||||
|
const mid = await src.gameState('local:g2');
|
||||||
|
expect(mid.game.moveCount).toBeGreaterThanOrEqual(2);
|
||||||
|
// ...and its move is delivered to subscribers via the per-game event.
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(events.some((e) => e.kind === 'opponent_moved')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not wall-clock-gate the hint (the idle gate is client-side and monotonic)', async () => {
|
||||||
|
const src = await newSource('local:g3', 7n);
|
||||||
|
// hint() serves the engine's top move (or no_hint when the tiny sample dictionary has none for
|
||||||
|
// this rack), but never the old timestamp gate 'hint_not_ready' — that moved to the client
|
||||||
|
// (lib/hints, measured with performance.now()) so a device clock change cannot defeat it.
|
||||||
|
await src.hint('local:g3').then(
|
||||||
|
(h) => expect(h.move).toBeDefined(),
|
||||||
|
(e) => expect((e as { code: string }).code).toBe('no_hint'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records decoded moves in the history', async () => {
|
||||||
|
const src = await newSource('local:g4', 55n);
|
||||||
|
await src.pass('local:g4');
|
||||||
|
const h = await src.gameHistory('local:g4');
|
||||||
|
expect(h.moves.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(h.moves[0].action).toBe('pass');
|
||||||
|
// the robot's reply, whatever it is, is decoded (a play carries glyph tiles + lower-case words)
|
||||||
|
const play = h.moves.find((m) => m.action === 'play');
|
||||||
|
if (play) {
|
||||||
|
expect(play.tiles.every((t) => typeof t.letter === 'string' && t.letter.length >= 1)).toBe(true);
|
||||||
|
expect(play.words.every((w) => w === w.toLowerCase())).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drives a whole local game to completion', async () => {
|
||||||
|
const src = await newSource('local:g5', 2024n);
|
||||||
|
src.events('local:g5', () => {});
|
||||||
|
let turns = 0;
|
||||||
|
while ((await src.gameState('local:g5')).game.status === 'active' && turns < 500) {
|
||||||
|
await src.pass('local:g5');
|
||||||
|
await Promise.resolve();
|
||||||
|
turns++;
|
||||||
|
}
|
||||||
|
const st = await src.gameState('local:g5');
|
||||||
|
expect(st.game.status).toBe('finished');
|
||||||
|
expect(turns).toBeLessThan(500);
|
||||||
|
const winners = st.game.seats.filter((s) => s.isWinner);
|
||||||
|
expect(winners.length).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
// The local game source: a GatewayClient-shaped facade over the offline engine, so the SAME game
|
||||||
|
// screen drives a local vs_ai game with no backend. It implements the game-loop subset of the
|
||||||
|
// gateway (GameLoopSource) for a local game id, backed by LocalGame + the IndexedDB store + the
|
||||||
|
// real robot (lib/robot/strategy). Unlike the network flow there is no live stream: after the
|
||||||
|
// human's move the robot plays synchronously in the same call, its move persisted atomically and
|
||||||
|
// delivered to the screen through this source's own per-game event emitter (events()).
|
||||||
|
//
|
||||||
|
// It translates between the UI's glyph space (PlacedTile.letter, rack strings) and the engine's
|
||||||
|
// alphabet-index space using the static letters table (ruleset.ts), so it needs no server alphabet.
|
||||||
|
|
||||||
|
import { LocalGame, type LocalMove } from './engine';
|
||||||
|
import { serializeGame, replayGame, type LocalGameRecord, type Seat as LocalSeat } from './serialize';
|
||||||
|
import { getLocalGame, saveLocalGame, listLocalGames, deleteLocalGame } from './store';
|
||||||
|
import { RULESETS } from './ruleset';
|
||||||
|
import { getDawg } from '../dict';
|
||||||
|
import { setAlphabet, hasAlphabet } from '../alphabet';
|
||||||
|
import { HINT_GATE_MS } from '../hints';
|
||||||
|
import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
|
||||||
|
import { decide } from '../robot/strategy';
|
||||||
|
import { verifyPin, type PinLock } from '../pin';
|
||||||
|
import { GatewayError, type PlacedTile } from '../client';
|
||||||
|
import type {
|
||||||
|
EvalResult,
|
||||||
|
GameView,
|
||||||
|
History,
|
||||||
|
HintResult,
|
||||||
|
MoveRecord,
|
||||||
|
MoveResult,
|
||||||
|
PushEvent,
|
||||||
|
Seat,
|
||||||
|
StateView,
|
||||||
|
Tile,
|
||||||
|
Variant,
|
||||||
|
WordCheckResult,
|
||||||
|
} from '../model';
|
||||||
|
import type { Move } from '../dict/validate';
|
||||||
|
|
||||||
|
export { LOCAL_ID_PREFIX, isLocalGameId };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GameLoopSource is the subset of GatewayClient the game screen calls to run a game. The real
|
||||||
|
* gateway satisfies it structurally; the local source implements it for offline play.
|
||||||
|
*/
|
||||||
|
export interface GameLoopSource {
|
||||||
|
gameState(gameId: string, includeAlphabet: boolean): Promise<StateView>;
|
||||||
|
gameHistory(gameId: string): Promise<History>;
|
||||||
|
submitPlay(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<MoveResult>;
|
||||||
|
pass(gameId: string): Promise<MoveResult>;
|
||||||
|
exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult>;
|
||||||
|
resign(gameId: string): Promise<MoveResult>;
|
||||||
|
hint(gameId: string): Promise<HintResult>;
|
||||||
|
evaluate(gameId: string, tiles: PlacedTile[], variant: Variant, signal?: AbortSignal): Promise<EvalResult>;
|
||||||
|
checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
|
||||||
|
draftGet(gameId: string): Promise<string>;
|
||||||
|
draftSave(gameId: string, json: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLANK_GLYPH = '?';
|
||||||
|
|
||||||
|
// letterToIndex builds a glyph -> index map for a variant's alphabet (upper-cased, matching the
|
||||||
|
// static letters table and lib/alphabet.ts).
|
||||||
|
const indexCache = new Map<Variant, Map<string, number>>();
|
||||||
|
function letterToIndex(variant: Variant, glyph: string): number {
|
||||||
|
if (glyph === BLANK_GLYPH) return 255; // BLANK_INDEX
|
||||||
|
let m = indexCache.get(variant);
|
||||||
|
if (!m) {
|
||||||
|
m = new Map();
|
||||||
|
RULESETS[variant].letters.forEach((l, i) => m!.set(l, i));
|
||||||
|
indexCache.set(variant, m);
|
||||||
|
}
|
||||||
|
const i = m.get(glyph.toUpperCase());
|
||||||
|
if (i === undefined) throw new GatewayError('illegal_play', `no index for "${glyph}" in ${variant}`);
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexToLetter decodes an alphabet index to its display glyph; the blank sentinel maps to "?".
|
||||||
|
function indexToLetter(variant: Variant, index: number): string {
|
||||||
|
if (index === 255) return BLANK_GLYPH;
|
||||||
|
return RULESETS[variant].letters[index] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodePlacements turns submitted glyph tiles into the engine's index-space placements.
|
||||||
|
function encodePlacements(variant: Variant, tiles: PlacedTile[]): { row: number; col: number; letter: number; blank: boolean }[] {
|
||||||
|
return tiles.map((t) => ({ row: t.row, col: t.col, letter: letterToIndex(variant, t.letter), blank: t.blank }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A live local game held in memory for the session, so calls do not re-replay from the store.
|
||||||
|
interface Live {
|
||||||
|
game: LocalGame;
|
||||||
|
record: LocalGameRecord;
|
||||||
|
/** Offline hotseat only: which seat is currently unlocked (its PIN was entered this turn), or null.
|
||||||
|
* Ephemeral — never persisted; cleared on every turn advance so each turn re-locks. */
|
||||||
|
unlockedSeat: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LocalSource runs local games. One instance backs every local game the app has open; it caches
|
||||||
|
* the live engine per id and persists after each move.
|
||||||
|
*/
|
||||||
|
export class LocalSource implements GameLoopSource {
|
||||||
|
private readonly live = new Map<string, Live>();
|
||||||
|
private readonly listeners = new Map<string, Set<(e: PushEvent) => void>>();
|
||||||
|
|
||||||
|
/** create starts a new local game — a vs_ai game or an offline pass-and-play (hotseat) game when
|
||||||
|
* hotseat is set — persists it and returns its state. hostPin is the hotseat master PIN. */
|
||||||
|
async create(opts: {
|
||||||
|
id: string;
|
||||||
|
variant: Variant;
|
||||||
|
dictVersion: string;
|
||||||
|
seed: bigint;
|
||||||
|
multipleWords: boolean;
|
||||||
|
seats: LocalSeat[];
|
||||||
|
hotseat?: boolean;
|
||||||
|
hostPin?: PinLock;
|
||||||
|
}): Promise<StateView> {
|
||||||
|
const dawg = await getDawg(opts.variant, opts.dictVersion);
|
||||||
|
if (!dawg) throw new GatewayError('dict_unavailable');
|
||||||
|
const game = new LocalGame({
|
||||||
|
variant: opts.variant,
|
||||||
|
version: opts.dictVersion,
|
||||||
|
seed: opts.seed,
|
||||||
|
players: opts.seats.length,
|
||||||
|
dawg,
|
||||||
|
multipleWords: opts.multipleWords,
|
||||||
|
});
|
||||||
|
const now = nowUnix();
|
||||||
|
const record = serializeGame(game, {
|
||||||
|
id: opts.id,
|
||||||
|
seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name, accountId: s.accountId, pin: s.pin })),
|
||||||
|
hotseat: opts.hotseat,
|
||||||
|
hostPin: opts.hostPin,
|
||||||
|
createdAtUnix: now,
|
||||||
|
updatedAtUnix: now,
|
||||||
|
hintUnlockAtMs: 0, // no robot move yet: a human-first opening's hint is open at once
|
||||||
|
});
|
||||||
|
const entry: Live = { game, record, unlockedSeat: null };
|
||||||
|
this.live.set(opts.id, entry);
|
||||||
|
await saveLocalGame(record);
|
||||||
|
return this.stateView(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* list returns a lobby GameView for every stored local game, most-recently-updated first, so the
|
||||||
|
* offline lobby renders them with the same card machinery as the online games. Each is
|
||||||
|
* reconstructed by replay; a record whose dictionary is unavailable (e.g. offline without its
|
||||||
|
* dawg) is skipped, since it cannot be rendered — never throws.
|
||||||
|
*/
|
||||||
|
async list(): Promise<GameView[]> {
|
||||||
|
const records = await listLocalGames();
|
||||||
|
records.sort((a, b) => b.updatedAtUnix - a.updatedAtUnix);
|
||||||
|
const views: GameView[] = [];
|
||||||
|
for (const record of records) {
|
||||||
|
try {
|
||||||
|
views.push(this.gameView(await this.load(record.id)));
|
||||||
|
} catch {
|
||||||
|
/* a record that cannot be replayed (its dawg is not cached offline) is left out */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return views;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** delete removes a local game from the store and the in-memory cache — the offline lobby's
|
||||||
|
* finished-game delete. Best-effort: a failed store delete still drops it from this session. */
|
||||||
|
async delete(gameId: string): Promise<void> {
|
||||||
|
this.live.delete(gameId);
|
||||||
|
this.listeners.delete(gameId);
|
||||||
|
await deleteLocalGame(gameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async gameState(gameId: string): Promise<StateView> {
|
||||||
|
return this.stateView(await this.load(gameId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async gameHistory(gameId: string): Promise<History> {
|
||||||
|
const { game, record } = await this.load(gameId);
|
||||||
|
return { gameId, moves: game.history.map((m) => this.moveRecord(record.variant, m)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitPlay(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<MoveResult> {
|
||||||
|
return this.humanMove(gameId, (g) => g.submitPlay(encodePlacements(variant, tiles)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async pass(gameId: string): Promise<MoveResult> {
|
||||||
|
return this.humanMove(gameId, (g) => g.pass());
|
||||||
|
}
|
||||||
|
|
||||||
|
async exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult> {
|
||||||
|
const idx = tiles.map((t) => letterToIndex(variant, t));
|
||||||
|
return this.humanMove(gameId, (g) => g.exchange(idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
async resign(gameId: string): Promise<MoveResult> {
|
||||||
|
// A resignation ends a two-player game immediately; no robot reply follows.
|
||||||
|
const entry = await this.load(gameId);
|
||||||
|
const seat = entry.game.currentPlayer;
|
||||||
|
const move = entry.game.resign();
|
||||||
|
entry.unlockedSeat = null;
|
||||||
|
await this.persist(entry);
|
||||||
|
return this.moveResult(entry, move, seat);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* unlockSeat reveals the current hotseat seat's rack once its PIN is entered. It verifies pin
|
||||||
|
* against the seat's stored lock (a seat with no lock unlocks with any pin) and, on success, marks
|
||||||
|
* the seat unlocked for this turn only — a subsequent move re-locks it. Throws 'wrong_pin' on a
|
||||||
|
* mismatch. Returns the freshly unlocked state (rack revealed).
|
||||||
|
*/
|
||||||
|
async unlockSeat(gameId: string, pin: string): Promise<StateView> {
|
||||||
|
const entry = await this.load(gameId);
|
||||||
|
const seat = entry.game.currentPlayer;
|
||||||
|
const lock = entry.record.seats[seat]?.pin;
|
||||||
|
if (lock && !(await verifyPin(pin, lock))) throw new GatewayError('wrong_pin');
|
||||||
|
entry.unlockedSeat = seat;
|
||||||
|
return this.stateView(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** relock drops any per-turn seat unlock, so re-opening a hotseat game re-prompts the current
|
||||||
|
* seat's PIN. Called when the game screen is left (returning to the lobby). No-op when the game is
|
||||||
|
* not cached, or a seat with no PIN (which is never withheld anyway). */
|
||||||
|
relock(gameId: string): void {
|
||||||
|
const entry = this.live.get(gameId);
|
||||||
|
if (entry) entry.unlockedSeat = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** verifyHostPin reports whether pin matches the hotseat master (host/referee) PIN. */
|
||||||
|
async verifyHostPin(gameId: string, pin: string): Promise<boolean> {
|
||||||
|
const entry = await this.load(gameId);
|
||||||
|
return !!entry.record.hostPin && (await verifyPin(pin, entry.record.hostPin));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hostAction runs a host (referee) override on a hotseat game, gated by the master PIN:
|
||||||
|
* 'skip' — pass the current seat's turn;
|
||||||
|
* 'resign' — resign seat targetSeat (exclude a player / concede on their behalf);
|
||||||
|
* 'terminate' — end the game with no result and delete it from the store.
|
||||||
|
* Returns the updated StateView, or null when the game was terminated. Throws 'wrong_pin' on a bad
|
||||||
|
* PIN.
|
||||||
|
*/
|
||||||
|
async hostAction(
|
||||||
|
gameId: string,
|
||||||
|
pin: string,
|
||||||
|
action: 'skip' | 'resign' | 'terminate',
|
||||||
|
targetSeat?: number,
|
||||||
|
): Promise<StateView | null> {
|
||||||
|
const entry = await this.load(gameId);
|
||||||
|
if (!entry.record.hostPin || !(await verifyPin(pin, entry.record.hostPin))) throw new GatewayError('wrong_pin');
|
||||||
|
if (action === 'terminate') {
|
||||||
|
await this.delete(gameId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (action === 'skip') entry.game.pass();
|
||||||
|
else entry.game.resignSeat(targetSeat ?? entry.game.currentPlayer);
|
||||||
|
entry.unlockedSeat = null;
|
||||||
|
await this.persist(entry);
|
||||||
|
return this.stateView(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async hint(gameId: string): Promise<HintResult> {
|
||||||
|
// The idle gate is enforced client-side with a monotonic clock (see Game.svelte / lib/hints):
|
||||||
|
// a wall-clock timestamp here could be defeated by changing the device clock. This just serves
|
||||||
|
// the engine's top-ranked move; the client only calls it once the gate is open.
|
||||||
|
const entry = await this.load(gameId);
|
||||||
|
const top = this.topMove(entry.game);
|
||||||
|
if (!top) throw new GatewayError('no_hint');
|
||||||
|
return { move: this.moveFromGen(entry.record.variant, entry.game.currentPlayer, top), hintsRemaining: 1, walletBalance: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<EvalResult> {
|
||||||
|
const { game } = await this.load(gameId);
|
||||||
|
const r = game.evaluatePlay(encodePlacements(variant, tiles));
|
||||||
|
return {
|
||||||
|
legal: r.legal,
|
||||||
|
score: r.score,
|
||||||
|
words: r.words.map((w) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase()),
|
||||||
|
dir: r.legal ? (r.dir === 0 ? 'H' : 'V') : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult> {
|
||||||
|
const { game } = await this.load(gameId);
|
||||||
|
const idx = Array.from(word, (ch) => letterToIndex(variant, ch));
|
||||||
|
return { word, legal: game.dictionaryHas(idx) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drafts (the in-progress composition) are not persisted for local games — the arrangement is
|
||||||
|
// rebuilt from the rack on reopen. The screen tolerates an empty draft.
|
||||||
|
async draftGet(): Promise<string> {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
async draftSave(): Promise<void> {
|
||||||
|
/* no-op offline */
|
||||||
|
}
|
||||||
|
|
||||||
|
/** events subscribes to a local game's push events (the robot's reply, game over). Returns an
|
||||||
|
* unsubscribe. The network path uses the gateway's global stream instead. */
|
||||||
|
events(gameId: string, onEvent: (e: PushEvent) => void): () => void {
|
||||||
|
let set = this.listeners.get(gameId);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
this.listeners.set(gameId, set);
|
||||||
|
}
|
||||||
|
set.add(onEvent);
|
||||||
|
return () => set!.delete(onEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internals -------------------------------------------------------------
|
||||||
|
|
||||||
|
private async load(gameId: string): Promise<Live> {
|
||||||
|
const cached = this.live.get(gameId);
|
||||||
|
if (cached) return cached;
|
||||||
|
const record = await getLocalGame(gameId);
|
||||||
|
if (!record) throw new GatewayError('game_not_found');
|
||||||
|
const dawg = await getDawg(record.variant, record.dictVersion);
|
||||||
|
if (!dawg) throw new GatewayError('dict_unavailable');
|
||||||
|
const entry: Live = { game: replayGame(record, dawg), record, unlockedSeat: null };
|
||||||
|
this.live.set(gameId, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// humanMove applies the human's action, persists, returns their MoveResult, then (if the game is
|
||||||
|
// still on and it is the robot's turn) schedules the robot's synchronous reply — already applied
|
||||||
|
// and saved here, but emitted to subscribers on a microtask so the human's move renders first.
|
||||||
|
private async humanMove(gameId: string, act: (g: LocalGame) => LocalMove): Promise<MoveResult> {
|
||||||
|
const entry = await this.load(gameId);
|
||||||
|
const seat = entry.game.currentPlayer;
|
||||||
|
const move = act(entry.game);
|
||||||
|
entry.unlockedSeat = null; // the turn has advanced; a hotseat seat re-locks (no effect for vs_ai)
|
||||||
|
const robotMoves = this.runRobots(entry);
|
||||||
|
await this.persist(entry);
|
||||||
|
const result = this.moveResult(entry, move, seat);
|
||||||
|
if (robotMoves.length > 0) {
|
||||||
|
queueMicrotask(() => this.emitRobot(entry, robotMoves));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRobots plays every consecutive robot seat until it is a human's turn or the game ends,
|
||||||
|
// returning the robot moves in order. For a two-player vs_ai game this is a single move.
|
||||||
|
private runRobots(entry: Live): LocalMove[] {
|
||||||
|
const out: LocalMove[] = [];
|
||||||
|
while (!entry.game.isOver && this.isRobotSeat(entry, entry.game.currentPlayer)) {
|
||||||
|
const g = entry.game;
|
||||||
|
const seat = g.currentPlayer;
|
||||||
|
const cands = this.legalMoves(g);
|
||||||
|
const dec = decide(g.seed, g.moveCount, cands, g.scoreOf(seat), this.bestOpponent(g, seat), g.handOf(seat), g.bagLength);
|
||||||
|
if (dec.kind === 'play') out.push(g.play(dec.move.dir, dec.move.tiles));
|
||||||
|
else if (dec.kind === 'exchange' && g.bagLength >= RULESETS[entry.record.variant].rackSize) out.push(g.exchange(dec.exchange));
|
||||||
|
else out.push(g.pass());
|
||||||
|
}
|
||||||
|
// Arm the idle hint gate from the robot's reply: a wall-clock unlock time, persisted so the wait
|
||||||
|
// survives a relaunch. Read back through a sanitiser (lib/hints / stateView) so a clock set back
|
||||||
|
// cannot freeze it.
|
||||||
|
if (out.length > 0) entry.record.hintUnlockAtMs = Date.now() + HINT_GATE_MS;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitRobot(entry: Live, moves: LocalMove[]): void {
|
||||||
|
const set = this.listeners.get(entry.record.id);
|
||||||
|
if (!set) return;
|
||||||
|
for (const m of moves) {
|
||||||
|
const game = this.gameView(entry);
|
||||||
|
const ev: PushEvent = { kind: 'opponent_moved', gameId: entry.record.id, move: this.moveRecord(entry.record.variant, m), game, bagLen: entry.game.bagLength };
|
||||||
|
for (const cb of set) cb(ev);
|
||||||
|
}
|
||||||
|
if (entry.game.isOver) {
|
||||||
|
const game = this.gameView(entry);
|
||||||
|
const ev: PushEvent = { kind: 'game_over', gameId: entry.record.id, result: entry.game.endReason, scoreLine: '', game };
|
||||||
|
for (const cb of set) cb(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persist(entry: Live): Promise<void> {
|
||||||
|
entry.record = serializeGame(entry.game, {
|
||||||
|
id: entry.record.id,
|
||||||
|
seats: entry.record.seats,
|
||||||
|
hotseat: entry.record.hotseat,
|
||||||
|
hostPin: entry.record.hostPin,
|
||||||
|
createdAtUnix: entry.record.createdAtUnix,
|
||||||
|
updatedAtUnix: nowUnix(),
|
||||||
|
hintUnlockAtMs: entry.record.hintUnlockAtMs,
|
||||||
|
});
|
||||||
|
await saveLocalGame(entry.record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRobotSeat(entry: Live, seat: number): boolean {
|
||||||
|
return entry.record.seats[seat]?.kind === 'robot';
|
||||||
|
}
|
||||||
|
|
||||||
|
private humanSeat(entry: Live): number {
|
||||||
|
const i = entry.record.seats.findIndex((s) => s.kind === 'human');
|
||||||
|
return i >= 0 ? i : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bestOpponent(game: LocalGame, seat: number): number {
|
||||||
|
let best = 0;
|
||||||
|
for (let i = 0; i < game.playerCount; i++) if (i !== seat) best = Math.max(best, game.scoreOf(i));
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private legalMoves(game: LocalGame): Move[] {
|
||||||
|
// The generator can emit a one-letter play only for a dictionary that has one-letter words;
|
||||||
|
// real dictionaries have none. Filtering to main length >= 2 keeps the robot to legal plays.
|
||||||
|
return game.generateMoves().filter((m) => m.main.letters.length >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private topMove(game: LocalGame): Move | null {
|
||||||
|
const moves = this.legalMoves(game);
|
||||||
|
return moves.length > 0 ? moves[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- shape builders --------------------------------------------------------
|
||||||
|
|
||||||
|
// The vs_ai idle-hint seconds left, from the stored unlock instant (device wall clock, persisted
|
||||||
|
// for the offline record) minus now — capped at the window so a device clock set back cannot push
|
||||||
|
// it far ahead and freeze the gate, ceiled to whole seconds and floored at 0. The client anchors a
|
||||||
|
// monotonic countdown to this; 0 while open (a human-first opening, no robot move yet).
|
||||||
|
private hintUnlockLeft(entry: Live): number {
|
||||||
|
if (!entry.record.hintUnlockAtMs) return 0;
|
||||||
|
const leftMs = Math.min(HINT_GATE_MS, entry.record.hintUnlockAtMs - Date.now());
|
||||||
|
return leftMs > 0 ? Math.ceil(leftMs / 1000) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// stateView is the seated player's private view. For a vs_ai game that is the single human seat; for
|
||||||
|
// a hotseat game it is the seat to move — and when that seat carries a PIN, the rack is withheld
|
||||||
|
// (empty, locked=true) until unlockSeat is called this turn.
|
||||||
|
private stateView(entry: Live): StateView {
|
||||||
|
const hotseat = !!entry.record.hotseat;
|
||||||
|
const seat = hotseat ? entry.game.currentPlayer : this.humanSeat(entry);
|
||||||
|
// A finished game is never locked — its final rack is shown for review, not an unlock button.
|
||||||
|
const locked = !entry.game.isOver && hotseat && !!entry.record.seats[seat]?.pin && entry.unlockedSeat !== seat;
|
||||||
|
const rack = locked ? [] : entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t));
|
||||||
|
return {
|
||||||
|
game: this.gameView(entry),
|
||||||
|
seat,
|
||||||
|
rack,
|
||||||
|
bagLen: entry.game.bagLength,
|
||||||
|
hintsRemaining: 1,
|
||||||
|
walletBalance: 0,
|
||||||
|
hintUnlockLeftSeconds: this.hintUnlockLeft(entry),
|
||||||
|
locked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline there is no server to send the per-variant alphabet table (letters + tile values) the
|
||||||
|
// rack and board render from (lib/alphabet); seed it from the static offline ruleset so a cold-
|
||||||
|
// booted local game shows real tile weights instead of zeros. Idempotent and cheap — a no-op once
|
||||||
|
// the table is cached (from here or a prior online session).
|
||||||
|
private ensureAlphabet(variant: Variant): void {
|
||||||
|
if (hasAlphabet(variant)) return;
|
||||||
|
const rs = RULESETS[variant];
|
||||||
|
setAlphabet(
|
||||||
|
variant,
|
||||||
|
rs.letters.map((letter, index) => ({ index, letter, value: rs.values[index] })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private gameView(entry: Live): GameView {
|
||||||
|
const { game, record } = entry;
|
||||||
|
this.ensureAlphabet(record.variant);
|
||||||
|
const seats: Seat[] = record.seats.map((s, i) => ({
|
||||||
|
seat: i,
|
||||||
|
accountId: s.accountId ?? `${LOCAL_ID_PREFIX}${s.kind}:${i}`,
|
||||||
|
displayName: s.name,
|
||||||
|
score: game.scoreOf(i),
|
||||||
|
hintsUsed: 0,
|
||||||
|
isWinner: game.isOver && i === game.winnerIndex,
|
||||||
|
resigned: game.resignedOf(i),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
variant: record.variant,
|
||||||
|
dictVersion: record.dictVersion,
|
||||||
|
status: game.isOver ? 'finished' : 'active',
|
||||||
|
players: game.playerCount,
|
||||||
|
toMove: game.currentPlayer,
|
||||||
|
turnTimeoutSecs: 0,
|
||||||
|
multipleWordsPerTurn: record.multipleWords,
|
||||||
|
moveCount: game.moveCount,
|
||||||
|
endReason: game.isOver ? game.endReason : '',
|
||||||
|
lastActivityUnix: record.updatedAtUnix,
|
||||||
|
vsAi: !record.hotseat,
|
||||||
|
hotseat: !!record.hotseat,
|
||||||
|
unreadChat: false,
|
||||||
|
unreadMessages: false,
|
||||||
|
seats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private moveResult(entry: Live, move: LocalMove, seat: number): MoveResult {
|
||||||
|
return {
|
||||||
|
move: this.moveRecord(entry.record.variant, move),
|
||||||
|
game: this.gameView(entry),
|
||||||
|
rack: entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t)),
|
||||||
|
bagLen: entry.game.bagLength,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private moveRecord(variant: Variant, m: LocalMove): MoveRecord {
|
||||||
|
const tiles: Tile[] = (m.tiles ?? []).map((t) => ({ row: t.row, col: t.col, letter: indexToLetter(variant, t.letter), blank: t.blank }));
|
||||||
|
const words = (m.words ?? []).map((w) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase());
|
||||||
|
return {
|
||||||
|
player: m.player,
|
||||||
|
action: m.action,
|
||||||
|
dir: m.dir === undefined ? '' : m.dir === 0 ? 'H' : 'V',
|
||||||
|
mainRow: m.mainRow ?? 0,
|
||||||
|
mainCol: m.mainCol ?? 0,
|
||||||
|
tiles,
|
||||||
|
words,
|
||||||
|
count: m.count ?? 0,
|
||||||
|
score: m.score,
|
||||||
|
total: m.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private moveFromGen(variant: Variant, player: number, gm: Move): MoveRecord {
|
||||||
|
const tiles: Tile[] = gm.tiles.map((t) => ({ row: t.row, col: t.col, letter: indexToLetter(variant, t.letter), blank: t.blank }));
|
||||||
|
const words = [gm.main, ...gm.cross].map((w) => w.letters.map((i) => indexToLetter(variant, i)).join('').toLowerCase());
|
||||||
|
return {
|
||||||
|
player,
|
||||||
|
action: 'play',
|
||||||
|
dir: gm.dir === 0 ? 'H' : 'V',
|
||||||
|
mainRow: gm.main.row,
|
||||||
|
mainCol: gm.main.col,
|
||||||
|
tiles,
|
||||||
|
words,
|
||||||
|
count: 0,
|
||||||
|
score: gm.score,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowUnix(): number {
|
||||||
|
return Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Persistent store for local (offline) games, keyed by game id, in its own IndexedDB database
|
||||||
|
// (separate from the dict blob cache in lib/dict/store.ts and from the session store). Mirrors that
|
||||||
|
// module's shape: everything is best-effort — any failure resolves to null / an empty list / a
|
||||||
|
// no-op, and the caller degrades gracefully (a game that cannot be read is simply absent).
|
||||||
|
//
|
||||||
|
// A record is small (seed + rules + seat metadata + the move journal — see localgame/serialize),
|
||||||
|
// so games are stored whole; the live board/bag/racks are rebuilt by replay on load.
|
||||||
|
|
||||||
|
import type { LocalGameRecord } from './serialize';
|
||||||
|
|
||||||
|
const DB_NAME = 'scrabble-localgames';
|
||||||
|
const STORE = 'games';
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBDatabase> | null | undefined;
|
||||||
|
|
||||||
|
function openDb(): Promise<IDBDatabase> | null {
|
||||||
|
if (dbPromise !== undefined) return dbPromise;
|
||||||
|
if (typeof indexedDB === 'undefined') {
|
||||||
|
dbPromise = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, 1);
|
||||||
|
req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: 'id' });
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
}).catch(() => {
|
||||||
|
dbPromise = null;
|
||||||
|
throw new Error('indexedDB unavailable');
|
||||||
|
});
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** saveLocalGame stores (inserts or replaces) a game record, swallowing any failure (best-effort). */
|
||||||
|
export async function saveLocalGame(record: LocalGameRecord): Promise<void> {
|
||||||
|
const db = openDb();
|
||||||
|
if (!db) return;
|
||||||
|
try {
|
||||||
|
const d = await db;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const tx = d.transaction(STORE, 'readwrite');
|
||||||
|
tx.objectStore(STORE).put(record);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* best-effort: a failed save just loses this snapshot */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** getLocalGame returns the record for id, or null on a miss or any failure. */
|
||||||
|
export async function getLocalGame(id: string): Promise<LocalGameRecord | null> {
|
||||||
|
const db = openDb();
|
||||||
|
if (!db) return null;
|
||||||
|
try {
|
||||||
|
const d = await db;
|
||||||
|
return await new Promise<LocalGameRecord | null>((resolve, reject) => {
|
||||||
|
const r = d.transaction(STORE, 'readonly').objectStore(STORE).get(id);
|
||||||
|
r.onsuccess = () => resolve((r.result ?? null) as LocalGameRecord | null);
|
||||||
|
r.onerror = () => reject(r.error);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** listLocalGames returns every stored game record (unordered), or an empty list on any failure —
|
||||||
|
* the offline lobby sorts and filters them. */
|
||||||
|
export async function listLocalGames(): Promise<LocalGameRecord[]> {
|
||||||
|
const db = openDb();
|
||||||
|
if (!db) return [];
|
||||||
|
try {
|
||||||
|
const d = await db;
|
||||||
|
return await new Promise<LocalGameRecord[]>((resolve) => {
|
||||||
|
const req = d.transaction(STORE, 'readonly').objectStore(STORE).getAll();
|
||||||
|
req.onsuccess = () => resolve((req.result ?? []) as LocalGameRecord[]);
|
||||||
|
req.onerror = () => resolve([]);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** deleteLocalGame removes a game record, swallowing any failure (best-effort). */
|
||||||
|
export async function deleteLocalGame(id: string): Promise<void> {
|
||||||
|
const db = openDb();
|
||||||
|
if (!db) return;
|
||||||
|
try {
|
||||||
|
const d = await db;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const tx = d.transaction(STORE, 'readwrite');
|
||||||
|
tx.objectStore(STORE).delete(id);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => resolve();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
+246
@@ -0,0 +1,246 @@
|
|||||||
|
{
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "out-basic",
|
||||||
|
"variant": "scrabble_en",
|
||||||
|
"reason": "out_of_tiles",
|
||||||
|
"hands": [
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"scores": [
|
||||||
|
50,
|
||||||
|
40
|
||||||
|
],
|
||||||
|
"resigned": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"toMove": 0,
|
||||||
|
"scoresAfter": [
|
||||||
|
57,
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"winner": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "out-blank",
|
||||||
|
"variant": "scrabble_en",
|
||||||
|
"reason": "out_of_tiles",
|
||||||
|
"hands": [
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
255,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"scores": [
|
||||||
|
30,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"resigned": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"toMove": 0,
|
||||||
|
"scoresAfter": [
|
||||||
|
31,
|
||||||
|
29
|
||||||
|
],
|
||||||
|
"winner": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "out-erudit-yo",
|
||||||
|
"variant": "erudit_ru",
|
||||||
|
"reason": "out_of_tiles",
|
||||||
|
"hands": [
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
6,
|
||||||
|
32
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"scores": [
|
||||||
|
10,
|
||||||
|
10
|
||||||
|
],
|
||||||
|
"resigned": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"toMove": 0,
|
||||||
|
"scoresAfter": [
|
||||||
|
13,
|
||||||
|
7
|
||||||
|
],
|
||||||
|
"winner": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "out-tie",
|
||||||
|
"variant": "scrabble_en",
|
||||||
|
"reason": "out_of_tiles",
|
||||||
|
"hands": [
|
||||||
|
[],
|
||||||
|
[]
|
||||||
|
],
|
||||||
|
"scores": [
|
||||||
|
30,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"resigned": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"toMove": 0,
|
||||||
|
"scoresAfter": [
|
||||||
|
30,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"winner": -1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "out-3p",
|
||||||
|
"variant": "scrabble_ru",
|
||||||
|
"reason": "out_of_tiles",
|
||||||
|
"hands": [
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"scores": [
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
10
|
||||||
|
],
|
||||||
|
"resigned": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"toMove": 0,
|
||||||
|
"scoresAfter": [
|
||||||
|
15,
|
||||||
|
6,
|
||||||
|
9
|
||||||
|
],
|
||||||
|
"winner": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "scoreless",
|
||||||
|
"variant": "scrabble_en",
|
||||||
|
"reason": "scoreless",
|
||||||
|
"hands": [
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
2,
|
||||||
|
3
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"scores": [
|
||||||
|
20,
|
||||||
|
20
|
||||||
|
],
|
||||||
|
"resigned": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"toMove": 0,
|
||||||
|
"scoresAfter": [
|
||||||
|
16,
|
||||||
|
15
|
||||||
|
],
|
||||||
|
"winner": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "resign-2p",
|
||||||
|
"variant": "scrabble_en",
|
||||||
|
"reason": "resign",
|
||||||
|
"hands": [
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"scores": [
|
||||||
|
100,
|
||||||
|
10
|
||||||
|
],
|
||||||
|
"resigned": [
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"toMove": 1,
|
||||||
|
"scoresAfter": [
|
||||||
|
100,
|
||||||
|
10
|
||||||
|
],
|
||||||
|
"winner": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "resign-3p",
|
||||||
|
"variant": "scrabble_en",
|
||||||
|
"reason": "resign",
|
||||||
|
"hands": [
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[]
|
||||||
|
],
|
||||||
|
"scores": [
|
||||||
|
50,
|
||||||
|
60,
|
||||||
|
40
|
||||||
|
],
|
||||||
|
"resigned": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"toMove": 0,
|
||||||
|
"scoresAfter": [
|
||||||
|
50,
|
||||||
|
60,
|
||||||
|
40
|
||||||
|
],
|
||||||
|
"winner": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "aborted",
|
||||||
|
"variant": "scrabble_en",
|
||||||
|
"reason": "aborted",
|
||||||
|
"hands": [
|
||||||
|
[
|
||||||
|
0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"scores": [
|
||||||
|
40,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"resigned": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"toMove": 0,
|
||||||
|
"scoresAfter": [
|
||||||
|
40,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"winner": -1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+314
@@ -0,0 +1,314 @@
|
|||||||
|
{
|
||||||
|
"erudit_ru": {
|
||||||
|
"size": 33,
|
||||||
|
"rackSize": 7,
|
||||||
|
"bingo": 15,
|
||||||
|
"blanks": 3,
|
||||||
|
"values": [
|
||||||
|
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
|
||||||
|
],
|
||||||
|
"counts": [
|
||||||
|
10,
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
9,
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
8,
|
||||||
|
4,
|
||||||
|
6,
|
||||||
|
4,
|
||||||
|
5,
|
||||||
|
8,
|
||||||
|
10,
|
||||||
|
6,
|
||||||
|
6,
|
||||||
|
6,
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"letters": [
|
||||||
|
"А",
|
||||||
|
"Б",
|
||||||
|
"В",
|
||||||
|
"Г",
|
||||||
|
"Д",
|
||||||
|
"Е",
|
||||||
|
"Ё",
|
||||||
|
"Ж",
|
||||||
|
"З",
|
||||||
|
"И",
|
||||||
|
"Й",
|
||||||
|
"К",
|
||||||
|
"Л",
|
||||||
|
"М",
|
||||||
|
"Н",
|
||||||
|
"О",
|
||||||
|
"П",
|
||||||
|
"Р",
|
||||||
|
"С",
|
||||||
|
"Т",
|
||||||
|
"У",
|
||||||
|
"Ф",
|
||||||
|
"Х",
|
||||||
|
"Ц",
|
||||||
|
"Ч",
|
||||||
|
"Ш",
|
||||||
|
"Щ",
|
||||||
|
"Ъ",
|
||||||
|
"Ы",
|
||||||
|
"Ь",
|
||||||
|
"Э",
|
||||||
|
"Ю",
|
||||||
|
"Я"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scrabble_en": {
|
||||||
|
"size": 26,
|
||||||
|
"rackSize": 7,
|
||||||
|
"bingo": 50,
|
||||||
|
"blanks": 2,
|
||||||
|
"values": [
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
1,
|
||||||
|
8,
|
||||||
|
5,
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
10,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
8,
|
||||||
|
4,
|
||||||
|
10
|
||||||
|
],
|
||||||
|
"counts": [
|
||||||
|
9,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
12,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
9,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
6,
|
||||||
|
8,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
6,
|
||||||
|
4,
|
||||||
|
6,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"letters": [
|
||||||
|
"A",
|
||||||
|
"B",
|
||||||
|
"C",
|
||||||
|
"D",
|
||||||
|
"E",
|
||||||
|
"F",
|
||||||
|
"G",
|
||||||
|
"H",
|
||||||
|
"I",
|
||||||
|
"J",
|
||||||
|
"K",
|
||||||
|
"L",
|
||||||
|
"M",
|
||||||
|
"N",
|
||||||
|
"O",
|
||||||
|
"P",
|
||||||
|
"Q",
|
||||||
|
"R",
|
||||||
|
"S",
|
||||||
|
"T",
|
||||||
|
"U",
|
||||||
|
"V",
|
||||||
|
"W",
|
||||||
|
"X",
|
||||||
|
"Y",
|
||||||
|
"Z"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scrabble_ru": {
|
||||||
|
"size": 33,
|
||||||
|
"rackSize": 7,
|
||||||
|
"bingo": 50,
|
||||||
|
"blanks": 2,
|
||||||
|
"values": [
|
||||||
|
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
|
||||||
|
],
|
||||||
|
"counts": [
|
||||||
|
8,
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
8,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
5,
|
||||||
|
1,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
10,
|
||||||
|
4,
|
||||||
|
5,
|
||||||
|
5,
|
||||||
|
5,
|
||||||
|
4,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"letters": [
|
||||||
|
"А",
|
||||||
|
"Б",
|
||||||
|
"В",
|
||||||
|
"Г",
|
||||||
|
"Д",
|
||||||
|
"Е",
|
||||||
|
"Ё",
|
||||||
|
"Ж",
|
||||||
|
"З",
|
||||||
|
"И",
|
||||||
|
"Й",
|
||||||
|
"К",
|
||||||
|
"Л",
|
||||||
|
"М",
|
||||||
|
"Н",
|
||||||
|
"О",
|
||||||
|
"П",
|
||||||
|
"Р",
|
||||||
|
"С",
|
||||||
|
"Т",
|
||||||
|
"У",
|
||||||
|
"Ф",
|
||||||
|
"Х",
|
||||||
|
"Ц",
|
||||||
|
"Ч",
|
||||||
|
"Ш",
|
||||||
|
"Щ",
|
||||||
|
"Ъ",
|
||||||
|
"Ы",
|
||||||
|
"Ь",
|
||||||
|
"Э",
|
||||||
|
"Ю",
|
||||||
|
"Я"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -166,9 +166,13 @@ export class MockGateway implements GatewayClient {
|
|||||||
// The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam.
|
// The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam.
|
||||||
return { blocked: false, permanent: false, until: '', reason: '' };
|
return { blocked: false, permanent: false, until: '', reason: '' };
|
||||||
}
|
}
|
||||||
async fetchDict(_variant: Variant, _version: string, _opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> {
|
async fetchDict(variant: Variant, _version: string, opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> {
|
||||||
// No local dictionary in mock mode; the caller falls back to the mock evaluate.
|
// The offline e2e serves the real per-variant dawgs from the preview build's /e2edict/ (copied
|
||||||
throw new Error('fetchDict unsupported in mock');
|
// in by scripts/e2e-dict.mjs from the scrabble-dictionary release, never committed), so a local
|
||||||
|
// vs_ai game can generate real moves. Version is ignored (a single served file per variant).
|
||||||
|
const res = await fetch(`/e2edict/${variant}.dawg`, { signal: opts?.signal });
|
||||||
|
if (!res.ok) throw new Error(`fetchDict ${variant}: HTTP ${res.status}`);
|
||||||
|
return res.arrayBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
async reportLocalEval(_counts: Record<string, number>): Promise<void> {
|
async reportLocalEval(_counts: Record<string, number>): Promise<void> {
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export const PROFILE: Profile = {
|
|||||||
email: 'you@example.com',
|
email: 'you@example.com',
|
||||||
telegramLinked: false,
|
telegramLinked: false,
|
||||||
vkLinked: false,
|
vkLinked: false,
|
||||||
|
// Every variant's current dictionary version, as the backend advertises it on the profile —
|
||||||
|
// the offline preloader targets `variant@version`.
|
||||||
|
dictVersions: { erudit_ru: 'v1.3.0', scrabble_ru: 'v1.3.0', scrabble_en: 'v1.3.0' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
|
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export interface Seat {
|
|||||||
score: number;
|
score: number;
|
||||||
hintsUsed: number;
|
hintsUsed: number;
|
||||||
isWinner: boolean;
|
isWinner: boolean;
|
||||||
|
/** Offline hotseat only: the seat resigned or was excluded by the host. Set by the local source so
|
||||||
|
* the finished-game medal ranking can place it last (no medal); undefined for online seats. */
|
||||||
|
resigned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameView {
|
export interface GameView {
|
||||||
@@ -47,6 +50,10 @@ export interface GameView {
|
|||||||
lastActivityUnix: number;
|
lastActivityUnix: number;
|
||||||
/** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */
|
/** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */
|
||||||
vsAi: boolean;
|
vsAi: boolean;
|
||||||
|
/** true = a local offline pass-and-play (hotseat) game: 2-4 humans share one device. Set only by
|
||||||
|
* the local source; the online gateway never sets it. Drives the per-seat PIN-lock UI and the host
|
||||||
|
* menu (which replaces the hint control). A hotseat game is never vsAi. */
|
||||||
|
hotseat?: boolean;
|
||||||
/** Per-viewer flag: the requesting player has at least one unread chat entry (message or
|
/** Per-viewer flag: the requesting player has at least one unread chat entry (message or
|
||||||
* nudge) in this game. Set on the authoritative REST views (lobby list, game state,
|
* nudge) in this game. Set on the authoritative REST views (lobby list, game state,
|
||||||
* move result); the live-event GameView leaves it false (events bump unread instead). */
|
* move result); the live-event GameView leaves it false (events bump unread instead). */
|
||||||
@@ -82,6 +89,14 @@ export interface StateView {
|
|||||||
bagLen: number;
|
bagLen: number;
|
||||||
hintsRemaining: number;
|
hintsRemaining: number;
|
||||||
walletBalance: number;
|
walletBalance: number;
|
||||||
|
/** For a vs_ai game, the seconds until the idle hint unlocks (computed by the source: the SERVER
|
||||||
|
* clock online, the device clock offline) — 0/undefined while open (the human's first move, or a
|
||||||
|
* non-vs_ai game). The client anchors a MONOTONIC countdown (performance.now()) to it on receipt,
|
||||||
|
* so a client clock change cannot skew it; a relaunch re-fetches a fresh value. See lib/hints. */
|
||||||
|
hintUnlockLeftSeconds?: number;
|
||||||
|
/** Offline hotseat only: the seat to move is PIN-locked, so its rack is withheld (empty) until the
|
||||||
|
* seat's PIN is entered (see LocalSource.unlockSeat). Absent/false everywhere else. */
|
||||||
|
locked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MoveResult {
|
export interface MoveResult {
|
||||||
@@ -161,6 +176,10 @@ export interface Profile {
|
|||||||
/** Whether a Telegram / VK identity is attached — drives the Add / Unlink controls. */
|
/** Whether a Telegram / VK identity is attached — drives the Add / Unlink controls. */
|
||||||
telegramLinked: boolean;
|
telegramLinked: boolean;
|
||||||
vkLinked: boolean;
|
vkLinked: boolean;
|
||||||
|
/** Current dictionary version per game variant (deployment-wide, not per-account). An
|
||||||
|
* offline-capable PWA reads it to preload the matching dawg for each enabled variant off
|
||||||
|
* this cold-start response. Empty only in a degenerate deployment with no resident dictionary. */
|
||||||
|
dictVersions: Partial<Record<Variant, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Banner is the advertising-banner block of an eligible viewer's profile. */
|
/** Banner is the advertising-banner block of an eligible viewer's profile. */
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// The deliberate offline MODE: a sticky, device-scoped reactive flag the app reads to gate the
|
||||||
|
// network, tint the chrome blue and show only local games. It is the player's own choice (the
|
||||||
|
// Settings toggle is the source of truth), distinct from connection.svelte.ts's transient
|
||||||
|
// gateway-reachability signal. The pure persistence + readiness logic lives in offline.ts.
|
||||||
|
|
||||||
|
import { loadOfflinePref, saveOfflinePref, offlinePreloadEligible } from './offline';
|
||||||
|
import { isStandalone } from './pwa';
|
||||||
|
import { insideTelegram } from './telegram';
|
||||||
|
import { insideVK } from './vk';
|
||||||
|
import type { Profile } from './model';
|
||||||
|
|
||||||
|
// Not named `state` (a svelte-check hazard: `$state` would then read as a store subscription).
|
||||||
|
let active = $state(loadOfflinePref());
|
||||||
|
// True while the current offline state was entered automatically (no network detected), not chosen
|
||||||
|
// by the player. An auto offline self-heals to online when the network returns; a deliberate one is
|
||||||
|
// left as the player's choice.
|
||||||
|
let auto = $state(false);
|
||||||
|
|
||||||
|
/** offlineMode exposes the reactive offline flags; read them in markup / $derived. */
|
||||||
|
export const offlineMode = {
|
||||||
|
/** active is true while the app is in offline mode (deliberate or auto-detected). */
|
||||||
|
get active(): boolean {
|
||||||
|
return active;
|
||||||
|
},
|
||||||
|
/** auto is true while offline was entered automatically (no network), not by the player. */
|
||||||
|
get auto(): boolean {
|
||||||
|
return auto;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* setOfflineMode enters or leaves offline mode. By default it persists the choice (device-scoped) —
|
||||||
|
* a deliberate choice (the Settings toggle, or the cold-start "no connection" dialog). Pass
|
||||||
|
* persist=false for a transient, auto-detected offline (a cold start with no network interface): the
|
||||||
|
* flag holds for the session but is not saved, so the next launch re-evaluates the network.
|
||||||
|
*/
|
||||||
|
export function setOfflineMode(on: boolean, persist = true): void {
|
||||||
|
active = on;
|
||||||
|
auto = on && !persist; // a non-persisted offline is auto-detected; a persisted one is deliberate
|
||||||
|
if (persist) saveOfflinePref(on);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The toggle-flip readiness wait: entering offline waits at most this long for the enabled
|
||||||
|
* variants' dictionaries before reverting to online (the fetch then continues in the background). */
|
||||||
|
export const TOGGLE_READY_BUDGET_MS = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* requestOffline attempts to enter offline mode from the Settings toggle. It fetches the enabled
|
||||||
|
* variants' dictionaries cache-first and, if every one is available within the readiness budget,
|
||||||
|
* switches to a deliberate (persisted) offline mode and returns true. Otherwise it stays online and
|
||||||
|
* returns false — the caller shows the "needs internet" note — while the fetch keeps warming the
|
||||||
|
* cache so a later flip is instant. The readiness glue and the dict loader are imported dynamically,
|
||||||
|
* so neither is pulled into the main bundle. Returns false with no profile.
|
||||||
|
*/
|
||||||
|
export async function requestOffline(prof: Profile | null, budgetMs = TOGGLE_READY_BUDGET_MS): Promise<boolean> {
|
||||||
|
if (!prof) return false;
|
||||||
|
const m = await import('./dict/offlineready');
|
||||||
|
const ready = await m.ensureOfflineDicts(prof, budgetMs);
|
||||||
|
if (ready) setOfflineMode(true);
|
||||||
|
return ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dict-preload warning: true when a first-lobby background preload could not fetch every
|
||||||
|
// enabled variant's dictionary (typically a poor connection), so offline mode may be incomplete.
|
||||||
|
// The lobby shows a notice in place of the ad banner while it holds.
|
||||||
|
let preloadWarn = $state(false);
|
||||||
|
|
||||||
|
/** dictPreloadWarning exposes the reactive preload-failure flag; read it in markup / $derived. */
|
||||||
|
export const dictPreloadWarning = {
|
||||||
|
/** active is true while a first-lobby dictionary preload has left a variant unavailable. */
|
||||||
|
get active(): boolean {
|
||||||
|
return preloadWarn;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** setDictPreloadWarning raises or clears the in-lobby preload-failure notice. */
|
||||||
|
export function setDictPreloadWarning(on: boolean): void {
|
||||||
|
preloadWarn = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A preload runs at most once at a time; it finishes before a fresh trigger (a repeated lobby
|
||||||
|
// mount or a variant-preference change) can start another. Module-scoped so mounts do not stack.
|
||||||
|
let preloadInFlight = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* kickDictPreload starts a background preload of the enabled variants' dictionaries for an
|
||||||
|
* offline-capable install (a standalone web PWA with a confirmed email) while online, so a later
|
||||||
|
* switch to offline mode already has the data. It is a no-op in a Telegram/VK mini-app, in a plain
|
||||||
|
* browser tab, without a confirmed email, while offline, or when a preload is already running;
|
||||||
|
* getDawg's caching makes a repeat run cheap. When warnOnFail is set (the first lobby entry), a
|
||||||
|
* fetch failure raises the in-lobby notice, and a later successful run clears it. The dict loader
|
||||||
|
* and generator are imported dynamically, so neither is pulled into the main bundle.
|
||||||
|
*/
|
||||||
|
export function kickDictPreload(prof: Profile | null, warnOnFail = false): void {
|
||||||
|
if (preloadInFlight || !prof) return;
|
||||||
|
const eligible = offlinePreloadEligible({
|
||||||
|
hasEmail: !!prof.email,
|
||||||
|
standalone: isStandalone(),
|
||||||
|
inTelegram: insideTelegram(),
|
||||||
|
inVK: insideVK(),
|
||||||
|
online: typeof navigator === 'undefined' || navigator.onLine !== false,
|
||||||
|
});
|
||||||
|
if (!eligible) return;
|
||||||
|
preloadInFlight = true;
|
||||||
|
void import('./dict/preloadrun')
|
||||||
|
.then((m) => m.runPreload(prof, warnOnFail))
|
||||||
|
.catch(() => {
|
||||||
|
/* best-effort warmup — a failed dynamic import just leaves offline data unprimed */
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
preloadInFlight = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline, raceOfflineReady } from './offline';
|
||||||
|
import type { Variant } from './model';
|
||||||
|
|
||||||
|
// A minimal in-memory localStorage for the persistence tests (node has none).
|
||||||
|
beforeEach(() => {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
(globalThis as unknown as { localStorage: Storage }).localStorage = {
|
||||||
|
getItem: (k: string) => store.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void store.set(k, v),
|
||||||
|
removeItem: (k: string) => void store.delete(k),
|
||||||
|
clear: () => store.clear(),
|
||||||
|
key: () => null,
|
||||||
|
length: 0,
|
||||||
|
} as Storage;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('offline mode helpers', () => {
|
||||||
|
it('persists and reads the device-scoped offline flag', () => {
|
||||||
|
expect(loadOfflinePref()).toBe(false);
|
||||||
|
saveOfflinePref(true);
|
||||||
|
expect(loadOfflinePref()).toBe(true);
|
||||||
|
saveOfflinePref(false);
|
||||||
|
expect(loadOfflinePref()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offlineReady requires every enabled variant to have a dictionary', () => {
|
||||||
|
const has = (v: Variant): boolean => v !== 'erudit_ru';
|
||||||
|
expect(offlineReady(['scrabble_en', 'scrabble_ru'], has)).toBe(true);
|
||||||
|
expect(offlineReady(['scrabble_en', 'erudit_ru'], has)).toBe(false);
|
||||||
|
expect(offlineReady([], has)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('missingDicts lists the enabled variants without a dictionary', () => {
|
||||||
|
const has = (v: Variant): boolean => v === 'scrabble_en';
|
||||||
|
expect(missingDicts(['scrabble_en', 'scrabble_ru', 'erudit_ru'], has)).toEqual(['scrabble_ru', 'erudit_ru']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offlinePreloadEligible requires a standalone PWA with email, online, outside mini-apps', () => {
|
||||||
|
const base = { hasEmail: true, standalone: true, inTelegram: false, inVK: false, online: true };
|
||||||
|
expect(offlinePreloadEligible(base)).toBe(true);
|
||||||
|
expect(offlinePreloadEligible({ ...base, hasEmail: false })).toBe(false);
|
||||||
|
expect(offlinePreloadEligible({ ...base, standalone: false })).toBe(false);
|
||||||
|
expect(offlinePreloadEligible({ ...base, inTelegram: true })).toBe(false);
|
||||||
|
expect(offlinePreloadEligible({ ...base, inVK: true })).toBe(false);
|
||||||
|
expect(offlinePreloadEligible({ ...base, online: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shouldBootOffline requires the offline flag plus a cached session and profile', () => {
|
||||||
|
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: true })).toBe(true);
|
||||||
|
expect(shouldBootOffline({ offlineActive: false, hasSession: true, hasProfile: true })).toBe(false);
|
||||||
|
expect(shouldBootOffline({ offlineActive: true, hasSession: false, hasProfile: true })).toBe(false);
|
||||||
|
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: false })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('raceOfflineReady (toggle-flip readiness wait)', () => {
|
||||||
|
// A sleep that never elapses: the fetch always wins the race, exercising its resolution.
|
||||||
|
const never = (): Promise<void> => new Promise<void>(() => {});
|
||||||
|
// A sleep that elapses immediately: the budget always wins the race, exercising the timeout.
|
||||||
|
const now = (): Promise<void> => Promise.resolve();
|
||||||
|
|
||||||
|
it('is ready when the fetch resolves with nothing failed before the budget', async () => {
|
||||||
|
const run = Promise.resolve({ failed: [] as Variant[] });
|
||||||
|
expect(await raceOfflineReady(run, 5000, never)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not ready when a variant is still missing after the fetch', async () => {
|
||||||
|
const run = Promise.resolve({ failed: ['erudit_ru'] as Variant[] });
|
||||||
|
expect(await raceOfflineReady(run, 5000, never)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not ready when the budget elapses before the fetch resolves', async () => {
|
||||||
|
const run = new Promise<{ failed: Variant[] }>(() => {}); // never resolves
|
||||||
|
expect(await raceOfflineReady(run, 5000, now)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Pure helpers for the deliberate offline MODE — its device-scoped persistence and the readiness
|
||||||
|
// decision — kept out of the reactive module (offline.svelte.ts) so they unit-test in the node env.
|
||||||
|
// The deliberate offline mode is distinct from connection.svelte.ts's transient "can we reach the
|
||||||
|
// gateway" signal: it is the player's own sticky choice, and it gates the network, tints the chrome
|
||||||
|
// and shows only local games.
|
||||||
|
|
||||||
|
import type { Variant } from './model';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'scrabble.offlineMode';
|
||||||
|
|
||||||
|
/** loadOfflinePref reads the persisted offline-mode flag (device-scoped); false when unset or when
|
||||||
|
* storage is unavailable, so a device that cannot persist simply starts online. */
|
||||||
|
export function loadOfflinePref(): boolean {
|
||||||
|
try {
|
||||||
|
return typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** saveOfflinePref persists the offline-mode flag (best-effort). */
|
||||||
|
export function saveOfflinePref(on: boolean): void {
|
||||||
|
try {
|
||||||
|
if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, on ? '1' : '0');
|
||||||
|
} catch {
|
||||||
|
/* best-effort — a failed persist just reverts to online on the next launch */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* offlineReady reports whether the device can play offline right now: at least one variant is
|
||||||
|
* enabled and every enabled variant's dictionary is already available on the device (hasDict). The
|
||||||
|
* offline toggle uses it to decide whether flipping to offline can succeed immediately.
|
||||||
|
*/
|
||||||
|
export function offlineReady(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): boolean {
|
||||||
|
return enabled.length > 0 && enabled.every((v) => hasDict(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** missingDicts lists the enabled variants whose dictionary is not yet available — the ones the
|
||||||
|
* toggle must fetch (or wait on) before offline mode can be entered. */
|
||||||
|
export function missingDicts(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): Variant[] {
|
||||||
|
return enabled.filter((v) => !hasDict(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* raceOfflineReady runs the dictionary fetch `run` against a `budgetMs` wait and reports whether
|
||||||
|
* offline mode can be entered now: ready only when the fetch resolves with nothing still failed
|
||||||
|
* before the budget elapses. On a timeout the caller stops waiting but does NOT abort `run` — it
|
||||||
|
* keeps warming the on-device cache so a later flip to offline is instant. The sleep is injected so
|
||||||
|
* the logic stays pure and unit-tests in the node env.
|
||||||
|
*/
|
||||||
|
export async function raceOfflineReady(
|
||||||
|
run: Promise<{ failed: readonly unknown[] }>,
|
||||||
|
budgetMs: number,
|
||||||
|
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
|
||||||
|
): Promise<boolean> {
|
||||||
|
const elapsed = sleep(budgetMs).then(() => null);
|
||||||
|
const res = await Promise.race([run, elapsed]);
|
||||||
|
return res !== null && res.failed.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* offlinePreloadEligible reports whether a background dictionary preload should run in this
|
||||||
|
* context: an installed standalone web PWA (not a Telegram/VK mini-app, not a plain browser tab)
|
||||||
|
* with a confirmed email, currently online. Elsewhere the preload is wasted bandwidth — the context
|
||||||
|
* has no offline mode to prepare for — so kickDictPreload skips it. Mirrors the Settings offline
|
||||||
|
* toggle's eligibility so the two never disagree.
|
||||||
|
*/
|
||||||
|
export function offlinePreloadEligible(opts: {
|
||||||
|
hasEmail: boolean;
|
||||||
|
standalone: boolean;
|
||||||
|
inTelegram: boolean;
|
||||||
|
inVK: boolean;
|
||||||
|
online: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return opts.hasEmail && opts.standalone && !opts.inTelegram && !opts.inVK && opts.online;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* shouldBootOffline decides whether a cold start skips the network and launches straight into
|
||||||
|
* offline mode: the deliberate offline flag is persisted-on AND a prior online session left a cached
|
||||||
|
* session and profile to start from. Without both (e.g. cleared storage, or a never-online install),
|
||||||
|
* the app must boot online once first — the caller then drops the sticky flag and continues online.
|
||||||
|
*/
|
||||||
|
export function shouldBootOffline(opts: { offlineActive: boolean; hasSession: boolean; hasProfile: boolean }): boolean {
|
||||||
|
return opts.offlineActive && opts.hasSession && opts.hasProfile;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { hashPin, newLock, newSalt, verifyPin } from './pin';
|
||||||
|
|
||||||
|
describe('hashPin', () => {
|
||||||
|
it('is deterministic for the same pin and salt', async () => {
|
||||||
|
const salt = 'fixedsalt';
|
||||||
|
expect(await hashPin('1234', salt)).toBe(await hashPin('1234', salt));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('differs when the salt differs', async () => {
|
||||||
|
expect(await hashPin('1234', 'saltA')).not.toBe(await hashPin('1234', 'saltB'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('differs when the pin differs', async () => {
|
||||||
|
const salt = 'fixedsalt';
|
||||||
|
expect(await hashPin('1234', salt)).not.toBe(await hashPin('4321', salt));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('newSalt', () => {
|
||||||
|
it('returns a non-empty value', () => {
|
||||||
|
expect(newSalt().length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a fresh value each call', () => {
|
||||||
|
expect(newSalt()).not.toBe(newSalt());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('newLock / verifyPin', () => {
|
||||||
|
it('produces a lock that verifies its own pin', async () => {
|
||||||
|
const lock = await newLock('1234');
|
||||||
|
expect(lock.hash.length).toBeGreaterThan(0);
|
||||||
|
expect(lock.salt.length).toBeGreaterThan(0);
|
||||||
|
expect(await verifyPin('1234', lock)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a wrong pin', async () => {
|
||||||
|
const lock = await newLock('1234');
|
||||||
|
expect(await verifyPin('0000', lock)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('salts each lock independently (same pin, different hashes)', async () => {
|
||||||
|
const a = await newLock('1234');
|
||||||
|
const b = await newLock('1234');
|
||||||
|
expect(a.salt).not.toBe(b.salt);
|
||||||
|
expect(a.hash).not.toBe(b.hash);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user