Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -252,7 +252,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold
|
go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold
|
||||||
go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold
|
go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold
|
||||||
go run ./backend/cmd/movegen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/movegold
|
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -272,7 +271,6 @@ jobs:
|
|||||||
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
|
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
|
||||||
DICT_GOLD_DIR: /tmp/dictgold
|
DICT_GOLD_DIR: /tmp/dictgold
|
||||||
DICT_VALID_DIR: /tmp/validgold
|
DICT_VALID_DIR: /tmp/validgold
|
||||||
DICT_MOVEGEN_DIR: /tmp/movegold
|
|
||||||
run: pnpm exec vitest run src/lib/dict/
|
run: pnpm exec vitest run src/lib/dict/
|
||||||
|
|
||||||
# gate is the single branch-protection required check. It always runs and passes
|
# gate is the single branch-protection required check. It always runs and passes
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"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
|
// Operator alert emails on new feedback / word complaints, coalesced into one digest
|
||||||
// per interval. Inert unless a distinct admin sender and recipient are configured.
|
// per interval. Inert unless a distinct admin sender and recipient are configured.
|
||||||
if cfg.SMTP.AdminFrom != "" && cfg.SMTP.AdminTo != "" {
|
if cfg.SMTP.AdminFrom != "" && cfg.SMTP.AdminTo != "" {
|
||||||
// The alert digest carries no admin-console link on purpose — an admin URL must not
|
consoleURL := ""
|
||||||
// travel in an email (a mail provider could cache or index it); see adminalert.New.
|
if cfg.PublicBaseURL != "" {
|
||||||
alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, logger)
|
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)
|
go alerts.Run(ctx, adminAlertInterval)
|
||||||
logger.Info("admin alert worker started", zap.Duration("interval", adminAlertInterval))
|
logger.Info("admin alert worker started", zap.Duration("interval", adminAlertInterval))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,374 +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"
|
|
||||||
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -105,10 +105,9 @@ func (s *EmailService) allowSend(email string) bool {
|
|||||||
|
|
||||||
// issueCode generates a fresh confirm-code and one-tap deeplink token for (accountID,
|
// 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
|
// 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
|
// locale; purpose selects the email wording and what verifying does. Only the SHA-256
|
||||||
// one-tap deeplink from the email (account deletion, or a login requested from an installed
|
// hashes of the code and token are stored.
|
||||||
// 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) error {
|
||||||
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string, omitLink bool) error {
|
|
||||||
code, codeHash, err := generateCode()
|
code, codeHash, err := generateCode()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Omit the one-tap deeplink when asked: for a login from an installed PWA (the link would
|
// Account deletion is never a one-tap link (a prefetch or a stray click must not delete
|
||||||
// open in a separate browser whose minted session cannot reach the PWA), or for account
|
// an account): the delete code is entered in the app only, so the email omits the
|
||||||
// deletion (a prefetch or stray click must not delete an account — the delete code is entered
|
// deeplink and ConfirmByToken refuses a delete token.
|
||||||
// in the app only, and ConfirmByToken refuses a delete token).
|
|
||||||
deeplink := s.confirmURL(token, locale)
|
deeplink := s.confirmURL(token, locale)
|
||||||
if purpose == purposeDelete || omitLink {
|
if purpose == purposeDelete {
|
||||||
deeplink = ""
|
deeplink = ""
|
||||||
}
|
}
|
||||||
msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale)
|
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 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// language. It returns the target account id for the subsequent LoginWithCode.
|
||||||
// one-tap confirm link — it would open in a separate browser, out of the PWA's reach — so the
|
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string) (uuid.UUID, error) {
|
||||||
// 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) {
|
|
||||||
addr, err := normalizeEmail(email)
|
addr, err := normalizeEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.UUID{}, err
|
return uuid.UUID{}, err
|
||||||
@@ -239,7 +234,7 @@ func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, l
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.UUID{}, err
|
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 uuid.UUID{}, err
|
||||||
}
|
}
|
||||||
return acc.ID, nil
|
return acc.ID, nil
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID,
|
|||||||
if !s.allowSend(addr) {
|
if !s.allowSend(addr) {
|
||||||
return ErrTooManyRequests
|
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
|
// 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) {
|
if !s.allowSend(addr) {
|
||||||
return ErrTooManyRequests
|
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
|
// 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) {
|
if !s.allowSend(addr) {
|
||||||
return ErrTooManyRequests
|
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
|
// VerifyDeleteCode verifies the account-deletion code against the account's own email and
|
||||||
|
|||||||
@@ -33,21 +33,21 @@ type Notifier struct {
|
|||||||
complaints ComplaintCounter
|
complaints ComplaintCounter
|
||||||
from string
|
from string
|
||||||
to string
|
to string
|
||||||
|
consoleURL string
|
||||||
clock func() time.Time
|
clock func() time.Time
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
last time.Time
|
last time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs a Notifier. from and to are the alert sender and recipient(s); log may be
|
// New constructs a Notifier. from and to are the alert sender and recipient(s); consoleURL,
|
||||||
// nil. The watermark starts at "now", so only items arriving after start-up are reported. The
|
// when non-empty, is the admin-console link included in the email. log may be nil. The
|
||||||
// digest deliberately carries no admin-console link — an admin URL must never travel in an
|
// watermark starts at "now", so only items arriving after start-up are reported.
|
||||||
// email, where a mail provider could cache or index it.
|
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to, consoleURL string, log *zap.Logger) *Notifier {
|
||||||
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to string, log *zap.Logger) *Notifier {
|
|
||||||
if log == nil {
|
if log == nil {
|
||||||
log = zap.NewNop()
|
log = zap.NewNop()
|
||||||
}
|
}
|
||||||
return &Notifier{
|
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(),
|
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))
|
parts = append(parts, fmt.Sprintf("%d new word complaint(s)", cp))
|
||||||
}
|
}
|
||||||
summary := strings.Join(parts, ", ")
|
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 + "."
|
text := summary + "."
|
||||||
|
if n.consoleURL != "" {
|
||||||
|
text += "\n\nOpen the admin console: " + n.consoleURL
|
||||||
|
}
|
||||||
return account.Message{
|
return account.Message{
|
||||||
From: n.from,
|
From: n.from,
|
||||||
To: n.to,
|
To: n.to,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func (m *recordingMailer) Send(_ context.Context, msg account.Message) error {
|
|||||||
|
|
||||||
func TestNotifierSkipsWhenNothingNew(t *testing.T) {
|
func TestNotifierSkipsWhenNothingNew(t *testing.T) {
|
||||||
mailer := &recordingMailer{}
|
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())
|
n.tick(context.Background())
|
||||||
if len(mailer.sent) != 0 {
|
if len(mailer.sent) != 0 {
|
||||||
t.Fatalf("sent %d emails, want 0 when nothing is new", len(mailer.sent))
|
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) {
|
func TestNotifierDigestsNewItems(t *testing.T) {
|
||||||
mailer := &recordingMailer{}
|
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())
|
n.tick(context.Background())
|
||||||
if len(mailer.sent) != 1 {
|
if len(mailer.sent) != 1 {
|
||||||
t.Fatalf("sent %d emails, want 1 digest", len(mailer.sent))
|
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") {
|
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)
|
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
|
if !strings.Contains(msg.Text, "/_gm") {
|
||||||
// (mail providers cache/index it).
|
t.Errorf("digest body = %q, want the console link", msg.Text)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ func TestEmailLoginFlow(t *testing.T) {
|
|||||||
svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
|
svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
|
||||||
email := "login-" + uuid.NewString() + "@example.com"
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("request login code: %v", err)
|
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.
|
// 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)
|
t.Fatalf("second request: %v", err)
|
||||||
}
|
}
|
||||||
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
|
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")
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
email := "squat-" + uuid.NewString() + "@example.com"
|
email := "squat-" + uuid.NewString() + "@example.com"
|
||||||
|
|
||||||
id, err := svc.RequestLoginCode(ctx, email, "", "en", false)
|
id, err := svc.RequestLoginCode(ctx, email, "", "en")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("request login code: %v", err)
|
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
|
// confirmToken extracts the one-tap deeplink token from the /app/#/confirm/<token> link
|
||||||
// the branded email carries.
|
// the branded email carries.
|
||||||
var confirmToken = regexp.MustCompile(`/confirm/([A-Za-z0-9_-]+)`)
|
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")
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
email := "tok-login-" + uuid.NewString() + "@example.com"
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("request login: %v", err)
|
t.Fatalf("request login: %v", err)
|
||||||
}
|
}
|
||||||
@@ -410,7 +382,7 @@ func TestEmailAccountSeedsDisplayName(t *testing.T) {
|
|||||||
svc := account.NewEmailService(store, &capturingMailer{}, "https://erudit-game.ru")
|
svc := account.NewEmailService(store, &capturingMailer{}, "https://erudit-game.ru")
|
||||||
local := "kaya-" + uuid.NewString()[:8]
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("request login: %v", err)
|
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))
|
|
||||||
}
|
|
||||||
@@ -174,10 +174,6 @@ type emailRequest struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
BrowserTZ string `json:"browser_tz"`
|
BrowserTZ string `json:"browser_tz"`
|
||||||
Language string `json:"language"`
|
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
|
// 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")
|
abortBadRequest(c, "email is required")
|
||||||
return
|
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)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-14
@@ -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
|
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`
|
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,
|
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
|
and a would-be merge is deferred to the interactive flow. The confirm runs on load; the token rides the URL fragment (never
|
||||||
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
|
|
||||||
sent to the server), so a plain link prefetch cannot reach it, and the manual
|
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
|
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
|
**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
|
(`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,
|
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
|
**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
|
comma-separated addresses. Both paths are inert unless configured.
|
||||||
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.
|
|
||||||
- Per-request server-side timing via gin middleware from day one (the access log
|
- 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
|
carries method, route, status, latency and the active trace id). A
|
||||||
client-measured RTT piggybacked on the next request is a later enhancement.
|
client-measured RTT piggybacked on the next request is a later enhancement.
|
||||||
@@ -1237,14 +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
|
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,
|
browser-language detection — crawlers render with arbitrary languages). The favicon set,
|
||||||
`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by
|
`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by
|
||||||
`assets/icons/`, same tile design as the VK loader). On the web (`/app/`) the SPA is an installable **PWA**:
|
`assets/icons/`, same tile design as the VK loader). Hash-named `/assets/*` are served
|
||||||
`manifest.webmanifest` and an install-only **service worker** (`sw.js`) ship unhashed from
|
|
||||||
`ui/public/`, and the client registers the worker **web-only** — never inside a Mini App or the
|
|
||||||
mock build. The worker intercepts only top-level navigations (network-first with a cached-shell
|
|
||||||
fallback), leaving `/assets/*` and the Connect stream untouched; it exists to satisfy Chromium's
|
|
||||||
installability requirement (a registered SW, needed for install on Android) and is the single
|
|
||||||
growth point for a future opt-in offline mode. The gateway registers the `.webmanifest` MIME type
|
|
||||||
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
|
|
||||||
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
||||||
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
||||||
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
|
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
|
||||||
|
|||||||
+2
-10
@@ -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
|
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.
|
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
|
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**
|
theme, and links to the Telegram game channel and the VK Mini App; the game itself runs at
|
||||||
(`/app/`) — each under a caption (Telegram / VK / Веб-версия); the game itself runs at
|
|
||||||
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
|
`/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
|
(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
|
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
|
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.
|
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
|
### First-run onboarding
|
||||||
The first time a player opens the app it walks them through the interface with a light
|
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
|
**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
|
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
|
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
|
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
|
fragment, which is never sent to the server.
|
||||||
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.
|
|
||||||
Telegram runs a **single bot**: every player uses
|
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
|
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
|
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
||||||
|
|||||||
+2
-10
@@ -22,8 +22,7 @@ top-1 подсказку, безлимитную проверку слова с
|
|||||||
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
||||||
и ограничивает частоту повторов.
|
и ограничивает частоту повторов.
|
||||||
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
||||||
тему и ведёт в Telegram-канал игры, в VK Mini App и в **веб-версию** (`/app/`) — каждая под
|
тему и ведёт в Telegram-канал игры и в VK Mini App; сама игра живёт по адресам
|
||||||
подписью (Telegram / VK / Веб-версия); сама игра живёт по адресам
|
|
||||||
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (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 помечена
|
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
|
||||||
`noindex` — индексируется только посадочная страница.
|
`noindex` — индексируется только посадочная страница.
|
||||||
|
|
||||||
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
|
|
||||||
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
|
|
||||||
(домашний экран) там, где браузер это умеет (Chromium), либо показывает ручные шаги
|
|
||||||
«На экран „Домой“» в Safari на iOS; он скрыт после установки и внутри Mini App.
|
|
||||||
|
|
||||||
### Первый запуск: онбординг
|
### Первый запуск: онбординг
|
||||||
При первом открытии приложения игрока один раз проводят по интерфейсу лёгким
|
При первом открытии приложения игрока один раз проводят по интерфейсу лёгким
|
||||||
**coachmark-оверлеем**: затемнённый слой показывает по одной подсказке-«облачку» за раз, каждое
|
**coachmark-оверлеем**: затемнённый слой показывает по одной подсказке-«облачку» за раз, каждое
|
||||||
@@ -80,9 +74,7 @@ launch-параметрам VK (их проверяет gateway), и при пе
|
|||||||
не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
|
не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
|
||||||
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
|
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
|
||||||
приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется —
|
приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется —
|
||||||
он едет в URL-фрагменте, который не уходит на сервер. Вход, запрошенный из **установленного
|
он едет в URL-фрагменте, который не уходит на сервер.
|
||||||
PWA**, эту ссылку не содержит: в письме только код, который вводится в том же окне (ссылка
|
|
||||||
открылась бы в отдельном браузере, недоступном приложению).
|
|
||||||
Telegram держит **единого бота**: все игроки пользуются одним
|
Telegram держит **единого бота**: все игроки пользуются одним
|
||||||
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
||||||
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
||||||
|
|||||||
@@ -8,9 +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
|
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
|
||||||
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
||||||
**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model.
|
**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model.
|
||||||
The web SPA is an installable **PWA**: it serves `manifest.webmanifest` (with the `.webmanifest`
|
|
||||||
MIME type registered in-process — the distroless image has no `/etc/mime.types`) and the
|
|
||||||
install-only `sw.js`, both shipped unhashed from `ui/public/`.
|
|
||||||
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
||||||
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
||||||
in the deployed contour the front caddy owns `/_gm` (see
|
in the deployed contour the front caddy owns `/_gm` (see
|
||||||
|
|||||||
@@ -289,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
|
// 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
|
// 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
|
// account's time zone, since the email account is created here, not at login.
|
||||||
// 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) error {
|
||||||
func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language string, pwa bool) error {
|
|
||||||
return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "",
|
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.
|
// EmailLogin verifies a login code and mints a session.
|
||||||
|
|||||||
@@ -46,13 +46,7 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
|
|||||||
}
|
}
|
||||||
h, err := meter.Float64Histogram("edge_request_duration",
|
h, err := meter.Float64Histogram("edge_request_duration",
|
||||||
metric.WithUnit("s"),
|
metric.WithUnit("s"),
|
||||||
metric.WithDescription("Seconds to serve one Connect Execute call, by message type and result."),
|
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))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h, _ = noop.NewMeterProvider().Meter(meterName).Float64Histogram("edge_request_duration")
|
h, _ = noop.NewMeterProvider().Meter(meterName).Float64Histogram("edge_request_duration")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ func TestEdgeMetric(t *testing.T) {
|
|||||||
|
|
||||||
type key struct{ messageType, result string }
|
type key struct{ messageType, result string }
|
||||||
counts := map[key]uint64{}
|
counts := map[key]uint64{}
|
||||||
var bounds []float64
|
|
||||||
for _, sm := range rm.ScopeMetrics {
|
for _, sm := range rm.ScopeMetrics {
|
||||||
for _, md := range sm.Metrics {
|
for _, md := range sm.Metrics {
|
||||||
if md.Name != "edge_request_duration" {
|
if md.Name != "edge_request_duration" {
|
||||||
@@ -43,7 +42,6 @@ func TestEdgeMetric(t *testing.T) {
|
|||||||
mt, _ := dp.Attributes.Value(attribute.Key("message_type"))
|
mt, _ := dp.Attributes.Value(attribute.Key("message_type"))
|
||||||
res, _ := dp.Attributes.Value(attribute.Key("result"))
|
res, _ := dp.Attributes.Value(attribute.Key("result"))
|
||||||
counts[key{mt.AsString(), res.AsString()}] += dp.Count
|
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 {
|
if got := counts[key{"auth.guest", "domain"}]; got != 1 {
|
||||||
t.Errorf("edge auth.guest/domain = %d, want 1", got)
|
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
|
// TestRateLimitedMetric records limiter rejections through a manual reader and
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ func authGuestHandler(backend *backendclient.Client) Handler {
|
|||||||
func authEmailRequestHandler(backend *backendclient.Client) Handler {
|
func authEmailRequestHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsEmailRequestRequest(req.Payload, 0)
|
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 nil, err
|
||||||
}
|
}
|
||||||
return encodeAck(true), nil
|
return encodeAck(true), nil
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ package webui
|
|||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"mime"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -36,25 +35,13 @@ func distFS() fs.FS {
|
|||||||
return sub
|
return sub
|
||||||
}
|
}
|
||||||
|
|
||||||
// init registers the MIME type for .webmanifest, which Go's built-in table lacks and the
|
// Handler serves the embedded UI. An existing file is served directly (hash-named assets get
|
||||||
// distroless runtime image has no /etc/mime.types to supply. Without it the PWA Web App Manifest
|
// an immutable cache); every other path falls back to indexName (the SPA shell) so a
|
||||||
// served under /app/ would be content-sniffed to text/plain, which some browsers reject.
|
// client-side deep link still loads. When stripPrefix is non-empty it is removed from the
|
||||||
func init() {
|
// request path before lookup, so the same build serves under a sub-path (e.g. "/app/" or
|
||||||
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
|
// "/telegram/").
|
||||||
}
|
|
||||||
|
|
||||||
// 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/.
|
|
||||||
func Handler(stripPrefix, indexName string) http.Handler {
|
func Handler(stripPrefix, indexName string) http.Handler {
|
||||||
return handlerFor(distFS(), stripPrefix, indexName)
|
content := distFS()
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
files := http.FileServer(http.FS(content))
|
files := http.FileServer(http.FS(content))
|
||||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// get drives the handler with a GET for the given path and returns the response.
|
// get drives the handler with a GET for the given path and returns the response.
|
||||||
@@ -56,56 +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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -138,11 +138,6 @@ table EmailRequestRequest {
|
|||||||
email:string;
|
email:string;
|
||||||
browser_tz:string;
|
browser_tz:string;
|
||||||
language: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
|
// EmailLoginRequest logs in to the account owning email (provisioned at the
|
||||||
|
|||||||
@@ -65,20 +65,8 @@ func (rcv *EmailRequestRequest) Language() []byte {
|
|||||||
return nil
|
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) {
|
func EmailRequestRequestStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(4)
|
builder.StartObject(3)
|
||||||
}
|
}
|
||||||
func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
|
func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0)
|
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) {
|
func EmailRequestRequestAddLanguage(builder *flatbuffers.Builder, language flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(language), 0)
|
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 {
|
func EmailRequestRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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.shell).toBe(false);
|
||||||
expect(state.bodyPosition).not.toBe('fixed');
|
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();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -256,18 +256,6 @@
|
|||||||
<link rel="icon" href="favicon.ico" sizes="32x32" />
|
<link rel="icon" href="favicon.ico" sizes="32x32" />
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
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 |
@@ -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" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
// Install-only service worker.
|
|
||||||
//
|
|
||||||
// Its sole job today is to satisfy Chromium's PWA installability requirement (a registered
|
|
||||||
// service worker with a fetch handler) so the web app can be installed to the home screen /
|
|
||||||
// desktop — notably on Android, where the manifest alone is not enough. It deliberately does
|
|
||||||
// NOT cache assets or the Connect-RPC stream: only top-level navigations are handled,
|
|
||||||
// network-first with a cached-shell fallback, so the live app, its immutable hashed assets and
|
|
||||||
// the live event stream are never served stale.
|
|
||||||
//
|
|
||||||
// This is the single designated growth point for the planned offline mode (a Settings toggle,
|
|
||||||
// default online — see docs/ARCHITECTURE.md). Extend the fetch router here; keep the same
|
|
||||||
// /app/ scope; gate any real caching behind the toggle and coordinate updates with the boot
|
|
||||||
// version guard.
|
|
||||||
const SHELL = 'scrabble-shell-v1'; // bump only when the SW strategy itself changes.
|
|
||||||
|
|
||||||
self.addEventListener('install', () => {
|
|
||||||
// No cached content to migrate carefully, so activate the new worker immediately.
|
|
||||||
self.skipWaiting();
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
(async () => {
|
|
||||||
const keys = await caches.keys();
|
|
||||||
await Promise.all(keys.filter((k) => k !== SHELL).map((k) => caches.delete(k)));
|
|
||||||
await self.clients.claim();
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
|
||||||
const req = event.request;
|
|
||||||
// Only top-level navigations are handled. Hashed assets and the Connect-RPC requests are
|
|
||||||
// left untouched (no respondWith -> the browser performs its default fetch), so the worker
|
|
||||||
// can never break the live stream or serve a stale immutable asset.
|
|
||||||
if (req.mode !== 'navigate') return;
|
|
||||||
event.respondWith(
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(req);
|
|
||||||
const cache = await caches.open(SHELL);
|
|
||||||
// One shell entry regardless of the deep-link path: the hash router resolves the route
|
|
||||||
// client-side, so any offline navigation can be served the same cached shell.
|
|
||||||
cache.put('shell', res.clone());
|
|
||||||
return res;
|
|
||||||
} catch {
|
|
||||||
const cache = await caches.open(SHELL);
|
|
||||||
const cached = await cache.match('shell');
|
|
||||||
return cached ?? Response.error();
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -19,11 +19,10 @@ import { join } from 'node:path';
|
|||||||
|
|
||||||
const DIST = 'dist';
|
const DIST = 'dist';
|
||||||
|
|
||||||
// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview
|
// Per-chunk gzip budgets in KB. The app entry was raised to 110 when the local
|
||||||
// wiring, then to 112 for the PWA install feature (the install CTA + the pwa detection /
|
// move-preview wiring landed (the heavy dict subsystem stays in lazy chunks; this
|
||||||
// service-worker registration live in the app entry; the heavy dict subsystem stays in lazy
|
// covers the small in-entry game/debug wiring it needs).
|
||||||
// chunks). Its scoped CSS lands in the CSS chunk, not this JS budget.
|
const BUDGET = { app: 110, shared: 30, landing: 5 };
|
||||||
const BUDGET = { app: 112, shared: 30, landing: 5 };
|
|
||||||
|
|
||||||
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
||||||
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
||||||
|
|||||||
+4
-19
@@ -80,26 +80,20 @@
|
|||||||
<section class="hero">
|
<section class="hero">
|
||||||
<h1>{about.title}</h1>
|
<h1>{about.title}</h1>
|
||||||
<p class="tagline">{t('landing.tagline')}</p>
|
<p class="tagline">{t('landing.tagline')}</p>
|
||||||
<!-- Platform entries: Telegram/VK are gated by their build vars; the web version (/app/) is
|
{#if tgLink || vkLink}
|
||||||
always available. A caption under each clarifies what it opens. -->
|
|
||||||
<div class="entries">
|
<div class="entries">
|
||||||
{#if tgLink}
|
{#if tgLink}
|
||||||
<a class="entry" href={tgLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playTelegram')}>
|
<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" />
|
<img src="telegram-logo.svg" alt="" width="64" height="64" />
|
||||||
<span class="caption">{t('landing.captionTelegram')}</span>
|
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
{#if vkLink}
|
{#if vkLink}
|
||||||
<a class="entry" href={vkLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playVK')}>
|
<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" />
|
<img src="vk-logo.svg" alt="" width="64" height="64" />
|
||||||
<span class="caption">{t('landing.captionVK')}</span>
|
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="info">
|
<section class="info">
|
||||||
@@ -213,8 +207,8 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
}
|
}
|
||||||
/* The platform entries are the bigger logos in a row, each with a short caption below; the
|
/* The platform entries are just the bigger logos in a row (no button chrome, no captions);
|
||||||
link keeps an aria-label for assistive tech. */
|
each link keeps an aria-label for assistive tech. */
|
||||||
.entries {
|
.entries {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -223,11 +217,6 @@
|
|||||||
}
|
}
|
||||||
.entry {
|
.entry {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
.entry img {
|
.entry img {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -236,10 +225,6 @@
|
|||||||
.entry:hover img {
|
.entry:hover img {
|
||||||
transform: scale(1.06);
|
transform: scale(1.06);
|
||||||
}
|
}
|
||||||
.caption {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.info {
|
.info {
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -41,13 +41,8 @@ language(optionalEncoding?:any):string|Uint8Array|null {
|
|||||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
static startEmailRequestRequest(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(4);
|
builder.startObject(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
|
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);
|
builder.addFieldOffset(2, languageOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addPwa(builder:flatbuffers.Builder, pwa:boolean) {
|
|
||||||
builder.addFieldInt8(3, +pwa, +false);
|
|
||||||
}
|
|
||||||
|
|
||||||
static endEmailRequestRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endEmailRequestRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
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.startEmailRequestRequest(builder);
|
||||||
EmailRequestRequest.addEmail(builder, emailOffset);
|
EmailRequestRequest.addEmail(builder, emailOffset);
|
||||||
EmailRequestRequest.addBrowserTz(builder, browserTzOffset);
|
EmailRequestRequest.addBrowserTz(builder, browserTzOffset);
|
||||||
EmailRequestRequest.addLanguage(builder, languageOffset);
|
EmailRequestRequest.addLanguage(builder, languageOffset);
|
||||||
EmailRequestRequest.addPwa(builder, pwa);
|
|
||||||
return EmailRequestRequest.endEmailRequestRequest(builder);
|
return EmailRequestRequest.endEmailRequestRequest(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ import {
|
|||||||
} from './telegram';
|
} from './telegram';
|
||||||
import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
|
import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
|
||||||
import { pendingVKLink, type VKLinkCallback } from './vkid';
|
import { pendingVKLink, type VKLinkCallback } from './vkid';
|
||||||
import { isStandalone } from './pwa';
|
|
||||||
import { registerServiceWorker } from './pwa.svelte';
|
|
||||||
import { haptic } from './haptics';
|
import { haptic } from './haptics';
|
||||||
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
||||||
import { parseStartParam } from './deeplink';
|
import { parseStartParam } from './deeplink';
|
||||||
@@ -785,11 +783,6 @@ export async function bootstrap(): Promise<void> {
|
|||||||
return;
|
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();
|
|
||||||
|
|
||||||
const saved = await loadSession();
|
const saved = await loadSession();
|
||||||
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
|
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
|
||||||
// from VK; capture and clear it here (it needs the restored session to link against).
|
// from VK; capture and clear it here (it needs the restored session to link against).
|
||||||
@@ -981,7 +974,7 @@ export async function loginGuest(): Promise<void> {
|
|||||||
|
|
||||||
export async function requestEmailCode(email: string): Promise<boolean> {
|
export async function requestEmailCode(email: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await gateway.authEmailRequest(email, app.locale, isStandalone());
|
await gateway.authEmailRequest(email, app.locale);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err);
|
handleError(err);
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export interface GatewayClient {
|
|||||||
* cosmetic seed for a brand-new account). */
|
* cosmetic seed for a brand-new account). */
|
||||||
authVK(params: string, displayName: string): Promise<Session>;
|
authVK(params: string, displayName: string): Promise<Session>;
|
||||||
authGuest(locale?: 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>;
|
authEmailLogin(email: string, code: string): Promise<Session>;
|
||||||
/** Confirm a one-tap email deeplink token: a login returns a session to adopt; a
|
/** Confirm a one-tap email deeplink token: a login returns a session to adopt; a
|
||||||
* link returns a status ('confirmed' | 'merge_required'). */
|
* link returns a status ('confirmed' | 'merge_required'). */
|
||||||
|
|||||||
@@ -122,11 +122,10 @@ describe('codec', () => {
|
|||||||
expect(guest.browserTz()).toBe('-05:30');
|
expect(guest.browserTz()).toBe('-05:30');
|
||||||
|
|
||||||
const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest(
|
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.email()).toBe('a@example.com');
|
||||||
expect(email.browserTz()).toBe('+00:00');
|
expect(email.browserTz()).toBe('+00:00');
|
||||||
expect(email.pwa()).toBe(true);
|
|
||||||
expect(email.language()).toBe('en');
|
expect(email.language()).toBe('en');
|
||||||
|
|
||||||
const confirm = fb.EmailConfirmLinkRequest.getRootAsEmailConfirmLinkRequest(
|
const confirm = fb.EmailConfirmLinkRequest.getRootAsEmailConfirmLinkRequest(
|
||||||
|
|||||||
+1
-7
@@ -214,12 +214,7 @@ export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array
|
|||||||
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
|
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeEmailRequest(
|
export function encodeEmailRequest(email: string, browserTz: string, language: string): Uint8Array {
|
||||||
email: string,
|
|
||||||
browserTz: string,
|
|
||||||
language: string,
|
|
||||||
pwa: boolean,
|
|
||||||
): Uint8Array {
|
|
||||||
const b = new Builder(128);
|
const b = new Builder(128);
|
||||||
const e = b.createString(email);
|
const e = b.createString(email);
|
||||||
const tz = b.createString(browserTz);
|
const tz = b.createString(browserTz);
|
||||||
@@ -228,7 +223,6 @@ export function encodeEmailRequest(
|
|||||||
fb.EmailRequestRequest.addEmail(b, e);
|
fb.EmailRequestRequest.addEmail(b, e);
|
||||||
fb.EmailRequestRequest.addBrowserTz(b, tz);
|
fb.EmailRequestRequest.addBrowserTz(b, tz);
|
||||||
fb.EmailRequestRequest.addLanguage(b, l);
|
fb.EmailRequestRequest.addLanguage(b, l);
|
||||||
fb.EmailRequestRequest.addPwa(b, pwa);
|
|
||||||
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
|
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -95,85 +95,6 @@ export class Dawg {
|
|||||||
return this.indexOf(word) >= 0;
|
return this.indexOf(word) >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Step-by-step traversal (the move generator's primitive) ---------------
|
|
||||||
//
|
|
||||||
// A `Node` is a bit offset into the graph; 0 denotes the root (which resolves
|
|
||||||
// to firstNodeOffset). These mirror dafsa's traverse.go Cursor (Root/Final/
|
|
||||||
// Next/Arcs) over the same bitstream this reader already decodes, so the ported
|
|
||||||
// generator can drive the automaton one transition at a time. Single-threaded
|
|
||||||
// JS shares this reader's position across calls; every method re-seeks to its
|
|
||||||
// node on entry, and arcs brackets the callback with a save/restore, so nested
|
|
||||||
// use during a walk is safe. Mirrors dafsa (*Cursor).
|
|
||||||
|
|
||||||
/** root returns the start state of the automaton. */
|
|
||||||
root(): number {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** final reports whether node is an accepting state (a stored word ends there). */
|
|
||||||
final(node: number): boolean {
|
|
||||||
if (this.numEdges <= 0) {
|
|
||||||
return this.hasEmptyWord && node === 0;
|
|
||||||
}
|
|
||||||
this.p = node === 0 ? this.firstNodeOffset : node;
|
|
||||||
return this.readBits(1) === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* next follows the edge labelled ch (an alphabet index) from node, returning the
|
|
||||||
* destination node, or -1 when no such edge exists.
|
|
||||||
*/
|
|
||||||
next(node: number, ch: number): number {
|
|
||||||
return this.getEdge(node, ch) ? this.eNode : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* arcs calls fn for each out-edge of node in ascending label order, passing the
|
|
||||||
* edge's label, its destination node and whether that destination is accepting.
|
|
||||||
* It stops early if fn returns false. Mirrors dafsa (*Cursor).Arcs.
|
|
||||||
*/
|
|
||||||
arcs(node: number, fn: (label: number, dest: number, final: boolean) => boolean): void {
|
|
||||||
if (this.numEdges <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.p = node === 0 ? this.firstNodeOffset : node;
|
|
||||||
this.readBits(1); // node final flag — not needed here
|
|
||||||
const fallthrough = this.readBits(1);
|
|
||||||
|
|
||||||
if (fallthrough === 1) {
|
|
||||||
const label = this.readBits(this.cbits);
|
|
||||||
// The reader now sits at the destination node, whose first bit is its final flag.
|
|
||||||
const dest = this.p;
|
|
||||||
const final = this.readBits(1) === 1;
|
|
||||||
fn(label, dest, final);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nskiplen = bitsLen(this.wbits);
|
|
||||||
let nskip = 0;
|
|
||||||
let numEdges = 1;
|
|
||||||
if (this.readBits(1) !== 1) {
|
|
||||||
// not a single edge
|
|
||||||
numEdges = this.readUnsigned();
|
|
||||||
nskip = this.readBits(nskiplen);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < numEdges; i++) {
|
|
||||||
const label = this.readBits(this.cbits);
|
|
||||||
if (i > 0) {
|
|
||||||
this.readBits(nskip); // per-edge skip count, unused for traversal
|
|
||||||
}
|
|
||||||
const dest = this.readBits(this.abits);
|
|
||||||
const resume = this.p;
|
|
||||||
this.p = dest;
|
|
||||||
const final = this.readBits(1) === 1;
|
|
||||||
if (!fn(label, dest, final)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.p = resume;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getEdge resolves the outgoing edge for ch from the node at the given bit
|
// getEdge resolves the outgoing edge for ch from the node at the given bit
|
||||||
// offset. On success it fills eNode/eCount/eFinal and returns true. Mirrors
|
// offset. On success it fills eNode/eCount/eFinal and returns true. Mirrors
|
||||||
// dafsa (*dawg).getEdge.
|
// dafsa (*dawg).getEdge.
|
||||||
|
|||||||
@@ -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));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
BIN
Binary file not shown.
-9802
File diff suppressed because it is too large
Load Diff
-122
@@ -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
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
BIN
Binary file not shown.
-1271
File diff suppressed because it is too large
Load Diff
-38
@@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"alphabet": "ru",
|
|
||||||
"numAdded": 6,
|
|
||||||
"words": [
|
|
||||||
"ад",
|
|
||||||
"ар",
|
|
||||||
"оса",
|
|
||||||
"я",
|
|
||||||
"яд",
|
|
||||||
"яр"
|
|
||||||
],
|
|
||||||
"indexes": [
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
4
|
|
||||||
],
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
17
|
|
||||||
],
|
|
||||||
[
|
|
||||||
15,
|
|
||||||
18,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
[
|
|
||||||
32
|
|
||||||
],
|
|
||||||
[
|
|
||||||
32,
|
|
||||||
4
|
|
||||||
],
|
|
||||||
[
|
|
||||||
32,
|
|
||||||
17
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -297,9 +297,8 @@ export function validatePlay(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// connected reports whether the play connects to the position (or covers the
|
// connected reports whether the play connects to the position (or covers the
|
||||||
// centre on the first move). Mirrors (*Solver).connected. Exported so the move
|
// centre on the first move). Mirrors (*Solver).connected.
|
||||||
// generator can apply the same post-generation connectivity filter.
|
function connected(b: Board, rs: Ruleset, m: Move): boolean {
|
||||||
export function connected(b: Board, rs: Ruleset, m: Move): boolean {
|
|
||||||
if (b.isEmpty()) {
|
if (b.isEmpty()) {
|
||||||
const cr = Math.floor(rs.center / rs.cols);
|
const cr = Math.floor(rs.center / rs.cols);
|
||||||
const cc = rs.center % rs.cols;
|
const cc = rs.center % rs.cols;
|
||||||
|
|||||||
@@ -234,17 +234,6 @@ export const en = {
|
|||||||
'landing.tagline': 'Play Scrabble with friends, random opponent or an AI.',
|
'landing.tagline': 'Play Scrabble with friends, random opponent or an AI.',
|
||||||
'landing.playTelegram': 'Play in Telegram',
|
'landing.playTelegram': 'Play in Telegram',
|
||||||
'landing.playVK': 'Play on VK',
|
'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.en': 'English',
|
||||||
'lang.ru': 'Русский',
|
'lang.ru': 'Русский',
|
||||||
|
|||||||
@@ -234,17 +234,6 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'landing.tagline': 'Играй в «Эрудита» с друзьями, случайным соперником или ИИ-роботом.',
|
'landing.tagline': 'Играй в «Эрудита» с друзьями, случайным соперником или ИИ-роботом.',
|
||||||
'landing.playTelegram': 'Играть в Telegram',
|
'landing.playTelegram': 'Играть в Telegram',
|
||||||
'landing.playVK': 'Играть во ВКонтакте',
|
'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.en': 'English',
|
||||||
'lang.ru': 'Русский',
|
'lang.ru': 'Русский',
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
async authGuest(): Promise<Session> {
|
async authGuest(): Promise<Session> {
|
||||||
return { ...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> {
|
async confirmEmailLink(_token: string): Promise<ConfirmLinkResult> {
|
||||||
return { purpose: 'link', status: 'confirmed', session: null };
|
return { purpose: 'link', status: 'confirmed', session: null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,88 +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 install-only worker (ui/public/sw.js). 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.
|
|
||||||
*/
|
|
||||||
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(() => {});
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
// The offline robot's move choice, ported from backend/internal/robot/strategy.go so a
|
|
||||||
// local vs_ai game plays exactly as the server robot does: it picks the candidate whose
|
|
||||||
// resulting score margin lands closest to a target band (playing to win ~40% of games and
|
|
||||||
// to lose the rest), with an occasional off-strategy "wobble" that fades as the bag empties.
|
|
||||||
//
|
|
||||||
// Everything derives deterministically from the game's bag seed, folded by FNV-1a (mix),
|
|
||||||
// so the choice is reproducible. The 64-bit hash is computed with BigInt to stay bit-exact
|
|
||||||
// with Go's uint64 arithmetic; faithfulness is pinned by strategy.parity.test.ts against
|
|
||||||
// golden fixtures. Only the move-choice slice of strategy.go is ported — the server's
|
|
||||||
// think-time, sleep-window and nudge scheduling are irrelevant to on-device play.
|
|
||||||
|
|
||||||
// playToWinPercent is the probability, in percent, that the robot plays to win for a whole
|
|
||||||
// game; the rest of the time it plays to lose, so the human wins about 60% of games.
|
|
||||||
const playToWinPercent = 40;
|
|
||||||
|
|
||||||
// deviateMaxProb is the peak probability of a single off-strategy move, held through the
|
|
||||||
// opening and midgame; it tapers linearly to 0 over the last deviateTaperTiles bag tiles.
|
|
||||||
const deviateMaxProb = 0.2;
|
|
||||||
const deviateTaperTiles = 14;
|
|
||||||
|
|
||||||
const FNV_OFFSET = 14695981039346656037n;
|
|
||||||
const FNV_PRIME = 1099511628211n;
|
|
||||||
const TWO_POW_64 = 1n << 64n;
|
|
||||||
const MASK64 = TWO_POW_64 - 1n;
|
|
||||||
|
|
||||||
// u64le returns the 8 little-endian bytes of v reduced mod 2^64, matching Go's
|
|
||||||
// binary.LittleEndian.PutUint64(uint64(int64(v))) — negatives wrap two's-complement.
|
|
||||||
function u64le(v: bigint): number[] {
|
|
||||||
let x = ((v % TWO_POW_64) + TWO_POW_64) % TWO_POW_64;
|
|
||||||
const out: number[] = [];
|
|
||||||
for (let i = 0; i < 8; i++) {
|
|
||||||
out.push(Number(x & 0xffn));
|
|
||||||
x >>= 8n;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* mix folds the game seed and a salt (a label plus optional integers such as the move
|
|
||||||
* index) into a stable 64-bit value, mirroring robot.mix (FNV-1a over the seed's 8 bytes,
|
|
||||||
* the salt bytes, then each number's 8 bytes). seed is the game's bag seed.
|
|
||||||
*/
|
|
||||||
export function mix(seed: bigint, salt: string, ...nums: number[]): bigint {
|
|
||||||
let h = FNV_OFFSET;
|
|
||||||
const feed = (b: number): void => {
|
|
||||||
h = ((h ^ BigInt(b & 0xff)) * FNV_PRIME) & MASK64;
|
|
||||||
};
|
|
||||||
for (const b of u64le(seed)) feed(b);
|
|
||||||
for (let i = 0; i < salt.length; i++) feed(salt.charCodeAt(i));
|
|
||||||
for (const n of nums) for (const b of u64le(BigInt(n))) feed(b);
|
|
||||||
return h;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** unitFloat maps a mixed value to a float in [0, 1), mirroring robot.unitFloat. */
|
|
||||||
export function unitFloat(v: bigint): number {
|
|
||||||
return Number(v) / Number(TWO_POW_64);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* playToWin reports the robot's once-per-game decision to play to win, derived from the
|
|
||||||
* seed so it is fixed for the whole game. Mirrors robot.playToWin.
|
|
||||||
*/
|
|
||||||
export function playToWin(seed: bigint): boolean {
|
|
||||||
return mix(seed, 'win') % 100n < BigInt(playToWinPercent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// deviateProb is the probability of a single off-strategy move given the tiles left in the
|
|
||||||
// bag: deviateMaxProb through the midgame, tapering to 0 over the last deviateTaperTiles.
|
|
||||||
function deviateProb(bagLen: number): number {
|
|
||||||
if (bagLen <= 0) return 0;
|
|
||||||
if (bagLen >= deviateTaperTiles) return deviateMaxProb;
|
|
||||||
return (deviateMaxProb * bagLen) / deviateTaperTiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* deviates reports whether the robot plays a single move against its per-game intent at
|
|
||||||
* moveCount, given the tiles left in the bag — a deterministic per-turn draw, never firing
|
|
||||||
* once the bag is empty. Mirrors robot.deviates.
|
|
||||||
*/
|
|
||||||
export function deviates(seed: bigint, moveCount: number, bagLen: number): boolean {
|
|
||||||
const p = deviateProb(bagLen);
|
|
||||||
if (p <= 0) return false;
|
|
||||||
return unitFloat(mix(seed, 'deviate', moveCount)) < p;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** MarginBand is an inclusive target range for the resulting score margin (own score after
|
|
||||||
* the move minus the opponent's). */
|
|
||||||
export interface MarginBand {
|
|
||||||
lo: number;
|
|
||||||
hi: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** DEFAULT_BAND aims to lead by 1..30 when playing to win (negated when playing to lose),
|
|
||||||
* matching robot.defaultBand. */
|
|
||||||
export const DEFAULT_BAND: MarginBand = { lo: 1, hi: 30 };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decision is the robot's chosen action: a play (the picked candidate), an exchange of the
|
|
||||||
* listed tiles, or a pass. Generic over the candidate type (needs only a score) and the
|
|
||||||
* rack tile type.
|
|
||||||
*/
|
|
||||||
export type Decision<C, R> =
|
|
||||||
| { kind: 'play'; move: C }
|
|
||||||
| { kind: 'exchange'; exchange: R[] }
|
|
||||||
| { kind: 'pass' };
|
|
||||||
|
|
||||||
// distanceToBand is how far m lies outside [lo, hi], or 0 when inside.
|
|
||||||
function distanceToBand(m: number, lo: number, hi: number): number {
|
|
||||||
if (m < lo) return lo - m;
|
|
||||||
if (m > hi) return m - hi;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* selectMove chooses the robot's action from the ranked candidate plays and the current
|
|
||||||
* scores. With at least one legal play it picks the candidate whose resulting margin
|
|
||||||
* (myScore + score - oppScore) is closest to the band (negated when not playing to win),
|
|
||||||
* breaking ties toward the conservative edge (smallest lead when winning, smallest deficit
|
|
||||||
* when losing). With no legal play it exchanges the whole rack when the bag can refill it,
|
|
||||||
* else passes. Mirrors robot.selectMove.
|
|
||||||
*/
|
|
||||||
export function selectMove<C extends { score: number }, R>(
|
|
||||||
cands: readonly C[],
|
|
||||||
myScore: number,
|
|
||||||
oppScore: number,
|
|
||||||
win: boolean,
|
|
||||||
band: MarginBand,
|
|
||||||
rack: readonly R[],
|
|
||||||
bagLen: number,
|
|
||||||
): Decision<C, R> {
|
|
||||||
if (cands.length === 0) {
|
|
||||||
if (rack.length > 0 && bagLen >= rack.length) {
|
|
||||||
return { kind: 'exchange', exchange: [...rack] };
|
|
||||||
}
|
|
||||||
return { kind: 'pass' };
|
|
||||||
}
|
|
||||||
|
|
||||||
let lo = band.lo;
|
|
||||||
let hi = band.hi;
|
|
||||||
if (!win) {
|
|
||||||
lo = -band.hi;
|
|
||||||
hi = -band.lo;
|
|
||||||
}
|
|
||||||
|
|
||||||
const margin = (c: C): number => myScore + c.score - oppScore;
|
|
||||||
let best = 0;
|
|
||||||
let bestDist = Infinity;
|
|
||||||
for (let i = 0; i < cands.length; i++) {
|
|
||||||
const m = margin(cands[i]);
|
|
||||||
const dist = distanceToBand(m, lo, hi);
|
|
||||||
if (dist < bestDist) {
|
|
||||||
best = i;
|
|
||||||
bestDist = dist;
|
|
||||||
} else if (dist === bestDist) {
|
|
||||||
// Conservative tie-break: keep the lead (win) or the deficit (lose) small.
|
|
||||||
if ((win && m < margin(cands[best])) || (!win && m > margin(cands[best]))) best = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { kind: 'play', move: cands[best] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* decide is the robot's whole per-turn choice: the once-per-game play-to-win intent, flipped
|
|
||||||
* for this move by the occasional deviation, fed to selectMove. Mirrors the decision slice of
|
|
||||||
* robot (*Service).act. seed is the game's bag seed; cands are the ranked legal plays.
|
|
||||||
*/
|
|
||||||
export function decide<C extends { score: number }, R>(
|
|
||||||
seed: bigint,
|
|
||||||
moveCount: number,
|
|
||||||
cands: readonly C[],
|
|
||||||
myScore: number,
|
|
||||||
oppScore: number,
|
|
||||||
rack: readonly R[],
|
|
||||||
bagLen: number,
|
|
||||||
band: MarginBand = DEFAULT_BAND,
|
|
||||||
): Decision<C, R> {
|
|
||||||
let win = playToWin(seed);
|
|
||||||
if (deviates(seed, moveCount, bagLen)) win = !win;
|
|
||||||
return selectMove(cands, myScore, oppScore, win, band, rack, bagLen);
|
|
||||||
}
|
|
||||||
-1057
File diff suppressed because it is too large
Load Diff
@@ -118,8 +118,8 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
async authGuest(locale) {
|
async authGuest(locale) {
|
||||||
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
|
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
|
||||||
},
|
},
|
||||||
async authEmailRequest(email, language, pwa) {
|
async authEmailRequest(email, language) {
|
||||||
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset(), language, pwa));
|
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset(), language));
|
||||||
},
|
},
|
||||||
async confirmEmailLink(token) {
|
async confirmEmailLink(token) {
|
||||||
return codec.decodeConfirmLinkResult(await exec('auth.email.confirm_link', codec.encodeEmailConfirmLink(token)));
|
return codec.decodeConfirmLinkResult(await exec('auth.email.confirm_link', codec.encodeEmailConfirmLink(token)));
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import { mount } from 'svelte';
|
import { mount } from 'svelte';
|
||||||
import './app.css';
|
import './app.css';
|
||||||
import App from './App.svelte';
|
import App from './App.svelte';
|
||||||
import { initInstallListeners } from './lib/pwa.svelte';
|
|
||||||
|
|
||||||
// Pin the document for the game SPA (see app.css `html.app-shell`) so iOS/WKWebView — notably
|
// Pin the document for the game SPA (see app.css `html.app-shell`) so iOS/WKWebView — notably
|
||||||
// the Telegram Mini App — cannot rubber-band the whole page on a vertical drag. The standalone
|
// the Telegram Mini App — cannot rubber-band the whole page on a vertical drag. The standalone
|
||||||
// landing page (landing.ts) is a normal scrolling document and deliberately omits this class.
|
// landing page (landing.ts) is a normal scrolling document and deliberately omits this class.
|
||||||
document.documentElement.classList.add('app-shell');
|
document.documentElement.classList.add('app-shell');
|
||||||
|
|
||||||
// Capture the PWA install prompt as early as possible — before the install CTA mounts. Harmless
|
|
||||||
// where it never fires (Firefox, Safari, the Telegram/VK Mini App webviews).
|
|
||||||
initInstallListeners();
|
|
||||||
|
|
||||||
export default mount(App, { target: document.getElementById('app')! });
|
export default mount(App, { target: document.getElementById('app')! });
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { loginEmail, loginGuest, requestEmailCode } from '../lib/app.svelte';
|
import { loginEmail, loginGuest, requestEmailCode } from '../lib/app.svelte';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import InstallApp from '../components/InstallApp.svelte';
|
|
||||||
|
|
||||||
let email = $state('');
|
let email = $state('');
|
||||||
let code = $state('');
|
let code = $state('');
|
||||||
@@ -56,9 +55,6 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<!-- Web-only install call-to-action, under the login card. Renders nothing unless the app is
|
|
||||||
installable here (see components/InstallApp.svelte / lib/pwa). -->
|
|
||||||
<div class="promo"><InstallApp /></div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -66,15 +62,8 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
/* Centre the card + install promo as a group, with a gap between them. */
|
|
||||||
align-content: center;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
/* Match the card width so the install CTA lines up under it. */
|
|
||||||
.promo {
|
|
||||||
width: min(94vw, 360px);
|
|
||||||
}
|
|
||||||
.card {
|
.card {
|
||||||
width: min(94vw, 360px);
|
width: min(94vw, 360px);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
import type { ThemePref } from '../lib/theme';
|
import type { ThemePref } from '../lib/theme';
|
||||||
import type { BoardLabelMode } from '../lib/boardlabels';
|
import type { BoardLabelMode } from '../lib/boardlabels';
|
||||||
import { insideTelegram } from '../lib/telegram';
|
import { insideTelegram } from '../lib/telegram';
|
||||||
import InstallApp from '../components/InstallApp.svelte';
|
|
||||||
|
|
||||||
const themes: ThemePref[] = ['auto', 'light', 'dark'];
|
const themes: ThemePref[] = ['auto', 'light', 'dark'];
|
||||||
const themeLabel: Record<ThemePref, MessageKey> = {
|
const themeLabel: Record<ThemePref, MessageKey> = {
|
||||||
@@ -74,10 +73,6 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Web-only install call-to-action at the bottom of Settings (renders nothing unless the app
|
|
||||||
is installable here — see components/InstallApp.svelte / lib/pwa). -->
|
|
||||||
<InstallApp />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
Reference in New Issue
Block a user