Compare commits

..

18 Commits

Author SHA1 Message Date
developer a9d0986e74 Merge pull request 'Release v1.10.0: banner colours + urgent, and 3 UI fixes' (#183) from development into master 2026-07-05 14:10:25 +00:00
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
114 changed files with 160 additions and 21717 deletions
-2
View File
@@ -252,7 +252,6 @@ jobs:
run: |
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/movegen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out "${GITHUB_WORKSPACE}/movegold"
- name: Set up Node
uses: actions/setup-node@v4
@@ -272,7 +271,6 @@ jobs:
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
DICT_GOLD_DIR: /tmp/dictgold
DICT_VALID_DIR: /tmp/validgold
DICT_MOVEGEN_DIR: ${{ github.workspace }}/movegold
run: pnpm exec vitest run src/lib/dict/
# gate is the single branch-protection required check. It always runs and passes
+6 -3
View File
@@ -12,6 +12,7 @@ import (
"fmt"
"log"
"os/signal"
"strings"
"syscall"
"time"
@@ -215,9 +216,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
// Operator alert emails on new feedback / word complaints, coalesced into one digest
// per interval. Inert unless a distinct admin sender and recipient are configured.
if cfg.SMTP.AdminFrom != "" && cfg.SMTP.AdminTo != "" {
// The alert digest carries no admin-console link on purpose — an admin URL must not
// travel in an email (a mail provider could cache or index it); see adminalert.New.
alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, logger)
consoleURL := ""
if cfg.PublicBaseURL != "" {
consoleURL = strings.TrimRight(cfg.PublicBaseURL, "/") + "/_gm"
}
alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, consoleURL, logger)
go alerts.Run(ctx, adminAlertInterval)
logger.Info("admin alert worker started", zap.Duration("interval", adminAlertInterval))
}
-417
View File
@@ -1,417 +0,0 @@
// 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)
}
}
+11 -16
View File
@@ -105,10 +105,9 @@ func (s *EmailService) allowSend(email string) bool {
// issueCode generates a fresh confirm-code and one-tap deeplink token for (accountID,
// email), replaces any prior pending confirmation, and mails the branded code in
// locale; purpose selects the email wording and what verifying does. omitLink drops the
// one-tap deeplink from the email (account deletion, or a login requested from an installed
// PWA). Only the SHA-256 hashes of the code and token are stored.
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string, omitLink bool) error {
// locale; purpose selects the email wording and what verifying does. Only the SHA-256
// hashes of the code and token are stored.
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string) error {
code, codeHash, err := generateCode()
if err != nil {
return err
@@ -120,12 +119,11 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
return err
}
// Omit the one-tap deeplink when asked: for a login from an installed PWA (the link would
// open in a separate browser whose minted session cannot reach the PWA), or for account
// deletion (a prefetch or stray click must not delete an account — the delete code is entered
// in the app only, and ConfirmByToken refuses a delete token).
// Account deletion is never a one-tap link (a prefetch or a stray click must not delete
// an account): the delete code is entered in the app only, so the email omits the
// deeplink and ConfirmByToken refuses a delete token.
deeplink := s.confirmURL(token, locale)
if purpose == purposeDelete || omitLink {
if purpose == purposeDelete {
deeplink = ""
}
msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale)
@@ -177,7 +175,7 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
}
return ErrEmailTaken
}
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false)
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
}
// ConfirmCode verifies code for accountID and email. On success it attaches a
@@ -223,11 +221,8 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
// the ordinary returning-user login. The code is mailed to the address, so only its
// real owner can complete the login. On first contact browserTZ (the client's
// detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI
// language. When pwa is set (the request came from an installed PWA) the login email omits the
// one-tap confirm link — it would open in a separate browser, out of the PWA's reach — so the
// code is entered in the same window. It returns the target account id for the subsequent
// LoginWithCode.
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string, pwa bool) (uuid.UUID, error) {
// language. It returns the target account id for the subsequent LoginWithCode.
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string) (uuid.UUID, error) {
addr, err := normalizeEmail(email)
if err != nil {
return uuid.UUID{}, err
@@ -239,7 +234,7 @@ func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, l
if err != nil {
return uuid.UUID{}, err
}
if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language, pwa); err != nil {
if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language); err != nil {
return uuid.UUID{}, err
}
return acc.ID, nil
+3 -3
View File
@@ -92,7 +92,7 @@ func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID,
if !s.allowSend(addr) {
return ErrTooManyRequests
}
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false)
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
}
// ConfirmLink verifies code for (accountID, email) and reports the address's
@@ -140,7 +140,7 @@ func (s *EmailService) RequestChangeCode(ctx context.Context, accountID uuid.UUI
if !s.allowSend(addr) {
return ErrTooManyRequests
}
return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID), false)
return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID))
}
// ConfirmChange verifies code for (accountID, newEmail) and atomically replaces the
@@ -199,7 +199,7 @@ func (s *EmailService) RequestDeleteCode(ctx context.Context, accountID uuid.UUI
if !s.allowSend(addr) {
return ErrTooManyRequests
}
return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID), false)
return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID))
}
// VerifyDeleteCode verifies the account-deletion code against the account's own email and
+9 -8
View File
@@ -33,21 +33,21 @@ type Notifier struct {
complaints ComplaintCounter
from string
to string
consoleURL string
clock func() time.Time
log *zap.Logger
last time.Time
}
// New constructs a Notifier. from and to are the alert sender and recipient(s); log may be
// nil. The watermark starts at "now", so only items arriving after start-up are reported. The
// digest deliberately carries no admin-console link — an admin URL must never travel in an
// email, where a mail provider could cache or index it.
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to string, log *zap.Logger) *Notifier {
// New constructs a Notifier. from and to are the alert sender and recipient(s); consoleURL,
// when non-empty, is the admin-console link included in the email. log may be nil. The
// watermark starts at "now", so only items arriving after start-up are reported.
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to, consoleURL string, log *zap.Logger) *Notifier {
if log == nil {
log = zap.NewNop()
}
return &Notifier{
mailer: mailer, feedback: fb, complaints: cp, from: from, to: to,
mailer: mailer, feedback: fb, complaints: cp, from: from, to: to, consoleURL: consoleURL,
clock: func() time.Time { return time.Now().UTC() }, log: log, last: time.Now().UTC(),
}
}
@@ -103,9 +103,10 @@ func (n *Notifier) digest(fb, cp int) account.Message {
parts = append(parts, fmt.Sprintf("%d new word complaint(s)", cp))
}
summary := strings.Join(parts, ", ")
// No admin-console link in the body: an admin URL must never travel in an email (a mail
// provider could cache or index it). The operator opens the console directly.
text := summary + "."
if n.consoleURL != "" {
text += "\n\nOpen the admin console: " + n.consoleURL
}
return account.Message{
From: n.from,
To: n.to,
+4 -6
View File
@@ -28,7 +28,7 @@ func (m *recordingMailer) Send(_ context.Context, msg account.Message) error {
func TestNotifierSkipsWhenNothingNew(t *testing.T) {
mailer := &recordingMailer{}
n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", nil)
n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", "", nil)
n.tick(context.Background())
if len(mailer.sent) != 0 {
t.Fatalf("sent %d emails, want 0 when nothing is new", len(mailer.sent))
@@ -37,7 +37,7 @@ func TestNotifierSkipsWhenNothingNew(t *testing.T) {
func TestNotifierDigestsNewItems(t *testing.T) {
mailer := &recordingMailer{}
n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", nil)
n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", "https://erudit-game.ru/_gm", nil)
n.tick(context.Background())
if len(mailer.sent) != 1 {
t.Fatalf("sent %d emails, want 1 digest", len(mailer.sent))
@@ -49,9 +49,7 @@ func TestNotifierDigestsNewItems(t *testing.T) {
if !strings.Contains(msg.Subject, "2 new feedback") || !strings.Contains(msg.Subject, "1 new word complaint") {
t.Errorf("digest subject = %q, want the feedback + complaint counts", msg.Subject)
}
// The digest must never carry an admin-console link — an admin URL in an email is a leak
// (mail providers cache/index it).
if strings.Contains(msg.Text, "/_gm") || strings.Contains(strings.ToLower(msg.Text), "admin console") {
t.Errorf("digest body = %q, must not carry an admin-console link", msg.Text)
if !strings.Contains(msg.Text, "/_gm") {
t.Errorf("digest body = %q, want the console link", msg.Text)
}
}
-123
View File
@@ -1,123 +0,0 @@
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))
}
+5 -33
View File
@@ -206,7 +206,7 @@ func TestEmailLoginFlow(t *testing.T) {
svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
email := "login-" + uuid.NewString() + "@example.com"
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en", false)
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en")
if err != nil {
t.Fatalf("request login code: %v", err)
}
@@ -233,7 +233,7 @@ func TestEmailLoginFlow(t *testing.T) {
}
// A second login for the same email is the returning user: same account.
if _, err := svc.RequestLoginCode(ctx, email, "", "ru", false); err != nil {
if _, err := svc.RequestLoginCode(ctx, email, "", "ru"); err != nil {
t.Fatalf("second request: %v", err)
}
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
@@ -255,7 +255,7 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) {
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
email := "squat-" + uuid.NewString() + "@example.com"
id, err := svc.RequestLoginCode(ctx, email, "", "en", false)
id, err := svc.RequestLoginCode(ctx, email, "", "en")
if err != nil {
t.Fatalf("request login code: %v", err)
}
@@ -279,34 +279,6 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) {
}
}
// TestEmailLoginPWAOmitsLink: a login requested from an installed PWA (pwa=true) mails only the
// code — no one-tap confirm link, which would otherwise open in a separate browser out of the
// PWA's reach. A non-PWA request keeps the link.
func TestEmailLoginPWAOmitsLink(t *testing.T) {
ctx := context.Background()
mailer := &capturingMailer{}
svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
pwaEmail := "pwa-login-" + uuid.NewString() + "@example.com"
if _, err := svc.RequestLoginCode(ctx, pwaEmail, "", "en", true); err != nil {
t.Fatalf("pwa request login: %v", err)
}
if sixDigit.FindString(mailer.lastBody) == "" {
t.Errorf("pwa login mail must still carry the code, body %q", mailer.lastBody)
}
if confirmToken.MatchString(mailer.lastBody) {
t.Errorf("pwa login mail must omit the one-tap confirm link, body %q", mailer.lastBody)
}
webEmail := "web-login-" + uuid.NewString() + "@example.com"
if _, err := svc.RequestLoginCode(ctx, webEmail, "", "en", false); err != nil {
t.Fatalf("web request login: %v", err)
}
if !confirmToken.MatchString(mailer.lastBody) {
t.Errorf("non-pwa login mail must keep the one-tap confirm link, body %q", mailer.lastBody)
}
}
// confirmToken extracts the one-tap deeplink token from the /app/#/confirm/<token> link
// the branded email carries.
var confirmToken = regexp.MustCompile(`/confirm/([A-Za-z0-9_-]+)`)
@@ -329,7 +301,7 @@ func TestConfirmByTokenLogin(t *testing.T) {
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
email := "tok-login-" + uuid.NewString() + "@example.com"
id, err := svc.RequestLoginCode(ctx, email, "", "en", false)
id, err := svc.RequestLoginCode(ctx, email, "", "en")
if err != nil {
t.Fatalf("request login: %v", err)
}
@@ -410,7 +382,7 @@ func TestEmailAccountSeedsDisplayName(t *testing.T) {
svc := account.NewEmailService(store, &capturingMailer{}, "https://erudit-game.ru")
local := "kaya-" + uuid.NewString()[:8]
id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en", false)
id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en")
if err != nil {
t.Fatalf("request login: %v", err)
}
@@ -1,146 +0,0 @@
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))
}
-26
View File
@@ -8,7 +8,6 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/ads"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/notify"
)
@@ -57,34 +56,9 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi
r := profileResponseFor(acc)
r.Banner = s.bannerFor(ctx, acc)
s.fillLinkedIdentities(ctx, &r, acc.ID)
r.DictVersions = s.currentDictVersions()
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
// 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
-14
View File
@@ -63,20 +63,6 @@ type profileResponse struct {
Email string `json:"email"`
TelegramLinked bool `json:"telegram_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.
+1 -5
View File
@@ -174,10 +174,6 @@ type emailRequest struct {
Email string `json:"email"`
BrowserTZ string `json:"browser_tz"`
Language string `json:"language"`
// Pwa marks a request from an installed PWA (standalone display mode): the login email then
// omits the one-tap confirm link so the code is typed in the same window (the link would open
// in a separate browser whose session cannot reach the PWA). See EmailService.RequestLoginCode.
Pwa bool `json:"pwa"`
}
// handleEmailRequest issues a login confirm-code to the email. It always reports
@@ -189,7 +185,7 @@ func (s *Server) handleEmailRequest(c *gin.Context) {
abortBadRequest(c, "email is required")
return
}
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ, req.Language, req.Pwa); err != nil {
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ, req.Language); err != nil {
s.abortErr(c, err)
return
}
+3 -34
View File
@@ -244,9 +244,7 @@ arrive from a platform rather than completing a mandatory registration).
256-bit token stored only as its SHA-256, 12-hour TTL): a login mints a session in the
browser that opens it (magic-link), a link confirms the identity and emits a `notify`
profile-refresh to the in-app session, a change switches the account's email in place,
and a would-be merge is deferred to the interactive flow. A login requested from an installed **PWA** omits the deeplink
entirely — its email carries the code only, typed in the same window, since the link would open
in a separate browser whose minted session cannot reach the PWA. The confirm runs on load; the token rides the URL fragment (never
and a would-be merge is deferred to the interactive flow. The confirm runs on load; the token rides the URL fragment (never
sent to the server), so a plain link prefetch cannot reach it, and the manual
six-digit code is the fallback if an aggressive scanner runs the page. An
**email-login** account is created flagged `is_guest` and stays reapable until the
@@ -1043,9 +1041,7 @@ link — misses the event; while an add-email confirmation is pending the client
(`internal/adminalert`, started from `main`) emails the operator (`ADMIN_EMAIL`, from a
distinct `SMTP_RELAY_ADMIN_FROM`) when new player feedback or word complaints arrive,
**coalescing** a burst into one digest per interval; both recipients may be several
comma-separated addresses. The digest carries **no admin-console link** — an admin URL must
never travel in an email, where a mail provider could cache or index it; the operator opens the
console directly. Both paths are inert unless configured.
comma-separated addresses. Both paths are inert unless configured.
- Per-request server-side timing via gin middleware from day one (the access log
carries method, route, status, latency and the active trace id). A
client-measured RTT piggybacked on the next request is a later enhancement.
@@ -1237,34 +1233,7 @@ origin, so the test contour canonicalises to production instead of being indexed
separate site; the SPA shell is `noindex`, and the landing always boots in Russian (no
browser-language detection — crawlers render with arbitrary languages). The favicon set,
`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**:
`manifest.webmanifest` ships unhashed from `ui/public/`, while the **service worker** (`sw.js`) is
built from `ui/src/sw.ts` by **vite-plugin-pwa** (injectManifest strategy) — the client registers it
**web-only**, never inside a Mini App or the mock build (the plugin is disabled there entirely). It
**precaches the app shell and the hashed assets** (Workbox) so an installed PWA cold-launches with
no network, and falls every in-scope navigation back to the precached shell (the hash router
resolves the route client-side); 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 friend/random options in New Game) are disabled
or hidden. 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. 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. The gateway registers the `.webmanifest` MIME type
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
`assets/icons/`, same tile design as the VK loader). Hash-named `/assets/*` are served
`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
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
+2 -22
View File
@@ -21,8 +21,7 @@ Settings also pick the board's bonus-label style (beginner / classic / none). A
costs nothing when the rack has no legal move. The word-check accepts only the
variant's alphabet, remembers answers within the session and rate-limits repeats.
A public **landing page** at the site root introduces the game, switches language and
theme, and links to the Telegram game channel, the VK Mini App and the **web version**
(`/app/`) — each under a caption (Telegram / VK / Веб-версия); the game itself runs at
theme, and links to the Telegram game channel and the VK Mini App; the game itself runs at
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
(it follows the system scheme, not the saved preference); its language choice is saved. The
landing always opens in **Russian** — the browser language is never auto-detected (crawlers
@@ -32,11 +31,6 @@ nondeterministic); a saved 🌐 choice still wins. The page carries a static Rus
pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA
shell is `noindex` — the landing is the only indexable page.
On the plain web the client is an **installable PWA**: a logged-out player sees an install
call-to-action under the login form (and at the bottom of Settings) that installs the app to the
home screen / desktop in one tap where the browser supports it (Chromium), or shows manual
Add-to-Home-Screen steps on iOS Safari; it is hidden once installed and inside the Mini Apps.
### First-run onboarding
The first time a player opens the app it walks them through the interface with a light
**coachmark overlay**: a dimmed layer draws one hint bubble at a time, each pointing — with a
@@ -76,9 +70,7 @@ per address), so a mistyped address or an impatient tap cannot flood an inbox. T
also carries a **one-tap link** that confirms the address — or signs the player straight
in — in a single tap; opening it in another browser reflects in the app at once, and a
mail scanner that merely fetches the link cannot reach it — the token rides the URL
fragment, which is never sent to the server. A sign-in requested from an **installed PWA** omits
this link: the email carries only the code, entered in the same window, since the link would open
in a separate browser the app cannot reach.
fragment, which is never sent to the server.
Telegram runs a **single bot**: every player uses
the same bot, and all of its chat and out-of-app notifications are written in the
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
@@ -256,18 +248,6 @@ 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
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 *with friends* and *random opponent* options in New Game).
Only **vs_ai** games are playable — New Game creates a device-local game against the robot, which
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. The mode is device-scoped and
sticky across launches.
### Social: friends, block, chat, nudge
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
+2 -22
View File
@@ -22,8 +22,7 @@ top-1 подсказку, безлимитную проверку слова с
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
и ограничивает частоту повторов.
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
тему и ведёт в Telegram-канал игры, в VK Mini App и в **веб-версию** (`/app/`) — каждая под
подписью (Telegram / VK / Веб-версия); сама игра живёт по адресам
тему и ведёт в Telegram-канал игры и в VK Mini App; сама игра живёт по адресам
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
системной настройки, а не из сохранённой), выбор языка сохраняется. Страница всегда
открывается на **русском** — язык браузера не определяется автоматически (роботы
@@ -34,11 +33,6 @@ Open Graph, которую используют превью ссылок в Tel
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
`noindex` — индексируется только посадочная страница.
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
(домашний экран) там, где браузер это умеет (Chromium), либо показывает ручные шаги
«На экран „Домой“» в Safari на iOS; он скрыт после установки и внутри Mini App.
### Первый запуск: онбординг
При первом открытии приложения игрока один раз проводят по интерфейсу лёгким
**coachmark-оверлеем**: затемнённый слой показывает по одной подсказке-«облачку» за раз, каждое
@@ -80,9 +74,7 @@ launch-параметрам VK (их проверяет gateway), и при пе
не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется —
он едет в URL-фрагменте, который не уходит на сервер. Вход, запрошенный из **установленного
PWA**, эту ссылку не содержит: в письме только код, который вводится в том же окне (ссылка
открылась бы в отдельном браузере, недоступном приложению).
он едет в URL-фрагменте, который не уходит на сервер.
Telegram держит **единого бота**: все игроки пользуются одним
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
@@ -261,18 +253,6 @@ e-mail) либо ввод фразы. Активные игры форфейтя
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
### Офлайн-режим
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
отключены или скрыты (вкладка Статистика; варианты «с другом» и «случайный соперник» в Новой игре).
Играть можно только в **vs_ai** — Новая игра создаёт локальную игру против робота, который ходит
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
запускается из прекешированного шелла даже без сети. Режим привязан к устройству и сохраняется между
запусками.
### Социальное: друзья, блок, чат, nudge
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
-4
View File
@@ -8,10 +8,6 @@ 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
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.
The web SPA is an installable **PWA**: it serves `manifest.webmanifest` (unhashed from `ui/public/`,
with the `.webmanifest` MIME type registered in-process — the distroless image has no
`/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
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
+3 -13
View File
@@ -42,15 +42,6 @@ type ProfileResp struct {
Email string `json:"email"`
TelegramLinked bool `json:"telegram_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
@@ -298,11 +289,10 @@ func (c *Client) GuestAuth(ctx context.Context, browserTz string) (SessionResp,
// EmailRequest asks the backend to mail a login code, provisioning the account on
// first contact; browserTz (the client's detected "±HH:MM" UTC offset) seeds the new
// account's time zone, since the email account is created here, not at login. pwa marks a
// request from an installed PWA, so the backend omits the one-tap confirm link from the email.
func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language string, pwa bool) error {
// account's time zone, since the email account is created here, not at login.
func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language string) error {
return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "",
map[string]any{"email": email, "browser_tz": browserTz, "language": language, "pwa": pwa}, nil)
map[string]string{"email": email, "browser_tz": browserTz, "language": language}, nil)
}
// EmailLogin verifies a login code and mints a session.
+1 -7
View File
@@ -46,13 +46,7 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
}
h, err := meter.Float64Histogram("edge_request_duration",
metric.WithUnit("s"),
metric.WithDescription("Seconds to serve one Connect Execute call, by message type and result."),
// Explicit second-scale buckets. The durations are recorded in seconds, so the SDK's
// default millisecond-calibrated boundaries (first boundary 5) would bin every sub-5s
// request into one bucket — making histogram_quantile(0.99) interpolate to ~4.95s
// regardless of the real latency, which flapped the >1s edge-latency alert. These
// boundaries straddle the 1s SLO so the p99 reflects real (mostly sub-second) latency.
metric.WithExplicitBucketBoundaries(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10))
metric.WithDescription("Seconds to serve one Connect Execute call, by message type and result."))
if err != nil {
h, _ = noop.NewMeterProvider().Meter(meterName).Float64Histogram("edge_request_duration")
}
@@ -29,7 +29,6 @@ func TestEdgeMetric(t *testing.T) {
type key struct{ messageType, result string }
counts := map[key]uint64{}
var bounds []float64
for _, sm := range rm.ScopeMetrics {
for _, md := range sm.Metrics {
if md.Name != "edge_request_duration" {
@@ -43,7 +42,6 @@ func TestEdgeMetric(t *testing.T) {
mt, _ := dp.Attributes.Value(attribute.Key("message_type"))
res, _ := dp.Attributes.Value(attribute.Key("result"))
counts[key{mt.AsString(), res.AsString()}] += dp.Count
bounds = dp.Bounds
}
}
}
@@ -53,19 +51,6 @@ func TestEdgeMetric(t *testing.T) {
if got := counts[key{"auth.guest", "domain"}]; got != 1 {
t.Errorf("edge auth.guest/domain = %d, want 1", got)
}
// The buckets must be second-scaled. The default (millisecond-calibrated) boundaries have no
// boundary between 0 and 5, so every sub-5s request bins into one bucket and p99 interpolates
// to ~4.95s, flapping the >1s alert. Require at least one sub-second boundary.
subSecond := false
for _, b := range bounds {
if b > 0 && b < 1 {
subSecond = true
break
}
}
if !subSecond {
t.Errorf("edge_request_duration bounds = %v, want sub-second boundaries (seconds-scaled)", bounds)
}
}
// TestRateLimitedMetric records limiter rejections through a manual reader and
-29
View File
@@ -95,7 +95,6 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
banner = encodeBanner(b, *p.Banner)
}
prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector)
dictVersions := encodeDictVersions(b, p.DictVersions)
fb.ProfileStart(b)
fb.ProfileAddUserId(b, uid)
fb.ProfileAddDisplayName(b, name)
@@ -112,9 +111,6 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
fb.ProfileAddEmail(b, email)
fb.ProfileAddTelegramLinked(b, p.TelegramLinked)
fb.ProfileAddVkLinked(b, p.VkLinked)
if dictVersions != 0 {
fb.ProfileAddDictVersions(b, dictVersions)
}
if p.Banner != nil {
fb.ProfileAddBanner(b, banner)
}
@@ -122,31 +118,6 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
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
// optional field (the client then reads it as absent).
func optString(b *flatbuffers.Builder, s string) flatbuffers.UOffsetT {
+1 -1
View File
@@ -253,7 +253,7 @@ func authGuestHandler(backend *backendclient.Client) Handler {
func authEmailRequestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEmailRequestRequest(req.Payload, 0)
if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz()), string(in.Language()), in.Pwa()); err != nil {
if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz()), string(in.Language())); err != nil {
return nil, err
}
return encodeAck(true), nil
+6 -24
View File
@@ -17,7 +17,6 @@ package webui
import (
"embed"
"io/fs"
"mime"
"net/http"
"path"
"strings"
@@ -36,25 +35,13 @@ func distFS() fs.FS {
return sub
}
// init registers the MIME type for .webmanifest, which Go's built-in table lacks and the
// distroless runtime image has no /etc/mime.types to supply. Without it the PWA Web App Manifest
// served under /app/ would be content-sniffed to text/plain, which some browsers reject.
func init() {
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
}
// Handler serves the compile-time embedded UI build over the public edge — the game SPA under
// /app/, /telegram/ and /vk/. It delegates to handlerFor over the embedded dist/.
// Handler serves the embedded UI. An existing file is served directly (hash-named assets get
// an immutable cache); every other path falls back to indexName (the SPA shell) so a
// client-side deep link still loads. When stripPrefix is non-empty it is removed from the
// request path before lookup, so the same build serves under a sub-path (e.g. "/app/" or
// "/telegram/").
func Handler(stripPrefix, indexName string) http.Handler {
return handlerFor(distFS(), stripPrefix, indexName)
}
// handlerFor serves content as the UI: an existing file is served directly (hash-named assets get
// an immutable cache); every other path falls back to indexName (the SPA shell) so a client-side
// deep link still loads. When stripPrefix is non-empty it is removed from the request path before
// lookup, so the same build serves under a sub-path (e.g. "/app/" or "/telegram/"). Split from
// Handler so it can be exercised over an in-memory fs.FS in tests.
func handlerFor(content fs.FS, stripPrefix, indexName string) http.Handler {
content := distFS()
files := http.FileServer(http.FS(content))
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
@@ -71,11 +58,6 @@ 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.
if strings.HasPrefix(name, "assets/") {
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)
})
-58
View File
@@ -6,7 +6,6 @@ import (
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
)
// get drives the handler with a GET for the given path and returns the response.
@@ -56,60 +55,3 @@ func TestAppMountServesShellStripsPrefixAndCachesAssets(t *testing.T) {
}
}
}
// testFS mirrors what Vite emits into dist/: the SPA shell, the PWA manifest + service worker at
// the root (copied from ui/public), and a hash-named asset. The compile-time embedded dist/ holds
// only a placeholder shell, so the manifest/sw.js serving is exercised over this in-memory FS.
func testFS() fstest.MapFS {
return fstest.MapFS{
"index.html": {Data: []byte("<!doctype html><title>shell</title>")},
"manifest.webmanifest": {Data: []byte(`{"name":"Эрудит"}`)},
"sw.js": {Data: []byte("self.addEventListener('fetch', () => {});")},
"assets/app-abc123.js": {Data: []byte("export const x = 1;")},
}
}
// TestHandlerServesManifestWithManifestType: the PWA manifest is served with the manifest MIME
// type (registered in init, since the distroless image has no /etc/mime.types), not sniffed to
// text/plain, which some browsers reject.
func TestHandlerServesManifestWithManifestType(t *testing.T) {
h := handlerFor(testFS(), "/app/", "index.html")
resp := get(t, h, "/app/manifest.webmanifest")
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/manifest+json") {
t.Errorf("Content-Type = %q, want application/manifest+json", ct)
}
}
// TestHandlerServesServiceWorkerAsJavaScript: sw.js is served as JavaScript from the root, so its
// registration (scope /app/) succeeds.
func TestHandlerServesServiceWorkerAsJavaScript(t *testing.T) {
h := handlerFor(testFS(), "/app/", "index.html")
resp := get(t, h, "/app/sw.js")
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
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
// into dist/ falls back to the SPA shell as text/html, on which SW registration would fail. The two
// tests above pin that the real files are served instead of the shell.
func TestHandlerFallsBackToShellForUnknownManifestPath(t *testing.T) {
h := handlerFor(testFS(), "/app/", "index.html")
resp := get(t, h, "/app/not-emitted.webmanifest")
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html (SPA shell fallback)", ct)
}
}
-16
View File
@@ -138,11 +138,6 @@ table EmailRequestRequest {
email:string;
browser_tz:string;
language:string;
// Set when the request originates from an installed PWA (standalone display mode): the
// backend then omits the one-tap confirm link from the login email so the code is typed in
// the same window, avoiding a link that opens in a separate browser (a different storage
// context) where the minted session could not reach the PWA.
pwa:bool;
}
// EmailLoginRequest logs in to the account owning email (provisioned at the
@@ -223,13 +218,6 @@ table BannerInfo {
// 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
// (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 {
user_id:string;
display_name:string;
@@ -252,10 +240,6 @@ table Profile {
email:string;
telegram_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
-71
View File
@@ -1,71 +0,0 @@
// 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()
}
+1 -16
View File
@@ -65,20 +65,8 @@ func (rcv *EmailRequestRequest) Language() []byte {
return nil
}
func (rcv *EmailRequestRequest) Pwa() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *EmailRequestRequest) MutatePwa(n bool) bool {
return rcv._tab.MutateBoolSlot(10, n)
}
func EmailRequestRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(4)
builder.StartObject(3)
}
func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0)
@@ -89,9 +77,6 @@ func EmailRequestRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz fla
func EmailRequestRequestAddLanguage(builder *flatbuffers.Builder, language flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(language), 0)
}
func EmailRequestRequestAddPwa(builder *flatbuffers.Builder, pwa bool) {
builder.PrependBoolSlot(3, pwa, false)
}
func EmailRequestRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+1 -27
View File
@@ -211,28 +211,8 @@ func (rcv *Profile) MutateVkLinked(n bool) bool {
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) {
builder.StartObject(17)
builder.StartObject(16)
}
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
@@ -285,12 +265,6 @@ func ProfileAddTelegramLinked(builder *flatbuffers.Builder, telegramLinked bool)
func ProfileAddVkLinked(builder *flatbuffers.Builder, vkLinked bool) {
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 {
return builder.EndObject()
}
-59
View File
@@ -1,59 +0,0 @@
import { expect, test } from './fixtures';
// The install CTA (components/InstallApp.svelte) is platform-adaptive: a one-tap button where the
// browser offers a beforeinstallprompt (Chromium), a manual instructions modal on iOS Safari, and
// nothing elsewhere / once installed / inside a Mini App. The native OS install dialog is
// browser-level and not drivable from Playwright, so these assert the CTA's presence and branch.
const IPHONE_SAFARI =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
test('web: a captured install prompt shows the one-tap CTA and clicking it prompts', async ({ page }) => {
await page.goto('/');
// The web login screen is shown (an ordinary browser tab, not a Mini App).
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
// Hidden until the browser offers installation (Playwright never fires beforeinstallprompt).
await expect(page.getByRole('button', { name: /Install the app/i })).toHaveCount(0);
// Simulate Chromium offering the install: dispatch a beforeinstallprompt the app can capture.
await page.evaluate(() => {
const e = new Event('beforeinstallprompt') as Event & {
prompt?: () => Promise<void>;
userChoice?: Promise<unknown>;
};
(window as unknown as { __promptCalled: boolean }).__promptCalled = false;
e.prompt = () => {
(window as unknown as { __promptCalled: boolean }).__promptCalled = true;
return Promise.resolve();
};
e.userChoice = Promise.resolve({ outcome: 'accepted', platform: '' });
window.dispatchEvent(e);
});
const cta = page.getByRole('button', { name: /Install the app/i });
await expect(cta).toBeVisible();
await cta.click();
// Clicking calls the captured prompt (the native dialog itself is not observable here).
await expect
.poll(() => page.evaluate(() => (window as unknown as { __promptCalled: boolean }).__promptCalled))
.toBe(true);
});
test.describe('iOS Safari', () => {
test.use({ userAgent: IPHONE_SAFARI });
test('shows manual Add-to-Home-Screen instructions (no programmatic install)', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
// On iOS Safari the CTA shows at once (no beforeinstallprompt is ever fired there).
const cta = page.getByRole('button', { name: /Install the app/i });
await expect(cta).toBeVisible();
await cta.click();
// It opens the app's own instructions modal (not a native / Telegram popup).
await expect(page.getByRole('heading', { name: 'Add to Home Screen' })).toBeVisible();
await expect(page.getByText(/Tap the Share button/)).toBeVisible();
});
});
-12
View File
@@ -46,15 +46,3 @@ test('the landing is a normal scrolling document (the SPA document-pin does not
expect(state.shell).toBe(false);
expect(state.bodyPosition).not.toBe('fixed');
});
// The web-version entry (/app/) is always present, alongside the build-var-gated Telegram/VK ones
// (absent here, since e2e sets no VITE_* vars), each with a caption.
test('the landing shows a web-version entry linking /app/, with a caption', async ({ page }) => {
await page.goto('/landing.html');
await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible(); // Russian by default
const web = page.getByRole('link', { name: 'Играть в браузере' });
await expect(web).toBeVisible();
expect(await web.getAttribute('href')).toContain('/app/');
await expect(page.getByText('Веб-версия')).toBeVisible();
});
-12
View File
@@ -256,18 +256,6 @@
<link rel="icon" href="favicon.ico" sizes="32x32" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
<!-- PWA installability (web /app/ only): the manifest + a service worker (registered from
lib/pwa, web-only) let the app be added to the home screen / desktop. The relative href
resolves per serving path; these tags are inert inside the Telegram/VK Mini App webviews,
which manage their own chrome. theme-color mirrors the light/dark --bg tokens (app.css). -->
<link rel="manifest" href="manifest.webmanifest" />
<meta name="theme-color" content="#f3f4f6" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0f1115" media="(prefers-color-scheme: dark)" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Эрудит" />
<meta name="application-name" content="Эрудит" />
</head>
<body>
<div id="app"></div>
+1 -5
View File
@@ -32,10 +32,6 @@
"svelte-check": "^4.1.0",
"typescript": "^5.7.0",
"vite": "^6.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"
"vitest": "^3.0.0"
}
}
+21 -3195
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

-17
View File
@@ -1,17 +0,0 @@
{
"name": "Эрудит (Скрэббл)",
"short_name": "Эрудит",
"description": "Игра в слова «Эрудит» (Скрэббл): играйте с друзьями, случайным соперником или ИИ-роботом.",
"lang": "ru",
"dir": "ltr",
"start_url": ".",
"scope": ".",
"display": "standalone",
"background_color": "#f3f4f6",
"theme_color": "#f3f4f6",
"icons": [
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+4 -7
View File
@@ -19,13 +19,10 @@ import { join } from 'node:path';
const DIST = 'dist';
// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview
// wiring, to 112 for the PWA install feature, to 113 for the offline-mode wiring, then to 114 for
// the offline auto-detect: the cold-start reachability check and the "no connection" dialog live in
// the boot path (app.svelte.ts / the App shell), which cannot be lazy-loaded. 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: 114, shared: 30, landing: 5 };
// Per-chunk gzip budgets in KB. The app entry was raised to 110 when the local
// move-preview wiring landed (the heavy dict subsystem stays in lazy chunks; this
// covers the small in-entry game/debug wiring it needs).
const BUDGET = { app: 110, shared: 30, landing: 5 };
// 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.
+1 -59
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { app, bootstrap, resolveOfflinePrompt } from './lib/app.svelte';
import { app, bootstrap } from './lib/app.svelte';
import { router, type RouteName } from './lib/router.svelte';
import { t } from './lib/i18n/index.svelte';
import Toast from './components/Toast.svelte';
@@ -136,21 +136,6 @@
<Coachmark />
<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}
<Splash />
{/if}
@@ -160,49 +145,6 @@
{/if}
<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 {
height: 100%;
display: grid;
+16 -31
View File
@@ -80,26 +80,20 @@
<section class="hero">
<h1>{about.title}</h1>
<p class="tagline">{t('landing.tagline')}</p>
<!-- Platform entries: Telegram/VK are gated by their build vars; the web version (/app/) is
always available. A caption under each clarifies what it opens. -->
<div class="entries">
{#if tgLink}
<a class="entry" href={tgLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playTelegram')}>
<img src="telegram-logo.svg" alt="" width="64" height="64" />
<span class="caption">{t('landing.captionTelegram')}</span>
</a>
{/if}
{#if vkLink}
<a class="entry" href={vkLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playVK')}>
<img src="vk-logo.svg" alt="" width="64" height="64" />
<span class="caption">{t('landing.captionVK')}</span>
</a>
{/if}
<a class="entry" href="/app/" aria-label={t('landing.playWeb')}>
<img src="favicon.svg" alt="" width="64" height="64" />
<span class="caption">{t('landing.captionWeb')}</span>
</a>
</div>
{#if tgLink || vkLink}
<div class="entries">
{#if tgLink}
<a class="entry" href={tgLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playTelegram')}>
<img src="telegram-logo.svg" alt="" width="64" height="64" />
</a>
{/if}
{#if vkLink}
<a class="entry" href={vkLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playVK')}>
<img src="vk-logo.svg" alt="" width="64" height="64" />
</a>
{/if}
</div>
{/if}
</section>
<section class="info">
@@ -213,8 +207,8 @@
color: var(--text-muted);
font-size: 1.05rem;
}
/* The platform entries are the bigger logos in a row, each with a short caption below; the
link keeps an aria-label for assistive tech. */
/* The platform entries are just the bigger logos in a row (no button chrome, no captions);
each link keeps an aria-label for assistive tech. */
.entries {
align-self: center;
display: flex;
@@ -223,11 +217,6 @@
}
.entry {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-decoration: none;
color: var(--text);
}
.entry img {
display: block;
@@ -236,10 +225,6 @@
.entry:hover img {
transform: scale(1.06);
}
.caption {
font-size: 0.9rem;
color: var(--text-muted);
}
.info {
background: var(--surface-2);
border-radius: var(--radius-sm);
+5 -49
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte';
import { connection } from '../lib/connection.svelte';
import { offlineMode, dictPreloadWarning } from '../lib/offline.svelte';
import { t } from '../lib/i18n/index.svelte';
import { app, openDebug } from '../lib/app.svelte';
import Spinner from './Spinner.svelte';
@@ -28,7 +27,7 @@
}
</script>
<header class="nav" class:grow class:offline={offlineMode.active}>
<header class="nav" class:grow>
<div class="bar">
{#if showBack}
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
@@ -37,20 +36,15 @@
{:else}
<span class="spacer"></span>
{/if}
{#if connection.online || offlineMode.active}
{#if connection.online}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<h1 onclick={onTitleTap}>{title}</h1>
{:else}
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
{/if}
<!-- A right-hand spacer balances the back button so the title stays centred; in offline mode it
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}
<!-- A right-hand spacer balances the back button so the title stays centred. -->
<span class="spacer"></span>
</div>
<!-- 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
@@ -58,11 +52,7 @@
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,
per this same condition, once onboarding closes. -->
{#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}
{#if app.profile?.banner && app.profile.banner.campaigns.length && !app.coachActive}
<AdBanner
campaigns={app.profile.banner.campaigns}
timings={app.profile.banner.timings}
@@ -84,12 +74,6 @@
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 {
/* 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
@@ -130,34 +114,6 @@
align-items: 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 {
background: none;
border: none;
-85
View File
@@ -1,85 +0,0 @@
<script lang="ts">
import { t } from '../lib/i18n/index.svelte';
import { installMode, promptInstall } from '../lib/pwa.svelte';
import Modal from './Modal.svelte';
// Platform-adaptive: 'oneTap' fires the native Chromium install dialog; 'iosInstructions' opens
// the manual Share -> Add-to-Home-Screen guide (iOS Safari has no programmatic install); 'hidden'
// renders nothing (already installed, inside a Telegram/VK Mini App, or an unsupported browser).
const mode = $derived(installMode());
let showIos = $state(false);
function onClick(): void {
// promptInstall must run synchronously from this gesture — the deferred prompt is gated on it.
if (mode === 'oneTap') void promptInstall();
else if (mode === 'iosInstructions') showIos = true;
}
</script>
{#if mode !== 'hidden'}
<button type="button" class="install" onclick={onClick}>
<img src="favicon.svg" alt="" width="40" height="40" />
<span class="text">
<span class="title">{t('install.title')}</span>
<span class="subtitle">{t('install.subtitle')}</span>
</span>
</button>
{/if}
{#if showIos}
<Modal title={t('install.iosTitle')} onclose={() => (showIos = false)}>
<ol class="steps">
<li>{t('install.iosStep1')}</li>
<li>{t('install.iosStep2')}</li>
<li>{t('install.iosStep3')}</li>
</ol>
<button type="button" class="ok" onclick={() => (showIos = false)}>{t('common.ok')}</button>
</Modal>
{/if}
<style>
.install {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
text-align: left;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
cursor: pointer;
}
.install img {
flex: none;
border-radius: 8px;
}
.text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.title {
font-weight: 600;
}
.subtitle {
font-size: 0.85rem;
color: var(--text-muted);
line-height: 1.35;
}
.steps {
margin: 0 0 16px;
padding-left: 1.25rem;
line-height: 1.6;
}
.ok {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
}
</style>
+4 -11
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { gateway } from '../lib/gateway';
import { gameSource, isLocalGameId } from '../lib/gamesource';
import { handleError, showToast } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import { alphabetLetters } from '../lib/alphabet';
@@ -21,10 +20,8 @@
onMount(async () => {
try {
// Include the alphabet so input sanitising + the check accept the variant's letters. Routed
// through gameSource so an offline (local) game resolves its state from the device, not the
// network.
const st = await gameSource(id).gameState(id, true);
// Include the alphabet so input sanitising + the check accept the variant's letters.
const st = await gateway.gameState(id, true);
variant = st.game.variant;
} catch (e) {
handleError(e);
@@ -44,7 +41,7 @@
cooling = true;
setTimeout(() => (cooling = false), 5000);
try {
const r = await gameSource(id).checkWord(id, w, variant);
const r = await gateway.checkWord(id, w, variant);
checked.set(w, r.legal);
result = { word: w, legal: r.legal };
} catch (e) {
@@ -80,11 +77,7 @@
: t('game.wordIllegal', { word: result.word })}
</p>
<div class="actions">
<!-- 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}
<button class="complain" onclick={complain}>{t('game.complain')}</button>
{#if result.legal}
<a
class="lookup"
+1 -4
View File
@@ -21,11 +21,8 @@
// 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
// deep-link falls back to Chat).
// An honest-AI game has only the Dictionary, so start there 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
let tab = $state<CommsTab>(vsAi ? 'dictionary' : initialTab);
let tab = $state<CommsTab>(initialTab);
$effect(() => {
if (vsAi) tab = 'dictionary';
else if (tab === 'dictionary' && !active) tab = 'chat';
+17 -38
View File
@@ -8,12 +8,10 @@
import Board from './Board.svelte';
import Rack from './Rack.svelte';
import { gateway } from '../lib/gateway';
import { gameSource, localSource, isLocalGameId } from '../lib/gamesource';
import { notePreviewLocal, notePreviewNetwork } from '../lib/localeval-metrics';
import { navigate } from '../lib/router.svelte';
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { offlineMode } from '../lib/offline.svelte';
import { GatewayError } from '../lib/client';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
@@ -49,18 +47,6 @@
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 moves = $state<MoveRecord[]>([]);
let placement = $state<Placement>(newPlacement([]));
@@ -204,9 +190,9 @@
// 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.
const [st, hist, draft] = await Promise.all([
source.gameState(id, includeAlphabet),
source.gameHistory(id),
source.draftGet(id).catch(() => ''),
gateway.gameState(id, includeAlphabet),
gateway.gameHistory(id),
gateway.draftGet(id).catch(() => ''),
]);
view = st;
syncWallet(st.walletBalance);
@@ -234,7 +220,7 @@
setCachedDraft(id, json);
if (draftSaveTimer) clearTimeout(draftSaveTimer);
draftSaveTimer = setTimeout(() => {
void source.draftSave(id, json).catch(() => {});
void gateway.draftSave(id, json).catch(() => {});
}, 500);
}
// applyDraft restores the player's saved composition over a freshly loaded state: the rack
@@ -278,12 +264,6 @@
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
@@ -631,9 +611,8 @@
// Flush a pending draft save so leaving mid-composition still persists it.
if (draftSaveTimer) {
clearTimeout(draftSaveTimer);
void source.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
}
localUnsub?.();
});
function onCell(row: number, col: number) {
@@ -761,7 +740,7 @@
evalCtrl = ctrl;
previewTimer = setTimeout(async () => {
try {
preview = await source.evaluate(id, sub.tiles, variant, ctrl.signal);
preview = await gateway.evaluate(id, sub.tiles, variant, ctrl.signal);
notePreviewNetwork();
} catch {
/* best-effort (or aborted) */
@@ -802,7 +781,7 @@
if (!sub) return;
busy = true;
try {
applyMoveResult(await source.submitPlay(id, sub.tiles, variant));
applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant));
haptic('success');
zoomed = false;
} catch (e) {
@@ -821,7 +800,7 @@
async function doPass() {
busy = true;
try {
applyMoveResult(await source.pass(id));
applyMoveResult(await gateway.pass(id));
} catch (e) {
handleError(e);
} finally {
@@ -842,7 +821,7 @@
resignOpen = false;
busy = true;
try {
applyMoveResult(await source.resign(id));
applyMoveResult(await gateway.resign(id));
// 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.
historyOpen = false;
@@ -855,7 +834,7 @@
}
async function doHint() {
try {
const h = await source.hint(id);
const h = await gateway.hint(id);
if (h.move.tiles.length && view) {
placement = placementFromHint(h.move.tiles, view.rack);
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
@@ -927,7 +906,7 @@
exchangeOpen = false;
busy = true;
try {
applyMoveResult(await source.exchange(id, tiles, variant));
applyMoveResult(await gateway.exchange(id, tiles, variant));
} catch (e) {
handleError(e);
} finally {
@@ -1487,19 +1466,19 @@
/>
</div>
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
{/if}
</div>
{/snippet}
{#snippet controlButtons()}
<button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={openExchange}>
<button class="tab" disabled={busy || !isMyTurn || !connection.online} onclick={openExchange}>
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
</button>
<TapConfirm
triggerClass="tab"
label={t('game.hint')}
disabled={busy || !isMyTurn || !netReady || hintCount <= 0}
disabled={busy || !isMyTurn || !connection.online || hintCount <= 0}
onconfirm={doHint}
>
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
@@ -1557,7 +1536,7 @@
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
<div class="confirm-row">
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
<button class="danger" onclick={doResign} disabled={!netReady}>{t('game.dropGame')}</button>
<button class="danger" onclick={doResign} disabled={!connection.online}>{t('game.dropGame')}</button>
</div>
</Modal>
{/if}
@@ -1569,7 +1548,7 @@
<button
class="confirm"
onclick={() => { exportOpen = false; void exportArtifact('png'); }}
disabled={!netReady}
disabled={!connection.online}
>
{t('game.exportImageOpt')}
</button>
@@ -1577,7 +1556,7 @@
<button
class={exportImageAvailable ? 'export-alt' : 'confirm'}
onclick={() => { exportOpen = false; void exportArtifact('gcg'); }}
disabled={!netReady}
disabled={!connection.online}
>
{t('game.exportGcgOpt')}
</button>
-1
View File
@@ -17,7 +17,6 @@ export { ChatPostRequest } from './scrabblefb/chat-post-request.js';
export { CheckWordRequest } from './scrabblefb/check-word-request.js';
export { ComplaintRequest } from './scrabblefb/complaint-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 { DraftView } from './scrabblefb/draft-view.js';
export { EmailConfirmLinkRequest } from './scrabblefb/email-confirm-link-request.js';
-60
View File
@@ -1,60 +0,0 @@
// 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);
}
}
@@ -41,13 +41,8 @@ language(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
pwa():boolean {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startEmailRequestRequest(builder:flatbuffers.Builder) {
builder.startObject(4);
builder.startObject(3);
}
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
@@ -62,21 +57,16 @@ static addLanguage(builder:flatbuffers.Builder, languageOffset:flatbuffers.Offse
builder.addFieldOffset(2, languageOffset, 0);
}
static addPwa(builder:flatbuffers.Builder, pwa:boolean) {
builder.addFieldInt8(3, +pwa, +false);
}
static endEmailRequestRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, languageOffset:flatbuffers.Offset, pwa:boolean):flatbuffers.Offset {
static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, languageOffset:flatbuffers.Offset):flatbuffers.Offset {
EmailRequestRequest.startEmailRequestRequest(builder);
EmailRequestRequest.addEmail(builder, emailOffset);
EmailRequestRequest.addBrowserTz(builder, browserTzOffset);
EmailRequestRequest.addLanguage(builder, languageOffset);
EmailRequestRequest.addPwa(builder, pwa);
return EmailRequestRequest.endEmailRequestRequest(builder);
}
}
+1 -28
View File
@@ -3,7 +3,6 @@
import * as flatbuffers from 'flatbuffers';
import { BannerInfo } from '../scrabblefb/banner-info.js';
import { DictVersion } from '../scrabblefb/dict-version.js';
export class Profile {
@@ -125,18 +124,8 @@ vkLinked():boolean {
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) {
builder.startObject(17);
builder.startObject(16);
}
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
@@ -215,22 +204,6 @@ static addVkLinked(builder:flatbuffers.Builder, vkLinked:boolean) {
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 {
const offset = builder.endObject();
return offset;
+3 -146
View File
@@ -35,17 +35,12 @@ import {
} from './telegram';
import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
import { pendingVKLink, type VKLinkCallback } from './vkid';
import { isStandalone } from './pwa';
import { registerServiceWorker } from './pwa.svelte';
import { haptic } from './haptics';
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
import { parseStartParam } from './deeplink';
import {
clearOnboarding,
clearSession,
clearProfile,
loadProfile,
saveProfile,
loadOnboarding,
loadPrefs,
loadSession,
@@ -55,9 +50,7 @@ import {
savePrefs,
} from './session';
import type { CoachSeries } from './coachmark';
import { connection, reportOffline, reportOnline, resetConnection, checkReachable } from './connection.svelte';
import { offlineMode, setOfflineMode } from './offline.svelte';
import { shouldBootOffline } from './offline';
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
import { isConnectionCode } from './retry';
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
import { advanceCached } from './gamedelta';
@@ -78,10 +71,6 @@ export const app = $state<{
* 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. */
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
* 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
@@ -152,7 +141,6 @@ export const app = $state<{
}>({
ready: false,
bootError: false,
offlinePrompt: false,
launchError: null,
debugOpen: false,
lobbyReady: false,
@@ -304,9 +292,7 @@ export function handleError(err: unknown): void {
void enterBlocked();
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;
if (isConnectionCode(code) || !connection.online) return;
haptic('error');
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
}
@@ -472,7 +458,6 @@ export async function refreshProfile(): Promise<void> {
if (!app.session) return;
try {
app.profile = await gateway.profileGet();
void saveProfile($state.snapshot(app.profile)); // keep the offline-boot cache fresh (plain snapshot)
} catch {
// Best-effort; the banner just stays as it was until the next fetch.
}
@@ -530,11 +515,6 @@ async function adoptSession(s: Session): Promise<void> {
await saveSession(s);
try {
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
// 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
@@ -558,74 +538,6 @@ async function adoptSession(s: Session): Promise<void> {
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
* switched session (a guest initiator whose durable counterpart won, so the active
@@ -871,65 +783,11 @@ export async function bootstrap(): Promise<void> {
return;
}
// On the plain web (both Mini-App branches returned above) register the install-only service
// 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.
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();
// 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).
const vkcb = pendingVKLink();
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);
if (vkcb) {
// The full-page redirect lost the in-app route, so hand the callback to Profile, which
@@ -1116,7 +974,7 @@ export async function loginGuest(): Promise<void> {
export async function requestEmailCode(email: string): Promise<boolean> {
try {
await gateway.authEmailRequest(email, app.locale, isStandalone());
await gateway.authEmailRequest(email, app.locale);
return true;
} catch (err) {
handleError(err);
@@ -1158,7 +1016,6 @@ export async function logout(): Promise<void> {
app.splashDone = false;
gateway.setToken(null);
await clearSession();
await clearProfile();
app.session = null;
app.profile = null;
navigate('/login');
+1 -1
View File
@@ -65,7 +65,7 @@ export interface GatewayClient {
* cosmetic seed for a brand-new account). */
authVK(params: string, displayName: string): Promise<Session>;
authGuest(locale?: string): Promise<Session>;
authEmailRequest(email: string, language: string, pwa: boolean): Promise<void>;
authEmailRequest(email: string, language: string): Promise<void>;
authEmailLogin(email: string, code: string): Promise<Session>;
/** Confirm a one-tap email deeplink token: a login returns a session to adopt; a
* link returns a status ('confirmed' | 'merge_required'). */
+1 -24
View File
@@ -122,11 +122,10 @@ describe('codec', () => {
expect(guest.browserTz()).toBe('-05:30');
const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest(
new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en', true)),
new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en')),
);
expect(email.email()).toBe('a@example.com');
expect(email.browserTz()).toBe('+00:00');
expect(email.pwa()).toBe(true);
expect(email.language()).toBe('en');
const confirm = fb.EmailConfirmLinkRequest.getRootAsEmailConfirmLinkRequest(
@@ -712,28 +711,6 @@ describe('codec', () => {
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', () => {
const buf = encodeUpdateProfile({
displayName: 'Kaya',
+1 -23
View File
@@ -214,12 +214,7 @@ export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
}
export function encodeEmailRequest(
email: string,
browserTz: string,
language: string,
pwa: boolean,
): Uint8Array {
export function encodeEmailRequest(email: string, browserTz: string, language: string): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
const tz = b.createString(browserTz);
@@ -228,7 +223,6 @@ export function encodeEmailRequest(
fb.EmailRequestRequest.addEmail(b, e);
fb.EmailRequestRequest.addBrowserTz(b, tz);
fb.EmailRequestRequest.addLanguage(b, l);
fb.EmailRequestRequest.addPwa(b, pwa);
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
}
@@ -364,25 +358,9 @@ export function decodeProfile(buf: Uint8Array): Profile {
email: s(p.email()),
telegramLinked: p.telegramLinked(),
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
// the player enabled in Settings).
function decodeVariantPreferences(p: fb.Profile): Variant[] {
+1 -22
View File
@@ -9,7 +9,6 @@
// other traffic is in flight.
import { backoffMs } from './retry';
import { offlineMode } from './offline.svelte';
let online = $state(true);
let watchTimer: ReturnType<typeof setTimeout> | null = null;
@@ -28,24 +27,6 @@ export function registerProbe(fn: () => Promise<void>): void {
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. */
export function reportOnline(): void {
online = true;
@@ -70,9 +51,7 @@ function scheduleProbe(attempt: number): void {
watchTimer = setTimeout(
() => {
watchTimer = null;
// Never probe the network in offline mode (the kill switch); the online/offline events drive
// recovery there instead.
if (online || !probe || offlineMode.active) return;
if (online || !probe) return;
probe().then(reportOnline, () => scheduleProbe(Math.min(attempt + 1, 6)));
},
backoffMs(attempt),
-68
View File
@@ -1,68 +0,0 @@
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);
});
});
-79
View File
@@ -95,85 +95,6 @@ export class Dawg {
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
// offset. On success it fills eNode/eCount/eFinal and returns true. Mirrors
// dafsa (*dawg).getEdge.
-99
View File
@@ -1,99 +0,0 @@
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));
});
}
});
}
-113
View File
@@ -1,113 +0,0 @@
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,
);
}
});
}
});
-418
View File
@@ -1,418 +0,0 @@
// 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;
}
-86
View File
@@ -1,86 +0,0 @@
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);
});
});
-68
View File
@@ -1,68 +0,0 @@
// 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 };
}
-24
View File
@@ -1,24 +0,0 @@
// 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);
}
Binary file not shown.
File diff suppressed because it is too large Load Diff
-122
View File
@@ -1,122 +0,0 @@
{
"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
]
]
}
Binary file not shown.
File diff suppressed because it is too large Load Diff
-38
View File
@@ -1,38 +0,0 @@
{
"alphabet": "ru",
"numAdded": 6,
"words": [
"ад",
"ар",
"оса",
"я",
"яд",
"яр"
],
"indexes": [
[
0,
4
],
[
0,
17
],
[
15,
18,
0
],
[
32
],
[
32,
4
],
[
32,
17
]
]
}
+2 -3
View File
@@ -297,9 +297,8 @@ export function validatePlay(
}
// connected reports whether the play connects to the position (or covers the
// centre on the first move). Mirrors (*Solver).connected. Exported so the move
// generator can apply the same post-generation connectivity filter.
export function connected(b: Board, rs: Ruleset, m: Move): boolean {
// centre on the first move). Mirrors (*Solver).connected.
function connected(b: Board, rs: Ruleset, m: Move): boolean {
if (b.isEmpty()) {
const cr = Math.floor(rs.center / rs.cols);
const cc = rs.center % rs.cols;
-67
View File
@@ -1,67 +0,0 @@
// 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)),
// 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'>;
/**
* 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';
-18
View File
@@ -214,13 +214,6 @@ export const en = {
'settings.labelsClassic': 'Classic',
'settings.labelsNone': 'None',
'settings.reduceMotion': 'Reduce motion',
'settings.offlineMode': 'Play mode',
'settings.online': 'Online',
'settings.offline': 'Offline',
'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.tab': 'Info',
@@ -241,17 +234,6 @@ export const en = {
'landing.tagline': 'Play Scrabble with friends, random opponent or an AI.',
'landing.playTelegram': 'Play in Telegram',
'landing.playVK': 'Play on VK',
'landing.playWeb': 'Play in your browser',
'landing.captionTelegram': 'Telegram',
'landing.captionVK': 'VK',
'landing.captionWeb': 'Web',
'install.title': 'Install the app',
'install.subtitle': 'Put the app icon on your desktop (home screen) to open the game in one tap.',
'install.iosTitle': 'Add to Home Screen',
'install.iosStep1': 'Tap the Share button in the browser toolbar.',
'install.iosStep2': 'Choose “Add to Home Screen”.',
'install.iosStep3': 'Tap “Add”.',
'lang.en': 'English',
'lang.ru': 'Русский',
-18
View File
@@ -214,13 +214,6 @@ export const ru: Record<MessageKey, string> = {
'settings.labelsClassic': 'Классика',
'settings.labelsNone': 'Без текста',
'settings.reduceMotion': 'Меньше анимаций',
'settings.offlineMode': 'Режим игры',
'settings.online': 'Онлайн',
'settings.offline': 'Оффлайн',
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
'offline.promptTitle': 'Нет связи. Включить офлайн-режим?',
'offline.promptYes': 'Включить',
'offline.promptNo': 'Ждать сеть',
'about.title': 'О программе',
'about.tab': 'Инфо',
@@ -241,17 +234,6 @@ export const ru: Record<MessageKey, string> = {
'landing.tagline': 'Играй в «Эрудита» с друзьями, случайным соперником или ИИ-роботом.',
'landing.playTelegram': 'Играть в Telegram',
'landing.playVK': 'Играть во ВКонтакте',
'landing.playWeb': 'Играть в браузере',
'landing.captionTelegram': 'Telegram',
'landing.captionVK': 'VK',
'landing.captionWeb': 'Веб-версия',
'install.title': 'Установить приложение',
'install.subtitle': 'Поместите иконку приложения на рабочий стол (домашний экран), чтобы открывать игру одним нажатием.',
'install.iosTitle': 'На экран «Домой»',
'install.iosStep1': 'Нажмите кнопку «Поделиться» на панели браузера.',
'install.iosStep2': 'Выберите «На экран „Домой“».',
'install.iosStep3': 'Нажмите «Добавить».',
'lang.en': 'English',
'lang.ru': 'Русский',
-50
View File
@@ -1,50 +0,0 @@
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));
});
});
-76
View File
@@ -1,76 +0,0 @@
// 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 (FisherYates).
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;
}
}
}
-44
View File
@@ -1,44 +0,0 @@
// 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 };
}
}
@@ -1,35 +0,0 @@
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);
});
}
});
-78
View File
@@ -1,78 +0,0 @@
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());
});
});
-439
View File
@@ -1,439 +0,0 @@
// 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]];
}
/** 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);
}
}
-24
View File
@@ -1,24 +0,0 @@
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);
}
});
});
-23
View File
@@ -1,23 +0,0 @@
// 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)}`;
}
/** randomSeed returns a random 32-bit seed for a new local game's deterministic bag. */
export function randomSeed(): bigint {
return BigInt(Math.floor(Math.random() * 0xffffffff));
}
@@ -1,39 +0,0 @@
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]);
});
}
});
-62
View File
@@ -1,62 +0,0 @@
// 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: [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ'],
},
};
-86
View File
@@ -1,86 +0,0 @@
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, robotLastMoveAtUnix: 3 };
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);
});
});
-110
View File
@@ -1,110 +0,0 @@
// 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';
/** 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;
}
/** 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';
createdAtUnix: number;
updatedAtUnix: number;
/** Unix seconds of the robot's most recent move — the local hint gate (>30 min) reads this. */
robotLastMoveAtUnix: number;
}
/** The record fields the caller owns (the engine supplies the rest). */
export interface RecordMeta {
id: string;
seats: Seat[];
createdAtUnix: number;
updatedAtUnix: number;
robotLastMoveAtUnix: 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',
createdAtUnix: meta.createdAtUnix,
updatedAtUnix: meta.updatedAtUnix,
robotLastMoveAtUnix: meta.robotLastMoveAtUnix,
};
}
/**
* 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;
}
-83
View File
@@ -1,83 +0,0 @@
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');
});
});
-96
View File
@@ -1,96 +0,0 @@
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';
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('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('gates the hint until 30 minutes since the robot last moved', async () => {
const src = await newSource('local:g3', 7n);
await expect(src.hint('local:g3')).rejects.toMatchObject({ code: 'hint_not_ready' });
});
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);
});
});
-423
View File
@@ -1,423 +0,0 @@
// 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 { LOCAL_ID_PREFIX, isLocalGameId } from './id';
import { decide } from '../robot/strategy';
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 };
/** HINT_GATE_MS is the idle time since the robot's last move before an offline hint unlocks. */
export const HINT_GATE_MS = 30 * 60 * 1000;
/**
* 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;
}
/**
* 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 vs_ai game, persists it and returns its state. */
async create(opts: {
id: string;
variant: Variant;
dictVersion: string;
seed: bigint;
multipleWords: boolean;
seats: LocalSeat[];
}): 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 })),
createdAtUnix: now,
updatedAtUnix: now,
robotLastMoveAtUnix: now,
});
const entry: Live = { game, record };
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();
await this.persist(entry);
return this.moveResult(entry, move, seat);
}
async hint(gameId: string): Promise<HintResult> {
const entry = await this.load(gameId);
if (nowUnix() * 1000 - entry.record.robotLastMoveAtUnix * 1000 < HINT_GATE_MS) {
throw new GatewayError('hint_not_ready');
}
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 };
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);
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());
entry.record.robotLastMoveAtUnix = nowUnix();
}
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,
createdAtUnix: entry.record.createdAtUnix,
updatedAtUnix: nowUnix(),
robotLastMoveAtUnix: entry.record.robotLastMoveAtUnix,
});
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 --------------------------------------------------------
private stateView(entry: Live): StateView {
const seat = this.humanSeat(entry);
const rack = 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 };
}
private gameView(entry: Live): GameView {
const { game, record } = entry;
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,
}));
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: true,
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);
}
-99
View File
@@ -1,99 +0,0 @@
// 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
View File
@@ -1,246 +0,0 @@
{
"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
View File
@@ -1,314 +0,0 @@
{
"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": [
"А",
"Б",
"В",
"Г",
"Д",
"Е",
"Ё",
"Ж",
"З",
"И",
"Й",
"К",
"Л",
"М",
"Н",
"О",
"П",
"Р",
"С",
"Т",
"У",
"Ф",
"Х",
"Ц",
"Ч",
"Ш",
"Щ",
"Ъ",
"Ы",
"Ь",
"Э",
"Ю",
"Я"
]
}
}
+1 -1
View File
@@ -150,7 +150,7 @@ export class MockGateway implements GatewayClient {
async authGuest(): Promise<Session> {
return { ...SESSION };
}
async authEmailRequest(_email: string, _language: string, _pwa: boolean): Promise<void> {}
async authEmailRequest(_email: string, _language: string): Promise<void> {}
async confirmEmailLink(_token: string): Promise<ConfirmLinkResult> {
return { purpose: 'link', status: 'confirmed', session: null };
}
-3
View File
@@ -42,9 +42,6 @@ export const PROFILE: Profile = {
email: 'you@example.com',
telegramLinked: 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
-4
View File
@@ -161,10 +161,6 @@ export interface Profile {
/** Whether a Telegram / VK identity is attached — drives the Add / Unlink controls. */
telegramLinked: 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. */
-93
View File
@@ -1,93 +0,0 @@
// 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 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;
});
}
-55
View File
@@ -1,55 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline } 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);
});
});
-70
View File
@@ -1,70 +0,0 @@
// 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));
}
/**
* 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;
}
-90
View File
@@ -1,90 +0,0 @@
// Reactive PWA install runtime: captures the Chromium install prompt, tracks the installed
// state, and registers the install-only service worker. The pure platform branching lives in
// pwa.ts. Structured after the other reactive lib modules (app.svelte.ts, router.svelte.ts).
import { isIosSafari, isStandalone, resolveInstallMode, type InstallMode } from './pwa';
import { insideTelegram } from './telegram';
import { insideVK } from './vk';
/** inMiniApp reports whether the app runs inside a Telegram or VK Mini App webview, where a web
* install makes no sense (the platform manages its own launcher). Kept here, out of the pure
* pwa.ts, so that module stays free of app imports for the node unit tests. */
function inMiniApp(): boolean {
return insideTelegram() || insideVK();
}
// beforeinstallprompt is a Chromium-only event, absent from the DOM lib types.
interface BeforeInstallPromptEvent extends Event {
readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
prompt(): Promise<void>;
}
declare global {
interface WindowEventMap {
beforeinstallprompt: BeforeInstallPromptEvent;
appinstalled: Event;
}
}
// Not named `state` (a Svelte-check hazard: `$state` would then read as a store subscription).
const install = $state<{ deferred: BeforeInstallPromptEvent | null; installed: boolean }>({
deferred: null,
installed: isStandalone(),
});
/**
* installMode is the reactive install-CTA mode for the current platform and any captured prompt.
* Read inside a component's markup / $derived so the CTA updates when a Chromium prompt arrives
* (beforeinstallprompt fires shortly after load) or the app is installed.
*/
export function installMode(): InstallMode {
return resolveInstallMode({
deferredAvailable: install.deferred !== null,
standalone: install.installed,
iosSafari: isIosSafari(),
inMiniApp: inMiniApp(),
});
}
/**
* initInstallListeners wires the Chromium install prompt and the installed event. Harmless on
* any platform the events fire only in an installable browser so it may run unconditionally
* at startup, early enough to catch a prompt that fires before the CTA mounts.
*/
export function initInstallListeners(): void {
if (typeof window === 'undefined') return;
window.addEventListener('beforeinstallprompt', (e) => {
// Suppress Chromium's mini-infobar; the app presents its own CTA instead.
e.preventDefault();
install.deferred = e;
});
window.addEventListener('appinstalled', () => {
install.deferred = null;
install.installed = true;
});
}
/**
* promptInstall shows the native Chromium install dialog. It MUST be called directly from a user
* gesture (the deferred prompt is gesture-gated no await before it). A deferred prompt is
* single-use, so it is cleared here; a no-op when nothing was captured.
*/
export async function promptInstall(): Promise<void> {
const deferred = install.deferred;
if (!deferred) return;
install.deferred = null;
await deferred.prompt();
}
/**
* registerServiceWorker registers the app-shell service worker (built from ui/src/sw.ts to
* dist/sw.js by vite-plugin-pwa): it satisfies Chromium's installability requirement and precaches
* the shell + assets so an installed PWA cold-launches offline. Web-only: skipped in the mock build
* (a real worker would perturb the Playwright run) and inside a Telegram/VK Mini App. Best-effort
* a failed registration only means the app is not installable and cannot launch offline.
*/
export function registerServiceWorker(): void {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
if (import.meta.env.MODE === 'mock') return;
if (inMiniApp()) return;
void navigator.serviceWorker.register('sw.js').catch(() => {});
}
-96
View File
@@ -1,96 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { isIosSafari, isStandalone, resolveInstallMode } from './pwa';
describe('resolveInstallMode', () => {
const base = { deferredAvailable: false, standalone: false, iosSafari: false, inMiniApp: false };
it('hides inside a Mini App even with a captured prompt', () => {
expect(resolveInstallMode({ ...base, inMiniApp: true, deferredAvailable: true })).toBe('hidden');
});
it('hides once the app is installed (standalone)', () => {
expect(resolveInstallMode({ ...base, standalone: true, deferredAvailable: true })).toBe('hidden');
});
it('offers one-tap when a Chromium prompt was captured', () => {
expect(resolveInstallMode({ ...base, deferredAvailable: true })).toBe('oneTap');
});
it('offers iOS instructions on iOS Safari without a prompt', () => {
expect(resolveInstallMode({ ...base, iosSafari: true })).toBe('iosInstructions');
});
it('prefers one-tap over iOS instructions when both apply', () => {
expect(resolveInstallMode({ ...base, deferredAvailable: true, iosSafari: true })).toBe('oneTap');
});
it('hides on an unsupported browser (no prompt, not iOS)', () => {
expect(resolveInstallMode(base)).toBe('hidden');
});
});
describe('isStandalone', () => {
afterEach(() => vi.unstubAllGlobals());
it('is true when the display-mode media query matches', () => {
vi.stubGlobal('window', {
matchMedia: (q: string) => ({ matches: q.includes('standalone') }),
navigator: {},
});
expect(isStandalone()).toBe(true);
});
it('is true for the iOS navigator.standalone flag', () => {
vi.stubGlobal('window', {
matchMedia: () => ({ matches: false }),
navigator: { standalone: true },
});
expect(isStandalone()).toBe(true);
});
it('is false in an ordinary browser tab', () => {
vi.stubGlobal('window', {
matchMedia: () => ({ matches: false }),
navigator: { standalone: false },
});
expect(isStandalone()).toBe(false);
});
});
describe('isIosSafari', () => {
afterEach(() => vi.unstubAllGlobals());
const IPHONE_SAFARI =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
const IPHONE_CHROME =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0 Mobile/15E148 Safari/604.1';
const ANDROID_CHROME =
'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36';
const IPAD_SAFARI =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15';
it('is true for Safari on iPhone', () => {
vi.stubGlobal('navigator', { userAgent: IPHONE_SAFARI, maxTouchPoints: 5 });
expect(isIosSafari()).toBe(true);
});
it('is true for Safari on iPadOS (Mac UA + touch points)', () => {
vi.stubGlobal('navigator', { userAgent: IPAD_SAFARI, maxTouchPoints: 5 });
expect(isIosSafari()).toBe(true);
});
it('is false for a desktop Mac (no touch points)', () => {
vi.stubGlobal('navigator', { userAgent: IPAD_SAFARI, maxTouchPoints: 0 });
expect(isIosSafari()).toBe(false);
});
it('is false for Chrome on iOS (CriOS)', () => {
vi.stubGlobal('navigator', { userAgent: IPHONE_CHROME, maxTouchPoints: 5 });
expect(isIosSafari()).toBe(false);
});
it('is false for Android Chrome', () => {
vi.stubGlobal('navigator', { userAgent: ANDROID_CHROME, maxTouchPoints: 5 });
expect(isIosSafari()).toBe(false);
});
});
-57
View File
@@ -1,57 +0,0 @@
// Pure PWA install helpers, kept out of the Svelte layer (and free of app imports) so the
// platform branching is unit-testable in the node test env. The reactive runtime (the captured
// install prompt, the Mini-App gate, the service-worker registration) lives in pwa.svelte.ts.
/** InstallMode is how the install call-to-action resolves for the current platform. */
export type InstallMode = 'oneTap' | 'iosInstructions' | 'hidden';
/**
* isStandalone reports whether the app is already running as an installed PWA launched from
* the home screen / desktop rather than a browser tab. Both the standard display-mode media
* query and the iOS-only navigator.standalone flag are checked. Returns false with no window
* (e.g. under the unit tests).
*/
export function isStandalone(): boolean {
if (typeof window === 'undefined') return false;
const displayMode = window.matchMedia?.('(display-mode: standalone)').matches ?? false;
const iosStandalone =
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
return displayMode || iosStandalone;
}
/**
* isIosSafari reports whether the app runs in Safari on iOS / iPadOS the only browser there
* that can add a site to the home screen, and one with no programmatic install (it never fires
* beforeinstallprompt). In-app webviews (Telegram, VK) and the iOS Chrome/Firefox/Edge wrappers
* are excluded, as is desktop Safari. Used to switch the CTA to manual Share -> Add-to-Home-Screen
* instructions.
*/
export function isIosSafari(): boolean {
if (typeof navigator === 'undefined') return false;
const ua = navigator.userAgent;
// iPadOS 13+ reports a Mac UA but exposes touch points — catch that as iOS too.
const isIos =
/iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && (navigator.maxTouchPoints ?? 0) > 1);
if (!isIos) return false;
// Safari only: the iOS Chrome (CriOS), Firefox (FxiOS), Edge (EdgiOS), Opera (OPiOS) and the
// Google-app (GSA) wrappers all carry "Safari" but cannot add to the home screen from our CTA.
return /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS|GSA/.test(ua);
}
/**
* resolveInstallMode chooses how the install CTA should behave from the platform facts:
* `deferredAvailable` a beforeinstallprompt event was captured (Chromium one-tap); `standalone`
* already installed; `iosSafari` manual instructions apply; `inMiniApp` running inside a
* Telegram/VK Mini App. The CTA is offered only on the plain web and never once installed.
*/
export function resolveInstallMode(facts: {
deferredAvailable: boolean;
standalone: boolean;
iosSafari: boolean;
inMiniApp: boolean;
}): InstallMode {
if (facts.inMiniApp || facts.standalone) return 'hidden';
if (facts.deferredAvailable) return 'oneTap';
if (facts.iosSafari) return 'iosInstructions';
return 'hidden';
}
-68
View File
@@ -1,68 +0,0 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { mix, playToWin, deviates, decide, DEFAULT_BAND } from './strategy';
// Conformance gate for the ported robot strategy: the client must fold the game seed and
// choose its action exactly as backend/internal/robot/strategy.go does, so a local vs_ai
// game plays the same. Golden fixtures come from the in-package emitter
// (backend/internal/robot/strategyfixture_test.go).
interface MixCase {
seed: string;
salt: string;
nums: number[];
value: string;
}
interface DecisionCase {
seed: string;
moveCount: number;
myScore: number;
oppScore: number;
candScores: number[];
rackLen: number;
bagLen: number;
playToWin: boolean;
win: boolean;
kind: string;
index: number;
}
interface Fixture {
playToWinPercent: number;
mix: MixCase[];
decisions: DecisionCase[];
}
const fx = JSON.parse(
readFileSync(new URL('./testdata/strategy.json', import.meta.url), 'utf8'),
) as Fixture;
describe('robot strategy parity vs Go', () => {
it('mix folds the seed like Go FNV-1a', () => {
for (const c of fx.mix) {
expect(mix(BigInt(c.seed), c.salt, ...c.nums), `${c.seed}/${c.salt}/${c.nums}`).toBe(BigInt(c.value));
}
});
it('chooses the same action as the Go robot', () => {
for (const c of fx.decisions) {
const seed = BigInt(c.seed);
const cands = c.candScores.map((score, index) => ({ score, index }));
const rack = new Array<string>(c.rackLen).fill('a');
expect(playToWin(seed), `playToWin ${c.seed}`).toBe(c.playToWin);
// the per-turn intent after the occasional off-strategy deviation
let win = playToWin(seed);
if (deviates(seed, c.moveCount, c.bagLen)) win = !win;
expect(win, `win ${c.seed}/${c.moveCount}/${c.bagLen}`).toBe(c.win);
const dec = decide(seed, c.moveCount, cands, c.myScore, c.oppScore, rack, c.bagLen, DEFAULT_BAND);
expect(dec.kind, `kind ${JSON.stringify(c)}`).toBe(c.kind);
if (dec.kind === 'play') {
expect(dec.move.index, `index ${JSON.stringify(c)}`).toBe(c.index);
} else if (dec.kind === 'exchange') {
expect(dec.exchange.length).toBe(c.rackLen);
}
}
});
});

Some files were not shown because too many files have changed in this diff Show More