Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 780ff68ec2 | |||
| ca48b3e18e | |||
| a7f483792f | |||
| 80cc2a76e8 | |||
| 1e087be90a | |||
| 84d0385c95 | |||
| 0ed34c7720 | |||
| 52d0c559f9 | |||
| beda6ccd3d | |||
| c1ac77bc6a | |||
| cafe67e9c8 | |||
| 3adc4e32c9 | |||
| 903de7d41d | |||
| baa9961c3e | |||
| 66278e34b7 | |||
| 452a686e07 | |||
| 4ddc690c38 | |||
| 6216359c6f | |||
| 8c67d679d9 | |||
| 69673e7727 | |||
| d7f3d93c6c | |||
| 16cd3d0411 | |||
| ef8a32bb82 | |||
| 53311cbc95 | |||
| 7fc1301b31 | |||
| ee7ff06403 | |||
| 537a265409 | |||
| 8c991429c6 | |||
| 359af83c34 | |||
| ff486c80f8 | |||
| 42a6308261 | |||
| 08792dad3d | |||
| e9f4cb0178 | |||
| 8fbbb3c5ef | |||
| 30770a759b | |||
| 05c445e4da | |||
| d43a740eb6 | |||
| 350013acd9 | |||
| 3ad66f49c7 | |||
| 09c5a5e72e | |||
| fffc6030ce | |||
| fc8143758a | |||
| e0d28733ff | |||
| ccd65f61b8 | |||
| 5bc2ad3b6d | |||
| 2a4b0fb25e | |||
| 2a7c632840 | |||
| 3a72bc29ba | |||
| 020742fad3 | |||
| fd225564a3 | |||
| fdf14d5897 | |||
| 1a456c4847 | |||
| 2a045a5b37 | |||
| 19e7ea5da0 | |||
| 54d701fd8a | |||
| ec0c13bebc | |||
| 5643c8be10 | |||
| 2f70ef1b85 | |||
| ef832b823d | |||
| 99f0a545be | |||
| 1a95a5f2cb | |||
| 8abd8b2ae6 | |||
| 2f867b8e6c | |||
| a692024b4e | |||
| 8349e222fc | |||
| 68ecd881d9 | |||
| d80d28a402 | |||
| 1654131904 | |||
| 9ecbe480db | |||
| 32298595f2 | |||
| 2847412b5b | |||
| 4fa77bf82c | |||
| afa44d41b4 | |||
| e4cf143e9f | |||
| 543cbe56b9 | |||
| a9c8f1ecfe | |||
| d694ead7b6 | |||
| 8c5995c076 | |||
| cedc9ffae1 | |||
| c334a9d7b7 | |||
| 57ff2d03f8 | |||
| 3a85f64726 | |||
| 00bb66ba0f | |||
| a2c79c149a | |||
| 061366da5a | |||
| d19609f87d | |||
| 51147a1429 | |||
| a9d0986e74 | |||
| d61ba68e9b | |||
| 6db9178449 | |||
| ac383880b7 | |||
| 16349f5ad9 | |||
| 101a3f118c | |||
| 400b6ac2a5 | |||
| fb7490f1df | |||
| c1805e5b7c | |||
| 45957bdcd6 | |||
| fcedadcb5b | |||
| 2feb638329 | |||
| 4dfedd02a3 |
@@ -214,9 +214,21 @@ jobs:
|
|||||||
run: pnpm exec playwright install chromium webkit
|
run: pnpm exec playwright install chromium webkit
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
# The offline e2e plays a real local vs_ai game, so it needs the per-variant dawgs; fetch the
|
||||||
|
# release the same way the Go jobs do and point the mock preview's copy step (scripts/
|
||||||
|
# e2e-dict.mjs, via E2E_DICT_DIR below) at it.
|
||||||
|
- name: Fetch dictionary DAWGs
|
||||||
|
run: |
|
||||||
|
mkdir -p "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
|
||||||
|
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
ls -la "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
|
||||||
- name: E2E smoke (mock)
|
- name: E2E smoke (mock)
|
||||||
run: pnpm run test:e2e
|
run: pnpm run test:e2e
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
|
env:
|
||||||
|
E2E_DICT_DIR: ${{ github.workspace }}/dawg
|
||||||
|
|
||||||
# conformance proves the client's local move preview (the ported dawg reader +
|
# conformance proves the client's local move preview (the ported dawg reader +
|
||||||
# validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine:
|
# validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine:
|
||||||
@@ -252,6 +264,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold
|
go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold
|
||||||
go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold
|
go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold
|
||||||
|
go run ./backend/cmd/movegen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out "${GITHUB_WORKSPACE}/movegold"
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -271,6 +284,7 @@ jobs:
|
|||||||
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
|
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
|
||||||
DICT_GOLD_DIR: /tmp/dictgold
|
DICT_GOLD_DIR: /tmp/dictgold
|
||||||
DICT_VALID_DIR: /tmp/validgold
|
DICT_VALID_DIR: /tmp/validgold
|
||||||
|
DICT_MOVEGEN_DIR: ${{ github.workspace }}/movegold
|
||||||
run: pnpm exec vitest run src/lib/dict/
|
run: pnpm exec vitest run src/lib/dict/
|
||||||
|
|
||||||
# gate is the single branch-protection required check. It always runs and passes
|
# gate is the single branch-protection required check. It always runs and passes
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -216,11 +215,9 @@ 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 != "" {
|
||||||
consoleURL := ""
|
// The alert digest carries no admin-console link on purpose — an admin URL must not
|
||||||
if cfg.PublicBaseURL != "" {
|
// travel in an email (a mail provider could cache or index it); see adminalert.New.
|
||||||
consoleURL = strings.TrimRight(cfg.PublicBaseURL, "/") + "/_gm"
|
alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, logger)
|
||||||
}
|
|
||||||
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
// Command movegen emits golden conformance fixtures for the client-side move
|
||||||
|
// generator port (ui/src/lib/dict). It is a dev tool, run by hand; its output is
|
||||||
|
// committed so the TypeScript parity tests run without a Go toolchain.
|
||||||
|
//
|
||||||
|
// For each small sample dictionary (English and Russian — the latter reaches
|
||||||
|
// alphabet index 32, exercising the 33-letter cross-set boundary) it writes:
|
||||||
|
//
|
||||||
|
// - sample_<tag>.dawg the serialized dictionary (the reader/cursor fixture)
|
||||||
|
// - sample_<tag>.words.json the stored words + their alphabet indexes
|
||||||
|
// - sample_<tag>.gen.json ranked move-generation results from the real solver,
|
||||||
|
// for a handful of positions, plus the ruleset the TS
|
||||||
|
// side rebuilds to score identically
|
||||||
|
//
|
||||||
|
// Positions are built with only the solver's public API: an empty board, and
|
||||||
|
// two-ply positions reached by applying the solver's own top move (so no internal
|
||||||
|
// encoding is needed). Regenerate with:
|
||||||
|
//
|
||||||
|
// go run ./backend/cmd/movegen -out ui/src/lib/dict/testdata
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||||
|
dawg "github.com/iliadenisov/dafsa"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sampleWordsEN is the English sample dictionary, in strictly increasing
|
||||||
|
// alphabet-index order (the builder requires it). Shared prefixes (car/care/cars),
|
||||||
|
// shared suffixes (cats/dogs), internal-final nodes (do, an) and a one-letter word.
|
||||||
|
var sampleWordsEN = []string{
|
||||||
|
"a", "an", "and", "ant",
|
||||||
|
"car", "care", "cared", "cares", "cars", "cat", "cats",
|
||||||
|
"do", "doe", "does", "dog", "dogs", "done", "dot",
|
||||||
|
}
|
||||||
|
|
||||||
|
// sampleWordsRU is the Russian sample dictionary, in strictly increasing index
|
||||||
|
// order. It deliberately includes words starting with я (index 32) so the ported
|
||||||
|
// cross-set handles alphabet indexes past JS's 31-bit shift boundary.
|
||||||
|
var sampleWordsRU = []string{"ад", "ар", "оса", "я", "яд", "яр"}
|
||||||
|
|
||||||
|
// sampleFixture is the JSON committed with the .dawg so the TypeScript cursor test
|
||||||
|
// knows the exact word set (as alphabet indexes) to expect from enumeration.
|
||||||
|
type sampleFixture struct {
|
||||||
|
Alphabet string `json:"alphabet"`
|
||||||
|
NumAdded int `json:"numAdded"`
|
||||||
|
Words []string `json:"words"`
|
||||||
|
Indexes [][]int `json:"indexes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// genFixture is the move-generation golden set for one sample dictionary.
|
||||||
|
type genFixture struct {
|
||||||
|
Ruleset genRuleset `json:"ruleset"`
|
||||||
|
Cases []genCase `json:"cases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// genRuleset is the scoring data the TS side rebuilds so evaluate() matches the Go
|
||||||
|
// solver: letter values, premium multipliers per square, the centre, rack size and bonus.
|
||||||
|
type genRuleset struct {
|
||||||
|
Size int `json:"size"`
|
||||||
|
Cols int `json:"cols"`
|
||||||
|
Center int `json:"center"`
|
||||||
|
RackSize int `json:"rackSize"`
|
||||||
|
Bingo int `json:"bingo"`
|
||||||
|
Values []int `json:"values"`
|
||||||
|
LetterMult [][]int `json:"letterMult"`
|
||||||
|
WordMult [][]int `json:"wordMult"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// genTile is one placed tile (a board tile or a move placement).
|
||||||
|
type genTile struct {
|
||||||
|
Row int `json:"row"`
|
||||||
|
Col int `json:"col"`
|
||||||
|
Letter int `json:"letter"`
|
||||||
|
Blank bool `json:"blank"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// genRack is a rack as a multiset of letter indexes plus a blank count.
|
||||||
|
type genRack struct {
|
||||||
|
Letters []int `json:"letters"`
|
||||||
|
Blanks int `json:"blanks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// genMove is one ranked generated play: its orientation, placed tiles and total score.
|
||||||
|
type genMove struct {
|
||||||
|
Dir int `json:"dir"`
|
||||||
|
Tiles []genTile `json:"tiles"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// genCase is one generation position: the tiles already on the board (empty when
|
||||||
|
// none), the rack, the mode/rule and the ranked moves the solver returns.
|
||||||
|
type genCase struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Placed []genTile `json:"placed"`
|
||||||
|
Rack genRack `json:"rack"`
|
||||||
|
Mode int `json:"mode"`
|
||||||
|
IgnoreCrossWords bool `json:"ignoreCrossWords"`
|
||||||
|
Moves []genMove `json:"moves"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
out := flag.String("out", "ui/src/lib/dict/testdata", "output directory for fixtures")
|
||||||
|
dawgDir := flag.String("dawg-dir", "", "when set, emit real-dictionary move-gen golden from the .dawg files in this dir (conformance mode) instead of the committed samples")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(*out, 0o755); err != nil {
|
||||||
|
log.Fatalf("movegen: mkdir %s: %v", *out, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *dawgDir != "" {
|
||||||
|
buildReal(*dawgDir, *out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emitRulesets()
|
||||||
|
|
||||||
|
buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{
|
||||||
|
emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false),
|
||||||
|
emptyCase("empty-dogs", englishRack("dogsent", 0), scrabble.Both, false),
|
||||||
|
emptyCase("empty-blank", englishRack("caret", 1), scrabble.Both, false),
|
||||||
|
emptyCase("empty-single-word", englishRack("caredts", 0), scrabble.Both, true),
|
||||||
|
})
|
||||||
|
|
||||||
|
buildSample(*out, "ru", rules.RussianScrabble(), sampleWordsRU, []genCase{
|
||||||
|
emptyCase("empty-yad", russianRack("ядрасо", 0), scrabble.Both, false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSample writes the dawg, the word fixture and the generation golden set for
|
||||||
|
// one sample dictionary. Two-ply cases are appended: the solver's own top move from
|
||||||
|
// the first non-empty result is applied, then generation runs again on the new rack.
|
||||||
|
func buildSample(out, tag string, rs *rules.Ruleset, words []string, cases []genCase) {
|
||||||
|
idx := rs.Alphabet
|
||||||
|
b := dawg.New(idx)
|
||||||
|
indexes := make([][]int, 0, len(words))
|
||||||
|
for _, w := range words {
|
||||||
|
if err := b.Add(w); err != nil {
|
||||||
|
log.Fatalf("movegen[%s]: add %q: %v", tag, w, err)
|
||||||
|
}
|
||||||
|
enc, err := idx.Encode(w)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("movegen[%s]: encode %q: %v", tag, w, err)
|
||||||
|
}
|
||||||
|
ints := make([]int, len(enc))
|
||||||
|
for i, x := range enc {
|
||||||
|
ints[i] = int(x)
|
||||||
|
}
|
||||||
|
indexes = append(indexes, ints)
|
||||||
|
}
|
||||||
|
finder := b.Finish()
|
||||||
|
|
||||||
|
writeJSON(filepath.Join(out, "sample_"+tag+".words.json"), sampleFixture{
|
||||||
|
Alphabet: tag, NumAdded: finder.NumAdded(), Words: words, Indexes: indexes,
|
||||||
|
})
|
||||||
|
dawgPath := filepath.Join(out, "sample_"+tag+".dawg")
|
||||||
|
if _, err := finder.Save(dawgPath); err != nil {
|
||||||
|
log.Fatalf("movegen[%s]: save %s: %v", tag, dawgPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := scrabble.NewSolver(rs, finder)
|
||||||
|
for i := range cases {
|
||||||
|
runCase(s, rs, &cases[i], nil)
|
||||||
|
}
|
||||||
|
// A two-ply position from the first standard case that produced a move.
|
||||||
|
if two := twoPly(s, rs, cases); two != nil {
|
||||||
|
cases = append(cases, *two)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(filepath.Join(out, "sample_"+tag+".gen.json"), genFixture{
|
||||||
|
Ruleset: rulesetOf(rs), Cases: cases,
|
||||||
|
})
|
||||||
|
log.Printf("movegen[%s]: %d words, %d cases", tag, finder.NumAdded(), len(cases))
|
||||||
|
}
|
||||||
|
|
||||||
|
// realVariant maps a shipped dictionary file to the ruleset that scores it and the racks
|
||||||
|
// the conformance positions use. smallRack/blankRack keep the first-move (empty board)
|
||||||
|
// lists bounded on a dense dictionary; fullRack drives a deep 7-tile mid-game position,
|
||||||
|
// kept small by the anchors around the already-placed word.
|
||||||
|
type realVariant struct {
|
||||||
|
file, variant, smallRack, blankRack, fullRack string
|
||||||
|
rs *rules.Ruleset
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildReal emits move-generation golden from the real shipped dictionaries in dawgDir —
|
||||||
|
// the full alphabets and deep graphs the tiny samples cannot reach — one
|
||||||
|
// <variant>.movegen.json per variant. Like the dictgen/validategen vectors it is
|
||||||
|
// regenerated in CI and never committed, so it pins no dictionary version into the repo.
|
||||||
|
func buildReal(dawgDir, out string) {
|
||||||
|
reals := []realVariant{
|
||||||
|
{"en_sowpods", "scrabble_en", "aine", "ain", "aeinrst", rules.English()},
|
||||||
|
{"ru_scrabble", "scrabble_ru", "аеин", "аен", "аеиноср", rules.RussianScrabble()},
|
||||||
|
{"ru_erudit", "erudit_ru", "аеин", "аен", "аеиноср", rules.Erudit()},
|
||||||
|
}
|
||||||
|
for _, v := range reals {
|
||||||
|
data, err := os.ReadFile(filepath.Join(dawgDir, v.file+".dawg"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("movegen[%s]: read dawg: %v", v.variant, err)
|
||||||
|
}
|
||||||
|
finder, err := dawg.Read(bytes.NewReader(data), 0)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("movegen[%s]: parse dawg: %v", v.variant, err)
|
||||||
|
}
|
||||||
|
s := scrabble.NewSolver(v.rs, finder)
|
||||||
|
|
||||||
|
cases := []genCase{
|
||||||
|
emptyCase("first-move", encRack(v.rs, v.smallRack, 0), scrabble.Both, false),
|
||||||
|
emptyCase("first-move-blank", encRack(v.rs, v.blankRack, 1), scrabble.Both, false),
|
||||||
|
}
|
||||||
|
for i := range cases {
|
||||||
|
runCase(s, v.rs, &cases[i], nil)
|
||||||
|
}
|
||||||
|
// A deep 7-tile mid-game: place the top first move, then generate again. The
|
||||||
|
// anchors around the placed word bound the list while still exercising a full rack,
|
||||||
|
// deep left/right extension and wide cross-sets over the real graph.
|
||||||
|
full := encRack(v.rs, v.fullRack, 0)
|
||||||
|
b := board.New(v.rs.Rows, v.rs.Cols)
|
||||||
|
if m1 := s.GenerateMovesOpts(b, toRack(v.rs.Size(), full), scrabble.Both, scrabble.PlayOptions{}); len(m1) > 0 {
|
||||||
|
mid := genCase{Name: "mid-game", Rack: full, Mode: int(scrabble.Both)}
|
||||||
|
runCase(s, v.rs, &mid, tilesOf(m1[0].Tiles))
|
||||||
|
cases = append(cases, mid)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(filepath.Join(out, v.variant+".movegen.json"), genFixture{Ruleset: rulesetOf(v.rs), Cases: cases})
|
||||||
|
total := 0
|
||||||
|
for _, c := range cases {
|
||||||
|
total += len(c.Moves)
|
||||||
|
}
|
||||||
|
_ = finder.Close()
|
||||||
|
log.Printf("movegen[%s]: %d cases, %d golden moves", v.variant, len(cases), total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// encRack encodes a rack given as the variant's letters (plus a blank count) into the
|
||||||
|
// index-based genRack the fixtures carry.
|
||||||
|
func encRack(rs *rules.Ruleset, letters string, blanks int) genRack {
|
||||||
|
enc, err := rs.Alphabet.Encode(letters)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("movegen: encode rack %q: %v", letters, err)
|
||||||
|
}
|
||||||
|
idx := make([]int, len(enc))
|
||||||
|
for i, b := range enc {
|
||||||
|
idx[i] = int(b)
|
||||||
|
}
|
||||||
|
return genRack{Letters: idx, Blanks: blanks}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCase fills a case's Moves by generating on a board holding the given placed
|
||||||
|
// tiles (nil = empty board).
|
||||||
|
func runCase(s *scrabble.Solver, rs *rules.Ruleset, c *genCase, placed []genTile) {
|
||||||
|
bd := board.New(rs.Rows, rs.Cols)
|
||||||
|
for _, t := range placed {
|
||||||
|
bd.Set(t.Row, t.Col, cellByte(t.Letter, t.Blank))
|
||||||
|
}
|
||||||
|
c.Placed = placed
|
||||||
|
rk := toRack(rs.Size(), c.Rack)
|
||||||
|
moves := s.GenerateMovesOpts(bd, rk, scrabble.Mode(c.Mode), scrabble.PlayOptions{IgnoreCrossWords: c.IgnoreCrossWords})
|
||||||
|
c.Moves = movesOf(moves)
|
||||||
|
}
|
||||||
|
|
||||||
|
// twoPly reaches a mid-game position by applying the top move of the first standard
|
||||||
|
// case that generated one, then generates again with a fresh rack of the same tiles.
|
||||||
|
func twoPly(s *scrabble.Solver, rs *rules.Ruleset, cases []genCase) *genCase {
|
||||||
|
for _, c := range cases {
|
||||||
|
if c.IgnoreCrossWords || len(c.Moves) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
placed := c.Moves[0].Tiles
|
||||||
|
next := genCase{Name: "two-ply", Rack: c.Rack, Mode: int(scrabble.Both)}
|
||||||
|
runCase(s, rs, &next, placed)
|
||||||
|
return &next
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cellByte encodes a board cell the way internal/encoding.Cell does (bits 0-5 hold
|
||||||
|
// letter+1, bit 7 marks a blank). Duplicated here because that package is internal
|
||||||
|
// to the solver module and cannot be imported.
|
||||||
|
func cellByte(letter int, blank bool) byte {
|
||||||
|
v := byte(letter+1) & 0x3f
|
||||||
|
if blank {
|
||||||
|
v |= 0x80
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func toRack(size int, r genRack) rack.Rack {
|
||||||
|
rk := rack.New(size)
|
||||||
|
for _, l := range r.Letters {
|
||||||
|
rk.Add(byte(l))
|
||||||
|
}
|
||||||
|
for i := 0; i < r.Blanks; i++ {
|
||||||
|
rk.AddBlank()
|
||||||
|
}
|
||||||
|
return rk
|
||||||
|
}
|
||||||
|
|
||||||
|
func rulesetOf(rs *rules.Ruleset) genRuleset {
|
||||||
|
lm := make([][]int, rs.Rows)
|
||||||
|
wm := make([][]int, rs.Rows)
|
||||||
|
for r := 0; r < rs.Rows; r++ {
|
||||||
|
lm[r] = make([]int, rs.Cols)
|
||||||
|
wm[r] = make([]int, rs.Cols)
|
||||||
|
for c := 0; c < rs.Cols; c++ {
|
||||||
|
p := rs.Premium(r, c)
|
||||||
|
lm[r][c] = p.LetterMult()
|
||||||
|
wm[r][c] = p.WordMult()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return genRuleset{
|
||||||
|
Size: rs.Size(), Cols: rs.Cols, Center: rs.Center, RackSize: rs.RackSize,
|
||||||
|
Bingo: rs.Bingo, Values: rs.Values, LetterMult: lm, WordMult: wm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func movesOf(ms []scrabble.Move) []genMove {
|
||||||
|
out := make([]genMove, len(ms))
|
||||||
|
for i, m := range ms {
|
||||||
|
out[i] = genMove{Dir: int(m.Dir), Tiles: tilesOf(m.Tiles), Score: m.Score}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func tilesOf(ps []scrabble.Placement) []genTile {
|
||||||
|
out := make([]genTile, len(ps))
|
||||||
|
for i, p := range ps {
|
||||||
|
out[i] = genTile{Row: p.Row, Col: p.Col, Letter: int(p.Letter), Blank: p.Blank}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// emptyCase builds an empty-board case (Moves filled later by runCase).
|
||||||
|
func emptyCase(name string, r genRack, mode scrabble.Mode, ignoreCross bool) genCase {
|
||||||
|
return genCase{Name: name, Rack: r, Mode: int(mode), IgnoreCrossWords: ignoreCross}
|
||||||
|
}
|
||||||
|
|
||||||
|
// englishRack builds a rack from lowercase a-z letters (index = letter-'a').
|
||||||
|
func englishRack(letters string, blanks int) genRack {
|
||||||
|
idx := make([]int, 0, len(letters))
|
||||||
|
for _, ch := range letters {
|
||||||
|
idx = append(idx, int(ch-'a'))
|
||||||
|
}
|
||||||
|
return genRack{Letters: idx, Blanks: blanks}
|
||||||
|
}
|
||||||
|
|
||||||
|
// russianRack builds a rack from the Russian sample letters used above.
|
||||||
|
func russianRack(letters string, blanks int) genRack {
|
||||||
|
m := map[rune]int{'а': 0, 'д': 4, 'о': 15, 'р': 17, 'с': 18, 'я': 32}
|
||||||
|
idx := make([]int, 0, len([]rune(letters)))
|
||||||
|
for _, ch := range letters {
|
||||||
|
i, ok := m[ch]
|
||||||
|
if !ok {
|
||||||
|
log.Fatalf("movegen: russianRack: no index for %q", string(ch))
|
||||||
|
}
|
||||||
|
idx = append(idx, i)
|
||||||
|
}
|
||||||
|
return genRack{Letters: idx, Blanks: blanks}
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitRulesets writes the per-variant static ruleset data (tile values, bag counts, blanks,
|
||||||
|
// bingo, rack size) the offline engine mirrors in ui/src/lib/localgame/ruleset.ts, so a TS
|
||||||
|
// parity test can pin that hand-copied table to the Go rulesets (scrabble-solver/rules).
|
||||||
|
func emitRulesets() {
|
||||||
|
type rsFix struct {
|
||||||
|
Size int `json:"size"`
|
||||||
|
RackSize int `json:"rackSize"`
|
||||||
|
Bingo int `json:"bingo"`
|
||||||
|
Blanks int `json:"blanks"`
|
||||||
|
Values []int `json:"values"`
|
||||||
|
Counts []int `json:"counts"`
|
||||||
|
Letters []string `json:"letters"`
|
||||||
|
}
|
||||||
|
out := map[string]rsFix{}
|
||||||
|
for _, v := range []struct {
|
||||||
|
name string
|
||||||
|
rs *rules.Ruleset
|
||||||
|
}{
|
||||||
|
{"scrabble_en", rules.English()},
|
||||||
|
{"scrabble_ru", rules.RussianScrabble()},
|
||||||
|
{"erudit_ru", rules.Erudit()},
|
||||||
|
} {
|
||||||
|
letters := make([]string, v.rs.Size())
|
||||||
|
for i := range letters {
|
||||||
|
ch, err := v.rs.Alphabet.Character(byte(i))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("movegen: %s letter %d: %v", v.name, i, err)
|
||||||
|
}
|
||||||
|
letters[i] = strings.ToUpper(ch)
|
||||||
|
}
|
||||||
|
out[v.name] = rsFix{Size: v.rs.Size(), RackSize: v.rs.RackSize, Bingo: v.rs.Bingo, Blanks: v.rs.Blanks, Values: v.rs.Values, Counts: v.rs.Counts, Letters: letters}
|
||||||
|
}
|
||||||
|
dir := filepath.Join("ui", "src", "lib", "localgame", "testdata")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
log.Fatalf("movegen: mkdir %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
writeJSON(filepath.Join(dir, "rulesets.json"), out)
|
||||||
|
log.Printf("movegen: wrote %s (3 variants)", filepath.Join(dir, "rulesets.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(path string, v any) {
|
||||||
|
data, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("movegen: marshal %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||||
|
log.Fatalf("movegen: write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,9 +105,10 @@ 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. Only the SHA-256
|
// locale; purpose selects the email wording and what verifying does. omitLink drops the
|
||||||
// hashes of the code and token are stored.
|
// one-tap deeplink from the email (account deletion, or a login requested from an installed
|
||||||
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string) error {
|
// PWA). Only the SHA-256 hashes of the code and token are stored.
|
||||||
|
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string, omitLink bool) error {
|
||||||
code, codeHash, err := generateCode()
|
code, codeHash, err := generateCode()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -119,11 +120,12 @@ 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
|
||||||
}
|
}
|
||||||
// Account deletion is never a one-tap link (a prefetch or a stray click must not delete
|
// Omit the one-tap deeplink when asked: for a login from an installed PWA (the link would
|
||||||
// an account): the delete code is entered in the app only, so the email omits the
|
// open in a separate browser whose minted session cannot reach the PWA), or for account
|
||||||
// deeplink and ConfirmByToken refuses a delete token.
|
// deletion (a prefetch or stray click must not delete an account — the delete code is entered
|
||||||
|
// in the app only, and ConfirmByToken refuses a delete token).
|
||||||
deeplink := s.confirmURL(token, locale)
|
deeplink := s.confirmURL(token, locale)
|
||||||
if purpose == purposeDelete {
|
if purpose == purposeDelete || omitLink {
|
||||||
deeplink = ""
|
deeplink = ""
|
||||||
}
|
}
|
||||||
msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale)
|
msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale)
|
||||||
@@ -175,7 +177,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))
|
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfirmCode verifies code for accountID and email. On success it attaches a
|
// ConfirmCode verifies code for accountID and email. On success it attaches a
|
||||||
@@ -221,8 +223,11 @@ 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. It returns the target account id for the subsequent LoginWithCode.
|
// language. When pwa is set (the request came from an installed PWA) the login email omits the
|
||||||
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string) (uuid.UUID, error) {
|
// one-tap confirm link — it would open in a separate browser, out of the PWA's reach — so the
|
||||||
|
// code is entered in the same window. It returns the target account id for the subsequent
|
||||||
|
// LoginWithCode.
|
||||||
|
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string, pwa bool) (uuid.UUID, error) {
|
||||||
addr, err := normalizeEmail(email)
|
addr, err := normalizeEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.UUID{}, err
|
return uuid.UUID{}, err
|
||||||
@@ -234,7 +239,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); err != nil {
|
if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language, pwa); 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))
|
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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))
|
return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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))
|
return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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); consoleURL,
|
// New constructs a Notifier. from and to are the alert sender and recipient(s); log may be
|
||||||
// when non-empty, is the admin-console link included in the email. log may be nil. The
|
// nil. The watermark starts at "now", so only items arriving after start-up are reported. The
|
||||||
// watermark starts at "now", so only items arriving after start-up are reported.
|
// digest deliberately carries no admin-console link — an admin URL must never travel in an
|
||||||
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to, consoleURL string, log *zap.Logger) *Notifier {
|
// email, where a mail provider could cache or index it.
|
||||||
|
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to string, log *zap.Logger) *Notifier {
|
||||||
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, consoleURL: consoleURL,
|
mailer: mailer, feedback: fb, complaints: cp, from: from, to: to,
|
||||||
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,10 +103,9 @@ 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", "https://erudit-game.ru/_gm", nil)
|
n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", 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,7 +49,9 @@ 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)
|
||||||
}
|
}
|
||||||
if !strings.Contains(msg.Text, "/_gm") {
|
// The digest must never carry an admin-console link — an admin URL in an email is a leak
|
||||||
t.Errorf("digest body = %q, want the console link", msg.Text)
|
// (mail providers cache/index it).
|
||||||
|
if strings.Contains(msg.Text, "/_gm") || strings.Contains(strings.ToLower(msg.Text), "admin console") {
|
||||||
|
t.Errorf("digest body = %q, must not carry an admin-console link", msg.Text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,3 +193,24 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
|
|||||||
.replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; }
|
.replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; }
|
||||||
.replay-log li { color: var(--ink-dim); padding: 0.1rem 0; }
|
.replay-log li { color: var(--ink-dim); padding: 0.1rem 0; }
|
||||||
.replay-log li.cur { color: var(--ink); font-weight: 600; }
|
.replay-log li.cur { color: var(--ink); font-weight: 600; }
|
||||||
|
|
||||||
|
/* Banner colour override editor + live preview (banner_detail). The override
|
||||||
|
fieldsets group the enable toggle with the native colour swatches; the preview
|
||||||
|
renders a sample strip on both themes from those inputs (see the inline script). */
|
||||||
|
.ovr { border: 1px solid var(--line); border-radius: 6px; padding: 0.3rem 0.8rem 0.7rem; margin: 0.2rem 0; }
|
||||||
|
.ovr legend { padding: 0 0.3rem; font-size: 0.85rem; color: var(--ink); }
|
||||||
|
.ovr legend label { flex-direction: row; align-items: center; gap: 0.4rem; color: var(--ink); }
|
||||||
|
.ovr .note { margin: 0.2rem 0 0.4rem; }
|
||||||
|
.ovr .swatches { display: flex; flex-wrap: wrap; gap: 1rem; }
|
||||||
|
.ovr .swatches label { flex-direction: column; gap: 0.25rem; align-items: flex-start; }
|
||||||
|
.ovr input[type=color] { width: 3rem; height: 1.8rem; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); cursor: pointer; }
|
||||||
|
.ovr input[type=color]:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.ovr .hex { font-size: 0.72rem; color: var(--ink-dim); font-variant-numeric: tabular-nums; }
|
||||||
|
.banner-preview { display: flex; flex-wrap: wrap; gap: 0.8rem; margin: 0.7rem 0 0.2rem; }
|
||||||
|
.banner-preview .bp { flex: 1 1 18rem; }
|
||||||
|
.banner-preview .bp-label { display: block; font-size: 0.75rem; color: var(--ink-dim); margin-bottom: 0.25rem; }
|
||||||
|
.ad-frame { padding: 0.7rem; border-radius: 6px; border: 1px solid var(--line); }
|
||||||
|
.ad-frame.light { background: #f4f6f9; }
|
||||||
|
.ad-frame.dark { background: #0f1420; }
|
||||||
|
.ad-sample { padding: 0.35rem 0.7rem; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.ad-sample .ad-link { text-decoration: underline; }
|
||||||
|
|||||||
@@ -11,10 +11,72 @@
|
|||||||
<label>Starts (UTC) <input type="datetime-local" name="starts_at" value="{{.StartsAt}}"></label>
|
<label>Starts (UTC) <input type="datetime-local" name="starts_at" value="{{.StartsAt}}"></label>
|
||||||
<label>Ends (UTC) <input type="datetime-local" name="ends_at" value="{{.EndsAt}}"></label>
|
<label>Ends (UTC) <input type="datetime-local" name="ends_at" value="{{.EndsAt}}"></label>
|
||||||
<label><input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}> Enabled</label>
|
<label><input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}> Enabled</label>
|
||||||
|
<fieldset class="ovr">
|
||||||
|
<legend><label><input type="checkbox" name="urgent"{{if .Urgent}} checked{{end}}> Urgent</label></legend>
|
||||||
|
<p class="note">Shows to <em>everyone</em>, always — bypassing paid accounts, hint wallets and the no-banner role. While any urgent campaign is live it is the only thing the strip shows (other campaigns and the default are suppressed). For system alerts.</p>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="ovr" data-ovr-group>
|
||||||
|
<legend><label><input type="checkbox" name="override_all_on" data-ovr="all"{{if .OverrideAllOn}} checked{{end}}> Colour override — all themes</label></legend>
|
||||||
|
<div class="swatches">
|
||||||
|
<label>Background <input type="color" name="override_bg" value="{{.AllBg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
|
||||||
|
<label>Text <input type="color" name="override_fg" value="{{.AllFg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
|
||||||
|
<label>Link <input type="color" name="override_link" value="{{.AllLink}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="ovr" data-ovr-group>
|
||||||
|
<legend><label><input type="checkbox" name="override_dark_on" data-ovr="dark"{{if .OverrideDarkOn}} checked{{end}}> Colour override — dark theme only</label></legend>
|
||||||
|
<div class="swatches">
|
||||||
|
<label>Background <input type="color" name="override_bg_dark" value="{{.DarkBg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
|
||||||
|
<label>Text <input type="color" name="override_fg_dark" value="{{.DarkFg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
|
||||||
|
<label>Link <input type="color" name="override_link_dark" value="{{.DarkLink}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div><button type="submit">Save</button></div>
|
<div><button type="submit">Save</button></div>
|
||||||
</form>
|
</form>
|
||||||
{{if not .IsDefault}}
|
{{if not .IsDefault}}
|
||||||
|
<div class="banner-preview">
|
||||||
|
<div class="bp"><span class="bp-label">Light theme</span><div class="ad-frame light"><div class="ad-sample" id="prev-light"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
|
||||||
|
<div class="bp"><span class="bp-label">Dark theme</span><div class="ad-frame dark"><div class="ad-sample" id="prev-dark"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
|
||||||
|
</div>
|
||||||
|
<p class="note">Live preview of the strip on both themes. An empty override falls back to the neutral theme colours; the top/bottom border is derived from the background.</p>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
// Neutral fallbacks — mirror ui/src/app.css (--ad-bg / --text-muted / --accent).
|
||||||
|
var TOK={light:{bg:'#e3e7ee',fg:'#6b7280',link:'#2f6df6'},dark:{bg:'#272f3c',fg:'#9aa3b2',link:'#5b8cff'}};
|
||||||
|
function byName(n){return document.querySelector('[name="'+n+'"]');}
|
||||||
|
var allOn=byName('override_all_on'), darkOn=byName('override_dark_on');
|
||||||
|
if(!allOn||!darkOn){return;}
|
||||||
|
var f={ab:byName('override_bg'),af:byName('override_fg'),al:byName('override_link'),db:byName('override_bg_dark'),df:byName('override_fg_dark'),dl:byName('override_link_dark')};
|
||||||
|
var lightEl=document.getElementById('prev-light'), darkEl=document.getElementById('prev-dark');
|
||||||
|
function hexToRgb(h){h=h.replace('#','');return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)];}
|
||||||
|
function pad(x){x=Math.max(0,Math.min(255,Math.round(x))).toString(16);return x.length<2?'0'+x:x;}
|
||||||
|
function rgbToHex(r){return '#'+pad(r[0])+pad(r[1])+pad(r[2]);}
|
||||||
|
function mix(a,b,t){return [a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t,a[2]+(b[2]-a[2])*t];}
|
||||||
|
function lum(r){return (0.2126*r[0]+0.7152*r[1]+0.0722*r[2])/255;}
|
||||||
|
// Derived border: nudge the background 14% toward black on a light bg, toward white on a dark bg.
|
||||||
|
function border(bg){var r=hexToRgb(bg);return rgbToHex(mix(r, lum(r)>0.5?[0,0,0]:[255,255,255], 0.14));}
|
||||||
|
function paint(el,c){
|
||||||
|
el.style.background=c.bg; el.style.color=c.fg;
|
||||||
|
el.style.borderTop='1px solid '+border(c.bg); el.style.borderBottom='1px solid '+border(c.bg);
|
||||||
|
var a=el.querySelector('.ad-link'); if(a){a.style.color=c.link;}
|
||||||
|
}
|
||||||
|
function resolve(){
|
||||||
|
var light=allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.light;
|
||||||
|
var dark=darkOn.checked?{bg:f.db.value,fg:f.df.value,link:f.dl.value}
|
||||||
|
:(allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.dark);
|
||||||
|
paint(lightEl,light); paint(darkEl,dark);
|
||||||
|
document.querySelectorAll('.ovr .swatches label').forEach(function(lab){
|
||||||
|
var inp=lab.querySelector('input[type=color]'), hx=lab.querySelector('.hex');
|
||||||
|
if(inp&&hx){hx.textContent=inp.value;}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function toggleGroup(chk){chk.closest('fieldset').querySelectorAll('[data-ovr-color]').forEach(function(inp){inp.disabled=!chk.checked;});}
|
||||||
|
[allOn,darkOn].forEach(function(chk){chk.addEventListener('change',function(){toggleGroup(chk);resolve();});});
|
||||||
|
Object.keys(f).forEach(function(k){f[k].addEventListener('input',resolve);});
|
||||||
|
resolve();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<form class="form" method="post" action="/_gm/banners/{{.ID}}/delete" onsubmit="return confirm('Delete this campaign and its messages?')">
|
<form class="form" method="post" action="/_gm/banners/{{.ID}}/delete" onsubmit="return confirm('Delete this campaign and its messages?')">
|
||||||
<button type="submit" class="danger">Delete campaign</button>
|
<button type="submit" class="danger">Delete campaign</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -498,6 +498,12 @@ type BannerCampaignRow struct {
|
|||||||
|
|
||||||
// BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the
|
// BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the
|
||||||
// "YYYY-MM-DDTHH:MM" (UTC) values for the datetime-local inputs, empty when open.
|
// "YYYY-MM-DDTHH:MM" (UTC) values for the datetime-local inputs, empty when open.
|
||||||
|
//
|
||||||
|
// The colour-override and urgent fields drive the non-default campaign's editor:
|
||||||
|
// OverrideAllOn/OverrideDarkOn report whether each colour set is active, and the
|
||||||
|
// six *Bg/*Fg/*Link values seed the native colour inputs — the stored override
|
||||||
|
// when a set is on, otherwise the neutral theme token so the picker starts from a
|
||||||
|
// sensible colour and the live preview shows the real fallback.
|
||||||
type BannerDetailView struct {
|
type BannerDetailView struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
@@ -506,6 +512,15 @@ type BannerDetailView struct {
|
|||||||
Enabled bool
|
Enabled bool
|
||||||
StartsAt string
|
StartsAt string
|
||||||
EndsAt string
|
EndsAt string
|
||||||
|
Urgent bool
|
||||||
|
OverrideAllOn bool
|
||||||
|
AllBg string
|
||||||
|
AllFg string
|
||||||
|
AllLink string
|
||||||
|
OverrideDarkOn bool
|
||||||
|
DarkBg string
|
||||||
|
DarkFg string
|
||||||
|
DarkLink string
|
||||||
Messages []BannerMessageRow
|
Messages []BannerMessageRow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+75
-11
@@ -30,6 +30,15 @@ var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modifi
|
|||||||
// window or message body).
|
// window or message body).
|
||||||
var ErrValidation = errors.New("ads: validation")
|
var ErrValidation = errors.New("ads: validation")
|
||||||
|
|
||||||
|
// ColorSet is an optional per-campaign colour override for the banner strip:
|
||||||
|
// background, foreground (text) and link, each a "#rrggbb" hex string. The three
|
||||||
|
// are set together or the whole set is absent (a nil *ColorSet).
|
||||||
|
type ColorSet struct {
|
||||||
|
Bg string
|
||||||
|
Fg string
|
||||||
|
Link string
|
||||||
|
}
|
||||||
|
|
||||||
// Campaign is one advertising placement order with its messages.
|
// Campaign is one advertising placement order with its messages.
|
||||||
type Campaign struct {
|
type Campaign struct {
|
||||||
ID uuid.UUID // uuid.Nil on create
|
ID uuid.UUID // uuid.Nil on create
|
||||||
@@ -40,6 +49,16 @@ type Campaign struct {
|
|||||||
StartsAt *time.Time // nil = open-ended start; always nil for the default
|
StartsAt *time.Time // nil = open-ended start; always nil for the default
|
||||||
EndsAt *time.Time // nil = open-ended end; always nil for the default
|
EndsAt *time.Time // nil = open-ended end; always nil for the default
|
||||||
Messages []Message
|
Messages []Message
|
||||||
|
// OverrideAll paints the strip on every theme; OverrideDark, when set, further
|
||||||
|
// overrides the dark theme (the client resolves dark ← dark ?? all ?? token,
|
||||||
|
// light ← all ?? token). Both are nil for the default campaign and for a
|
||||||
|
// campaign that keeps the neutral theme tokens. Non-default only.
|
||||||
|
OverrideAll *ColorSet
|
||||||
|
OverrideDark *ColorSet
|
||||||
|
// Urgent forces the banner on every viewer (bypassing eligibility) and, while
|
||||||
|
// any urgent campaign is active, suppresses every non-urgent campaign and the
|
||||||
|
// default remainder. Non-default only; always false for the default campaign.
|
||||||
|
Urgent bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -70,10 +89,15 @@ type Timings struct {
|
|||||||
|
|
||||||
// ActiveCampaign is one campaign in the resolved rotation feed sent to a client:
|
// ActiveCampaign is one campaign in the resolved rotation feed sent to a client:
|
||||||
// its GCD-reduced show weight and its messages, already resolved to the viewer's
|
// its GCD-reduced show weight and its messages, already resolved to the viewer's
|
||||||
// language and in display (round-robin) order.
|
// language and in display (round-robin) order, plus the optional colour overrides
|
||||||
|
// the client applies to the strip (nil = the neutral theme tokens). Urgency is not
|
||||||
|
// carried here: it is resolved server-side into the set's membership and the
|
||||||
|
// eligibility bypass, so the client only ever renders what it is sent.
|
||||||
type ActiveCampaign struct {
|
type ActiveCampaign struct {
|
||||||
Weight int
|
Weight int
|
||||||
Messages []string
|
Messages []string
|
||||||
|
OverrideAll *ColorSet
|
||||||
|
OverrideDark *ColorSet
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eligible reports whether an account should be shown the advertising banner: a
|
// Eligible reports whether an account should be shown the advertising banner: a
|
||||||
@@ -85,14 +109,20 @@ func Eligible(paidAccount bool, hintBalance int, hasNoBanner bool) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// computeActiveSet builds the resolved rotation feed from the enabled campaigns
|
// computeActiveSet builds the resolved rotation feed from the enabled campaigns
|
||||||
// at time now, in language lang. Campaigns outside their validity window, and
|
// at time now, in language lang, and reports whether the feed is an urgent one.
|
||||||
// campaigns with no messages, are dropped. The default campaign's effective
|
// Campaigns outside their validity window, and campaigns with no messages, are
|
||||||
// weight is the remainder up to 100% — max(0, 100 - sum of active timed
|
// dropped.
|
||||||
// weights) — so it fills unsold inventory and is dropped entirely when timed
|
//
|
||||||
// campaigns already reach 100%. Weights are then reduced by their GCD so the
|
// While any active campaign is urgent, the feed is the urgent campaigns alone —
|
||||||
// fair rotation cycle stays short. The input is expected to be the enabled
|
// every non-urgent timed campaign and the default remainder are suppressed for
|
||||||
// campaigns (ActiveCampaigns); disabled ones must already be excluded.
|
// the duration (and the caller shows the feed to every viewer, bypassing
|
||||||
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []ActiveCampaign {
|
// eligibility). Otherwise the default campaign's effective weight is the
|
||||||
|
// remainder up to 100% — max(0, 100 - sum of active timed weights) — so it fills
|
||||||
|
// unsold inventory and is dropped entirely when timed campaigns already reach
|
||||||
|
// 100%. Weights are then reduced by their GCD so the fair rotation cycle stays
|
||||||
|
// short. The input is expected to be the enabled campaigns (ActiveCampaigns);
|
||||||
|
// disabled ones must already be excluded.
|
||||||
|
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) ([]ActiveCampaign, bool) {
|
||||||
var timed []Campaign
|
var timed []Campaign
|
||||||
var def *Campaign
|
var def *Campaign
|
||||||
for i := range campaigns {
|
for i := range campaigns {
|
||||||
@@ -108,20 +138,54 @@ func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []Active
|
|||||||
timed = append(timed, c)
|
timed = append(timed, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if urgent := filterUrgent(timed); len(urgent) > 0 {
|
||||||
|
out := make([]ActiveCampaign, 0, len(urgent))
|
||||||
|
for _, c := range urgent {
|
||||||
|
out = append(out, activeFrom(c, lang))
|
||||||
|
}
|
||||||
|
reduceByGCD(out)
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
sumTimed := 0
|
sumTimed := 0
|
||||||
for _, c := range timed {
|
for _, c := range timed {
|
||||||
sumTimed += c.Weight
|
sumTimed += c.Weight
|
||||||
}
|
}
|
||||||
out := make([]ActiveCampaign, 0, len(timed)+1)
|
out := make([]ActiveCampaign, 0, len(timed)+1)
|
||||||
for _, c := range timed {
|
for _, c := range timed {
|
||||||
out = append(out, ActiveCampaign{Weight: c.Weight, Messages: resolveBodies(c.Messages, lang)})
|
out = append(out, activeFrom(c, lang))
|
||||||
}
|
}
|
||||||
if def != nil {
|
if def != nil {
|
||||||
if dw := 100 - sumTimed; dw > 0 {
|
if dw := 100 - sumTimed; dw > 0 {
|
||||||
out = append(out, ActiveCampaign{Weight: dw, Messages: resolveBodies(def.Messages, lang)})
|
a := activeFrom(*def, lang)
|
||||||
|
a.Weight = dw // the default's stored weight is nominal; it fills the remainder
|
||||||
|
out = append(out, a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reduceByGCD(out)
|
reduceByGCD(out)
|
||||||
|
return out, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeFrom projects a campaign to its rotation-feed entry: its show weight, its
|
||||||
|
// language-resolved messages and its colour overrides (carried through by
|
||||||
|
// reference, they are read-only).
|
||||||
|
func activeFrom(c Campaign, lang string) ActiveCampaign {
|
||||||
|
return ActiveCampaign{
|
||||||
|
Weight: c.Weight,
|
||||||
|
Messages: resolveBodies(c.Messages, lang),
|
||||||
|
OverrideAll: c.OverrideAll,
|
||||||
|
OverrideDark: c.OverrideDark,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterUrgent returns the urgent campaigns among cs, preserving order. It is the
|
||||||
|
// preempt selector: a non-empty result makes the whole feed urgent.
|
||||||
|
func filterUrgent(cs []Campaign) []Campaign {
|
||||||
|
var out []Campaign
|
||||||
|
for _, c := range cs {
|
||||||
|
if c.Urgent {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,12 +46,19 @@ func TestComputeActiveSet(t *testing.T) {
|
|||||||
timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign {
|
timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign {
|
||||||
return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs}
|
return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs}
|
||||||
}
|
}
|
||||||
|
urgent := func(name string, weight int, msgs []Message) Campaign {
|
||||||
|
c := timed(name, weight, nil, nil, msgs)
|
||||||
|
c.Urgent = true
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
red := &ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
campaigns []Campaign
|
campaigns []Campaign
|
||||||
lang string
|
lang string
|
||||||
want []ActiveCampaign
|
want []ActiveCampaign
|
||||||
|
wantUrgent bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "default only reduces to weight 1",
|
name: "default only reduces to weight 1",
|
||||||
@@ -152,22 +159,71 @@ func TestComputeActiveSet(t *testing.T) {
|
|||||||
lang: "en",
|
lang: "en",
|
||||||
want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}},
|
want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "urgent preempts default and normal timed",
|
||||||
|
campaigns: []Campaign{
|
||||||
|
def(msg("house")),
|
||||||
|
timed("promo", 40, nil, nil, msg("promo")),
|
||||||
|
urgent("alert", 50, msg("alert")),
|
||||||
|
},
|
||||||
|
lang: "en",
|
||||||
|
// only the urgent campaign survives; a lone weight reduces to 1.
|
||||||
|
want: []ActiveCampaign{{Weight: 1, Messages: []string{"alert-en"}}},
|
||||||
|
wantUrgent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple urgent share the feed by gcd",
|
||||||
|
campaigns: []Campaign{
|
||||||
|
def(msg("house")),
|
||||||
|
urgent("a", 60, msg("a")),
|
||||||
|
urgent("b", 40, msg("b")),
|
||||||
|
},
|
||||||
|
lang: "en",
|
||||||
|
// default and any non-urgent dropped; gcd(60,40)=20 -> 3 and 2.
|
||||||
|
want: []ActiveCampaign{
|
||||||
|
{Weight: 3, Messages: []string{"a-en"}},
|
||||||
|
{Weight: 2, Messages: []string{"b-en"}},
|
||||||
|
},
|
||||||
|
wantUrgent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "out-of-window urgent does not preempt",
|
||||||
|
campaigns: []Campaign{
|
||||||
|
def(msg("house")),
|
||||||
|
func() Campaign { c := urgent("future", 50, msg("future")); c.StartsAt = &future; return c }(),
|
||||||
|
},
|
||||||
|
lang: "en",
|
||||||
|
// the urgent campaign is not yet live, so the normal default feed stands.
|
||||||
|
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "colour overrides ride the active campaign",
|
||||||
|
campaigns: []Campaign{
|
||||||
|
def(msg("house")),
|
||||||
|
func() Campaign { c := timed("promo", 100, nil, nil, msg("promo")); c.OverrideAll = red; return c }(),
|
||||||
|
},
|
||||||
|
lang: "en",
|
||||||
|
want: []ActiveCampaign{{Weight: 1, Messages: []string{"promo-en"}, OverrideAll: red}},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got := computeActiveSet(tt.campaigns, now, tt.lang)
|
got, gotUrgent := computeActiveSet(tt.campaigns, now, tt.lang)
|
||||||
if !reflect.DeepEqual(got, tt.want) {
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want)
|
t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want)
|
||||||
}
|
}
|
||||||
|
if gotUrgent != tt.wantUrgent {
|
||||||
|
t.Errorf("computeActiveSet() urgent = %v, want %v", gotUrgent, tt.wantUrgent)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestComputeActiveSetEmpty(t *testing.T) {
|
func TestComputeActiveSetEmpty(t *testing.T) {
|
||||||
// No campaigns at all yields an empty (non-nil-or-nil) feed without panicking.
|
// No campaigns at all yields an empty (non-nil-or-nil) feed without panicking.
|
||||||
if got := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 {
|
if got, urgent := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 || urgent {
|
||||||
t.Errorf("computeActiveSet(nil) = %#v, want empty", got)
|
t.Errorf("computeActiveSet(nil) = %#v urgent=%v, want empty non-urgent", got, urgent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ads
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -40,17 +41,20 @@ func NewService(store *Store) *Service { return &Service{store: store} }
|
|||||||
// ActiveSet returns the resolved rotation feed for a viewer in language lang
|
// ActiveSet returns the resolved rotation feed for a viewer in language lang
|
||||||
// (en/ru) together with the global display timings: the currently-active
|
// (en/ru) together with the global display timings: the currently-active
|
||||||
// campaigns, each with its GCD-reduced show weight and its messages resolved to
|
// campaigns, each with its GCD-reduced show weight and its messages resolved to
|
||||||
// lang, ready for the client's weighted round-robin.
|
// lang, ready for the client's weighted round-robin. The bool result reports
|
||||||
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, error) {
|
// whether the feed is urgent — an urgent feed is shown to every viewer
|
||||||
|
// regardless of eligibility (the caller skips the eligibility gate for it).
|
||||||
|
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, bool, error) {
|
||||||
campaigns, err := s.store.ActiveCampaigns(ctx)
|
campaigns, err := s.store.ActiveCampaigns(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, Timings{}, err
|
return nil, Timings{}, false, err
|
||||||
}
|
}
|
||||||
timings, err := s.store.Settings(ctx)
|
timings, err := s.store.Settings(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, Timings{}, err
|
return nil, Timings{}, false, err
|
||||||
}
|
}
|
||||||
return computeActiveSet(campaigns, time.Now().UTC(), lang), timings, nil
|
set, urgent := computeActiveSet(campaigns, time.Now().UTC(), lang)
|
||||||
|
return set, timings, urgent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListCampaigns returns every campaign with its messages, for the admin console.
|
// ListCampaigns returns every campaign with its messages, for the admin console.
|
||||||
@@ -76,8 +80,13 @@ func (s *Service) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, er
|
|||||||
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
||||||
return uuid.Nil, err
|
return uuid.Nil, err
|
||||||
}
|
}
|
||||||
|
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
return s.store.CreateCampaign(ctx, Campaign{
|
return s.store.CreateCampaign(ctx, Campaign{
|
||||||
Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt,
|
Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt,
|
||||||
|
OverrideAll: all, OverrideDark: dark, Urgent: c.Urgent,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +108,7 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
|
|||||||
upd.Enabled = true
|
upd.Enabled = true
|
||||||
upd.StartsAt = nil
|
upd.StartsAt = nil
|
||||||
upd.EndsAt = nil
|
upd.EndsAt = nil
|
||||||
|
// The default (house) campaign stays plain: no colour overrides, never urgent.
|
||||||
} else {
|
} else {
|
||||||
if err := validWeight(c.Weight); err != nil {
|
if err := validWeight(c.Weight); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -106,10 +116,17 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
|
|||||||
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
upd.Weight = c.Weight
|
upd.Weight = c.Weight
|
||||||
upd.Enabled = c.Enabled
|
upd.Enabled = c.Enabled
|
||||||
upd.StartsAt = c.StartsAt
|
upd.StartsAt = c.StartsAt
|
||||||
upd.EndsAt = c.EndsAt
|
upd.EndsAt = c.EndsAt
|
||||||
|
upd.OverrideAll = all
|
||||||
|
upd.OverrideDark = dark
|
||||||
|
upd.Urgent = c.Urgent
|
||||||
}
|
}
|
||||||
return s.store.UpdateCampaign(ctx, upd)
|
return s.store.UpdateCampaign(ctx, upd)
|
||||||
}
|
}
|
||||||
@@ -254,6 +271,46 @@ func validWindow(starts, ends *time.Time) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hexColor matches a "#rrggbb" colour — the format the console's native colour
|
||||||
|
// input emits and the wire carries.
|
||||||
|
var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||||
|
|
||||||
|
// validOverrides validates the two optional colour sets together and returns
|
||||||
|
// their normalised copies (nil when a set is absent), so a caller can store them
|
||||||
|
// directly.
|
||||||
|
func validOverrides(all, dark *ColorSet) (*ColorSet, *ColorSet, error) {
|
||||||
|
va, err := validColorSet(all)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
vd, err := validColorSet(dark)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return va, vd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validColorSet validates one optional colour override: a nil set passes (no
|
||||||
|
// override); otherwise all three colours must be present and "#rrggbb". It
|
||||||
|
// returns a trimmed, lower-cased copy.
|
||||||
|
func validColorSet(cs *ColorSet) (*ColorSet, error) {
|
||||||
|
if cs == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
bg := strings.TrimSpace(cs.Bg)
|
||||||
|
fg := strings.TrimSpace(cs.Fg)
|
||||||
|
link := strings.TrimSpace(cs.Link)
|
||||||
|
if bg == "" || fg == "" || link == "" {
|
||||||
|
return nil, fmt.Errorf("%w: a colour override needs all of background, text and link", ErrValidation)
|
||||||
|
}
|
||||||
|
for _, h := range []string{bg, fg, link} {
|
||||||
|
if !hexColor.MatchString(h) {
|
||||||
|
return nil, fmt.Errorf("%w: colours must be #rrggbb hex", ErrValidation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &ColorSet{Bg: strings.ToLower(bg), Fg: strings.ToLower(fg), Link: strings.ToLower(link)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// validBodies trims and bounds both mandatory language bodies of a message.
|
// validBodies trims and bounds both mandatory language bodies of a message.
|
||||||
func validBodies(bodyEn, bodyRu string) (string, string, error) {
|
func validBodies(bodyEn, bodyRu string) (string, string, error) {
|
||||||
en := strings.TrimSpace(bodyEn)
|
en := strings.TrimSpace(bodyEn)
|
||||||
|
|||||||
@@ -90,15 +90,23 @@ func (s *Store) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) {
|
|||||||
func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
|
func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
|
||||||
id := uuid.New()
|
id := uuid.New()
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
abg, afg, alink := colorCols(c.OverrideAll)
|
||||||
|
dbg, dfg, dlink := colorCols(c.OverrideDark)
|
||||||
stmt := table.AdCampaigns.INSERT(
|
stmt := table.AdCampaigns.INSERT(
|
||||||
table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight,
|
table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight,
|
||||||
table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled,
|
table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled,
|
||||||
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
|
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
|
||||||
|
table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink,
|
||||||
|
table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark,
|
||||||
|
table.AdCampaigns.Urgent,
|
||||||
table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt,
|
table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt,
|
||||||
).VALUES(
|
).VALUES(
|
||||||
postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)),
|
postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)),
|
||||||
postgres.Bool(false), postgres.Bool(c.Enabled),
|
postgres.Bool(false), postgres.Bool(c.Enabled),
|
||||||
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
|
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
|
||||||
|
abg, afg, alink,
|
||||||
|
dbg, dfg, dlink,
|
||||||
|
postgres.Bool(c.Urgent),
|
||||||
postgres.TimestampzT(now), postgres.TimestampzT(now),
|
postgres.TimestampzT(now), postgres.TimestampzT(now),
|
||||||
)
|
)
|
||||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
@@ -111,12 +119,20 @@ func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, erro
|
|||||||
// window. The default flag is never touched here. Returns ErrNotFound when no
|
// window. The default flag is never touched here. Returns ErrNotFound when no
|
||||||
// campaign matches.
|
// campaign matches.
|
||||||
func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error {
|
func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error {
|
||||||
|
abg, afg, alink := colorCols(c.OverrideAll)
|
||||||
|
dbg, dfg, dlink := colorCols(c.OverrideDark)
|
||||||
stmt := table.AdCampaigns.UPDATE(
|
stmt := table.AdCampaigns.UPDATE(
|
||||||
table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled,
|
table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled,
|
||||||
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.UpdatedAt,
|
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
|
||||||
|
table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink,
|
||||||
|
table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark,
|
||||||
|
table.AdCampaigns.Urgent, table.AdCampaigns.UpdatedAt,
|
||||||
).SET(
|
).SET(
|
||||||
postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled),
|
postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled),
|
||||||
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), postgres.TimestampzT(time.Now().UTC()),
|
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
|
||||||
|
abg, afg, alink,
|
||||||
|
dbg, dfg, dlink,
|
||||||
|
postgres.Bool(c.Urgent), postgres.TimestampzT(time.Now().UTC()),
|
||||||
).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID)))
|
).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID)))
|
||||||
return execOne(ctx, s.db, stmt, "update campaign")
|
return execOne(ctx, s.db, stmt, "update campaign")
|
||||||
}
|
}
|
||||||
@@ -264,11 +280,33 @@ func modelToCampaign(r model.AdCampaigns) Campaign {
|
|||||||
Enabled: r.Enabled,
|
Enabled: r.Enabled,
|
||||||
StartsAt: r.StartsAt,
|
StartsAt: r.StartsAt,
|
||||||
EndsAt: r.EndsAt,
|
EndsAt: r.EndsAt,
|
||||||
|
OverrideAll: colorSetFrom(r.OverrideBg, r.OverrideFg, r.OverrideLink),
|
||||||
|
OverrideDark: colorSetFrom(r.OverrideBgDark, r.OverrideFgDark, r.OverrideLinkDark),
|
||||||
|
Urgent: r.Urgent,
|
||||||
CreatedAt: r.CreatedAt,
|
CreatedAt: r.CreatedAt,
|
||||||
UpdatedAt: r.UpdatedAt,
|
UpdatedAt: r.UpdatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// colorSetFrom rebuilds an optional ColorSet from three nullable colour columns.
|
||||||
|
// The all-or-nothing CHECK keeps the trio consistent, so a nil in any one means
|
||||||
|
// the set is absent.
|
||||||
|
func colorSetFrom(bg, fg, link *string) *ColorSet {
|
||||||
|
if bg == nil || fg == nil || link == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &ColorSet{Bg: *bg, Fg: *fg, Link: *link}
|
||||||
|
}
|
||||||
|
|
||||||
|
// colorCols renders a *ColorSet as its three column value-expressions in
|
||||||
|
// bg, fg, link order — NULL for each when the set is absent.
|
||||||
|
func colorCols(cs *ColorSet) (bg, fg, link postgres.Expression) {
|
||||||
|
if cs == nil {
|
||||||
|
return postgres.NULL, postgres.NULL, postgres.NULL
|
||||||
|
}
|
||||||
|
return postgres.String(cs.Bg), postgres.String(cs.Fg), postgres.String(cs.Link)
|
||||||
|
}
|
||||||
|
|
||||||
func modelToMessage(r model.AdMessages) Message {
|
func modelToMessage(r model.AdMessages) Message {
|
||||||
return Message{
|
return Message{
|
||||||
ID: r.MessageID,
|
ID: r.MessageID,
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The offline engine (ui/src/lib/localgame) reproduces the end-of-game rack settlement and the
|
||||||
|
// winner rule so a local game finishes with the same scores as the server. These golden fixtures
|
||||||
|
// pin the ported pure functions (applyEndAdjustment / winner / rackValue) to the real Go engine.
|
||||||
|
// Being in-package, this emitter constructs Game values directly and drives the unexported
|
||||||
|
// end-game math on chosen positions.
|
||||||
|
|
||||||
|
type endCaseIn struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Hands [][]int `json:"hands"`
|
||||||
|
Scores []int `json:"scores"`
|
||||||
|
Resigned []bool `json:"resigned"`
|
||||||
|
ToMove int `json:"toMove"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type endCaseOut struct {
|
||||||
|
endCaseIn
|
||||||
|
ScoresAfter []int `json:"scoresAfter"`
|
||||||
|
Winner int `json:"winner"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func rulesetFor(variant string) *rules.Ruleset {
|
||||||
|
switch variant {
|
||||||
|
case "scrabble_ru":
|
||||||
|
return rules.RussianScrabble()
|
||||||
|
case "erudit_ru":
|
||||||
|
return rules.Erudit()
|
||||||
|
default:
|
||||||
|
return rules.English()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reasonFor(s string) EndReason {
|
||||||
|
switch s {
|
||||||
|
case "out_of_tiles":
|
||||||
|
return EndOutOfTiles
|
||||||
|
case "scoreless":
|
||||||
|
return EndScoreless
|
||||||
|
case "resign":
|
||||||
|
return EndResign
|
||||||
|
case "aborted":
|
||||||
|
return EndAborted
|
||||||
|
}
|
||||||
|
return EndNotOver
|
||||||
|
}
|
||||||
|
|
||||||
|
func handsBytes(hands [][]int) [][]byte {
|
||||||
|
out := make([][]byte, len(hands))
|
||||||
|
for i, h := range hands {
|
||||||
|
b := make([]byte, len(h))
|
||||||
|
for j, x := range h {
|
||||||
|
b[j] = byte(x)
|
||||||
|
}
|
||||||
|
out[i] = b
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmitEndgameFixtures regenerates ui/src/lib/localgame/testdata/endgame.json. Gated by
|
||||||
|
// EMIT_ENGINE_FIXTURES. Regenerate with:
|
||||||
|
//
|
||||||
|
// EMIT_ENGINE_FIXTURES=1 go test ./backend/internal/engine -run TestEmitEndgameFixtures
|
||||||
|
func TestEmitEndgameFixtures(t *testing.T) {
|
||||||
|
if os.Getenv("EMIT_ENGINE_FIXTURES") == "" {
|
||||||
|
t.Skip("set EMIT_ENGINE_FIXTURES=1 to regenerate ui/src/lib/localgame/testdata/endgame.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []endCaseIn{
|
||||||
|
{"out-basic", "scrabble_en", "out_of_tiles", [][]int{{}, {0, 1, 2}}, []int{50, 40}, []bool{false, false}, 0},
|
||||||
|
{"out-blank", "scrabble_en", "out_of_tiles", [][]int{{}, {255, 0}}, []int{30, 30}, []bool{false, false}, 0},
|
||||||
|
{"out-erudit-yo", "erudit_ru", "out_of_tiles", [][]int{{}, {6, 32}}, []int{10, 10}, []bool{false, false}, 0},
|
||||||
|
{"out-tie", "scrabble_en", "out_of_tiles", [][]int{{}, {}}, []int{30, 30}, []bool{false, false}, 0},
|
||||||
|
{"out-3p", "scrabble_ru", "out_of_tiles", [][]int{{}, {0, 1}, {2}}, []int{10, 10, 10}, []bool{false, false, false}, 0},
|
||||||
|
{"scoreless", "scrabble_en", "scoreless", [][]int{{0, 1}, {2, 3}}, []int{20, 20}, []bool{false, false}, 0},
|
||||||
|
{"resign-2p", "scrabble_en", "resign", [][]int{{}, {0}}, []int{100, 10}, []bool{true, false}, 1},
|
||||||
|
{"resign-3p", "scrabble_en", "resign", [][]int{{}, {}, {}}, []int{50, 60, 40}, []bool{false, true, false}, 0},
|
||||||
|
{"aborted", "scrabble_en", "aborted", [][]int{{0}, {1}}, []int{40, 30}, []bool{false, false}, 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]endCaseOut, 0, len(cases))
|
||||||
|
for _, c := range cases {
|
||||||
|
rs := rulesetFor(c.Variant)
|
||||||
|
scores := append([]int(nil), c.Scores...)
|
||||||
|
g := &Game{
|
||||||
|
rules: rs,
|
||||||
|
hands: handsBytes(c.Hands),
|
||||||
|
scores: scores,
|
||||||
|
resigned: c.Resigned,
|
||||||
|
toMove: c.ToMove,
|
||||||
|
}
|
||||||
|
reason := reasonFor(c.Reason)
|
||||||
|
g.over = true
|
||||||
|
g.reason = reason
|
||||||
|
g.applyEndAdjustment(reason)
|
||||||
|
out = append(out, endCaseOut{endCaseIn: c, ScoresAfter: g.scores, Winner: g.winner()})
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "localgame", "testdata")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(map[string]any{"cases": out}, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "endgame.json")
|
||||||
|
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
t.Logf("wrote %s (%d cases)", path, len(out))
|
||||||
|
}
|
||||||
@@ -69,6 +69,29 @@ func TestHintsRemaining(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHintUnlockLeftSeconds(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
// A gated vs_ai game on the caller's turn, the robot having moved 10 min ago (turn started then).
|
||||||
|
gated := Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
g Game
|
||||||
|
seat int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"non-vs_ai is open", Game{VsAI: false, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)}, 0, 0},
|
||||||
|
{"not the caller's turn is open", gated, 1, 0},
|
||||||
|
{"human first move (no robot move) is open", Game{VsAI: true, ToMove: 0, MoveCount: 0, TurnStartedAt: now}, 0, 0},
|
||||||
|
{"robot moved 10 min ago leaves 20 min", gated, 0, 20 * 60},
|
||||||
|
{"robot moved past the window is open", Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-40 * time.Minute)}, 0, 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := hintUnlockLeftSeconds(c.g, c.seat, now); got != c.want {
|
||||||
|
t.Errorf("%s: hintUnlockLeftSeconds = %d, want %d", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAllowedTimeout(t *testing.T) {
|
func TestAllowedTimeout(t *testing.T) {
|
||||||
if !allowedTimeout(24 * time.Hour) {
|
if !allowedTimeout(24 * time.Hour) {
|
||||||
t.Error("24h must be allowed")
|
t.Error("24h must be allowed")
|
||||||
|
|||||||
@@ -1058,10 +1058,31 @@ func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Varia
|
|||||||
return svc.store.MarkChangesApplied(ctx, variant.String(), version)
|
return svc.store.MarkChangesApplied(ctx, variant.String(), version)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hint reveals the top-scoring legal play for the requesting player on their
|
// hintIdleWindow is how long a vs_ai player must be stuck on a turn (since the robot's last move)
|
||||||
// turn, spending one hint from their per-game allowance and then their profile
|
// before the idle hint unlocks. Mirrors the offline client (lib/hints HINT_GATE_MS = 30 min).
|
||||||
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
|
const hintIdleWindow = 30 * time.Minute
|
||||||
// appropriate.
|
|
||||||
|
// hintUnlockLeftSeconds is the seconds until g's vs_ai idle hint unlocks for seat, measured from now:
|
||||||
|
// the robot's last move (the current turn's start, on the human's turn) plus the window, floored at
|
||||||
|
// 0 and ceiled to whole seconds. It is 0 for a non-vs_ai game, when it is not seat's turn, or on the
|
||||||
|
// human's first move (MoveCount 0, no robot move yet) — the gate is an anti-frustration aid, not a
|
||||||
|
// first-move tax. The client anchors a monotonic countdown to it, so a client clock cannot skew it.
|
||||||
|
func hintUnlockLeftSeconds(g Game, seat int, now time.Time) int {
|
||||||
|
if !g.VsAI || g.ToMove != seat || g.MoveCount < 1 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
left := g.TurnStartedAt.Add(hintIdleWindow).Sub(now)
|
||||||
|
if left <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int((left + time.Second - 1) / time.Second) // ceil to whole seconds (no math import)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hint reveals the top-scoring legal play for the requesting player on their turn. For a human game
|
||||||
|
// it spends one hint from the per-game allowance then the profile wallet (ErrHintsDisabled /
|
||||||
|
// ErrNoHintsLeft / ErrNoHintAvailable). For a vs_ai game the hint is unlimited and wallet-free but
|
||||||
|
// idle-gated from the server clock (ErrHintLocked until the window elapses) and counts toward no
|
||||||
|
// hint statistic.
|
||||||
func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
|
func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
|
||||||
pre, err := svc.store.GetGame(ctx, gameID)
|
pre, err := svc.store.GetGame(ctx, gameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1080,6 +1101,26 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
|||||||
if !pre.HintsAllowed {
|
if !pre.HintsAllowed {
|
||||||
return HintResult{}, ErrHintsDisabled
|
return HintResult{}, ErrHintsDisabled
|
||||||
}
|
}
|
||||||
|
if pre.VsAI {
|
||||||
|
// vs_ai: unlimited and wallet-free, but idle-gated from the server clock — and counted toward
|
||||||
|
// no hint statistic. Enforce the gate (the client normally pre-gates from the view's
|
||||||
|
// HintUnlockLeftSeconds; this is the authoritative backstop), then serve the top move without
|
||||||
|
// touching the allowance, the wallet or hints_used.
|
||||||
|
if hintUnlockLeftSeconds(pre, seat, svc.clock()) > 0 {
|
||||||
|
return HintResult{}, ErrHintLocked
|
||||||
|
}
|
||||||
|
unlock := svc.locks.lock(gameID)
|
||||||
|
defer unlock()
|
||||||
|
g, err := svc.liveGame(ctx, pre)
|
||||||
|
if err != nil {
|
||||||
|
return HintResult{}, err
|
||||||
|
}
|
||||||
|
move, ok := g.HintView()
|
||||||
|
if !ok {
|
||||||
|
return HintResult{}, ErrNoHintAvailable
|
||||||
|
}
|
||||||
|
return HintResult{Move: move}, nil
|
||||||
|
}
|
||||||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return HintResult{}, err
|
return HintResult{}, err
|
||||||
@@ -1208,6 +1249,8 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
|||||||
BagLen: g.BagLen(),
|
BagLen: g.BagLen(),
|
||||||
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
|
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
|
||||||
WalletBalance: acc.HintBalance,
|
WalletBalance: acc.HintBalance,
|
||||||
|
// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
|
||||||
|
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ var (
|
|||||||
ErrNoHintsLeft = errors.New("game: no hints remaining")
|
ErrNoHintsLeft = errors.New("game: no hints remaining")
|
||||||
// ErrNoHintAvailable is returned when the player has no legal play to reveal.
|
// ErrNoHintAvailable is returned when the player has no legal play to reveal.
|
||||||
ErrNoHintAvailable = errors.New("game: no legal move to suggest")
|
ErrNoHintAvailable = errors.New("game: no legal move to suggest")
|
||||||
|
// ErrHintLocked is returned when a vs_ai game's idle hint is still gated (the player has not yet
|
||||||
|
// been stuck the idle window since the robot's last move). The client normally pre-gates from
|
||||||
|
// StateView.HintUnlockLeftSeconds, so this is the authoritative server-clock backstop.
|
||||||
|
ErrHintLocked = errors.New("game: hint is not available yet")
|
||||||
// ErrGameLimitReached is returned when an account already holds MaxActiveQuickGames
|
// ErrGameLimitReached is returned when an account already holds MaxActiveQuickGames
|
||||||
// simultaneous quick games and tries to create another game (quick or by invitation).
|
// simultaneous quick games and tries to create another game (quick or by invitation).
|
||||||
ErrGameLimitReached = errors.New("game: simultaneous game limit reached")
|
ErrGameLimitReached = errors.New("game: simultaneous game limit reached")
|
||||||
@@ -240,6 +244,11 @@ type StateView struct {
|
|||||||
// WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds
|
// WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds
|
||||||
// it in with the per-game allowance), so the client keeps the wallet live across games.
|
// it in with the per-game allowance), so the client keeps the wallet live across games.
|
||||||
WalletBalance int
|
WalletBalance int
|
||||||
|
// HintUnlockLeftSeconds is, for a vs_ai game on the requesting player's turn, the seconds left
|
||||||
|
// until the idle hint unlocks (the robot's last move plus the idle window, from the server clock);
|
||||||
|
// 0 for the human's first move, when it is not their turn, or a non-vs_ai game. The vs_ai hint is
|
||||||
|
// unlimited and wallet-free; this idle gate replaces the allowance/wallet for it.
|
||||||
|
HintUnlockLeftSeconds int
|
||||||
}
|
}
|
||||||
|
|
||||||
// HistoryMove is one decoded journal row, independent of any dictionary.
|
// HistoryMove is one decoded journal row, independent of any dictionary.
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ type profileBanner struct {
|
|||||||
Campaigns []struct {
|
Campaigns []struct {
|
||||||
Weight int `json:"weight"`
|
Weight int `json:"weight"`
|
||||||
Messages []string `json:"messages"`
|
Messages []string `json:"messages"`
|
||||||
|
OverrideBg string `json:"override_bg"`
|
||||||
} `json:"campaigns"`
|
} `json:"campaigns"`
|
||||||
Timings struct {
|
Timings struct {
|
||||||
HoldMs int `json:"hold_ms"`
|
HoldMs int `json:"hold_ms"`
|
||||||
@@ -274,3 +275,142 @@ func TestBannerSurvivesProfileUpdate(t *testing.T) {
|
|||||||
t.Fatalf("profile update dropped the banner block: %v", p.Banner)
|
t.Fatalf("profile update dropped the banner block: %v", p.Banner)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBannerConsoleColorsAndUrgent drives the colour-override + urgent editor over
|
||||||
|
// HTTP against real Postgres: an incomplete or malformed colour set is refused, a
|
||||||
|
// full two-set override + urgent round-trips through the store (exercising the new
|
||||||
|
// columns and CHECK constraints), clearing the sets removes the override, and the
|
||||||
|
// default campaign stays plain (colours + urgent are ignored for it).
|
||||||
|
func TestBannerConsoleColorsAndUrgent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
srv, adsSvc := bannerServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
base := "http://admin.test/_gm"
|
||||||
|
const origin = "http://admin.test"
|
||||||
|
|
||||||
|
name := "Brand-" + uuid.NewString()[:8]
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK || !strings.Contains(body, "Created") {
|
||||||
|
t.Fatalf("create = %d, has Created=%v", code, strings.Contains(body, "Created"))
|
||||||
|
}
|
||||||
|
id := findCampaign(t, adsSvc, name)
|
||||||
|
t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, id) })
|
||||||
|
detail := base + "/banners/" + id.String()
|
||||||
|
|
||||||
|
// A partial colour set (a missing colour) is refused by validation.
|
||||||
|
partial := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=%23aa0000&override_fg=&override_link=%23ffdd00"
|
||||||
|
if code, b := consoleDo(h, http.MethodPost, detail, partial, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") {
|
||||||
|
t.Fatalf("partial colour = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved"))
|
||||||
|
}
|
||||||
|
// A malformed hex is refused.
|
||||||
|
badHex := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=red&override_fg=%23ffffff&override_link=%23ffdd00"
|
||||||
|
if code, b := consoleDo(h, http.MethodPost, detail, badHex, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") {
|
||||||
|
t.Fatalf("bad hex = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved"))
|
||||||
|
}
|
||||||
|
// Both colour sets + urgent persist and round-trip through the store.
|
||||||
|
full := "name=" + name + "&weight=20&enabled=on&urgent=on" +
|
||||||
|
"&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00" +
|
||||||
|
"&override_dark_on=on&override_bg_dark=%23330000&override_fg_dark=%23eeeeee&override_link_dark=%23ffcc00"
|
||||||
|
if code, b := consoleDo(h, http.MethodPost, detail, full, origin); code != http.StatusOK || !strings.Contains(b, "Saved") {
|
||||||
|
t.Fatalf("full save = %d, has Saved=%v", code, strings.Contains(b, "Saved"))
|
||||||
|
}
|
||||||
|
cm, err := adsSvc.Campaign(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read campaign: %v", err)
|
||||||
|
}
|
||||||
|
if cm.OverrideAll == nil || cm.OverrideAll.Bg != "#aa0000" || cm.OverrideAll.Fg != "#ffffff" || cm.OverrideAll.Link != "#ffdd00" {
|
||||||
|
t.Fatalf("override all = %+v", cm.OverrideAll)
|
||||||
|
}
|
||||||
|
if cm.OverrideDark == nil || cm.OverrideDark.Bg != "#330000" || cm.OverrideDark.Fg != "#eeeeee" || cm.OverrideDark.Link != "#ffcc00" {
|
||||||
|
t.Fatalf("override dark = %+v", cm.OverrideDark)
|
||||||
|
}
|
||||||
|
if !cm.Urgent {
|
||||||
|
t.Fatal("urgent not persisted")
|
||||||
|
}
|
||||||
|
// Clearing the sets (both checkboxes off, urgent off) removes the override.
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, detail, "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK {
|
||||||
|
t.Fatalf("clear = %d", code)
|
||||||
|
}
|
||||||
|
if cm, _ := adsSvc.Campaign(ctx, id); cm.OverrideAll != nil || cm.OverrideDark != nil || cm.Urgent {
|
||||||
|
t.Fatalf("override not cleared: all=%v dark=%v urgent=%v", cm.OverrideAll, cm.OverrideDark, cm.Urgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The default campaign stays plain: submitted colours + urgent are ignored for it.
|
||||||
|
def := defaultCampaign(t, adsSvc)
|
||||||
|
defForm := "name=Default+%28house%29&urgent=on&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00"
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+def.ID.String(), defForm, origin); code != http.StatusOK {
|
||||||
|
t.Fatalf("update default = %d", code)
|
||||||
|
}
|
||||||
|
if again := defaultCampaign(t, adsSvc); again.OverrideAll != nil || again.OverrideDark != nil || again.Urgent {
|
||||||
|
t.Fatalf("default not plain: all=%v dark=%v urgent=%v", again.OverrideAll, again.OverrideDark, again.Urgent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBannerUrgentBypassesEligibility checks the urgent flag at the profile level:
|
||||||
|
// an urgent campaign preempts the feed (only it shows, with its colours) and reaches
|
||||||
|
// every viewer — including the no_banner role and a non-empty hint wallet that
|
||||||
|
// otherwise suppress the banner.
|
||||||
|
func TestBannerUrgentBypassesEligibility(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
srv, adsSvc := bannerServer(t)
|
||||||
|
accounts := account.NewStore(testDB)
|
||||||
|
id := provisionAccount(t)
|
||||||
|
|
||||||
|
urgentID, err := adsSvc.CreateCampaign(ctx, ads.Campaign{
|
||||||
|
Name: "Alert-" + uuid.NewString()[:8], Weight: 10, Enabled: true, Urgent: true,
|
||||||
|
OverrideAll: &ads.ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create urgent: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, urgentID) })
|
||||||
|
if _, err := adsSvc.AddMessage(ctx, urgentID, "System maintenance tonight", "Ночью тех.работы"); err != nil {
|
||||||
|
t.Fatalf("add urgent message: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
get := func() profileBanner {
|
||||||
|
t.Helper()
|
||||||
|
rec := userGet(t, srv, "/api/v1/user/profile", id)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("profile = %d", rec.Code)
|
||||||
|
}
|
||||||
|
var p profileBanner
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
assertUrgentOnly := func(what string, p profileBanner) {
|
||||||
|
t.Helper()
|
||||||
|
if p.Banner == nil {
|
||||||
|
t.Fatalf("%s: no banner", what)
|
||||||
|
}
|
||||||
|
if len(p.Banner.Campaigns) != 1 {
|
||||||
|
t.Fatalf("%s: %d campaigns, want only the urgent one (default preempted)", what, len(p.Banner.Campaigns))
|
||||||
|
}
|
||||||
|
c := p.Banner.Campaigns[0]
|
||||||
|
if len(c.Messages) != 1 || c.Messages[0] != "System maintenance tonight" {
|
||||||
|
t.Fatalf("%s: messages = %v, want only the urgent one", what, c.Messages)
|
||||||
|
}
|
||||||
|
if c.OverrideBg != "#aa0000" {
|
||||||
|
t.Fatalf("%s: override_bg = %q, want #aa0000", what, c.OverrideBg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A normal viewer sees only the urgent campaign (the default is preempted).
|
||||||
|
assertUrgentOnly("eligible", get())
|
||||||
|
|
||||||
|
// The no_banner role no longer suppresses it — urgent reaches everyone.
|
||||||
|
if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil {
|
||||||
|
t.Fatalf("grant role: %v", err)
|
||||||
|
}
|
||||||
|
assertUrgentOnly("no_banner", get())
|
||||||
|
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
|
||||||
|
t.Fatalf("revoke role: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-empty hint wallet no longer suppresses it either.
|
||||||
|
if _, err := accounts.GrantHints(ctx, id, 5); err != nil {
|
||||||
|
t.Fatalf("grant hints: %v", err)
|
||||||
|
}
|
||||||
|
assertUrgentOnly("hints", get())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en", false)
|
||||||
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"); err != nil {
|
if _, err := svc.RequestLoginCode(ctx, email, "", "ru", false); 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")
|
id, err := svc.RequestLoginCode(ctx, email, "", "en", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("request login code: %v", err)
|
t.Fatalf("request login code: %v", err)
|
||||||
}
|
}
|
||||||
@@ -279,6 +279,34 @@ 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_-]+)`)
|
||||||
@@ -301,7 +329,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")
|
id, err := svc.RequestLoginCode(ctx, email, "", "en", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("request login: %v", err)
|
t.Fatalf("request login: %v", err)
|
||||||
}
|
}
|
||||||
@@ -382,7 +410,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")
|
id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("request login: %v", err)
|
t.Fatalf("request login: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,11 @@ type AdCampaigns struct {
|
|||||||
EndsAt *time.Time
|
EndsAt *time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
OverrideBg *string
|
||||||
|
OverrideFg *string
|
||||||
|
OverrideLink *string
|
||||||
|
OverrideBgDark *string
|
||||||
|
OverrideFgDark *string
|
||||||
|
OverrideLinkDark *string
|
||||||
|
Urgent bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ type adCampaignsTable struct {
|
|||||||
EndsAt postgres.ColumnTimestampz
|
EndsAt postgres.ColumnTimestampz
|
||||||
CreatedAt postgres.ColumnTimestampz
|
CreatedAt postgres.ColumnTimestampz
|
||||||
UpdatedAt postgres.ColumnTimestampz
|
UpdatedAt postgres.ColumnTimestampz
|
||||||
|
OverrideBg postgres.ColumnString
|
||||||
|
OverrideFg postgres.ColumnString
|
||||||
|
OverrideLink postgres.ColumnString
|
||||||
|
OverrideBgDark postgres.ColumnString
|
||||||
|
OverrideFgDark postgres.ColumnString
|
||||||
|
OverrideLinkDark postgres.ColumnString
|
||||||
|
Urgent postgres.ColumnBool
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -76,9 +83,16 @@ func newAdCampaignsTableImpl(schemaName, tableName, alias string) adCampaignsTab
|
|||||||
EndsAtColumn = postgres.TimestampzColumn("ends_at")
|
EndsAtColumn = postgres.TimestampzColumn("ends_at")
|
||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||||
allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn}
|
OverrideBgColumn = postgres.StringColumn("override_bg")
|
||||||
mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn}
|
OverrideFgColumn = postgres.StringColumn("override_fg")
|
||||||
defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn}
|
OverrideLinkColumn = postgres.StringColumn("override_link")
|
||||||
|
OverrideBgDarkColumn = postgres.StringColumn("override_bg_dark")
|
||||||
|
OverrideFgDarkColumn = postgres.StringColumn("override_fg_dark")
|
||||||
|
OverrideLinkDarkColumn = postgres.StringColumn("override_link_dark")
|
||||||
|
UrgentColumn = postgres.BoolColumn("urgent")
|
||||||
|
allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn, OverrideBgColumn, OverrideFgColumn, OverrideLinkColumn, OverrideBgDarkColumn, OverrideFgDarkColumn, OverrideLinkDarkColumn, UrgentColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn, OverrideBgColumn, OverrideFgColumn, OverrideLinkColumn, OverrideBgDarkColumn, OverrideFgDarkColumn, OverrideLinkDarkColumn, UrgentColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn, UrgentColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return adCampaignsTable{
|
return adCampaignsTable{
|
||||||
@@ -94,6 +108,13 @@ func newAdCampaignsTableImpl(schemaName, tableName, alias string) adCampaignsTab
|
|||||||
EndsAt: EndsAtColumn,
|
EndsAt: EndsAtColumn,
|
||||||
CreatedAt: CreatedAtColumn,
|
CreatedAt: CreatedAtColumn,
|
||||||
UpdatedAt: UpdatedAtColumn,
|
UpdatedAt: UpdatedAtColumn,
|
||||||
|
OverrideBg: OverrideBgColumn,
|
||||||
|
OverrideFg: OverrideFgColumn,
|
||||||
|
OverrideLink: OverrideLinkColumn,
|
||||||
|
OverrideBgDark: OverrideBgDarkColumn,
|
||||||
|
OverrideFgDark: OverrideFgDarkColumn,
|
||||||
|
OverrideLinkDark: OverrideLinkDarkColumn,
|
||||||
|
Urgent: UrgentColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
-- Per-campaign banner colour overrides and the urgent (always-show) flag.
|
||||||
|
--
|
||||||
|
-- ad_campaigns gains two optional colour sets and an urgent flag, all for
|
||||||
|
-- non-default campaigns only:
|
||||||
|
-- override_bg/fg/link — paints the strip on every theme
|
||||||
|
-- override_bg/fg/link_dark — further overrides the dark theme
|
||||||
|
-- urgent — force the banner on every viewer (bypassing
|
||||||
|
-- eligibility) and suppress all non-urgent
|
||||||
|
-- campaigns while any urgent one is active
|
||||||
|
--
|
||||||
|
-- Each colour set is all-or-nothing (bg+fg+link together, or absent) and every
|
||||||
|
-- colour is a "#rrggbb" hex string. The default (house) campaign stays plain: no
|
||||||
|
-- colours, never urgent.
|
||||||
|
--
|
||||||
|
-- Expand-contract: additive (nullable columns plus one NOT NULL boolean with a
|
||||||
|
-- default), so a backend image rollback stays DB-safe — older code ignores them.
|
||||||
|
-- The ad_campaigns table shape changes, so its generated go-jet model is
|
||||||
|
-- regenerated (by hand here, to keep the diff to this one table).
|
||||||
|
|
||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE backend.ad_campaigns
|
||||||
|
ADD COLUMN override_bg text,
|
||||||
|
ADD COLUMN override_fg text,
|
||||||
|
ADD COLUMN override_link text,
|
||||||
|
ADD COLUMN override_bg_dark text,
|
||||||
|
ADD COLUMN override_fg_dark text,
|
||||||
|
ADD COLUMN override_link_dark text,
|
||||||
|
ADD COLUMN urgent boolean DEFAULT false NOT NULL,
|
||||||
|
ADD CONSTRAINT ad_campaigns_override_all_chk CHECK (
|
||||||
|
(override_bg IS NULL AND override_fg IS NULL AND override_link IS NULL)
|
||||||
|
OR (override_bg IS NOT NULL AND override_fg IS NOT NULL AND override_link IS NOT NULL)
|
||||||
|
),
|
||||||
|
ADD CONSTRAINT ad_campaigns_override_dark_chk CHECK (
|
||||||
|
(override_bg_dark IS NULL AND override_fg_dark IS NULL AND override_link_dark IS NULL)
|
||||||
|
OR (override_bg_dark IS NOT NULL AND override_fg_dark IS NOT NULL AND override_link_dark IS NOT NULL)
|
||||||
|
),
|
||||||
|
ADD CONSTRAINT ad_campaigns_override_hex_chk CHECK (
|
||||||
|
(override_bg IS NULL OR override_bg ~ '^#[0-9a-fA-F]{6}$')
|
||||||
|
AND (override_fg IS NULL OR override_fg ~ '^#[0-9a-fA-F]{6}$')
|
||||||
|
AND (override_link IS NULL OR override_link ~ '^#[0-9a-fA-F]{6}$')
|
||||||
|
AND (override_bg_dark IS NULL OR override_bg_dark ~ '^#[0-9a-fA-F]{6}$')
|
||||||
|
AND (override_fg_dark IS NULL OR override_fg_dark ~ '^#[0-9a-fA-F]{6}$')
|
||||||
|
AND (override_link_dark IS NULL OR override_link_dark ~ '^#[0-9a-fA-F]{6}$')
|
||||||
|
),
|
||||||
|
ADD CONSTRAINT ad_campaigns_default_plain_chk CHECK (
|
||||||
|
(NOT is_default)
|
||||||
|
OR (override_bg IS NULL AND override_fg IS NULL AND override_link IS NULL
|
||||||
|
AND override_bg_dark IS NULL AND override_fg_dark IS NULL AND override_link_dark IS NULL
|
||||||
|
AND NOT urgent)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE backend.ad_campaigns
|
||||||
|
DROP COLUMN override_bg,
|
||||||
|
DROP COLUMN override_fg,
|
||||||
|
DROP COLUMN override_link,
|
||||||
|
DROP COLUMN override_bg_dark,
|
||||||
|
DROP COLUMN override_fg_dark,
|
||||||
|
DROP COLUMN override_link_dark,
|
||||||
|
DROP COLUMN urgent;
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package robot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The client-side offline robot (ui/src/lib/robot) reproduces the robot's move choice
|
||||||
|
// so a local vs_ai game plays the same way as the server. These golden fixtures pin that
|
||||||
|
// TS port to the real Go strategy. Being in-package, this emitter can exercise the
|
||||||
|
// unexported mix / playToWin / deviates / selectMove that the port mirrors.
|
||||||
|
|
||||||
|
type mixCase struct {
|
||||||
|
Seed string `json:"seed"`
|
||||||
|
Salt string `json:"salt"`
|
||||||
|
Nums []int `json:"nums"`
|
||||||
|
Value string `json:"value"` // the mix() uint64, as a decimal string (exceeds JS safe ints)
|
||||||
|
}
|
||||||
|
|
||||||
|
type decisionCase struct {
|
||||||
|
Seed string `json:"seed"`
|
||||||
|
MoveCount int `json:"moveCount"`
|
||||||
|
MyScore int `json:"myScore"`
|
||||||
|
OppScore int `json:"oppScore"`
|
||||||
|
CandScores []int `json:"candScores"`
|
||||||
|
RackLen int `json:"rackLen"`
|
||||||
|
BagLen int `json:"bagLen"`
|
||||||
|
PlayToWin bool `json:"playToWin"`
|
||||||
|
Win bool `json:"win"` // the per-turn intent after the deviate flip
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Index int `json:"index"` // chosen candidate index for a play, else -1
|
||||||
|
}
|
||||||
|
|
||||||
|
type strategyFixture struct {
|
||||||
|
PlayToWinPercent int `json:"playToWinPercent"`
|
||||||
|
Mix []mixCase `json:"mix"`
|
||||||
|
Decisions []decisionCase `json:"decisions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// scenario is one selectMove situation exercised across several seeds.
|
||||||
|
type scenario struct {
|
||||||
|
moveCount, myScore, oppScore, rackLen, bagLen int
|
||||||
|
cands []int
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmitStrategyFixtures regenerates ui/src/lib/robot/testdata/strategy.json. It is
|
||||||
|
// gated by EMIT_STRATEGY_FIXTURES so a normal `go test` skips it; the committed output is
|
||||||
|
// what the TS parity test consumes. Regenerate with:
|
||||||
|
//
|
||||||
|
// EMIT_STRATEGY_FIXTURES=1 go test ./backend/internal/robot -run TestEmitStrategyFixtures
|
||||||
|
func TestEmitStrategyFixtures(t *testing.T) {
|
||||||
|
if os.Getenv("EMIT_STRATEGY_FIXTURES") == "" {
|
||||||
|
t.Skip("set EMIT_STRATEGY_FIXTURES=1 to regenerate ui/src/lib/robot/testdata/strategy.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
seeds := []int64{1, 42, -7, 1000003, 9999999999, 123456789, -123456789}
|
||||||
|
|
||||||
|
mixCases := []mixCase{}
|
||||||
|
addMix := func(seed int64, salt string, nums ...int) {
|
||||||
|
mixCases = append(mixCases, mixCase{
|
||||||
|
Seed: strconv.FormatInt(seed, 10),
|
||||||
|
Salt: salt,
|
||||||
|
Nums: append([]int{}, nums...),
|
||||||
|
Value: strconv.FormatUint(mix(seed, salt, nums...), 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, sd := range seeds {
|
||||||
|
addMix(sd, "win")
|
||||||
|
addMix(sd, "deviate", 0)
|
||||||
|
addMix(sd, "deviate", 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []scenario{
|
||||||
|
{3, 40, 55, 7, 20, []int{30, 12, 8}}, // behind, mid-game
|
||||||
|
{10, 120, 90, 7, 8, []int{25, 18, 5}}, // ahead, late
|
||||||
|
{1, 0, 0, 7, 30, []int{60, 30, 15, 7}}, // first move, big spread
|
||||||
|
{20, 200, 40, 5, 2, []int{50, 20}}, // big lead, bag near empty (no deviate)
|
||||||
|
{5, 30, 30, 7, 14, []int{20, 20, 20}}, // equal margins (tie-break)
|
||||||
|
{8, 50, 60, 7, 20, []int{}}, // no play, bag can refill -> exchange
|
||||||
|
{8, 50, 60, 3, 2, []int{}}, // no play, bag can't refill -> pass
|
||||||
|
{15, 300, 10, 7, 6, []int{80, 40, 10}}, // overshoots the band -> nearest to +30
|
||||||
|
}
|
||||||
|
|
||||||
|
decisions := []decisionCase{}
|
||||||
|
for _, sd := range seeds {
|
||||||
|
for _, sc := range scenarios {
|
||||||
|
cands := make([]engine.MoveRecord, len(sc.cands))
|
||||||
|
for i, s := range sc.cands {
|
||||||
|
cands[i] = engine.MoveRecord{Score: s, Player: i}
|
||||||
|
}
|
||||||
|
rack := make([]string, sc.rackLen)
|
||||||
|
for i := range rack {
|
||||||
|
rack[i] = "a"
|
||||||
|
}
|
||||||
|
|
||||||
|
ptw := playToWin(sd)
|
||||||
|
win := ptw
|
||||||
|
if deviates(sd, sc.moveCount, sc.bagLen) {
|
||||||
|
win = !win
|
||||||
|
}
|
||||||
|
d := selectMove(cands, sc.myScore, sc.oppScore, win, defaultBand, rack, sc.bagLen)
|
||||||
|
|
||||||
|
kind, index := "pass", -1
|
||||||
|
switch d.kind {
|
||||||
|
case decidePlay:
|
||||||
|
kind, index = "play", d.move.Player
|
||||||
|
case decideExchange:
|
||||||
|
kind = "exchange"
|
||||||
|
}
|
||||||
|
|
||||||
|
decisions = append(decisions, decisionCase{
|
||||||
|
Seed: strconv.FormatInt(sd, 10),
|
||||||
|
MoveCount: sc.moveCount,
|
||||||
|
MyScore: sc.myScore,
|
||||||
|
OppScore: sc.oppScore,
|
||||||
|
CandScores: append([]int{}, sc.cands...),
|
||||||
|
RackLen: sc.rackLen,
|
||||||
|
BagLen: sc.bagLen,
|
||||||
|
PlayToWin: ptw,
|
||||||
|
Win: win,
|
||||||
|
Kind: kind,
|
||||||
|
Index: index,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fx := strategyFixture{PlayToWinPercent: playToWinPercent, Mix: mixCases, Decisions: decisions}
|
||||||
|
dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "robot", "testdata")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(fx, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "strategy.json")
|
||||||
|
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
t.Logf("wrote %s (%d mix, %d decisions)", path, len(mixCases), len(decisions))
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/ads"
|
"scrabble/backend/internal/ads"
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,10 +23,20 @@ type bannerDTO struct {
|
|||||||
|
|
||||||
// bannerCampaignDTO is one campaign in the rotation feed: its GCD-reduced show
|
// bannerCampaignDTO is one campaign in the rotation feed: its GCD-reduced show
|
||||||
// weight (for the client's smooth weighted round-robin) and its messages, in
|
// weight (for the client's smooth weighted round-robin) and its messages, in
|
||||||
// display order, already resolved to one language.
|
// display order, already resolved to one language. The override_* colours are
|
||||||
|
// present only for a campaign that carries a colour override: override_bg/fg/link
|
||||||
|
// paint every theme, and the *_dark trio further overrides the dark theme (the
|
||||||
|
// client resolves the cascade). They are absent for a plain campaign, which keeps
|
||||||
|
// the neutral theme tokens.
|
||||||
type bannerCampaignDTO struct {
|
type bannerCampaignDTO struct {
|
||||||
Weight int `json:"weight"`
|
Weight int `json:"weight"`
|
||||||
Messages []string `json:"messages"`
|
Messages []string `json:"messages"`
|
||||||
|
OverrideBg string `json:"override_bg,omitempty"`
|
||||||
|
OverrideFg string `json:"override_fg,omitempty"`
|
||||||
|
OverrideLink string `json:"override_link,omitempty"`
|
||||||
|
OverrideBgDark string `json:"override_bg_dark,omitempty"`
|
||||||
|
OverrideFgDark string `json:"override_fg_dark,omitempty"`
|
||||||
|
OverrideLinkDark string `json:"override_link_dark,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// bannerTimingsDTO mirrors ads.Timings on the wire.
|
// bannerTimingsDTO mirrors ads.Timings on the wire.
|
||||||
@@ -46,9 +57,34 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi
|
|||||||
r := profileResponseFor(acc)
|
r := profileResponseFor(acc)
|
||||||
r.Banner = s.bannerFor(ctx, acc)
|
r.Banner = s.bannerFor(ctx, acc)
|
||||||
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
||||||
|
r.DictVersions = s.currentDictVersions()
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currentDictVersions reports the current dictionary version of every game variant with a
|
||||||
|
// resident dictionary, as engine.Variant stable labels. It backs profileResponse.DictVersions
|
||||||
|
// so an offline-capable client preloads the matching dawg per variant off the cold-start
|
||||||
|
// profile. A variant without a loaded dictionary is omitted (Registry.Latest reports
|
||||||
|
// ErrUnknownVariant); the returned slice is nil only when no variant is resident.
|
||||||
|
func (s *Server) currentDictVersions() []dictVersion {
|
||||||
|
if s.registry == nil {
|
||||||
|
return nil // no dictionary registry wired (e.g. a minimal test server): advertise none
|
||||||
|
}
|
||||||
|
variants := []engine.Variant{engine.VariantEnglish, engine.VariantRussianScrabble, engine.VariantErudit}
|
||||||
|
out := make([]dictVersion, 0, len(variants))
|
||||||
|
for _, v := range variants {
|
||||||
|
version, _, err := s.registry.Latest(v)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, dictVersion{Variant: v.String(), Version: version})
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// fillLinkedIdentities sets the profile's confirmed email address and platform-linked
|
// fillLinkedIdentities sets the profile's confirmed email address and platform-linked
|
||||||
// flags from the account's identities, so the client offers the right link / unlink /
|
// flags from the account's identities, so the client offers the right link / unlink /
|
||||||
// change-email controls. A read failure leaves them zero (no controls), logged as a
|
// change-email controls. A read failure leaves them zero (no controls), logged as a
|
||||||
@@ -83,6 +119,15 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
|
|||||||
if s.ads == nil {
|
if s.ads == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
set, timings, urgent, err := s.ads.ActiveSet(ctx, bannerLang(acc))
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// An urgent campaign is shown to every viewer; otherwise the normal eligibility
|
||||||
|
// gate applies (a paid account, a non-empty hint wallet or the no_banner role
|
||||||
|
// hides the banner).
|
||||||
|
if !urgent {
|
||||||
hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner)
|
hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||||
@@ -91,14 +136,10 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
|
|||||||
if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) {
|
if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
set, timings, err := s.ads.ActiveSet(ctx, bannerLang(acc))
|
|
||||||
if err != nil {
|
|
||||||
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
campaigns := make([]bannerCampaignDTO, 0, len(set))
|
campaigns := make([]bannerCampaignDTO, 0, len(set))
|
||||||
for _, c := range set {
|
for _, c := range set {
|
||||||
campaigns = append(campaigns, bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages})
|
campaigns = append(campaigns, bannerCampaignFromActive(c))
|
||||||
}
|
}
|
||||||
return &bannerDTO{
|
return &bannerDTO{
|
||||||
Campaigns: campaigns,
|
Campaigns: campaigns,
|
||||||
@@ -113,6 +154,20 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bannerCampaignFromActive flattens a resolved campaign into its wire DTO,
|
||||||
|
// projecting each optional colour set into its three "#rrggbb" fields (empty when
|
||||||
|
// the set is absent, so JSON omitempty drops them).
|
||||||
|
func bannerCampaignFromActive(c ads.ActiveCampaign) bannerCampaignDTO {
|
||||||
|
d := bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages}
|
||||||
|
if c.OverrideAll != nil {
|
||||||
|
d.OverrideBg, d.OverrideFg, d.OverrideLink = c.OverrideAll.Bg, c.OverrideAll.Fg, c.OverrideAll.Link
|
||||||
|
}
|
||||||
|
if c.OverrideDark != nil {
|
||||||
|
d.OverrideBgDark, d.OverrideFgDark, d.OverrideLinkDark = c.OverrideDark.Bg, c.OverrideDark.Fg, c.OverrideDark.Link
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
// bannerLang resolves the message language for a viewer: their interface language
|
// bannerLang resolves the message language for a viewer: their interface language
|
||||||
// (Russian, or English by default).
|
// (Russian, or English by default).
|
||||||
func bannerLang(acc account.Account) string {
|
func bannerLang(acc account.Account) string {
|
||||||
|
|||||||
@@ -63,6 +63,20 @@ type profileResponse struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
TelegramLinked bool `json:"telegram_linked"`
|
TelegramLinked bool `json:"telegram_linked"`
|
||||||
VkLinked bool `json:"vk_linked"`
|
VkLinked bool `json:"vk_linked"`
|
||||||
|
// DictVersions lists the current dictionary version per game variant (engine.Variant
|
||||||
|
// stable labels). An installed PWA preparing for offline play reads it to preload the
|
||||||
|
// right dawg per enabled variant off this cold-start response, without an extra request.
|
||||||
|
// Filled outside the pure projection (it reads the dictionary registry), so it is empty
|
||||||
|
// for callers that build the DTO without a Server. See Server.profileResponse.
|
||||||
|
DictVersions []dictVersion `json:"dict_versions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// dictVersion pairs a game variant's stable label (engine.Variant.String) with its current
|
||||||
|
// dictionary version. profileResponse.DictVersions carries the set so an offline-capable
|
||||||
|
// client preloads the matching dawg per variant.
|
||||||
|
type dictVersion struct {
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// tileDTO is one placed (or to-place) tile.
|
// tileDTO is one placed (or to-place) tile.
|
||||||
@@ -155,6 +169,9 @@ type stateDTO struct {
|
|||||||
BagLen int `json:"bag_len"`
|
BagLen int `json:"bag_len"`
|
||||||
HintsRemaining int `json:"hints_remaining"`
|
HintsRemaining int `json:"hints_remaining"`
|
||||||
WalletBalance int `json:"wallet_balance"`
|
WalletBalance int `json:"wallet_balance"`
|
||||||
|
// HintUnlockLeftSeconds is the vs_ai idle-hint gate: seconds until the hint unlocks (0 for a human
|
||||||
|
// game / first move / not your turn). The client anchors a monotonic countdown to it.
|
||||||
|
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
||||||
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
|
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +324,7 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
|
|||||||
BagLen: v.BagLen,
|
BagLen: v.BagLen,
|
||||||
HintsRemaining: v.HintsRemaining,
|
HintsRemaining: v.HintsRemaining,
|
||||||
WalletBalance: v.WalletBalance,
|
WalletBalance: v.WalletBalance,
|
||||||
|
HintUnlockLeftSeconds: v.HintUnlockLeftSeconds,
|
||||||
}
|
}
|
||||||
if includeAlphabet {
|
if includeAlphabet {
|
||||||
tab, err := engine.AlphabetTable(v.Game.Variant)
|
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||||
|
|||||||
@@ -239,6 +239,9 @@ func statusForError(err error) (int, string) {
|
|||||||
return http.StatusConflict, "no_hint_available"
|
return http.StatusConflict, "no_hint_available"
|
||||||
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft):
|
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft):
|
||||||
return http.StatusConflict, "hint_unavailable"
|
return http.StatusConflict, "hint_unavailable"
|
||||||
|
case errors.Is(err, game.ErrHintLocked):
|
||||||
|
// A vs_ai idle hint is still gated (the server-clock backstop); the client shows the lock.
|
||||||
|
return http.StatusConflict, "hint_locked"
|
||||||
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
|
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
|
||||||
return http.StatusUnprocessableEntity, "illegal_play"
|
return http.StatusUnprocessableEntity, "illegal_play"
|
||||||
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
|
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ const bannersBack = "/_gm/banners"
|
|||||||
// bound; the operator's entry is interpreted as UTC.
|
// bound; the operator's entry is interpreted as UTC.
|
||||||
const localTimeLayout = "2006-01-02T15:04"
|
const localTimeLayout = "2006-01-02T15:04"
|
||||||
|
|
||||||
|
// Neutral banner theme tokens, mirrored from ui/src/app.css (--ad-bg,
|
||||||
|
// --text-muted, --accent for the light and dark themes). They seed the console's
|
||||||
|
// colour pickers and the preview fallback when a campaign has no override, so the
|
||||||
|
// operator always edits against the real neutral colours.
|
||||||
|
const (
|
||||||
|
tokenLightBg, tokenLightFg, tokenLightLink = "#e3e7ee", "#6b7280", "#2f6df6"
|
||||||
|
tokenDarkBg, tokenDarkFg, tokenDarkLink = "#272f3c", "#9aa3b2", "#5b8cff"
|
||||||
|
)
|
||||||
|
|
||||||
// consoleBanners lists the advertising campaigns (default first), each with its
|
// consoleBanners lists the advertising campaigns (default first), each with its
|
||||||
// weight, validity window, enabled flag, message count and live-now status.
|
// weight, validity window, enabled flag, message count and live-now status.
|
||||||
func (s *Server) consoleBanners(c *gin.Context) {
|
func (s *Server) consoleBanners(c *gin.Context) {
|
||||||
@@ -63,7 +72,12 @@ func (s *Server) consoleBannerDetail(c *gin.Context) {
|
|||||||
Enabled: cm.Enabled,
|
Enabled: cm.Enabled,
|
||||||
StartsAt: localInput(cm.StartsAt),
|
StartsAt: localInput(cm.StartsAt),
|
||||||
EndsAt: localInput(cm.EndsAt),
|
EndsAt: localInput(cm.EndsAt),
|
||||||
|
Urgent: cm.Urgent,
|
||||||
|
OverrideAllOn: cm.OverrideAll != nil,
|
||||||
|
OverrideDarkOn: cm.OverrideDark != nil,
|
||||||
}
|
}
|
||||||
|
view.AllBg, view.AllFg, view.AllLink = seedColors(cm.OverrideAll, tokenLightBg, tokenLightFg, tokenLightLink)
|
||||||
|
view.DarkBg, view.DarkFg, view.DarkLink = seedColors(cm.OverrideDark, tokenDarkBg, tokenDarkFg, tokenDarkLink)
|
||||||
for i, m := range cm.Messages {
|
for i, m := range cm.Messages {
|
||||||
view.Messages = append(view.Messages, adminconsole.BannerMessageRow{
|
view.Messages = append(view.Messages, adminconsole.BannerMessageRow{
|
||||||
ID: m.ID.String(),
|
ID: m.ID.String(),
|
||||||
@@ -117,6 +131,9 @@ func (s *Server) consoleUpdateBanner(c *gin.Context) {
|
|||||||
Enabled: c.PostForm("enabled") != "",
|
Enabled: c.PostForm("enabled") != "",
|
||||||
StartsAt: starts,
|
StartsAt: starts,
|
||||||
EndsAt: ends,
|
EndsAt: ends,
|
||||||
|
Urgent: c.PostForm("urgent") != "",
|
||||||
|
OverrideAll: colorSetForm(c, "override_all_on", "override_bg", "override_fg", "override_link"),
|
||||||
|
OverrideDark: colorSetForm(c, "override_dark_on", "override_bg_dark", "override_fg_dark", "override_link_dark"),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.bannerError(c, err, back)
|
s.bannerError(c, err, back)
|
||||||
@@ -321,3 +338,29 @@ func atoiForm(c *gin.Context, name string) int {
|
|||||||
n, _ := strconv.Atoi(trimForm(c, name))
|
n, _ := strconv.Atoi(trimForm(c, name))
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedColors returns the three colour-input seed values for a campaign's optional
|
||||||
|
// override: the stored colours when the set is present, otherwise the given
|
||||||
|
// neutral theme tokens (so the native picker starts sensibly and the preview
|
||||||
|
// shows the real fallback).
|
||||||
|
func seedColors(cs *ads.ColorSet, tokenBg, tokenFg, tokenLink string) (bg, fg, link string) {
|
||||||
|
if cs == nil {
|
||||||
|
return tokenBg, tokenFg, tokenLink
|
||||||
|
}
|
||||||
|
return cs.Bg, cs.Fg, cs.Link
|
||||||
|
}
|
||||||
|
|
||||||
|
// colorSetForm reads an optional colour override from the form: the on flag gates
|
||||||
|
// it (an unchecked set is absent, nil), otherwise the three posted colours are
|
||||||
|
// returned for the ads service to validate. The colour inputs are disabled in the
|
||||||
|
// browser while the set is off, so they are not posted then.
|
||||||
|
func colorSetForm(c *gin.Context, on, bg, fg, link string) *ads.ColorSet {
|
||||||
|
if c.PostForm(on) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &ads.ColorSet{
|
||||||
|
Bg: trimForm(c, bg),
|
||||||
|
Fg: trimForm(c, fg),
|
||||||
|
Link: trimForm(c, link),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -174,6 +174,10 @@ 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
|
||||||
@@ -185,7 +189,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); err != nil {
|
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ, req.Language, req.Pwa); err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
# The game SPA and the Connect edge are served by the gateway. Strip any
|
# The game SPA and the Connect edge are served by the gateway. Strip any
|
||||||
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
|
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
|
||||||
# tag the honeypot block sets below (a client cannot self-tag a real request).
|
# tag the honeypot block sets below (a client cannot self-tag a real request).
|
||||||
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /scrabble.edge.v1.Gateway/*
|
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/*
|
||||||
handle @gateway {
|
handle @gateway {
|
||||||
reverse_proxy gateway:8081 {
|
reverse_proxy gateway:8081 {
|
||||||
header_up -X-Scrabble-Honeypot
|
header_up -X-Scrabble-Honeypot
|
||||||
|
|||||||
@@ -56,6 +56,22 @@
|
|||||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 },
|
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 },
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
"targets": [{ "refId": "A", "expr": "sum(rate(local_eval_preview_total[1h])) by (path)", "legendFormat": "{{path}}" }]
|
"targets": [{ "refId": "A", "expr": "sum(rate(local_eval_preview_total[1h])) by (path)", "legendFormat": "{{path}}" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Unsupported-engine screens (cumulative by reason)",
|
||||||
|
"description": "Clients turned away by the index.html boot guard because the engine cannot run the app (an old Android System WebView), by reason: no_bigint / no_proxy (a missing unpolyfillable primitive) or boot_error (an uncaught startup failure). Deduped per device/version, so this counts distinct blocked installs, not launches. The effective floor is Chrome 67 (BigInt).",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 32 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "sum(unsupported_engine_total) by (reason)", "legendFormat": "{{reason}}" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Unsupported engines by Chromium (rate)",
|
||||||
|
"description": "The same blocked clients by reported Chromium major version — which old in-app WebViews are still in the wild. \"other\" is an unparseable or out-of-range version.",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 32 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "sum(rate(unsupported_engine_total[1h])) by (chromium)", "legendFormat": "Chromium {{chromium}}" }]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-5
@@ -244,7 +244,9 @@ 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. The confirm runs on load; the token rides the URL fragment (never
|
and a would-be merge is deferred to the interactive flow. A login requested from an installed **PWA** omits the deeplink
|
||||||
|
entirely — its email carries the code only, typed in the same window, since the link would open
|
||||||
|
in a separate browser whose minted session cannot reach the PWA. The confirm runs on load; the token rides the URL fragment (never
|
||||||
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
|
||||||
@@ -980,10 +982,25 @@ edge-pause, scroll speed, and the fade-out → gap → fade-in transition) are o
|
|||||||
eligibility inputs (grants hints, grants/revokes `no_banner`; a future payment flow sets
|
eligibility inputs (grants hints, grants/revokes `no_banner`; a future payment flow sets
|
||||||
`paid_account`), the backend emits a `notify` **`banner`** sub-kind (a payload-free re-poll signal),
|
`paid_account`), the backend emits a `notify` **`banner`** sub-kind (a payload-free re-poll signal),
|
||||||
and the open client re-fetches `profile.get` to show or hide the banner in place. Operator *content*
|
and the open client re-fetches `profile.get` to show or hide the banner in place. Operator *content*
|
||||||
edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. The same
|
edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. A
|
||||||
|
non-default campaign may also carry an optional **colour override** (background, text, link) in two
|
||||||
|
sets — one for every theme, one for the dark theme only — plus an **urgent** flag. The colours ride
|
||||||
|
the same block (six optional strings appended to each `BannerCampaign` on the wire — trailing,
|
||||||
|
backward-compatible); the client resolves the cascade (dark ← dark ?? all, light ← all) and derives
|
||||||
|
the strip's border from the background in JS (no CSS `color-mix`, for the old-WebView floor).
|
||||||
|
**Urgent is resolved entirely server-side, with no wire field**: while any enabled, in-window urgent
|
||||||
|
campaign exists, `computeActiveSet` returns *only* the urgent campaigns (preempting the timed set and
|
||||||
|
the default remainder) and `bannerFor` **skips the eligibility gate** for that feed, so an urgent
|
||||||
|
notice reaches every viewer — paid, hint-holding or `no_banner` included. There is no instant
|
||||||
|
broadcast on an urgent toggle (the notify path is per-user); an urgent campaign appears on each
|
||||||
|
viewer's next `profile.get`. The default campaign stays plain (no colours, never urgent), enforced by
|
||||||
|
both the service and a DB CHECK. The same
|
||||||
mechanism carries a **`profile`** sub-kind — a payload-free re-fetch signal emitted when a viewer's
|
mechanism carries a **`profile`** sub-kind — a payload-free re-fetch signal emitted when a viewer's
|
||||||
own account changed out of band (an email confirmed through the one-tap deeplink opened in another
|
own account changed out of band (an email confirmed through the one-tap deeplink opened in another
|
||||||
browser), so an open in-app session reflects it at once.
|
browser), so an open in-app session reflects it at once. The live stream is single-shot with no
|
||||||
|
replay, so a **backgrounded Mini App** — suspended while the user is in their mail app tapping the
|
||||||
|
link — misses the event; while an add-email confirmation is pending the client therefore also
|
||||||
|
**polls `profile.get`** (and on foreground regain) as a fallback until the address lands.
|
||||||
|
|
||||||
> A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into
|
> A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into
|
||||||
> one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap
|
> one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap
|
||||||
@@ -1026,7 +1043,9 @@ browser), so an open in-app session reflects it at once.
|
|||||||
(`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. Both paths are inert unless configured.
|
comma-separated addresses. The digest carries **no admin-console link** — an admin URL must
|
||||||
|
never travel in an email, where a mail provider could cache or index it; the operator opens the
|
||||||
|
console directly. Both paths are inert unless configured.
|
||||||
- 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.
|
||||||
@@ -1070,6 +1089,17 @@ browser), so an open in-app session reflects it at once.
|
|||||||
it fills IndexedDB — and is surfaced on the **Scrabble — Users** dashboard. Lossy by
|
it fills IndexedDB — and is surfaced on the **Scrabble — Users** dashboard. Lossy by
|
||||||
design (a dropped beacon just retries its batch on the next flush); it is telemetry, never
|
design (a dropped beacon just retries its batch on the next flush); it is telemetry, never
|
||||||
a game input.
|
a game input.
|
||||||
|
- **Unsupported-engine screens (client-reported):** the ES5 boot guard in `index.html` (§13)
|
||||||
|
shows a full-screen "your device's OS or browser can't run the app" screen — instead of a white
|
||||||
|
screen — when the engine lacks an unpolyfillable essential (`BigInt`, the 64-bit FlatBuffers
|
||||||
|
decode; or `Proxy`, Svelte 5 runes) or an uncaught error aborts boot; the effective floor is
|
||||||
|
Chrome 67. It then fires one fire-and-forget beacon (`POST /telemetry/unsupported` —
|
||||||
|
unauthenticated, since the client never booted, but per-IP public-limited and body-capped),
|
||||||
|
deduped in `localStorage` by app version + reason + Chromium so a user reopening the app is one
|
||||||
|
report. The gateway folds it into `unsupported_engine_total` (`reason` =
|
||||||
|
no_bigint/no_proxy/boot_error/other; `chromium` = the major version reduced to a bounded range so
|
||||||
|
a spoofed beacon cannot inflate cardinality) and logs the full user agent (not a label). Surfaced
|
||||||
|
on the **Scrabble — Users** dashboard beside app opens.
|
||||||
- **Rate-limit observability:** every limiter rejection increments the gateway
|
- **Rate-limit observability:** every limiter rejection increments the gateway
|
||||||
counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate
|
counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate
|
||||||
only, honouring the no-per-user-label discipline above) and logs one **Debug** line;
|
only, honouring the no-per-user-label discipline above) and logs one **Debug** line;
|
||||||
@@ -1207,7 +1237,76 @@ 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). Hash-named `/assets/*` are served
|
`assets/icons/`, same tile design as the VK loader). On the web (`/app/`) the SPA is an installable **PWA**:
|
||||||
|
`manifest.webmanifest` ships unhashed from `ui/public/`, while the **service worker** (`sw.js`) is
|
||||||
|
built from `ui/src/sw.ts` by **vite-plugin-pwa** (injectManifest strategy) — the client registers it
|
||||||
|
**web-only**, never inside a Mini App or the mock build (the plugin is disabled there entirely). It
|
||||||
|
**precaches the app shell and the hashed assets** (Workbox) so an installed PWA cold-launches with
|
||||||
|
no network. Shell **navigations are network-first** — the fresh `no-cache` shell is fetched from the
|
||||||
|
server (so a new deploy is picked up on the very next launch, not the one after), falling back to the
|
||||||
|
precached shell only when the network is unreachable or too slow; the immutable hashed assets stay
|
||||||
|
cache-first, and the hash router resolves the route client-side. This navigation route is registered
|
||||||
|
**before** the precache route on purpose: Workbox matches in registration order, and the precache
|
||||||
|
route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve the shell
|
||||||
|
cache-first — the old build's version until a second load. The landing page and the conditional
|
||||||
|
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
|
||||||
|
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
|
||||||
|
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
|
||||||
|
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
|
||||||
|
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
|
||||||
|
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
|
||||||
|
creates one through the in-browser engine — the same game screen then drives it, the robot replying
|
||||||
|
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
|
||||||
|
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**.
|
||||||
|
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
|
||||||
|
per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host
|
||||||
|
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
|
||||||
|
current turn, **exclude** a player (a forced seat resign, `resignSeat`) or **terminate** the game
|
||||||
|
(deleted, no winner/loser); each seat may also carry its own **optional PIN** that withholds its rack
|
||||||
|
(the board stays visible — only the tray is gated) until it is entered, so players pass the device
|
||||||
|
around freely and lock a seat only against a distrusted tablemate. The unlock is **per turn**
|
||||||
|
(re-locks on advance). PINs are a **social lock, not cryptography** — a salted SHA-256 kept only in
|
||||||
|
the device record (`lib/pin`); a 4-digit PIN is trivially brute-forceable and the racks live in
|
||||||
|
client memory regardless, so they defeat a casual glance, not a determined peek. A hotseat game is
|
||||||
|
**not `vs_ai`**: hints (the freed control becomes the host button) and chat/self-resign are gone, and
|
||||||
|
its lobby card — active *and* finished — is master-PIN-gated to delete (so the last mover cannot
|
||||||
|
instantly wipe it); a naturally finished game is saved with its result like any local game. A `vs_ai`
|
||||||
|
hint (online and offline alike) is unlimited and wallet-free but idle-gated
|
||||||
|
(unlocked ~30 min into a stuck turn), and counts toward no hint statistic. The **source** reports the
|
||||||
|
**seconds left** (`hint_unlock_left_seconds` on the game view) — computed by the **backend from the
|
||||||
|
server clock** online (which also enforces the gate, returning `hint_locked` for an early request),
|
||||||
|
and by the offline source from the device clock (persisted, capped at the window). The **client
|
||||||
|
anchors a MONOTONIC countdown** (`performance.now()`, `lib/hints`) to that seconds-left when it lands
|
||||||
|
(on load, and to the full window when the robot moves), so a client clock change cannot skew it, and
|
||||||
|
a relaunch re-reads a fresh value so the wait is not forgotten. To have data ready before the switch, the **Profile advertises the current dictionary
|
||||||
|
version per variant** (`dict_versions`,
|
||||||
|
filled from the registry on the existing cold-start profile request — no extra round-trip), and an
|
||||||
|
eligible installed PWA (standalone web + confirmed email) **background-preloads** those dictionaries
|
||||||
|
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
|
||||||
|
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
|
||||||
|
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
|
||||||
|
the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch
|
||||||
|
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only
|
||||||
|
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the
|
||||||
|
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline
|
||||||
|
mode** boots from the persisted session and
|
||||||
|
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
|
||||||
|
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
|
||||||
|
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
|
||||||
|
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the
|
||||||
|
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient
|
||||||
|
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate
|
||||||
|
one (the toggle, or the cold-start dialog's *Switch*) persists. At cold start `navigator.onLine ===
|
||||||
|
false` enters offline for the session; otherwise a single bounded reachability probe (a `profile.get`,
|
||||||
|
~3 s) decides — success boots online, a timeout on an eligible install raises a *No connection* dialog
|
||||||
|
(*Switch* = sticky offline, *Wait for network* = boot online with the reachability watcher retrying).
|
||||||
|
Mid-session the `window` `online`/`offline` events drive it — `offline` auto-enters, `online`
|
||||||
|
re-verifies reachability before returning — backed by a `navigator.onLine` poll because those events
|
||||||
|
are unreliable on some platforms (notably iOS PWAs). Offline is a real **transport kill switch**
|
||||||
|
(every gateway call is refused, so no traffic leaks); the reachability probe is the one call exempt
|
||||||
|
from it (it is the return-to-online mechanism), and the transient reachability watcher is suppressed
|
||||||
|
while offline. The gateway registers the `.webmanifest` MIME type
|
||||||
|
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
|
||||||
`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
|
||||||
|
|||||||
+76
-8
@@ -21,7 +21,8 @@ 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 and the VK Mini App; the game itself runs at
|
theme, and links to the Telegram game channel, the VK Mini App and the **web version**
|
||||||
|
(`/app/`) — each under a caption (Telegram / VK / Веб-версия); the game itself runs at
|
||||||
`/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
|
||||||
@@ -31,6 +32,11 @@ 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
|
||||||
@@ -70,7 +76,9 @@ 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.
|
fragment, which is never sent to the server. A sign-in requested from an **installed PWA** omits
|
||||||
|
this link: the email carries only the code, entered in the same window, since the link would open
|
||||||
|
in a separate browser the app cannot reach.
|
||||||
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
|
||||||
@@ -109,7 +117,9 @@ two accounts share a game still in progress.
|
|||||||
|
|
||||||
The profile lists the account's **sign-in methods**. On the web a player can add
|
The profile lists the account's **sign-in methods**. On the web a player can add
|
||||||
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
|
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
|
||||||
back); inside a Mini App the host platform is already linked. Linking a provider that
|
back); inside a Mini App the host platform is already linked, and its own sign-in-method
|
||||||
|
row is hidden — the platform the player is currently signed in through cannot be unlinked
|
||||||
|
from there, only the other provider's row is shown. Linking a provider that
|
||||||
already belongs to another account offers the same irreversible **merge** as email
|
already belongs to another account offers the same irreversible **merge** as email
|
||||||
linking. A linked provider can be **unlinked** — except the last
|
linking. A linked provider can be **unlinked** — except the last
|
||||||
remaining sign-in method, which is refused so the account stays reachable. **Email is
|
remaining sign-in method, which is refused so the account stays reachable. **Email is
|
||||||
@@ -205,7 +215,16 @@ unlimited and offers a complaint on any result; for a word it finds, it also lin
|
|||||||
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
|
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
|
||||||
English) to look it up. Hints are governed per game —
|
English) to look it up. Hints are governed per game —
|
||||||
whether they are allowed and how many each player starts with — and draw on a
|
whether they are allowed and how many each player starts with — and draw on a
|
||||||
personal hint wallet once the per-game allowance is spent. The game ends when the
|
personal hint wallet once the per-game allowance is spent. Against the robot
|
||||||
|
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
|
||||||
|
anti-frustration aid: one unlocks only after the player has been **stuck ~30 minutes
|
||||||
|
on a turn** (timed from the robot's last move; the very first move, before the robot
|
||||||
|
has played, is exempt). While gated the hint button carries a small **🔒 lock** and a
|
||||||
|
tap shows how long remains; the lock lifts live at the mark. The same gate applies **online and
|
||||||
|
offline**: the countdown runs on a **steady in-app timer**, not the device clock, so changing the
|
||||||
|
clock cannot skew it, and a relaunch re-reads the remaining time so a stuck turn is not forgotten.
|
||||||
|
Online the **server enforces** it from its own clock (which the player cannot touch); a vs_ai hint
|
||||||
|
counts toward no hint statistic. The game ends when the
|
||||||
bag empties and a player clears their rack, after 6 consecutive scoreless turns,
|
bag empties and a player clears their rack, after 6 consecutive scoreless turns,
|
||||||
by resignation, or by the per-game move timeout (5 minutes to 24 hours, default
|
by resignation, or by the per-game move timeout (5 minutes to 24 hours, default
|
||||||
24 hours): a missed turn auto-resigns, except while the player is inside their
|
24 hours): a missed turn auto-resigns, except while the player is inside their
|
||||||
@@ -246,6 +265,44 @@ the same occasional move against its plan that fades out by the endgame), and ch
|
|||||||
add-friend are off. AI games are **practice** — they never count toward a player's
|
add-friend are off. AI games are **practice** — they never count toward a player's
|
||||||
statistics.
|
statistics.
|
||||||
|
|
||||||
|
### Offline mode
|
||||||
|
An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode**
|
||||||
|
(Settings → Play mode). Offline, the app never touches the network: the header turns blue with an
|
||||||
|
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are
|
||||||
|
disabled or hidden (the Stats tab; the *random opponent* option in New Game).
|
||||||
|
A New Game against the robot creates a device-local **vs_ai** game — it
|
||||||
|
plays entirely in the browser with no backend. Local games are kept on the device, visible only in
|
||||||
|
offline mode, and never sync to the account. So a game can be created and played with no connection,
|
||||||
|
the app quietly preloads the dictionaries for the player's enabled variants while still online, and
|
||||||
|
(once installed) launches from a precached shell even with no network. Switching **to** offline first
|
||||||
|
checks that the enabled variants' dictionaries are on the device — if any are missing it fetches them
|
||||||
|
and waits briefly, and if they cannot be readied it stays online with a short *needs internet* note
|
||||||
|
(the download keeps running in the background, so a later switch is instant). The mode is
|
||||||
|
device-scoped and sticky across launches.
|
||||||
|
|
||||||
|
Offline, New Game's **with friends** starts a **local pass-and-play** game for 2-4 people sharing one
|
||||||
|
device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
|
||||||
|
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
|
||||||
|
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
|
||||||
|
rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**.
|
||||||
|
During the game the board is always visible, but a PIN-locked seat shows an **Unlock** button in place
|
||||||
|
of its rack until its owner enters the PIN — so the device passes hand to hand and only a protected
|
||||||
|
seat hides its letters; the lock returns each turn. The **leader** (the host button in the game) can,
|
||||||
|
with the leader password, **skip** the current player's turn, **remove** a player, or **end the game
|
||||||
|
early** — an early end discards the game with no winner or loser. Hints and chat are off in a
|
||||||
|
pass-and-play game. A game that finishes normally is saved to the offline lobby like any other;
|
||||||
|
deleting any pass-and-play game — running or finished — from the lobby asks for the leader password,
|
||||||
|
so no one can wipe a game the moment they have moved.
|
||||||
|
|
||||||
|
The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch
|
||||||
|
with no connection it switches to offline for that session; when the device is online but the gateway
|
||||||
|
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with
|
||||||
|
**Switch** (go offline) or **Wait for network** (keep retrying online). While the app is open, losing
|
||||||
|
the network — airplane mode — switches to offline automatically, and restoring it returns online by
|
||||||
|
itself. A self-entered offline is temporary: it reverts to online the moment the network is back, and
|
||||||
|
the next launch re-checks. A **deliberate** offline — the Settings toggle, or **Switch** in that
|
||||||
|
dialog — is the player's choice: it is kept, and never undone automatically.
|
||||||
|
|
||||||
### Social: friends, block, chat, nudge
|
### Social: friends, block, chat, nudge
|
||||||
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
||||||
digits, valid for twelve hours), or send a **request to someone you have played
|
digits, valid for twelve hours), or send a **request to someone you have played
|
||||||
@@ -483,8 +540,19 @@ through**, regardless of their interface language. The client rotates the eligib
|
|||||||
**fairly** — every campaign gets its weighted share each cycle, evenly spread rather than at random —
|
**fairly** — every campaign gets its weighted share each cycle, evenly spread rather than at random —
|
||||||
and a campaign with several messages shows them in turn.
|
and a campaign with several messages shows them in turn.
|
||||||
|
|
||||||
|
A non-default campaign can carry its own **colours** — background, text and link — so a sponsored or
|
||||||
|
operational message stands out from the neutral strip. Colours come in two sets: one that applies on
|
||||||
|
**every theme**, and an optional second that overrides the **dark theme** only, so a campaign reads
|
||||||
|
well on both; the strip's border is derived from the background automatically. A campaign can also be
|
||||||
|
marked **urgent**: an urgent campaign is shown to **everyone** — even paid players, hint holders and
|
||||||
|
`no_banner` users — and, while it is live, it is the **only** thing the strip shows (ordinary
|
||||||
|
campaigns and the house default step aside). Urgent is for genuine system notices (maintenance,
|
||||||
|
outages), not advertising.
|
||||||
|
|
||||||
Operators manage all of this in the admin console at **`/_gm/banners`**: create, edit, enable/disable
|
Operators manage all of this in the admin console at **`/_gm/banners`**: create, edit, enable/disable
|
||||||
and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), and set
|
and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), pick a
|
||||||
the global display **timings** (how long a message holds, the scroll of an over-long message, and the
|
non-default campaign's optional override colours (a native colour picker with a live light-and-dark
|
||||||
fade-out → gap → fade-in transition between messages). The default campaign cannot be deleted and
|
preview of the strip) and mark it urgent, and set the global display **timings** (how long a message
|
||||||
keeps at least one message.
|
holds, the scroll of an over-long message, and the fade-out → gap → fade-in transition between
|
||||||
|
messages). The default campaign cannot be deleted, keeps at least one message, and stays plain (no
|
||||||
|
colours, never urgent).
|
||||||
|
|||||||
+76
-7
@@ -22,7 +22,8 @@ top-1 подсказку, безлимитную проверку слова с
|
|||||||
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
||||||
и ограничивает частоту повторов.
|
и ограничивает частоту повторов.
|
||||||
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
||||||
тему и ведёт в Telegram-канал игры и в VK Mini App; сама игра живёт по адресам
|
тему и ведёт в Telegram-канал игры, в VK Mini App и в **веб-версию** (`/app/`) — каждая под
|
||||||
|
подписью (Telegram / VK / Веб-версия); сама игра живёт по адресам
|
||||||
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
|
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
|
||||||
системной настройки, а не из сохранённой), выбор языка сохраняется. Страница всегда
|
системной настройки, а не из сохранённой), выбор языка сохраняется. Страница всегда
|
||||||
открывается на **русском** — язык браузера не определяется автоматически (роботы
|
открывается на **русском** — язык браузера не определяется автоматически (роботы
|
||||||
@@ -33,6 +34,11 @@ 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-оверлеем**: затемнённый слой показывает по одной подсказке-«облачку» за раз, каждое
|
||||||
@@ -74,7 +80,9 @@ launch-параметрам VK (их проверяет gateway), и при пе
|
|||||||
не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
|
не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
|
||||||
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
|
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
|
||||||
приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется —
|
приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется —
|
||||||
он едет в URL-фрагменте, который не уходит на сервер.
|
он едет в URL-фрагменте, который не уходит на сервер. Вход, запрошенный из **установленного
|
||||||
|
PWA**, эту ссылку не содержит: в письме только код, который вводится в том же окне (ссылка
|
||||||
|
открылась бы в отдельном браузере, недоступном приложению).
|
||||||
Telegram держит **единого бота**: все игроки пользуются одним
|
Telegram держит **единого бота**: все игроки пользуются одним
|
||||||
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
||||||
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
||||||
@@ -113,7 +121,9 @@ Telegram держит **единого бота**: все игроки поль
|
|||||||
|
|
||||||
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
|
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
|
||||||
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
|
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
|
||||||
обратно); внутри Mini App платформа-хозяин уже привязана. Привязка провайдера, уже
|
обратно); внутри Mini App платформа-хозяин уже привязана, и её собственная плашка способа
|
||||||
|
входа скрыта — платформу, через которую игрок сейчас вошёл, отсюда отвязать нельзя,
|
||||||
|
показывается только плашка другого провайдера. Привязка провайдера, уже
|
||||||
принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка
|
принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка
|
||||||
email. Привязанного провайдера можно **отвязать** — кроме последнего
|
email. Привязанного провайдера можно **отвязать** — кроме последнего
|
||||||
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
|
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
|
||||||
@@ -212,7 +222,16 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
||||||
чтобы его посмотреть. Подсказки управляются настройками
|
чтобы его посмотреть. Подсказки управляются настройками
|
||||||
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
||||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия
|
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
|
||||||
|
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
|
||||||
|
анти-фрустрация: подсказка открывается, только когда игрок **застрял на ходу ~30 минут**
|
||||||
|
(отсчёт от последнего хода робота; самый первый ход, до хода робота, исключён). Пока гейт
|
||||||
|
закрыт, кнопка подсказки несёт маленький **🔒 замок**, а тап показывает, сколько осталось;
|
||||||
|
замок снимается вживую в нужный момент. Один и тот же гейт работает **онлайн и офлайн**: отсчёт
|
||||||
|
идёт по **ровному внутриигровому таймеру**, а не по системным часам, поэтому их перевод его не
|
||||||
|
собьёт, а повторный вход перечитывает остаток — застрявший ход не забывается. Онлайн гейт
|
||||||
|
**принуждает сервер** по своим часам (их игрок не тронет); vs_ai-подсказка не учитывается ни в
|
||||||
|
одной статистике подсказок. Партия
|
||||||
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
|
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
|
||||||
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
|
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
|
||||||
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
|
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
|
||||||
@@ -251,6 +270,45 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же
|
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же
|
||||||
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
||||||
|
|
||||||
|
### Офлайн-режим
|
||||||
|
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
|
||||||
|
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
|
||||||
|
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
|
||||||
|
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
|
||||||
|
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
|
||||||
|
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
|
||||||
|
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
|
||||||
|
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
|
||||||
|
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
|
||||||
|
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
|
||||||
|
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
|
||||||
|
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
|
||||||
|
сохраняется между запусками.
|
||||||
|
|
||||||
|
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 2–4 человек
|
||||||
|
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||||
|
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
|
||||||
|
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
|
||||||
|
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
|
||||||
|
дать любому месту **свой необязательный PIN**. Во время игры доска видна всегда, но место с PIN
|
||||||
|
вместо стойки показывает кнопку **«Разблокировать»**, пока владелец не введёт PIN, — так устройство
|
||||||
|
передаётся из рук в руки, и только защищённое место прячет свои буквы; на каждом ходу замок
|
||||||
|
возвращается. **Ведущий** (кнопка ведущего в игре) может по паролю ведущего **пропустить** ход
|
||||||
|
текущего игрока, **исключить** игрока или **завершить игру досрочно** — досрочное завершение стирает
|
||||||
|
партию без победителей и проигравших. Подсказки и чат в игре по очереди отключены. Нормально
|
||||||
|
завершённая партия сохраняется в офлайн-лобби как любая другая; удаление любой игры по очереди —
|
||||||
|
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
|
||||||
|
своего хода.
|
||||||
|
|
||||||
|
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
|
||||||
|
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
|
||||||
|
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
|
||||||
|
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети —
|
||||||
|
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн.
|
||||||
|
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
|
||||||
|
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
|
||||||
|
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.
|
||||||
|
|
||||||
### Социальное: друзья, блок, чат, nudge
|
### Социальное: друзья, блок, чат, nudge
|
||||||
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
||||||
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
|
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
|
||||||
@@ -494,8 +552,19 @@ high-rate флага. С карточки пользователя операт
|
|||||||
**честно** — каждая получает свою долю по весу за цикл, равномерно размазанную, а не случайно — а
|
**честно** — каждая получает свою долю по весу за цикл, равномерно размазанную, а не случайно — а
|
||||||
кампания с несколькими сообщениями показывает их по очереди.
|
кампания с несколькими сообщениями показывает их по очереди.
|
||||||
|
|
||||||
|
Недефолтная кампания может нести собственные **цвета** — фон, текст и ссылку — чтобы рекламное или
|
||||||
|
служебное сообщение выделялось на фоне нейтральной ленты. Цвета задаются двумя наборами: один
|
||||||
|
применяется на **любой теме**, а необязательный второй переопределяет только **тёмную тему**, чтобы
|
||||||
|
кампания хорошо читалась на обеих; рамка ленты выводится из фона автоматически. Кампанию можно также
|
||||||
|
пометить как **срочную (urgent)**: срочная кампания показывается **всем** — даже платным игрокам,
|
||||||
|
владельцам подсказок и пользователям с ролью `no_banner` — и, пока она активна, лента показывает
|
||||||
|
**только её** (обычные кампании и домашняя дефолтная уступают место). Срочность — для настоящих
|
||||||
|
системных уведомлений (тех.работы, сбои), а не для рекламы.
|
||||||
|
|
||||||
Всем этим оператор управляет в админ-консоли по адресу **`/_gm/banners`**: создаёт, редактирует,
|
Всем этим оператор управляет в админ-консоли по адресу **`/_gm/banners`**: создаёт, редактирует,
|
||||||
включает/выключает и планирует кампании, пишет двуязычные сообщения кампании (переставляет и удаляет
|
включает/выключает и планирует кампании, пишет двуязычные сообщения кампании (переставляет и удаляет
|
||||||
их) и задаёт общие **тайминги** показа (сколько держится сообщение, прокрутка слишком длинного, и
|
их), выбирает необязательные цвета недефолтной кампании (нативный color-picker с живым превью ленты
|
||||||
переход fade-out → пауза → fade-in между сообщениями). Дефолтную кампанию нельзя удалить, и в ней
|
на светлой и тёмной темах) и помечает её срочной, а также задаёт общие **тайминги** показа (сколько
|
||||||
всегда остаётся хотя бы одно сообщение.
|
держится сообщение, прокрутка слишком длинного, и переход fade-out → пауза → fade-in между
|
||||||
|
сообщениями). Дефолтную кампанию нельзя удалить, в ней всегда остаётся хотя бы одно сообщение, и она
|
||||||
|
остаётся простой (без цветов, никогда не срочная).
|
||||||
|
|||||||
+12
-1
@@ -20,7 +20,18 @@ tests or touching CI.
|
|||||||
derivation and the GCG share/copy/download choice, plus Playwright specs against the
|
derivation and the GCG share/copy/download choice, plus Playwright specs against the
|
||||||
mock for the friends screen (code issue/redeem, accept a request), the lobby
|
mock for the friends screen (code issue/redeem, accept a request), the lobby
|
||||||
invitations section, the stats screen, profile editing, and the export chooser's
|
invitations section, the stats screen, profile editing, and the export chooser's
|
||||||
finished-only visibility + its signed-URL download flow (route-intercepted).
|
finished-only visibility + its signed-URL download flow (route-intercepted). The
|
||||||
|
**offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game
|
||||||
|
end-to-end: it forces the installed-PWA display mode, enters offline through the
|
||||||
|
Settings toggle (whose readiness check fetches the enabled variants' dawgs), then creates
|
||||||
|
and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
|
||||||
|
rack is deterministic and the human can tap out a precomputed opening), asserting the
|
||||||
|
robot's real reply and the IndexedDB replay after a reload. A local game needs a real
|
||||||
|
dictionary, so the mock's `fetchDict` serves the per-variant dawgs from the preview
|
||||||
|
build's `/e2edict/`, copied in by `scripts/e2e-dict.mjs` from `E2E_DICT_DIR` — the `ui`
|
||||||
|
CI job fetches the `scrabble-dictionary` release like the Go jobs; locally it defaults to
|
||||||
|
the sibling `scrabble-solver/dawg`. These dawgs are never committed and never enter the
|
||||||
|
production build.
|
||||||
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
|
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
|
||||||
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
|
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
|
||||||
real self-played 35-move game) must rasterize to a plausible PNG
|
real self-played 35-move game) must rasterize to a plausible PNG
|
||||||
|
|||||||
@@ -315,6 +315,19 @@ left. A **viewport size change** (e.g. a portrait↔landscape rotation) re-measu
|
|||||||
operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades collapse to an instant swap
|
operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades collapse to an instant swap
|
||||||
and a long message does not scroll.
|
and a long message does not scroll.
|
||||||
|
|
||||||
|
**Per-campaign colours.** A campaign may override the strip's colours (background, text, link). The
|
||||||
|
engine carries the current message's campaign colours to the host, and `AdBanner` resolves them for
|
||||||
|
the **rendered theme** (`lib/bannerColors`: dark ← dark-set ?? all-set, light ← all-set) and applies
|
||||||
|
them as inline CSS variables scoped to `.ad` (`--ad-bg`, `--text-muted`, `--accent`, and a derived
|
||||||
|
`--ad-border`) — so an override never leaks past the strip, and a campaign with no override keeps the
|
||||||
|
neutral tokens. The border is computed from the background in JS (a luminance-aware nudge toward
|
||||||
|
black/white — no CSS `color-mix`, which the old Android WebView floor lacks), matching the admin
|
||||||
|
console's live preview. The resolution re-runs when the theme flips (a `[data-theme]` /
|
||||||
|
`prefers-color-scheme` observer), so switching light↔dark repaints an overridden strip at once. An
|
||||||
|
**urgent** campaign has no client-specific styling — it simply arrives (with its colours) as the only
|
||||||
|
campaign in the feed; the preempt-and-show-everyone behaviour is entirely server-side (ARCHITECTURE
|
||||||
|
§10).
|
||||||
|
|
||||||
## Result / status iconography (`lib/result.ts`)
|
## Result / status iconography (`lib/result.ts`)
|
||||||
|
|
||||||
Lobby rows show two lines (opponents, then result + score) with a large place-based emoji
|
Lobby rows show two lines (opponents, then result + score) with a large place-based emoji
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ backend over REST/JSON, and bridges the backend's gRPC push stream to each
|
|||||||
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
|
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
|
||||||
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
||||||
**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model.
|
**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model.
|
||||||
|
The web SPA is an installable **PWA**: it serves `manifest.webmanifest` (unhashed from `ui/public/`,
|
||||||
|
with the `.webmanifest` MIME type registered in-process — the distroless image has no
|
||||||
|
`/etc/mime.types`) and the app-shell `sw.js` (built from `ui/src/sw.ts` by vite-plugin-pwa, which
|
||||||
|
precaches the shell + assets so an installed PWA cold-launches offline).
|
||||||
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
||||||
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
||||||
in the deployed contour the front caddy owns `/_gm` (see
|
in the deployed contour the front caddy owns `/_gm` (see
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ type ProfileResp struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
TelegramLinked bool `json:"telegram_linked"`
|
TelegramLinked bool `json:"telegram_linked"`
|
||||||
VkLinked bool `json:"vk_linked"`
|
VkLinked bool `json:"vk_linked"`
|
||||||
|
// DictVersions is the current dictionary version per game variant, forwarded verbatim
|
||||||
|
// into the Profile payload so an offline-capable client preloads the matching dawg.
|
||||||
|
DictVersions []DictVersion `json:"dict_versions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DictVersion pairs a game variant's stable label with its current dictionary version.
|
||||||
|
type DictVersion struct {
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BannerResp is the advertising-banner block of an eligible viewer's profile: the
|
// BannerResp is the advertising-banner block of an eligible viewer's profile: the
|
||||||
@@ -53,9 +62,18 @@ type BannerResp struct {
|
|||||||
|
|
||||||
// BannerCampaignResp is one campaign in the rotation feed: a GCD-reduced show
|
// BannerCampaignResp is one campaign in the rotation feed: a GCD-reduced show
|
||||||
// weight and its messages, in display order, already resolved to one language.
|
// weight and its messages, in display order, already resolved to one language.
|
||||||
|
// The override_* colours are the optional per-campaign palette (empty when the
|
||||||
|
// campaign keeps the neutral theme tokens); they ride through to the client
|
||||||
|
// verbatim, mirroring the backend DTO.
|
||||||
type BannerCampaignResp struct {
|
type BannerCampaignResp struct {
|
||||||
Weight int `json:"weight"`
|
Weight int `json:"weight"`
|
||||||
Messages []string `json:"messages"`
|
Messages []string `json:"messages"`
|
||||||
|
OverrideBg string `json:"override_bg,omitempty"`
|
||||||
|
OverrideFg string `json:"override_fg,omitempty"`
|
||||||
|
OverrideLink string `json:"override_link,omitempty"`
|
||||||
|
OverrideBgDark string `json:"override_bg_dark,omitempty"`
|
||||||
|
OverrideFgDark string `json:"override_fg_dark,omitempty"`
|
||||||
|
OverrideLinkDark string `json:"override_link_dark,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BannerTimingsResp mirrors the backend's global display timings.
|
// BannerTimingsResp mirrors the backend's global display timings.
|
||||||
@@ -169,6 +187,7 @@ type StateResp struct {
|
|||||||
BagLen int `json:"bag_len"`
|
BagLen int `json:"bag_len"`
|
||||||
HintsRemaining int `json:"hints_remaining"`
|
HintsRemaining int `json:"hints_remaining"`
|
||||||
WalletBalance int `json:"wallet_balance"`
|
WalletBalance int `json:"wallet_balance"`
|
||||||
|
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
||||||
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,10 +299,11 @@ 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.
|
// account's time zone, since the email account is created here, not at login. pwa marks a
|
||||||
func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language string) error {
|
// request from an installed PWA, so the backend omits the one-tap confirm link from the email.
|
||||||
|
func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language string, pwa bool) error {
|
||||||
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]string{"email": email, "browser_tz": browserTz, "language": language}, nil)
|
map[string]any{"email": email, "browser_tz": browserTz, "language": language, "pwa": pwa}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmailLogin verifies a login code and mints a session.
|
// EmailLogin verifies a login code and mints a session.
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ type serverMetrics struct {
|
|||||||
localColdStart metric.Int64Counter
|
localColdStart metric.Int64Counter
|
||||||
localDictLoad metric.Int64Counter
|
localDictLoad metric.Int64Counter
|
||||||
localPreview metric.Int64Counter
|
localPreview metric.Int64Counter
|
||||||
|
// Clients turned away by the unsupported-engine boot screen (see unsupportedEngineHandler).
|
||||||
|
unsupportedEngine metric.Int64Counter
|
||||||
}
|
}
|
||||||
|
|
||||||
// newServerMetrics builds the instruments on meter (nil selects a no-op meter),
|
// newServerMetrics builds the instruments on meter (nil selects a no-op meter),
|
||||||
@@ -44,7 +46,13 @@ 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")
|
||||||
}
|
}
|
||||||
@@ -63,6 +71,8 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
|
|||||||
localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."),
|
localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."),
|
||||||
localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."),
|
localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."),
|
||||||
localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."),
|
localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."),
|
||||||
|
unsupportedEngine: counterOf(meter, "unsupported_engine_total",
|
||||||
|
"Clients that hit the unsupported-engine boot screen (the app cannot run), by reason (no_bigint, no_proxy, boot_error, other) and Chromium major — a deduped beacon from the index.html boot guard; the full user agent is logged, not labelled."),
|
||||||
}
|
}
|
||||||
|
|
||||||
gauge, err := meter.Int64ObservableGauge("active_users",
|
gauge, err := meter.Int64ObservableGauge("active_users",
|
||||||
@@ -108,6 +118,16 @@ func (m *serverMetrics) recordBan(ctx context.Context, reason string) {
|
|||||||
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
|
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordUnsupportedEngine counts one client turned away by the unsupported-engine boot screen,
|
||||||
|
// labelled by reason and Chromium major. The caller passes both already reduced to bounded label
|
||||||
|
// sets (see normalizeUnsupported) so a spoofed beacon cannot explode the metric cardinality.
|
||||||
|
func (m *serverMetrics) recordUnsupportedEngine(ctx context.Context, reason, chromium string) {
|
||||||
|
m.unsupportedEngine.Add(ctx, 1, metric.WithAttributes(
|
||||||
|
attribute.String("reason", reason),
|
||||||
|
attribute.String("chromium", chromium),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// localEvalReport is the client-reported local move-preview telemetry batch — deltas since
|
// localEvalReport is the client-reported local move-preview telemetry batch — deltas since
|
||||||
// the client's previous report. It backs the adoption dashboard: app cold starts vs cached
|
// the client's previous report. It backs the adoption dashboard: app cold starts vs cached
|
||||||
// dictionaries vs on-device previews.
|
// dictionaries vs on-device previews.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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" {
|
||||||
@@ -42,6 +43,7 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,6 +53,19 @@ 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
|
||||||
@@ -128,3 +143,70 @@ func TestBannedMetric(t *testing.T) {
|
|||||||
t.Errorf("banned counts = %v, want tripwire=2 honeytoken=1", counts)
|
t.Errorf("banned counts = %v, want tripwire=2 honeytoken=1", counts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestUnsupportedEngineMetric records unsupported-engine beacons through a manual reader and asserts
|
||||||
|
// unsupported_engine_total splits by reason and Chromium major.
|
||||||
|
func TestUnsupportedEngineMetric(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
reader := sdkmetric.NewManualReader()
|
||||||
|
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
||||||
|
m := newServerMetrics(meter)
|
||||||
|
|
||||||
|
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
|
||||||
|
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
|
||||||
|
m.recordUnsupportedEngine(ctx, "boot_error", "74")
|
||||||
|
|
||||||
|
var rm metricdata.ResourceMetrics
|
||||||
|
if err := reader.Collect(ctx, &rm); err != nil {
|
||||||
|
t.Fatalf("collect: %v", err)
|
||||||
|
}
|
||||||
|
type key struct{ reason, chromium string }
|
||||||
|
counts := map[key]int64{}
|
||||||
|
for _, sm := range rm.ScopeMetrics {
|
||||||
|
for _, md := range sm.Metrics {
|
||||||
|
if md.Name != "unsupported_engine_total" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sum, ok := md.Data.(metricdata.Sum[int64])
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unsupported_engine_total is not an int64 sum")
|
||||||
|
}
|
||||||
|
for _, dp := range sum.DataPoints {
|
||||||
|
reason, _ := dp.Attributes.Value(attribute.Key("reason"))
|
||||||
|
chromium, _ := dp.Attributes.Value(attribute.Key("chromium"))
|
||||||
|
counts[key{reason.AsString(), chromium.AsString()}] += dp.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := counts[key{"no_bigint", "66"}]; got != 2 {
|
||||||
|
t.Errorf("unsupported no_bigint/66 = %d, want 2", got)
|
||||||
|
}
|
||||||
|
if got := counts[key{"boot_error", "74"}]; got != 1 {
|
||||||
|
t.Errorf("unsupported boot_error/74 = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeUnsupported checks that a beacon's reason and Chromium are reduced to bounded label
|
||||||
|
// values, so a spoofed beacon cannot explode the metric cardinality.
|
||||||
|
func TestNormalizeUnsupported(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
reason, chromium string
|
||||||
|
wantReason, wantChromium string
|
||||||
|
}{
|
||||||
|
{"no_bigint", "66", "no_bigint", "66"},
|
||||||
|
{"no_proxy", " 74 ", "no_proxy", "74"},
|
||||||
|
{"boot_error", "105", "boot_error", "105"},
|
||||||
|
{"garbage", "66", "other", "66"},
|
||||||
|
{"", "", "other", "other"},
|
||||||
|
{"no_bigint", "not-a-number", "no_bigint", "other"},
|
||||||
|
{"no_bigint", "9999", "no_bigint", "other"},
|
||||||
|
{"no_bigint", "0", "no_bigint", "other"},
|
||||||
|
{"no_bigint", "-5", "no_bigint", "other"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
gotR, gotC := normalizeUnsupported(c.reason, c.chromium)
|
||||||
|
if gotR != c.wantReason || gotC != c.wantChromium {
|
||||||
|
t.Errorf("normalizeUnsupported(%q,%q) = %q,%q; want %q,%q", c.reason, c.chromium, gotR, gotC, c.wantReason, c.wantChromium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -197,6 +198,9 @@ func (s *Server) HTTPHandler() http.Handler {
|
|||||||
mux.Handle("/dl/", s.exportDownloadHandler())
|
mux.Handle("/dl/", s.exportDownloadHandler())
|
||||||
// The client posts its local-move-preview adoption telemetry here (session-gated).
|
// The client posts its local-move-preview adoption telemetry here (session-gated).
|
||||||
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
|
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
|
||||||
|
// The index.html boot guard beacons here when it turns a client away on the unsupported-engine
|
||||||
|
// screen (the app cannot run). Unauthenticated — the client never booted — but rate-limited.
|
||||||
|
mux.Handle("/telemetry/unsupported", s.unsupportedEngineHandler())
|
||||||
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
|
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
|
||||||
// App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md
|
// App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md
|
||||||
// §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps
|
// §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps
|
||||||
@@ -582,6 +586,76 @@ func clampReport(r *localEvalReport) {
|
|||||||
clamp(&r.PreviewNetwork)
|
clamp(&r.PreviewNetwork)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unsupportedEngineBeacon is the small fire-and-forget report the index.html boot guard sends when
|
||||||
|
// it turns a client away on the unsupported-engine screen (no BigInt/Proxy, or an uncaught boot
|
||||||
|
// error). It is deduped client-side (one per device / app version / reason).
|
||||||
|
type unsupportedEngineBeacon struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Chromium string `json:"chromium"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
UA string `json:"ua"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// unsupportedEngineHandler folds one unsupported-engine beacon into the edge counter. It is
|
||||||
|
// unauthenticated (the client never booted, so it carries no session) but per-IP rate-limited with
|
||||||
|
// the public limiter and body-capped. reason and the Chromium major are reduced to bounded label
|
||||||
|
// sets (normalizeUnsupported) so a spoofed beacon cannot inflate the metric cardinality; the full
|
||||||
|
// user agent is logged, not labelled. Only POST; the reply is always 204.
|
||||||
|
func (s *Server) unsupportedEngineHandler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ip := peerIP(r.RemoteAddr, r.Header)
|
||||||
|
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
|
||||||
|
s.noteRateLimited(r.Context(), classPublic, ip, "unsupported")
|
||||||
|
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var b unsupportedEngineBeacon
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&b); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reason, chromium := normalizeUnsupported(b.Reason, b.Chromium)
|
||||||
|
s.metrics.recordUnsupportedEngine(r.Context(), reason, chromium)
|
||||||
|
s.log.Info("unsupported engine",
|
||||||
|
zap.String("reason", reason),
|
||||||
|
zap.String("chromium", chromium),
|
||||||
|
zap.String("app_version", truncate(b.Version, 40)),
|
||||||
|
zap.String("user_agent", truncate(b.UA, 400)),
|
||||||
|
)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeUnsupported reduces a beacon's reason and Chromium fields to bounded label values, so a
|
||||||
|
// spoofed beacon cannot explode the metric cardinality: reason is allow-listed, and Chromium is
|
||||||
|
// parsed as a major version kept only within a plausible range, otherwise "other".
|
||||||
|
func normalizeUnsupported(reason, chromium string) (string, string) {
|
||||||
|
switch reason {
|
||||||
|
case "no_bigint", "no_proxy", "boot_error":
|
||||||
|
// a recognised reason — keep as-is
|
||||||
|
default:
|
||||||
|
reason = "other"
|
||||||
|
}
|
||||||
|
major := "other"
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(chromium)); err == nil && n >= 1 && n <= 199 {
|
||||||
|
major = strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
return reason, major
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate bounds a logged, client-supplied string to n bytes (a spoofed beacon field is not
|
||||||
|
// trusted to be small).
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) > n {
|
||||||
|
return s[:n]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// resolve extracts and resolves the Authorization bearer token to an account id
|
// resolve extracts and resolves the Authorization bearer token to an account id
|
||||||
// and its guest flag, returning a Connect Unauthenticated error when it is missing
|
// and its guest flag, returning a Connect Unauthenticated error when it is missing
|
||||||
// or unknown.
|
// or unknown.
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package connectsrv_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||||
|
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||||
|
|
||||||
|
"scrabble/gateway/internal/config"
|
||||||
|
"scrabble/gateway/internal/connectsrv"
|
||||||
|
"scrabble/gateway/internal/ratelimit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUnsupportedEngineHandler drives the /telemetry/unsupported beacon route end to end: a POST
|
||||||
|
// increments unsupported_engine_total with the normalised labels and replies 204; a GET is 405.
|
||||||
|
func TestUnsupportedEngineHandler(t *testing.T) {
|
||||||
|
reader := sdkmetric.NewManualReader()
|
||||||
|
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
||||||
|
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||||
|
Limiter: ratelimit.New(),
|
||||||
|
RateLimit: config.DefaultRateLimit(),
|
||||||
|
Meter: meter,
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(edge.HTTPHandler())
|
||||||
|
defer srv.Close()
|
||||||
|
url := srv.URL + "/telemetry/unsupported"
|
||||||
|
|
||||||
|
// A GET is rejected.
|
||||||
|
getResp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
getResp.Body.Close()
|
||||||
|
if getResp.StatusCode != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("GET status = %d, want 405", getResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A POST is accepted (204) and folded into the counter with normalised labels.
|
||||||
|
body := `{"reason":"no_bigint","chromium":"66","version":"v1.2.3","ua":"Mozilla/5.0 ... Chrome/66"}`
|
||||||
|
resp, err := http.Post(url, "application/json", bytes.NewBufferString(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("POST status = %d, want 204", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rm metricdata.ResourceMetrics
|
||||||
|
if err := reader.Collect(context.Background(), &rm); err != nil {
|
||||||
|
t.Fatalf("collect: %v", err)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
for _, sm := range rm.ScopeMetrics {
|
||||||
|
for _, md := range sm.Metrics {
|
||||||
|
if md.Name != "unsupported_engine_total" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sum, ok := md.Data.(metricdata.Sum[int64])
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unsupported_engine_total is not an int64 sum")
|
||||||
|
}
|
||||||
|
for _, dp := range sum.DataPoints {
|
||||||
|
reason, _ := dp.Attributes.Value(attribute.Key("reason"))
|
||||||
|
chromium, _ := dp.Attributes.Value(attribute.Key("chromium"))
|
||||||
|
if reason.AsString() != "no_bigint" || chromium.AsString() != "66" {
|
||||||
|
t.Errorf("labels = %s/%s, want no_bigint/66", reason.AsString(), chromium.AsString())
|
||||||
|
}
|
||||||
|
total += dp.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total != 1 {
|
||||||
|
t.Errorf("unsupported_engine_total = %d, want 1", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,6 +95,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
banner = encodeBanner(b, *p.Banner)
|
banner = encodeBanner(b, *p.Banner)
|
||||||
}
|
}
|
||||||
prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector)
|
prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector)
|
||||||
|
dictVersions := encodeDictVersions(b, p.DictVersions)
|
||||||
fb.ProfileStart(b)
|
fb.ProfileStart(b)
|
||||||
fb.ProfileAddUserId(b, uid)
|
fb.ProfileAddUserId(b, uid)
|
||||||
fb.ProfileAddDisplayName(b, name)
|
fb.ProfileAddDisplayName(b, name)
|
||||||
@@ -111,6 +112,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
fb.ProfileAddEmail(b, email)
|
fb.ProfileAddEmail(b, email)
|
||||||
fb.ProfileAddTelegramLinked(b, p.TelegramLinked)
|
fb.ProfileAddTelegramLinked(b, p.TelegramLinked)
|
||||||
fb.ProfileAddVkLinked(b, p.VkLinked)
|
fb.ProfileAddVkLinked(b, p.VkLinked)
|
||||||
|
if dictVersions != 0 {
|
||||||
|
fb.ProfileAddDictVersions(b, dictVersions)
|
||||||
|
}
|
||||||
if p.Banner != nil {
|
if p.Banner != nil {
|
||||||
fb.ProfileAddBanner(b, banner)
|
fb.ProfileAddBanner(b, banner)
|
||||||
}
|
}
|
||||||
@@ -118,6 +122,48 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// encodeDictVersions builds the Profile's dict_versions vector — one DictVersion table per
|
||||||
|
// variant/version pair — and returns its offset, or 0 when there are none (the field is then
|
||||||
|
// omitted). Every child table and its strings are created before the vector is opened, per the
|
||||||
|
// FlatBuffers rule against nesting a table under one still being built; the caller invokes it
|
||||||
|
// before ProfileStart for the same reason.
|
||||||
|
func encodeDictVersions(b *flatbuffers.Builder, dvs []backendclient.DictVersion) flatbuffers.UOffsetT {
|
||||||
|
if len(dvs) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
offsets := make([]flatbuffers.UOffsetT, len(dvs))
|
||||||
|
for i, dv := range dvs {
|
||||||
|
variant := b.CreateString(dv.Variant)
|
||||||
|
version := b.CreateString(dv.Version)
|
||||||
|
fb.DictVersionStart(b)
|
||||||
|
fb.DictVersionAddVariant(b, variant)
|
||||||
|
fb.DictVersionAddVersion(b, version)
|
||||||
|
offsets[i] = fb.DictVersionEnd(b)
|
||||||
|
}
|
||||||
|
fb.ProfileStartDictVersionsVector(b, len(offsets))
|
||||||
|
for i := len(offsets) - 1; i >= 0; i-- {
|
||||||
|
b.PrependUOffsetT(offsets[i])
|
||||||
|
}
|
||||||
|
return b.EndVector(len(offsets))
|
||||||
|
}
|
||||||
|
|
||||||
|
// optString creates a FlatBuffers string for a non-empty value, or 0 to omit the
|
||||||
|
// optional field (the client then reads it as absent).
|
||||||
|
func optString(b *flatbuffers.Builder, s string) flatbuffers.UOffsetT {
|
||||||
|
if s == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return b.CreateString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addOptString adds an optional string slot only when it was created (a 0 offset
|
||||||
|
// leaves the field absent). add is the generated BannerCampaignAdd* setter.
|
||||||
|
func addOptString(b *flatbuffers.Builder, add func(*flatbuffers.Builder, flatbuffers.UOffsetT), off flatbuffers.UOffsetT) {
|
||||||
|
if off != 0 {
|
||||||
|
add(b, off)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// encodeBanner builds a BannerInfo table from the resolved banner block and
|
// encodeBanner builds a BannerInfo table from the resolved banner block and
|
||||||
// returns its offset. It is bottom-up: each campaign's messages vector and table
|
// returns its offset. It is bottom-up: each campaign's messages vector and table
|
||||||
// are built first, then the campaigns vector, then the BannerInfo table. The
|
// are built first, then the campaigns vector, then the BannerInfo table. The
|
||||||
@@ -134,9 +180,23 @@ func encodeBanner(b *flatbuffers.Builder, banner backendclient.BannerResp) flatb
|
|||||||
b.PrependUOffsetT(msgOffsets[j])
|
b.PrependUOffsetT(msgOffsets[j])
|
||||||
}
|
}
|
||||||
msgs := b.EndVector(len(msgOffsets))
|
msgs := b.EndVector(len(msgOffsets))
|
||||||
|
// The optional colour strings must be created before the table starts; an
|
||||||
|
// empty colour is omitted (offset 0), so the client reads it as absent.
|
||||||
|
obg := optString(b, c.OverrideBg)
|
||||||
|
ofg := optString(b, c.OverrideFg)
|
||||||
|
olink := optString(b, c.OverrideLink)
|
||||||
|
obgD := optString(b, c.OverrideBgDark)
|
||||||
|
ofgD := optString(b, c.OverrideFgDark)
|
||||||
|
olinkD := optString(b, c.OverrideLinkDark)
|
||||||
fb.BannerCampaignStart(b)
|
fb.BannerCampaignStart(b)
|
||||||
fb.BannerCampaignAddWeight(b, int32(c.Weight))
|
fb.BannerCampaignAddWeight(b, int32(c.Weight))
|
||||||
fb.BannerCampaignAddMessages(b, msgs)
|
fb.BannerCampaignAddMessages(b, msgs)
|
||||||
|
addOptString(b, fb.BannerCampaignAddOverrideBg, obg)
|
||||||
|
addOptString(b, fb.BannerCampaignAddOverrideFg, ofg)
|
||||||
|
addOptString(b, fb.BannerCampaignAddOverrideLink, olink)
|
||||||
|
addOptString(b, fb.BannerCampaignAddOverrideBgDark, obgD)
|
||||||
|
addOptString(b, fb.BannerCampaignAddOverrideFgDark, ofgD)
|
||||||
|
addOptString(b, fb.BannerCampaignAddOverrideLinkDark, olinkD)
|
||||||
campOffsets[i] = fb.BannerCampaignEnd(b)
|
campOffsets[i] = fb.BannerCampaignEnd(b)
|
||||||
}
|
}
|
||||||
fb.BannerInfoStartCampaignsVector(b, len(campOffsets))
|
fb.BannerInfoStartCampaignsVector(b, len(campOffsets))
|
||||||
@@ -245,6 +305,7 @@ func toWireState(s backendclient.StateResp) wire.StateView {
|
|||||||
BagLen: s.BagLen,
|
BagLen: s.BagLen,
|
||||||
HintsRemaining: s.HintsRemaining,
|
HintsRemaining: s.HintsRemaining,
|
||||||
WalletBalance: s.WalletBalance,
|
WalletBalance: s.WalletBalance,
|
||||||
|
HintUnlockLeftSeconds: s.HintUnlockLeftSeconds,
|
||||||
Alphabet: alphabet,
|
Alphabet: alphabet,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())); err != nil {
|
if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz()), string(in.Language()), in.Pwa()); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return encodeAck(true), nil
|
return encodeAck(true), nil
|
||||||
|
|||||||
@@ -61,6 +61,54 @@ func TestProfileGetEncodesBanner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProfileGetEncodesBannerColors verifies the gateway carries a campaign's
|
||||||
|
// optional colour overrides into the FlatBuffers payload, and leaves an absent
|
||||||
|
// colour (or an absent set) empty so the client falls back to the theme tokens.
|
||||||
|
func TestProfileGetEncodesBannerColors(t *testing.T) {
|
||||||
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en",` +
|
||||||
|
`"banner":{"campaigns":[` +
|
||||||
|
`{"weight":1,"messages":["promo-en"],` +
|
||||||
|
`"override_bg":"#aa0000","override_fg":"#ffffff","override_link":"#ffdd00",` +
|
||||||
|
`"override_bg_dark":"#330000","override_fg_dark":"#eeeeee","override_link_dark":"#ffcc00"},` +
|
||||||
|
`{"weight":1,"messages":["plain-en"]}],` +
|
||||||
|
`"timings":{"hold_ms":60000,"edge_pause_ms":5000,"scroll_px_per_sec":40,` +
|
||||||
|
`"fade_out_ms":1000,"gap_ms":250,"fade_in_ms":1000}}}`))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil)
|
||||||
|
op, _ := reg.Lookup(transcode.MsgProfileGet)
|
||||||
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler: %v", err)
|
||||||
|
}
|
||||||
|
banner := fb.GetRootAsProfile(payload, 0).Banner(nil)
|
||||||
|
if banner == nil {
|
||||||
|
t.Fatal("profile carries no banner block")
|
||||||
|
}
|
||||||
|
var c fb.BannerCampaign
|
||||||
|
banner.Campaigns(&c, 0)
|
||||||
|
if got := string(c.OverrideBg()); got != "#aa0000" {
|
||||||
|
t.Errorf("override_bg = %q, want #aa0000", got)
|
||||||
|
}
|
||||||
|
if got := string(c.OverrideLink()); got != "#ffdd00" {
|
||||||
|
t.Errorf("override_link = %q, want #ffdd00", got)
|
||||||
|
}
|
||||||
|
if got := string(c.OverrideBgDark()); got != "#330000" {
|
||||||
|
t.Errorf("override_bg_dark = %q, want #330000", got)
|
||||||
|
}
|
||||||
|
if got := string(c.OverrideLinkDark()); got != "#ffcc00" {
|
||||||
|
t.Errorf("override_link_dark = %q, want #ffcc00", got)
|
||||||
|
}
|
||||||
|
// The second campaign has no override: every colour field must be absent (nil).
|
||||||
|
banner.Campaigns(&c, 1)
|
||||||
|
if c.OverrideBg() != nil || c.OverrideFg() != nil || c.OverrideLink() != nil ||
|
||||||
|
c.OverrideBgDark() != nil || c.OverrideFgDark() != nil || c.OverrideLinkDark() != nil {
|
||||||
|
t.Error("plain campaign unexpectedly carries a colour override")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestProfileGetNoBanner verifies an ineligible viewer's profile carries no
|
// TestProfileGetNoBanner verifies an ineligible viewer's profile carries no
|
||||||
// banner block (the backend omits it).
|
// banner block (the backend omits it).
|
||||||
func TestProfileGetNoBanner(t *testing.T) {
|
func TestProfileGetNoBanner(t *testing.T) {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
|||||||
if r.URL.Path != "/api/v1/user/games/g-1/state" {
|
if r.URL.Path != "/api/v1/user/games/g-1/state" {
|
||||||
t.Errorf("unexpected path %q", r.URL.Path)
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
}
|
}
|
||||||
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"wallet_balance":3}`))
|
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"wallet_balance":3,"hint_unlock_left_seconds":1200}`))
|
||||||
})
|
})
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
@@ -77,8 +77,8 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
|||||||
t.Fatalf("handler: %v", err)
|
t.Fatalf("handler: %v", err)
|
||||||
}
|
}
|
||||||
st := fb.GetRootAsStateView(payload, 0)
|
st := fb.GetRootAsStateView(payload, 0)
|
||||||
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 {
|
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 || st.HintUnlockLeftSeconds() != 1200 {
|
||||||
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance())
|
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d unlockLeft=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance(), st.HintUnlockLeftSeconds())
|
||||||
}
|
}
|
||||||
game := st.Game(nil)
|
game := st.Game(nil)
|
||||||
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
|
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ package webui
|
|||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -35,13 +36,25 @@ func distFS() fs.FS {
|
|||||||
return sub
|
return sub
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler serves the embedded UI. An existing file is served directly (hash-named assets get
|
// init registers the MIME type for .webmanifest, which Go's built-in table lacks and the
|
||||||
// an immutable cache); every other path falls back to indexName (the SPA shell) so a
|
// distroless runtime image has no /etc/mime.types to supply. Without it the PWA Web App Manifest
|
||||||
// client-side deep link still loads. When stripPrefix is non-empty it is removed from the
|
// served under /app/ would be content-sniffed to text/plain, which some browsers reject.
|
||||||
// request path before lookup, so the same build serves under a sub-path (e.g. "/app/" or
|
func init() {
|
||||||
// "/telegram/").
|
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler serves the compile-time embedded UI build over the public edge — the game SPA under
|
||||||
|
// /app/, /telegram/ and /vk/. It delegates to handlerFor over the embedded dist/.
|
||||||
func Handler(stripPrefix, indexName string) http.Handler {
|
func Handler(stripPrefix, indexName string) http.Handler {
|
||||||
content := distFS()
|
return handlerFor(distFS(), stripPrefix, indexName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlerFor serves content as the UI: an existing file is served directly (hash-named assets get
|
||||||
|
// an immutable cache); every other path falls back to indexName (the SPA shell) so a client-side
|
||||||
|
// deep link still loads. When stripPrefix is non-empty it is removed from the request path before
|
||||||
|
// lookup, so the same build serves under a sub-path (e.g. "/app/" or "/telegram/"). Split from
|
||||||
|
// Handler so it can be exercised over an in-memory fs.FS in tests.
|
||||||
|
func handlerFor(content fs.FS, stripPrefix, indexName string) http.Handler {
|
||||||
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), "/")
|
||||||
@@ -58,6 +71,11 @@ func Handler(stripPrefix, indexName string) http.Handler {
|
|||||||
// app (notably a relaunched Telegram Mini App) is a cache hit, not a re-download.
|
// app (notably a relaunched Telegram Mini App) is a cache hit, not a re-download.
|
||||||
if strings.HasPrefix(name, "assets/") {
|
if strings.HasPrefix(name, "assets/") {
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
} else if name == "sw.js" {
|
||||||
|
// The service worker must be revalidated on every load so a new deploy's worker (and its
|
||||||
|
// fresh precache manifest) is picked up promptly; a cached sw.js would strand clients on
|
||||||
|
// the old build. The worker's network-first navigation then serves the fresh shell online.
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
}
|
}
|
||||||
files.ServeHTTP(w, r)
|
files.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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.
|
||||||
@@ -55,3 +56,60 @@ func TestAppMountServesShellStripsPrefixAndCachesAssets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testFS mirrors what Vite emits into dist/: the SPA shell, the PWA manifest + service worker at
|
||||||
|
// the root (copied from ui/public), and a hash-named asset. The compile-time embedded dist/ holds
|
||||||
|
// only a placeholder shell, so the manifest/sw.js serving is exercised over this in-memory FS.
|
||||||
|
func testFS() fstest.MapFS {
|
||||||
|
return fstest.MapFS{
|
||||||
|
"index.html": {Data: []byte("<!doctype html><title>shell</title>")},
|
||||||
|
"manifest.webmanifest": {Data: []byte(`{"name":"Эрудит"}`)},
|
||||||
|
"sw.js": {Data: []byte("self.addEventListener('fetch', () => {});")},
|
||||||
|
"assets/app-abc123.js": {Data: []byte("export const x = 1;")},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandlerServesManifestWithManifestType: the PWA manifest is served with the manifest MIME
|
||||||
|
// type (registered in init, since the distroless image has no /etc/mime.types), not sniffed to
|
||||||
|
// text/plain, which some browsers reject.
|
||||||
|
func TestHandlerServesManifestWithManifestType(t *testing.T) {
|
||||||
|
h := handlerFor(testFS(), "/app/", "index.html")
|
||||||
|
resp := get(t, h, "/app/manifest.webmanifest")
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/manifest+json") {
|
||||||
|
t.Errorf("Content-Type = %q, want application/manifest+json", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandlerServesServiceWorkerAsJavaScript: sw.js is served as JavaScript from the root, so its
|
||||||
|
// registration (scope /app/) succeeds.
|
||||||
|
func TestHandlerServesServiceWorkerAsJavaScript(t *testing.T) {
|
||||||
|
h := handlerFor(testFS(), "/app/", "index.html")
|
||||||
|
resp := get(t, h, "/app/sw.js")
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
|
||||||
|
t.Errorf("Content-Type = %q, want text/javascript", ct)
|
||||||
|
}
|
||||||
|
// Revalidated on every load so a new deploy's worker (and its fresh precache) is picked up.
|
||||||
|
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
|
||||||
|
t.Errorf("sw.js Cache-Control = %q, want no-cache", cc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandlerFallsBackToShellForUnknownManifestPath guards the trap: a manifest/sw path NOT emitted
|
||||||
|
// into dist/ falls back to the SPA shell as text/html, on which SW registration would fail. The two
|
||||||
|
// tests above pin that the real files are served instead of the shell.
|
||||||
|
func TestHandlerFallsBackToShellForUnknownManifestPath(t *testing.T) {
|
||||||
|
h := handlerFor(testFS(), "/app/", "index.html")
|
||||||
|
resp := get(t, h, "/app/not-emitted.webmanifest")
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||||
|
t.Errorf("Content-Type = %q, want text/html (SPA shell fallback)", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+34
-1
@@ -138,6 +138,11 @@ 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
|
||||||
@@ -180,10 +185,20 @@ table Ack {
|
|||||||
// BannerCampaign is one campaign in the rotation feed: a GCD-reduced show weight
|
// BannerCampaign is one campaign in the rotation feed: a GCD-reduced show weight
|
||||||
// (the client runs a smooth weighted round-robin over campaigns by this weight)
|
// (the client runs a smooth weighted round-robin over campaigns by this weight)
|
||||||
// and its messages in display order, each already resolved to the viewer's bot
|
// and its messages in display order, each already resolved to the viewer's bot
|
||||||
// language (the stored en/ru pair is picked server-side).
|
// language (the stored en/ru pair is picked server-side). The override_* colours
|
||||||
|
// are an optional per-campaign palette: override_bg/fg/link paint the strip on
|
||||||
|
// every theme, and the *_dark trio further overrides the dark theme (the client
|
||||||
|
// resolves dark ← dark ?? all ?? token, light ← all ?? token). Empty means the
|
||||||
|
// campaign keeps the neutral theme tokens (all added trailing — backward-compatible).
|
||||||
table BannerCampaign {
|
table BannerCampaign {
|
||||||
weight:int;
|
weight:int;
|
||||||
messages:[string];
|
messages:[string];
|
||||||
|
override_bg:string;
|
||||||
|
override_fg:string;
|
||||||
|
override_link:string;
|
||||||
|
override_bg_dark:string;
|
||||||
|
override_fg_dark:string;
|
||||||
|
override_link_dark:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// BannerInfo is the advertising-banner block of an eligible viewer's profile: the
|
// BannerInfo is the advertising-banner block of an eligible viewer's profile: the
|
||||||
@@ -208,6 +223,13 @@ table BannerInfo {
|
|||||||
// suppresses out-of-app platform push, leaving only the in-app live stream. banner
|
// suppresses out-of-app platform push, leaving only the in-app live stream. banner
|
||||||
// carries the advertising-banner block for an eligible viewer, absent otherwise
|
// carries the advertising-banner block for an eligible viewer, absent otherwise
|
||||||
// (all added trailing — backward-compatible).
|
// (all added trailing — backward-compatible).
|
||||||
|
// DictVersion is one variant's current dictionary version, carried on the profile so an offline
|
||||||
|
// client learns it from an existing cold-start request (no extra call for a rarely-used feature).
|
||||||
|
table DictVersion {
|
||||||
|
variant:string;
|
||||||
|
version:string;
|
||||||
|
}
|
||||||
|
|
||||||
table Profile {
|
table Profile {
|
||||||
user_id:string;
|
user_id:string;
|
||||||
display_name:string;
|
display_name:string;
|
||||||
@@ -230,6 +252,10 @@ table Profile {
|
|||||||
email:string;
|
email:string;
|
||||||
telegram_linked:bool;
|
telegram_linked:bool;
|
||||||
vk_linked:bool;
|
vk_linked:bool;
|
||||||
|
// dict_versions carries each variant's current dictionary version so an offline client can
|
||||||
|
// preload the right dictionary and pin a new local game without a separate request (added
|
||||||
|
// trailing — backward-compatible).
|
||||||
|
dict_versions:[DictVersion];
|
||||||
}
|
}
|
||||||
|
|
||||||
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation
|
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation
|
||||||
@@ -290,6 +316,13 @@ table StateView {
|
|||||||
// the wallet is a single global figure the client keeps live across games (added trailing —
|
// the wallet is a single global figure the client keeps live across games (added trailing —
|
||||||
// backward-compatible).
|
// backward-compatible).
|
||||||
wallet_balance:int;
|
wallet_balance:int;
|
||||||
|
// hint_unlock_left_seconds is, for a vs_ai game, how many seconds until the idle hint unlocks
|
||||||
|
// (the robot's last move plus the idle window, computed from the SERVER clock online / the device
|
||||||
|
// clock offline, capped at the window and floored at 0); 0 for a human's first move (no robot move
|
||||||
|
// yet) or a non-vs_ai game. The client anchors a MONOTONIC countdown (performance.now()) to it, so a
|
||||||
|
// client clock change cannot skew it — see lib/hints. Seconds granularity is enough; sent trailing
|
||||||
|
// (additive, backward-compatible).
|
||||||
|
hint_unlock_left_seconds:int;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GameActionRequest carries just a game id (pass / resign / hint / history).
|
// GameActionRequest carries just a game id (pass / resign / hint / history).
|
||||||
|
|||||||
@@ -70,8 +70,56 @@ func (rcv *BannerCampaign) MessagesLength() int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerCampaign) OverrideBg() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerCampaign) OverrideFg() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerCampaign) OverrideLink() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerCampaign) OverrideBgDark() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerCampaign) OverrideFgDark() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BannerCampaign) OverrideLinkDark() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func BannerCampaignStart(builder *flatbuffers.Builder) {
|
func BannerCampaignStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(2)
|
builder.StartObject(8)
|
||||||
}
|
}
|
||||||
func BannerCampaignAddWeight(builder *flatbuffers.Builder, weight int32) {
|
func BannerCampaignAddWeight(builder *flatbuffers.Builder, weight int32) {
|
||||||
builder.PrependInt32Slot(0, weight, 0)
|
builder.PrependInt32Slot(0, weight, 0)
|
||||||
@@ -82,6 +130,24 @@ func BannerCampaignAddMessages(builder *flatbuffers.Builder, messages flatbuffer
|
|||||||
func BannerCampaignStartMessagesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
func BannerCampaignStartMessagesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
return builder.StartVector(4, numElems, 4)
|
return builder.StartVector(4, numElems, 4)
|
||||||
}
|
}
|
||||||
|
func BannerCampaignAddOverrideBg(builder *flatbuffers.Builder, overrideBg flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(overrideBg), 0)
|
||||||
|
}
|
||||||
|
func BannerCampaignAddOverrideFg(builder *flatbuffers.Builder, overrideFg flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(overrideFg), 0)
|
||||||
|
}
|
||||||
|
func BannerCampaignAddOverrideLink(builder *flatbuffers.Builder, overrideLink flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(overrideLink), 0)
|
||||||
|
}
|
||||||
|
func BannerCampaignAddOverrideBgDark(builder *flatbuffers.Builder, overrideBgDark flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(overrideBgDark), 0)
|
||||||
|
}
|
||||||
|
func BannerCampaignAddOverrideFgDark(builder *flatbuffers.Builder, overrideFgDark flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(6, flatbuffers.UOffsetT(overrideFgDark), 0)
|
||||||
|
}
|
||||||
|
func BannerCampaignAddOverrideLinkDark(builder *flatbuffers.Builder, overrideLinkDark flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(7, flatbuffers.UOffsetT(overrideLinkDark), 0)
|
||||||
|
}
|
||||||
func BannerCampaignEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func BannerCampaignEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package scrabblefb
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DictVersion struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsDictVersion(buf []byte, offset flatbuffers.UOffsetT) *DictVersion {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &DictVersion{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishDictVersionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.Finish(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsDictVersion(buf []byte, offset flatbuffers.UOffsetT) *DictVersion {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &DictVersion{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishSizePrefixedDictVersionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.FinishSizePrefixed(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *DictVersion) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *DictVersion) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *DictVersion) Variant() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *DictVersion) Version() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DictVersionStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(2)
|
||||||
|
}
|
||||||
|
func DictVersionAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(variant), 0)
|
||||||
|
}
|
||||||
|
func DictVersionAddVersion(builder *flatbuffers.Builder, version flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(version), 0)
|
||||||
|
}
|
||||||
|
func DictVersionEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
@@ -65,8 +65,20 @@ 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(3)
|
builder.StartObject(4)
|
||||||
}
|
}
|
||||||
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)
|
||||||
@@ -77,6 +89,9 @@ 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,8 +211,28 @@ func (rcv *Profile) MutateVkLinked(n bool) bool {
|
|||||||
return rcv._tab.MutateBoolSlot(34, n)
|
return rcv._tab.MutateBoolSlot(34, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *Profile) DictVersions(obj *DictVersion, j int) bool {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(36))
|
||||||
|
if o != 0 {
|
||||||
|
x := rcv._tab.Vector(o)
|
||||||
|
x += flatbuffers.UOffsetT(j) * 4
|
||||||
|
x = rcv._tab.Indirect(x)
|
||||||
|
obj.Init(rcv._tab.Bytes, x)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *Profile) DictVersionsLength() int {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(36))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.VectorLen(o)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func ProfileStart(builder *flatbuffers.Builder) {
|
func ProfileStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(16)
|
builder.StartObject(17)
|
||||||
}
|
}
|
||||||
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
|
||||||
@@ -265,6 +285,12 @@ func ProfileAddTelegramLinked(builder *flatbuffers.Builder, telegramLinked bool)
|
|||||||
func ProfileAddVkLinked(builder *flatbuffers.Builder, vkLinked bool) {
|
func ProfileAddVkLinked(builder *flatbuffers.Builder, vkLinked bool) {
|
||||||
builder.PrependBoolSlot(15, vkLinked, false)
|
builder.PrependBoolSlot(15, vkLinked, false)
|
||||||
}
|
}
|
||||||
|
func ProfileAddDictVersions(builder *flatbuffers.Builder, dictVersions flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(16, flatbuffers.UOffsetT(dictVersions), 0)
|
||||||
|
}
|
||||||
|
func ProfileStartDictVersionsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
|
return builder.StartVector(4, numElems, 4)
|
||||||
|
}
|
||||||
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,8 +156,20 @@ func (rcv *StateView) MutateWalletBalance(n int32) bool {
|
|||||||
return rcv._tab.MutateInt32Slot(16, n)
|
return rcv._tab.MutateInt32Slot(16, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *StateView) HintUnlockLeftSeconds() int32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *StateView) MutateHintUnlockLeftSeconds(n int32) bool {
|
||||||
|
return rcv._tab.MutateInt32Slot(18, n)
|
||||||
|
}
|
||||||
|
|
||||||
func StateViewStart(builder *flatbuffers.Builder) {
|
func StateViewStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(7)
|
builder.StartObject(8)
|
||||||
}
|
}
|
||||||
func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
|
func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(game), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(game), 0)
|
||||||
@@ -186,6 +198,9 @@ func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) fl
|
|||||||
func StateViewAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) {
|
func StateViewAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) {
|
||||||
builder.PrependInt32Slot(6, walletBalance, 0)
|
builder.PrependInt32Slot(6, walletBalance, 0)
|
||||||
}
|
}
|
||||||
|
func StateViewAddHintUnlockLeftSeconds(builder *flatbuffers.Builder, hintUnlockLeftSeconds int32) {
|
||||||
|
builder.PrependInt32Slot(7, hintUnlockLeftSeconds, 0)
|
||||||
|
}
|
||||||
func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ type StateView struct {
|
|||||||
BagLen int
|
BagLen int
|
||||||
HintsRemaining int
|
HintsRemaining int
|
||||||
WalletBalance int
|
WalletBalance int
|
||||||
|
HintUnlockLeftSeconds int
|
||||||
Alphabet []AlphabetEntry
|
Alphabet []AlphabetEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +257,7 @@ func BuildStateView(b *flatbuffers.Builder, s StateView) flatbuffers.UOffsetT {
|
|||||||
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
||||||
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
||||||
fb.StateViewAddWalletBalance(b, int32(s.WalletBalance))
|
fb.StateViewAddWalletBalance(b, int32(s.WalletBalance))
|
||||||
|
fb.StateViewAddHintUnlockLeftSeconds(b, int32(s.HintUnlockLeftSeconds))
|
||||||
if hasAlphabet {
|
if hasAlphabet {
|
||||||
fb.StateViewAddAlphabet(b, alphabet)
|
fb.StateViewAddAlphabet(b, alphabet)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// Offline pass-and-play (hotseat) is offered only in offline mode, which is gated to an installed
|
||||||
|
// standalone PWA with a confirmed email. The mock account has an email; force standalone so the
|
||||||
|
// Settings offline toggle shows.
|
||||||
|
async function forceStandalone(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const orig = window.matchMedia.bind(window);
|
||||||
|
window.matchMedia = (q: string) =>
|
||||||
|
(q.includes('display-mode: standalone')
|
||||||
|
? { matches: true, media: q, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent: () => false }
|
||||||
|
: orig(q)) as MediaQueryList;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterLobby(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /guest|гост/i }).first().click();
|
||||||
|
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goOffline(page: Page): Promise<void> {
|
||||||
|
await page.locator('button.tab').nth(2).click(); // Settings
|
||||||
|
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
|
||||||
|
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
|
||||||
|
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||||
|
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action).
|
||||||
|
// It first waits for the dots to be empty, so a call made right after a previous entry does not lose
|
||||||
|
// digits during the 250 ms verdict pause (the 4th digit → pause → clear/advance).
|
||||||
|
async function typePin(page: Page, digits: string): Promise<void> {
|
||||||
|
await expect(page.locator('.pad .dot.on')).toHaveCount(0);
|
||||||
|
for (const d of digits) await page.locator('.pad .key', { hasText: d }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('offline hotseat (pass-and-play)', () => {
|
||||||
|
test('create with a locked seat, unlock, host-skip, terminate from the lobby', async ({ page }) => {
|
||||||
|
await forceStandalone(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await enterLobby(page);
|
||||||
|
await goOffline(page);
|
||||||
|
|
||||||
|
// A known seed: the bag deals a full rack deterministically, AND the seeded seating shuffle keeps
|
||||||
|
// the roster order (Ann first) so the locked-seat assertions below are stable. (Seat order is
|
||||||
|
// randomised at start; the shuffle is seed-driven — seed '1' would seat Bob first, '2' Ann first.)
|
||||||
|
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('2'));
|
||||||
|
|
||||||
|
// New game -> the offline mode selector now offers "with friends" (hotseat).
|
||||||
|
await page.locator('button.tab').nth(0).click();
|
||||||
|
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
|
||||||
|
|
||||||
|
// The player rows are disabled until the mandatory host (master) PIN is set.
|
||||||
|
await expect(page.locator('.pname').first()).toBeDisabled();
|
||||||
|
|
||||||
|
// Set the host PIN (enter + confirm), then decline taking a seat.
|
||||||
|
await page.locator('.hostpin .plink').click();
|
||||||
|
await typePin(page, '9999');
|
||||||
|
await typePin(page, '9999');
|
||||||
|
await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
|
||||||
|
|
||||||
|
// Pick the English variant (the plaques mirror Quick Match; "Scrabble" is the Latin English name).
|
||||||
|
await page.locator('.variant', { hasText: 'Scrabble' }).click();
|
||||||
|
|
||||||
|
// Two players: Ann (PIN-locked) and Bob (open).
|
||||||
|
await page.locator('.pname').nth(0).fill('Ann');
|
||||||
|
await page.locator('.pname').nth(1).fill('Bob');
|
||||||
|
|
||||||
|
// Row-delete: a 3rd player's kebab asks the host PIN, which ARMS a ❌ (not a silent delete);
|
||||||
|
// tapping the ❌ removes the row.
|
||||||
|
await page.getByRole('button', { name: /Add player|Добавить/i }).click();
|
||||||
|
await expect(page.locator('.prow')).toHaveCount(3);
|
||||||
|
await page.locator('.prow').nth(2).locator('.pkebab').click();
|
||||||
|
await typePin(page, '9999');
|
||||||
|
const rowDel = page.locator('.prow').nth(2).locator('.prow-del');
|
||||||
|
await expect(rowDel).toBeVisible();
|
||||||
|
await rowDel.click();
|
||||||
|
await expect(page.locator('.prow')).toHaveCount(2);
|
||||||
|
|
||||||
|
// Lock Ann's seat with a PIN.
|
||||||
|
await page.locator('.prow').nth(0).locator('.plink').click();
|
||||||
|
await typePin(page, '1234');
|
||||||
|
await typePin(page, '1234');
|
||||||
|
|
||||||
|
await page.locator('button.invite').click();
|
||||||
|
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Ann is to move and PIN-locked: the board is visible but her rack is withheld behind Unlock.
|
||||||
|
await expect(page.locator('.unlock')).toBeVisible();
|
||||||
|
await expect(page.locator('.rack .tile')).toHaveCount(0);
|
||||||
|
|
||||||
|
// A wrong PIN keeps it locked; the right PIN reveals the full rack.
|
||||||
|
await page.locator('.unlock').click();
|
||||||
|
await typePin(page, '0000');
|
||||||
|
await expect(page.locator('.pad')).toBeVisible(); // still open after a wrong PIN
|
||||||
|
await typePin(page, '1234');
|
||||||
|
await expect(page.locator('.rack .tile')).toHaveCount(7);
|
||||||
|
|
||||||
|
// Opening the history reveals NO social controls on the seat plaques (a local game is
|
||||||
|
// account-less), and the Dictionary entry (the comms button) is kept for the active game.
|
||||||
|
await page.locator('.scoreboard').click();
|
||||||
|
await expect(page.locator('.fico')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.chat-ico')).toBeVisible();
|
||||||
|
await page.locator('.scoreboard').click();
|
||||||
|
|
||||||
|
// Host override: 🔐 -> master PIN -> skip the current turn -> confirm. The turn passes to Bob,
|
||||||
|
// whose seat is open, so his rack shows without a lock.
|
||||||
|
await page.locator('button.tab', { hasText: /Host|Ведущий/ }).click();
|
||||||
|
await typePin(page, '9999');
|
||||||
|
await page.getByRole('button', { name: /Skip|Пропустить/ }).click();
|
||||||
|
await page.getByRole('button', { name: /^(OK|ОК)$/ }).click();
|
||||||
|
await expect(page.locator('.unlock')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.rack .tile')).toHaveCount(7);
|
||||||
|
|
||||||
|
// Back in the lobby the active hotseat game has a kebab; terminating it needs the master PIN.
|
||||||
|
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||||
|
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
|
||||||
|
await expect(page.locator('.rowwrap')).toHaveCount(1);
|
||||||
|
await page.locator('.kebab').first().click();
|
||||||
|
const del = page.locator('.rowwrap.revealed .del');
|
||||||
|
await expect(del).toBeVisible();
|
||||||
|
await del.click();
|
||||||
|
await typePin(page, '9999');
|
||||||
|
await expect(page.locator('.rowwrap')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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,3 +46,15 @@ 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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// The offline mode is gated to an installed standalone PWA with a confirmed email. The mock account
|
||||||
|
// has an email; force the standalone display mode so the Settings offline toggle is offered. Only the
|
||||||
|
// `(display-mode: standalone)` query is overridden — theme/reduce-motion queries pass through.
|
||||||
|
async function forceStandalone(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const orig = window.matchMedia.bind(window);
|
||||||
|
window.matchMedia = (q: string) =>
|
||||||
|
(q.includes('display-mode: standalone')
|
||||||
|
? { matches: true, media: q, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent: () => false }
|
||||||
|
: orig(q)) as MediaQueryList;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a load, land in the lobby. The mock cold-starts on the login screen (no seeded session), so
|
||||||
|
// click through as guest. Waiting for the button (rather than a point-in-time count()) is what makes
|
||||||
|
// this deterministic: a count() sampled during the pre-login splash frame returned 0, skipped the
|
||||||
|
// click, and then hung — or latched a transient lobby tab-bar — instead of opening the real lobby.
|
||||||
|
async function enterLobby(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /guest|гост/i }).first().click();
|
||||||
|
// The lobby tab bar has three tabs in a fixed order: New (0), Stats (1), Settings (2). nth() is
|
||||||
|
// robust to locale, emoji variation selectors and the coachmark anchors (which the first-run
|
||||||
|
// onboarding strips after it completes).
|
||||||
|
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick a rack tile by its glyph and drop it on a board square (the rack of the pinned-seed game has
|
||||||
|
// all-distinct letters, so the glyph is unambiguous).
|
||||||
|
async function placeTile(page: Page, glyph: string, row: number, col: number): Promise<void> {
|
||||||
|
await page.locator('.rack .tile', { hasText: glyph }).first().click();
|
||||||
|
await page.locator(`[data-cell][data-row="${row}"][data-col="${col}"]`).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('offline mode', () => {
|
||||||
|
test('enter via the toggle, play a local vs_ai game, persist across a reload', async ({ page }) => {
|
||||||
|
await forceStandalone(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await enterLobby(page);
|
||||||
|
|
||||||
|
// Online: a seeded online game (vs Ann) is listed.
|
||||||
|
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Enter offline through the real Settings toggle (its readiness check fetches every enabled
|
||||||
|
// variant's dawg, served from /e2edict/ by the mock) — the header turns blue with the chip.
|
||||||
|
await page.locator('button.tab').nth(2).click();
|
||||||
|
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
|
||||||
|
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Back in the (now offline) lobby: the online games are hidden and the Stats tab is disabled.
|
||||||
|
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||||
|
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||||
|
await expect(page.getByText('Ann', { exact: false })).toHaveCount(0);
|
||||||
|
await expect(page.locator('button.tab').nth(1)).toBeDisabled();
|
||||||
|
|
||||||
|
// Create a device-local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO).
|
||||||
|
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
|
||||||
|
await page.locator('button.tab').nth(0).click();
|
||||||
|
// The English variant's display name is "Scrabble" (Latin) in both locales — the Russian
|
||||||
|
// variants are "Скрэббл"/"Эрудит"/"Erudite", so this uniquely selects the English game.
|
||||||
|
await page.locator('.variant', { hasText: 'Scrabble' }).click();
|
||||||
|
await page.locator('button.invite').click();
|
||||||
|
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||||
|
|
||||||
|
// The human's first move is not idle-gated: the hint is available at once (no 🔒 lock badge).
|
||||||
|
await expect(page.locator('.lock')).toHaveCount(0);
|
||||||
|
|
||||||
|
// The human plays WAY horizontally across the centre (7,5)-(7,7).
|
||||||
|
await placeTile(page, 'W', 7, 5);
|
||||||
|
await placeTile(page, 'A', 7, 6);
|
||||||
|
await placeTile(page, 'Y', 7, 7);
|
||||||
|
await expect(page.locator('[data-cell].pending')).toHaveCount(3);
|
||||||
|
await page.locator('.make').click();
|
||||||
|
|
||||||
|
// The play commits and the local robot replies with a real move, so the board carries more than
|
||||||
|
// WAY's three tiles.
|
||||||
|
await expect(async () => {
|
||||||
|
expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3);
|
||||||
|
}).toPass({ timeout: 15000 });
|
||||||
|
const filled = await page.locator('[data-cell].filled').count();
|
||||||
|
|
||||||
|
// Now it is the human's turn again after the robot moved: the idle hint gate arms, so the hint
|
||||||
|
// button shows the 🔒 lock (it lifts after 30 idle minutes on a monotonic clock — not waited here).
|
||||||
|
await expect(page.locator('.lock')).toBeVisible();
|
||||||
|
|
||||||
|
// Reload: the hash router restores the /game/<id> route and the local game replays from
|
||||||
|
// IndexedDB with every committed tile intact.
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||||
|
await expect(page.locator('[data-cell].filled')).toHaveCount(filled);
|
||||||
|
// The idle-hint gate is persisted (a wall-clock unlock time), so the 🔒 survives the reload —
|
||||||
|
// the wait was not reset by relaunching.
|
||||||
|
await expect(page.locator('.lock')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { expect, test } from './fixtures';
|
||||||
|
|
||||||
|
// The hash router must update its reactive `route` rune synchronously inside navigate(), not defer it
|
||||||
|
// to the asynchronous `hashchange` event. Bootstrap flips `app.ready` in the same tick right after
|
||||||
|
// `navigate('/login')` on an unauthenticated cold start; a route that trailed the hash for one frame
|
||||||
|
// let App.svelte render the stale route (the empty-hash lobby) under the new login screen — a visible
|
||||||
|
// lobby-shell flash plus a doomed games.list. It also made the offline e2e flaky: enterLobby could
|
||||||
|
// latch that transient lobby tab-bar instead of clicking through the login screen.
|
||||||
|
|
||||||
|
test('navigate updates the reactive route synchronously (no hashchange lag)', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// The bundle has loaded and installed the mock __router seam once the login screen is up.
|
||||||
|
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Read the route in the SAME microtask as the navigate: the fix makes it the new route at once;
|
||||||
|
// the pre-fix code returned the previous route (it waited for the async hashchange).
|
||||||
|
const routeAfterNavigate = await page.evaluate(() => {
|
||||||
|
const r = (window as unknown as { __router: { navigate(p: string): void; route(): string } }).__router;
|
||||||
|
r.navigate('/settings');
|
||||||
|
return r.route();
|
||||||
|
});
|
||||||
|
expect(routeAfterNavigate).toBe('settings');
|
||||||
|
|
||||||
|
// The hash was written too, so a reload/back still resolves the same route.
|
||||||
|
expect(new URL(page.url()).hash).toBe('#/settings');
|
||||||
|
});
|
||||||
@@ -327,6 +327,36 @@ test('change email: a free address replaces the current one', async ({ page }) =
|
|||||||
await expect(page.getByText('you@example.com')).toHaveCount(0);
|
await expect(page.getByText('you@example.com')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The add-email confirmation can complete out of band: the recipient taps the one-tap link in the
|
||||||
|
// email, which confirms in another browser/session. A backgrounded Mini App misses the live
|
||||||
|
// 'profile' event (the stream is single-shot, no replay), so the open code form falls back to
|
||||||
|
// polling the profile until the address lands. The mock attaches the email WITHOUT emitting an
|
||||||
|
// event, so only the poll can surface it.
|
||||||
|
test('add email: an out-of-band confirmation surfaces on the open form via polling', async ({ page }) => {
|
||||||
|
await loginLobby(page);
|
||||||
|
await openProfile(page);
|
||||||
|
|
||||||
|
// Drop the seeded email so the sign-in section offers the add-email flow.
|
||||||
|
await page.evaluate(() => (window as unknown as { __mock: { clearEmail(): void } }).__mock.clearEmail());
|
||||||
|
const emailInput = page.locator('.accounts input[type="email"]');
|
||||||
|
await expect(emailInput).toBeVisible();
|
||||||
|
|
||||||
|
await emailInput.fill('linked@example.com');
|
||||||
|
await page.getByRole('button', { name: 'Send code' }).click();
|
||||||
|
await expect(page.locator('.accounts .codein')).toBeVisible();
|
||||||
|
|
||||||
|
// Confirmed elsewhere via the one-tap link, with no live event delivered: only the poll surfaces it.
|
||||||
|
await page.evaluate(() =>
|
||||||
|
(
|
||||||
|
window as unknown as { __mock: { confirmEmailOutOfBand(email: string): void } }
|
||||||
|
).__mock.confirmEmailOutOfBand('linked@example.com'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The confirmed address surfaces as the email row, and the code form is gone.
|
||||||
|
await expect(page.locator('.acctrow').filter({ hasText: 'linked@example.com' })).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.accounts .codein')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('link then unlink Telegram from the sign-in methods', async ({ page }) => {
|
test('link then unlink Telegram from the sign-in methods', async ({ page }) => {
|
||||||
await loginLobby(page);
|
await loginLobby(page);
|
||||||
await openProfile(page);
|
await openProfile(page);
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// Playwright's WebKit has no real soft keyboard, so emulate iOS's visual-viewport behaviour: replace
|
||||||
|
// window.visualViewport with a controllable fake BEFORE the app boots. The app's syncViewport
|
||||||
|
// (app.svelte.ts) reads visualViewport.height/offsetTop and mirrors them into --vvh / --vv-top, which
|
||||||
|
// position the pinned app-shell (app.css html.app-shell body). Driving the fake exercises the exact
|
||||||
|
// code path the real keyboard triggers on iOS — where the layout viewport does NOT shrink and the
|
||||||
|
// visual viewport instead offsets down toward the focused field.
|
||||||
|
async function installFakeViewport(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const fake = new EventTarget() as EventTarget & { height: number; offsetTop: number; width: number };
|
||||||
|
fake.height = window.innerHeight;
|
||||||
|
fake.offsetTop = 0;
|
||||||
|
fake.width = window.innerWidth;
|
||||||
|
Object.defineProperty(window, 'visualViewport', { configurable: true, value: fake });
|
||||||
|
(window as unknown as { __vv: typeof fake }).__vv = fake;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setViewport(page: Page, height: number, offsetTop: number): Promise<void> {
|
||||||
|
await page.evaluate(
|
||||||
|
([h, t]) => {
|
||||||
|
const vv = (window as unknown as { __vv: { height: number; offsetTop: number; dispatchEvent(e: Event): boolean } }).__vv;
|
||||||
|
vv.height = h;
|
||||||
|
vv.offsetTop = t;
|
||||||
|
vv.dispatchEvent(new Event('resize'));
|
||||||
|
vv.dispatchEvent(new Event('scroll'));
|
||||||
|
},
|
||||||
|
[height, offsetTop],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shell(page: Page): Promise<{ vvh: string; top: string; bodyTop: string }> {
|
||||||
|
return page.evaluate(() => ({
|
||||||
|
vvh: getComputedStyle(document.documentElement).getPropertyValue('--vvh').trim(),
|
||||||
|
top: getComputedStyle(document.documentElement).getPropertyValue('--vv-top').trim(),
|
||||||
|
bodyTop: getComputedStyle(document.body).top,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('visual-viewport shell (soft-keyboard alignment)', () => {
|
||||||
|
test('the pinned shell follows the visual viewport height AND offset', async ({ page }) => {
|
||||||
|
await installFakeViewport(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('html.app-shell')).toBeAttached();
|
||||||
|
const innerH = await page.evaluate(() => window.innerHeight);
|
||||||
|
|
||||||
|
// Keyboard closed: full height, no offset.
|
||||||
|
await setViewport(page, innerH, 0);
|
||||||
|
let s = await shell(page);
|
||||||
|
expect(s.top).toBe('0px');
|
||||||
|
expect(s.bodyTop).toBe('0px');
|
||||||
|
|
||||||
|
// Keyboard open + iOS offset: the visual viewport shrinks AND offsets down. The pinned shell must
|
||||||
|
// follow BOTH — the body's top must equal the offset (not stay at 0), else the top-anchored
|
||||||
|
// content shows empty space below (the iOS bug this fixes).
|
||||||
|
await setViewport(page, innerH - 300, 180);
|
||||||
|
s = await shell(page);
|
||||||
|
expect(s.vvh).toBe(`${innerH - 300}px`);
|
||||||
|
expect(s.top).toBe('180px');
|
||||||
|
expect(s.bodyTop).toBe('180px');
|
||||||
|
|
||||||
|
// Keyboard closed again: the offset reverts to 0 — no stale shift left behind.
|
||||||
|
await setViewport(page, innerH, 0);
|
||||||
|
s = await shell(page);
|
||||||
|
expect(s.top).toBe('0px');
|
||||||
|
expect(s.bodyTop).toBe('0px');
|
||||||
|
});
|
||||||
|
});
|
||||||
+246
@@ -2,6 +2,240 @@
|
|||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<!-- Boot capability guard. Runs before the deferred ES module, in plain ES5 so it survives even
|
||||||
|
on an engine too old to parse the bundle. Three jobs, as early as possible:
|
||||||
|
1. Hard gate — if an UNPOLYFILLABLE essential is missing (BigInt for the 64-bit
|
||||||
|
FlatBuffers wire decode, Proxy for Svelte 5 runes) the app cannot run at all: show the
|
||||||
|
unsupported-engine screen (an old Android System WebView, e.g. Chromium 66, is the case
|
||||||
|
this guards) instead of a white screen.
|
||||||
|
2. Soft gate — if only polyfillable es2020+ globals are missing (globalThis,
|
||||||
|
structuredClone, Array.at, …) pull core-js (emitted as polyfills.js) BEFORE the module
|
||||||
|
runs. document.write is deliberate: the only way to inject a parser-blocking <script>
|
||||||
|
guaranteed to run ahead of a deferred module; its argument is a static literal, so no
|
||||||
|
injection surface.
|
||||||
|
3. Reactive net — record any uncaught error/rejection during boot; if the app has not
|
||||||
|
signalled window.__booted within a grace period AND an error fired, show the same
|
||||||
|
screen with the captured cause (covers a bundle that fails to parse, or an unforeseen
|
||||||
|
incompatibility). __booted is set in App.svelte once bootstrap resolves.
|
||||||
|
The screen has a "Diagnostic information" view (engine + feature table + reason + version)
|
||||||
|
with a Copy button. On a capable engine nothing here renders, so neither the bundle-size
|
||||||
|
budget nor the mock e2e is affected. -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var nav = navigator;
|
||||||
|
var ua = nav.userAgent || '';
|
||||||
|
var VERSION = '__BOOT_VERSION__'; // replaced at build (vite.config injectBootVersion)
|
||||||
|
var RU = (nav.language || '').toLowerCase().indexOf('ru') === 0;
|
||||||
|
var MINIAPP = /\/(?:telegram|vk)\//.test(location.pathname);
|
||||||
|
var WEBURL = location.protocol + '//' + location.host + '/app/';
|
||||||
|
|
||||||
|
// [label, test, Chrome version it landed in, hard?] — hard = required and unpolyfillable.
|
||||||
|
function has(fn) {
|
||||||
|
try {
|
||||||
|
return !!fn();
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var probes = [
|
||||||
|
['BigInt', function () { return typeof BigInt !== 'undefined'; }, 67, true],
|
||||||
|
['Proxy', function () { return typeof Proxy !== 'undefined'; }, 49, true],
|
||||||
|
['globalThis', function () { return typeof globalThis !== 'undefined'; }, 71, false],
|
||||||
|
['structuredClone', function () { return typeof structuredClone === 'function'; }, 98, false],
|
||||||
|
['Array.prototype.at', function () { return typeof [].at === 'function'; }, 92, false],
|
||||||
|
['Array.prototype.findLast', function () { return typeof [].findLast === 'function'; }, 97, false],
|
||||||
|
['Object.hasOwn', function () { return typeof Object.hasOwn === 'function'; }, 93, false],
|
||||||
|
['Object.fromEntries', function () { return typeof Object.fromEntries === 'function'; }, 73, false],
|
||||||
|
['Promise.allSettled', function () { return !!Promise.allSettled; }, 76, false],
|
||||||
|
['Promise.any', function () { return !!Promise.any; }, 85, false],
|
||||||
|
['WeakRef', function () { return typeof WeakRef !== 'undefined'; }, 84, false],
|
||||||
|
['queueMicrotask', function () { return typeof queueMicrotask === 'function'; }, 71, false]
|
||||||
|
];
|
||||||
|
var results = [];
|
||||||
|
var hardMissing = [];
|
||||||
|
var softMissing = false;
|
||||||
|
for (var i = 0; i < probes.length; i++) {
|
||||||
|
var ok = has(probes[i][1]);
|
||||||
|
results.push({ name: probes[i][0], ok: ok, chrome: probes[i][2], hard: probes[i][3] });
|
||||||
|
if (!ok) {
|
||||||
|
if (probes[i][3]) hardMissing.push(probes[i][0]);
|
||||||
|
else softMissing = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The diagnostic report (built on demand, behind the button).
|
||||||
|
function diag(reason) {
|
||||||
|
var cm = /Chrom(?:e|ium)\/(\d+)/.exec(ua);
|
||||||
|
var wv = /;\s*wv\)/.test(ua) || /\bwv\b/.test(ua);
|
||||||
|
var out = [];
|
||||||
|
out.push('reason : ' + reason);
|
||||||
|
out.push('app : ' + VERSION);
|
||||||
|
out.push('chromium : ' + (cm ? cm[1] : 'n/a') + (wv ? ' (Android WebView)' : ''));
|
||||||
|
out.push('userAgent : ' + ua);
|
||||||
|
out.push('url : ' + location.href);
|
||||||
|
out.push('viewport : ' + window.innerWidth + 'x' + window.innerHeight + ' @' + (window.devicePixelRatio || 1));
|
||||||
|
out.push('lang : ' + (nav.language || '?') + ' online: ' + nav.onLine);
|
||||||
|
out.push('');
|
||||||
|
out.push('features:');
|
||||||
|
for (var k = 0; k < results.length; k++) {
|
||||||
|
var r = results[k];
|
||||||
|
out.push(' ' + (r.ok ? 'OK' : 'NO') + ' ' + r.name + ' (' + (r.hard ? 'required' : 'polyfilled') + ', Chrome ' + r.chrome + ')');
|
||||||
|
}
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(tag, style, text) {
|
||||||
|
var e = document.createElement(tag);
|
||||||
|
if (style) e.setAttribute('style', style);
|
||||||
|
if (text != null) e.appendChild(document.createTextNode(text));
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
function btn(bg, fg) {
|
||||||
|
return 'display:inline-block;margin:0 10px 10px 0;padding:11px 18px;border:0;border-radius:8px;' +
|
||||||
|
'font:600 15px/1 inherit;cursor:pointer;color:' + fg + ';background:' + bg + ';';
|
||||||
|
}
|
||||||
|
|
||||||
|
var shown = false;
|
||||||
|
// Fire-and-forget beacon so the gateway can count who hits this screen (Grafana). Deduped
|
||||||
|
// in localStorage by app version + reason + Chromium, so a user reopening the app ten times
|
||||||
|
// is one report. sendBeacon (Chrome 39+, present on the engines that reach here) with a
|
||||||
|
// fetch fallback; both are best-effort and never block or throw.
|
||||||
|
function beacon(code) {
|
||||||
|
try {
|
||||||
|
var cm = /Chrom(?:e|ium)\/(\d+)/.exec(ua);
|
||||||
|
var chromium = cm ? cm[1] : '';
|
||||||
|
var sig = VERSION + '|' + code + '|' + chromium;
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem('scrabble_unsupp') === sig) return;
|
||||||
|
} catch (e) {}
|
||||||
|
var payload = JSON.stringify({ reason: code, chromium: chromium, version: VERSION, ua: ua });
|
||||||
|
var sent = false;
|
||||||
|
try {
|
||||||
|
if (nav.sendBeacon) sent = nav.sendBeacon('/telemetry/unsupported', new Blob([payload], { type: 'application/json' }));
|
||||||
|
} catch (e) {}
|
||||||
|
if (!sent && typeof fetch === 'function') {
|
||||||
|
try {
|
||||||
|
fetch('/telemetry/unsupported', { method: 'POST', body: payload, headers: { 'Content-Type': 'application/json' }, keepalive: true }).catch(function () {});
|
||||||
|
sent = true;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
if (sent) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('scrabble_unsupp', sig);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
function show(reason, code) {
|
||||||
|
if (shown) return;
|
||||||
|
shown = true;
|
||||||
|
beacon(code);
|
||||||
|
var root = el('div', 'position:fixed;left:0;top:0;right:0;bottom:0;z-index:2147483647;overflow:auto;' +
|
||||||
|
'-webkit-overflow-scrolling:touch;background:#0f1115;color:#e8eaed;box-sizing:border-box;padding:24px;' +
|
||||||
|
'font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;');
|
||||||
|
var wrap = el('div', 'max-width:520px;margin:0 auto;');
|
||||||
|
root.appendChild(wrap);
|
||||||
|
|
||||||
|
var msg = el('div', null);
|
||||||
|
msg.appendChild(el('div', 'font-size:22px;font-weight:700;margin-bottom:14px;', 'Эрудит'));
|
||||||
|
msg.appendChild(el('p', 'margin:0 0 14px;', RU
|
||||||
|
? 'На вашей версии операционной системы или браузера приложение не сможет работать.'
|
||||||
|
: "This app can't run on your device's operating system or browser version."));
|
||||||
|
if (MINIAPP) {
|
||||||
|
msg.appendChild(el('p', 'margin:0 0 6px;', RU
|
||||||
|
? 'Откройте веб-версию в обычном браузере (Chrome, Firefox):'
|
||||||
|
: 'Open the web version in a regular browser (Chrome, Firefox):'));
|
||||||
|
var a = el('a', 'color:#8ab4ff;word-break:break-all;', WEBURL);
|
||||||
|
a.setAttribute('href', WEBURL);
|
||||||
|
a.setAttribute('target', '_blank');
|
||||||
|
a.setAttribute('rel', 'noopener');
|
||||||
|
var lp = el('p', 'margin:0 0 18px;');
|
||||||
|
lp.appendChild(a);
|
||||||
|
msg.appendChild(lp);
|
||||||
|
}
|
||||||
|
var infoBtn = el('button', btn('#2a2f3a', '#e8eaed'), RU ? 'Диагностическая информация' : 'Diagnostic information');
|
||||||
|
msg.appendChild(infoBtn);
|
||||||
|
wrap.appendChild(msg);
|
||||||
|
|
||||||
|
var dv = el('div', 'display:none;');
|
||||||
|
var pre = el('pre', 'white-space:pre-wrap;word-break:break-word;background:#0b0d11;border-radius:8px;padding:12px;' +
|
||||||
|
'font:12.5px/1.45 ui-monospace,Menlo,Consolas,monospace;overflow:auto;', diag(reason));
|
||||||
|
dv.appendChild(pre);
|
||||||
|
var copyBtn = el('button', btn('#8ab4ff', '#0b0d11'), RU ? 'Копировать' : 'Copy');
|
||||||
|
var backBtn = el('button', btn('#2a2f3a', '#e8eaed'), RU ? 'Назад' : 'Back');
|
||||||
|
var row = el('div', 'margin-top:12px;');
|
||||||
|
row.appendChild(copyBtn);
|
||||||
|
row.appendChild(backBtn);
|
||||||
|
dv.appendChild(row);
|
||||||
|
wrap.appendChild(dv);
|
||||||
|
|
||||||
|
infoBtn.onclick = function () {
|
||||||
|
msg.style.display = 'none';
|
||||||
|
dv.style.display = 'block';
|
||||||
|
};
|
||||||
|
backBtn.onclick = function () {
|
||||||
|
dv.style.display = 'none';
|
||||||
|
msg.style.display = 'block';
|
||||||
|
};
|
||||||
|
copyBtn.onclick = function () {
|
||||||
|
var txt = diag(reason);
|
||||||
|
try {
|
||||||
|
if (nav.clipboard && nav.clipboard.writeText) {
|
||||||
|
nav.clipboard.writeText(txt);
|
||||||
|
copyBtn.firstChild.nodeValue = RU ? 'Скопировано' : 'Copied';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = txt;
|
||||||
|
ta.setAttribute('style', 'position:fixed;left:-9999px;top:0;');
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
copyBtn.firstChild.nodeValue = RU ? 'Скопировано' : 'Copied';
|
||||||
|
} catch (e2) {
|
||||||
|
copyBtn.firstChild.nodeValue = RU ? 'Выделите и скопируйте текст' : 'Select and copy the text';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
document.body.insertBefore(root, document.body.firstChild);
|
||||||
|
}
|
||||||
|
if (document.body) mount();
|
||||||
|
else document.addEventListener('DOMContentLoaded', mount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 + 2: the gate.
|
||||||
|
if (hardMissing.length) {
|
||||||
|
show((RU ? 'нет: ' : 'missing: ') + hardMissing.join(', '), hardMissing.indexOf('BigInt') >= 0 ? 'no_bigint' : 'no_proxy');
|
||||||
|
} else if (softMissing) {
|
||||||
|
document.write('<script src="polyfills.js"><\/script>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3: the reactive net for an unforeseen boot failure.
|
||||||
|
var bootErr = null;
|
||||||
|
function note(m) {
|
||||||
|
if (!bootErr) bootErr = m;
|
||||||
|
}
|
||||||
|
window.onerror = function (m, s, l, c, e) {
|
||||||
|
note(String(m) + (e && e.stack ? '\n' + e.stack : s ? ' @ ' + s + ':' + l : ''));
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
window.addEventListener('error', function (e) {
|
||||||
|
if (e && e.message) note(e.message + (e.filename ? ' @ ' + e.filename + ':' + e.lineno : ''));
|
||||||
|
}, true);
|
||||||
|
window.addEventListener('unhandledrejection', function (e) {
|
||||||
|
var r = e && e.reason;
|
||||||
|
note(r && (r.stack || r.message) ? r.stack || r.message : String(r));
|
||||||
|
});
|
||||||
|
setTimeout(function () {
|
||||||
|
if (!window.__booted && bootErr && !shown) show((RU ? 'ошибка запуска: ' : 'boot error: ') + bootErr, 'boot_error');
|
||||||
|
}, 8000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<!-- The SPA shell is an empty client-rendered document behind the public landing — keep it
|
<!-- The SPA shell is an empty client-rendered document behind the public landing — keep it
|
||||||
out of search indexes (robots.txt deliberately does NOT disallow /app/, so crawlers can
|
out of search indexes (robots.txt deliberately does NOT disallow /app/, so crawlers can
|
||||||
reach this tag). -->
|
reach this tag). -->
|
||||||
@@ -22,6 +256,18 @@
|
|||||||
<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>
|
||||||
|
|||||||
+6
-1
@@ -27,10 +27,15 @@
|
|||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.49.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
"core-js-bundle": "^3.49.0",
|
||||||
"svelte": "^5.15.0",
|
"svelte": "^5.15.0",
|
||||||
"svelte-check": "^4.1.0",
|
"svelte-check": "^4.1.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
"vitest": "^3.0.0"
|
"vite-plugin-pwa": "^0.21.2",
|
||||||
|
"vitest": "^3.0.0",
|
||||||
|
"workbox-core": "^7.4.1",
|
||||||
|
"workbox-precaching": "^7.4.1",
|
||||||
|
"workbox-routing": "^7.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ export default defineConfig({
|
|||||||
// under /app/ and /telegram/): served from the preview root, an absolute base keeps assets at
|
// under /app/ and /telegram/): served from the preview root, an absolute base keeps assets at
|
||||||
// /assets/ so the SPA-fallback also boots a subpath like /telegram/. Base only prefixes asset
|
// /assets/ so the SPA-fallback also boots a subpath like /telegram/. Base only prefixes asset
|
||||||
// URLs, so the minified JS under test is identical to the contour's.
|
// URLs, so the minified JS under test is identical to the contour's.
|
||||||
|
// After the mock build, copy the real per-variant dawgs into the preview output (dist-e2e) so the
|
||||||
|
// offline spec can play a real local vs_ai game; the files are e2e-only (never committed, never in
|
||||||
|
// the production build). Source: E2E_DICT_DIR (CI: the fetched release; local: the sibling).
|
||||||
command:
|
command:
|
||||||
'pnpm exec vite build --mode mock --base / --outDir dist-e2e --emptyOutDir && pnpm exec vite preview --outDir dist-e2e --port 4173 --strictPort',
|
'pnpm exec vite build --mode mock --base / --outDir dist-e2e --emptyOutDir && node scripts/e2e-dict.mjs && pnpm exec vite preview --outDir dist-e2e --port 4173 --strictPort',
|
||||||
url: 'http://localhost:4173',
|
url: 'http://localhost:4173',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
|
|||||||
Generated
+3203
-21
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,8 @@
|
|||||||
# pnpm 11 records build-script approval here. esbuild's postinstall materialises
|
# pnpm 11 records build-script approval here. esbuild's postinstall materialises
|
||||||
# its CLI shim; the platform binary itself ships as an optional dependency.
|
# its CLI shim; the platform binary itself ships as an optional dependency.
|
||||||
|
# core-js-bundle ships a prebuilt minified.js (we only read that file at build time,
|
||||||
|
# via vite.config emitPolyfills) and its install script is just a funding banner, so
|
||||||
|
# it is denied — nothing to build.
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
|
core-js-bundle: false
|
||||||
esbuild: true
|
esbuild: true
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -19,10 +19,17 @@ import { join } from 'node:path';
|
|||||||
|
|
||||||
const DIST = 'dist';
|
const DIST = 'dist';
|
||||||
|
|
||||||
// Per-chunk gzip budgets in KB. The app entry was raised to 110 when the local
|
// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview
|
||||||
// move-preview wiring landed (the heavy dict subsystem stays in lazy chunks; this
|
// wiring, to 112 for the PWA install feature, to 113 for the offline-mode wiring, to 114 for
|
||||||
// covers the small in-entry game/debug wiring it needs).
|
// the offline auto-detect (the cold-start reachability check and the "no connection" dialog live in
|
||||||
const BUDGET = { app: 110, shared: 30, landing: 5 };
|
// the boot path), then to 115 for the vs_ai idle-hint gate — its monotonic clock, lock badge and
|
||||||
|
// countdown toast live in the always-loaded game screen (Game.svelte) — and to 120 for the offline
|
||||||
|
// pass-and-play (hotseat) mode: the on-screen PIN pad, the creation roster and the in-game host menu
|
||||||
|
// live in the always-loaded New Game / Game / Lobby screens (the offline engine and the tiny PIN
|
||||||
|
// hashing stay in lazy chunks / are negligible). The heavy parts — the dict loader, the move
|
||||||
|
// generator and the preload orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS
|
||||||
|
// chunk, not this JS budget.
|
||||||
|
const BUDGET = { app: 120, shared: 30, landing: 5 };
|
||||||
|
|
||||||
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
||||||
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Copies the real per-variant dictionary DAWGs into the mock e2e preview output (dist-e2e/e2edict/)
|
||||||
|
// so the offline spec can create and play a real local vs_ai game (the mock's fetchDict serves these
|
||||||
|
// files — see lib/mock/client.ts). The dawgs are NEVER committed and never enter the production
|
||||||
|
// build; they live only in the throwaway dist-e2e/ that `vite preview` serves for Playwright.
|
||||||
|
//
|
||||||
|
// Source directory: E2E_DICT_DIR — in CI the fetched scrabble-dictionary release
|
||||||
|
// ($GITHUB_WORKSPACE/dawg); locally it defaults to the sibling scrabble-solver/dawg checkout. A
|
||||||
|
// missing source only warns (so the other, dawg-free specs still run); the offline spec then fails
|
||||||
|
// with an obvious HTTP 404 from fetchDict.
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const srcDir = process.env.E2E_DICT_DIR ?? '../../scrabble-solver/dawg';
|
||||||
|
const outDir = 'dist-e2e/e2edict';
|
||||||
|
|
||||||
|
// The app's Variant enum value -> the release dawg file name (matches the movegen/parity mapping).
|
||||||
|
const dawgFor = {
|
||||||
|
scrabble_en: 'en_sowpods',
|
||||||
|
scrabble_ru: 'ru_scrabble',
|
||||||
|
erudit_ru: 'ru_erudit',
|
||||||
|
};
|
||||||
|
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
let copied = 0;
|
||||||
|
for (const [variant, file] of Object.entries(dawgFor)) {
|
||||||
|
const src = join(srcDir, `${file}.dawg`);
|
||||||
|
if (!existsSync(src)) {
|
||||||
|
console.warn(`e2e-dict: missing ${src} — the offline spec will 404 (set E2E_DICT_DIR)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
copyFileSync(src, join(outDir, `${variant}.dawg`));
|
||||||
|
copied++;
|
||||||
|
}
|
||||||
|
console.log(`e2e-dict: copied ${copied}/${Object.keys(dawgFor).length} dawgs from ${srcDir} -> ${outDir}`);
|
||||||
+65
-2
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
import { app, bootstrap } from './lib/app.svelte';
|
import { app, bootstrap, resolveOfflinePrompt } from './lib/app.svelte';
|
||||||
import { router, type RouteName } from './lib/router.svelte';
|
import { router, type RouteName } from './lib/router.svelte';
|
||||||
import { t } from './lib/i18n/index.svelte';
|
import { t } from './lib/i18n/index.svelte';
|
||||||
import Toast from './components/Toast.svelte';
|
import Toast from './components/Toast.svelte';
|
||||||
@@ -26,7 +26,12 @@
|
|||||||
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
|
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void bootstrap();
|
// Tell the index.html boot guard that startup completed, so its reactive net does not raise the
|
||||||
|
// unsupported-engine screen on a healthy boot — it only fires when an uncaught error occurred
|
||||||
|
// AND this flag never sets (a bundle that failed to parse, or an unforeseen incompatibility).
|
||||||
|
void bootstrap().then(() => {
|
||||||
|
(window as unknown as { __booted?: boolean }).__booted = true;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// The lobby is the cold-start landing (an empty hash and Telegram launch params both parse
|
// The lobby is the cold-start landing (an empty hash and Telegram launch params both parse
|
||||||
@@ -131,6 +136,21 @@
|
|||||||
<Coachmark />
|
<Coachmark />
|
||||||
<MaintenanceOverlay />
|
<MaintenanceOverlay />
|
||||||
|
|
||||||
|
<!-- Cold-start "no connection" dialog: the reachability check timed out with the network interface
|
||||||
|
reportedly online, so it is ambiguous. The player chooses to go offline (play local vs_ai) or to
|
||||||
|
keep trying to connect. bootstrap awaits this choice (app.offlinePrompt). -->
|
||||||
|
{#if app.offlinePrompt}
|
||||||
|
<div class="offprompt" role="dialog" aria-modal="true">
|
||||||
|
<div class="card">
|
||||||
|
<p class="msg">{t('offline.promptTitle')}</p>
|
||||||
|
<div class="acts">
|
||||||
|
<button class="opt primary" onclick={() => resolveOfflinePrompt(true)}>{t('offline.promptYes')}</button>
|
||||||
|
<button class="opt" onclick={() => resolveOfflinePrompt(false)}>{t('offline.promptNo')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
||||||
<Splash />
|
<Splash />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -140,6 +160,49 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* The cold-start "no connection" dialog — a centred card over a scrim, above the splash. */
|
||||||
|
.offprompt {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
padding: var(--pad);
|
||||||
|
}
|
||||||
|
.offprompt .card {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 320px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.offprompt .msg {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.offprompt .acts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.offprompt .opt {
|
||||||
|
padding: 11px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.offprompt .opt.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
.splash {
|
.splash {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
+19
-4
@@ -80,20 +80,26 @@
|
|||||||
<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>
|
||||||
{#if tgLink || vkLink}
|
<!-- Platform entries: Telegram/VK are gated by their build vars; the web version (/app/) is
|
||||||
|
always available. A caption under each clarifies what it opens. -->
|
||||||
<div class="entries">
|
<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">
|
||||||
@@ -207,8 +213,8 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
}
|
}
|
||||||
/* The platform entries are just the bigger logos in a row (no button chrome, no captions);
|
/* The platform entries are the bigger logos in a row, each with a short caption below; the
|
||||||
each link keeps an aria-label for assistive tech. */
|
link keeps an aria-label for assistive tech. */
|
||||||
.entries {
|
.entries {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -217,6 +223,11 @@
|
|||||||
}
|
}
|
||||||
.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;
|
||||||
@@ -225,6 +236,10 @@
|
|||||||
.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);
|
||||||
|
|||||||
+9
-1
@@ -162,7 +162,15 @@ html.app-shell {
|
|||||||
}
|
}
|
||||||
html.app-shell body {
|
html.app-shell body {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
/* Follow the visual viewport (top + height), not merely fill the layout viewport: iOS Safari /
|
||||||
|
WKWebView does not shrink the layout viewport for the soft keyboard — it offsets the visual
|
||||||
|
viewport down toward the focused field — so a top-anchored (inset:0) shell would misalign, showing
|
||||||
|
empty space below the content (worst on a repeat focus). --vv-top / --vvh are kept in sync from
|
||||||
|
app.svelte.ts (syncViewport). The fallbacks equal inset:0 before the first sync / on the landing. */
|
||||||
|
top: var(--vv-top, 0px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: var(--vvh, 100%);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
import {
|
import {
|
||||||
attachBannerHost,
|
attachBannerHost,
|
||||||
bannerCurrent,
|
bannerCurrent,
|
||||||
|
bannerCurrentColors,
|
||||||
configureBanner,
|
configureBanner,
|
||||||
detachBannerHost,
|
detachBannerHost,
|
||||||
remeasureBanner,
|
remeasureBanner,
|
||||||
} from '../lib/bannerEngine';
|
} from '../lib/bannerEngine';
|
||||||
import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner';
|
import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner';
|
||||||
import type { BannerCampaign, BannerTimings } from '../lib/model';
|
import { resolveBannerColors, type ThemeMode } from '../lib/bannerColors';
|
||||||
|
import type { BannerCampaign, BannerColors, BannerTimings } from '../lib/model';
|
||||||
import { onExternalLinkClick } from '../lib/links';
|
import { onExternalLinkClick } from '../lib/links';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -21,12 +23,33 @@
|
|||||||
// current message and is visible at once (no fade — see inFade), so a screen change does not
|
// current message and is visible at once (no fade — see inFade), so a screen change does not
|
||||||
// replay the fade. Empty on the very first mount (engine not yet started).
|
// replay the fade. Empty on the very first mount (engine not yet started).
|
||||||
let current = $state(bannerCurrent());
|
let current = $state(bannerCurrent());
|
||||||
|
let currentColors = $state<BannerColors>(bannerCurrentColors());
|
||||||
let visible = $state(bannerCurrent() !== '');
|
let visible = $state(bannerCurrent() !== '');
|
||||||
let tx = $state(0);
|
let tx = $state(0);
|
||||||
let txDur = $state(0);
|
let txDur = $state(0);
|
||||||
let track = $state<HTMLElement>();
|
let track = $state<HTMLElement>();
|
||||||
let viewport = $state<HTMLElement>();
|
let viewport = $state<HTMLElement>();
|
||||||
|
|
||||||
|
// The rendered theme (light/dark), tracked so a campaign's colour override resolves against the
|
||||||
|
// theme actually on screen and re-resolves live when the operator/OS flips it. It follows the
|
||||||
|
// [data-theme] attribute (theme.ts), or the OS preference when unset ('auto').
|
||||||
|
let themeMode = $state<ThemeMode>(resolveThemeMode());
|
||||||
|
function resolveThemeMode(): ThemeMode {
|
||||||
|
if (typeof document === 'undefined') return 'light';
|
||||||
|
const attr = document.documentElement.getAttribute('data-theme');
|
||||||
|
if (attr === 'dark' || attr === 'light') return attr;
|
||||||
|
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The strip's colours for the current campaign + theme (null = the neutral tokens). Applied as
|
||||||
|
// inline CSS variables scoped to .ad, so an override never leaks to the rest of the page.
|
||||||
|
const adColors = $derived(resolveBannerColors(currentColors, themeMode));
|
||||||
|
const adStyle = $derived(
|
||||||
|
adColors
|
||||||
|
? `--ad-bg:${adColors.bg};--text-muted:${adColors.fg};--accent:${adColors.link};--ad-border:${adColors.border}`
|
||||||
|
: '',
|
||||||
|
);
|
||||||
|
|
||||||
// The first appearance after mounting onto an already-running cycle is instant; every later
|
// The first appearance after mounting onto an already-running cycle is instant; every later
|
||||||
// message change fades. (Consumed by the first in:fade.)
|
// message change fades. (Consumed by the first in:fade.)
|
||||||
let instantOnce = bannerCurrent() !== '';
|
let instantOnce = bannerCurrent() !== '';
|
||||||
@@ -50,8 +73,9 @@
|
|||||||
// The DOM host the engine drives. The fade lives on the {#if} layer (transition:fade), the scroll
|
// The DOM host the engine drives. The fade lives on the {#if} layer (transition:fade), the scroll
|
||||||
// on the inner track's transform, so a long message's scroll never blocks its fade in/out.
|
// on the inner track's transform, so a long message's scroll never blocks its fade in/out.
|
||||||
const host: BannerHost = {
|
const host: BannerHost = {
|
||||||
show(md) {
|
show(md, colors) {
|
||||||
current = md;
|
current = md;
|
||||||
|
currentColors = colors;
|
||||||
tx = 0;
|
tx = 0;
|
||||||
txDur = 0;
|
txDur = 0;
|
||||||
visible = true;
|
visible = true;
|
||||||
@@ -93,6 +117,20 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
configureBanner(campaigns, eff);
|
configureBanner(campaigns, eff);
|
||||||
});
|
});
|
||||||
|
// Track the rendered theme so a colour override re-resolves when the theme flips: watch the
|
||||||
|
// [data-theme] attribute (Settings toggle / Telegram) and, for 'auto', the OS colour scheme.
|
||||||
|
$effect(() => {
|
||||||
|
const update = () => (themeMode = resolveThemeMode());
|
||||||
|
update();
|
||||||
|
const obs = new MutationObserver(update);
|
||||||
|
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||||
|
const mq = window.matchMedia?.('(prefers-color-scheme: dark)');
|
||||||
|
mq?.addEventListener('change', update);
|
||||||
|
return () => {
|
||||||
|
obs.disconnect();
|
||||||
|
mq?.removeEventListener('change', update);
|
||||||
|
};
|
||||||
|
});
|
||||||
// Re-measure on a viewport size change (e.g. a portrait↔landscape rotation): a message that fit
|
// Re-measure on a viewport size change (e.g. a portrait↔landscape rotation): a message that fit
|
||||||
// may now overflow, or vice versa, so the scroll must be re-evaluated. Debounced.
|
// may now overflow, or vice versa, so the scroll must be re-evaluated. Debounced.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -116,7 +154,7 @@
|
|||||||
confirmation, the VK away-redirect keeps the Android WebView on the game. -->
|
confirmation, the VK away-redirect keeps the Android WebView on the game. -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
|
<div class="ad" bind:this={viewport} style={adStyle} onclick={onExternalLinkClick}>
|
||||||
<!-- An always-present, invisible spacer reserves exactly one line of height, so the strip never
|
<!-- An always-present, invisible spacer reserves exactly one line of height, so the strip never
|
||||||
collapses while the message layer is absent during the fade gap (the message is overlaid
|
collapses while the message layer is absent during the fade gap (the message is overlaid
|
||||||
absolutely, so its presence/absence does not change the strip height). -->
|
absolutely, so its presence/absence does not change the strip height). -->
|
||||||
@@ -145,8 +183,8 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--ad-border, var(--border));
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--ad-border, var(--border));
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
/* Reserves one line of height inside the padding so .ad keeps a constant height even during the
|
/* Reserves one line of height inside the padding so .ad keeps a constant height even during the
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
|
import { offlineMode, dictPreloadWarning } from '../lib/offline.svelte';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import { app, openDebug } from '../lib/app.svelte';
|
import { app, openDebug } from '../lib/app.svelte';
|
||||||
import Spinner from './Spinner.svelte';
|
import Spinner from './Spinner.svelte';
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="nav" class:grow>
|
<header class="nav" class:grow class:offline={offlineMode.active}>
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
{#if showBack}
|
{#if showBack}
|
||||||
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
|
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
|
||||||
@@ -36,15 +37,20 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if connection.online}
|
{#if connection.online || offlineMode.active}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
<h1 onclick={onTitleTap}>{title}</h1>
|
<h1 onclick={onTitleTap}>{title}</h1>
|
||||||
{:else}
|
{:else}
|
||||||
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
|
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- A right-hand spacer balances the back button so the title stays centred. -->
|
<!-- A right-hand spacer balances the back button so the title stays centred; in offline mode it
|
||||||
|
carries the "Offline" chip so the deliberate mode is always visible (not just the blue tint). -->
|
||||||
|
{#if offlineMode.active}
|
||||||
|
<span class="chip">{t('settings.offline')}</span>
|
||||||
|
{:else}
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<!-- The ad banner lives inside the nav, directly under the title bar, so it sits in the
|
<!-- The ad banner lives inside the nav, directly under the title bar, so it sits in the
|
||||||
same place on every screen — and in the game (grown nav) the spare height falls below
|
same place on every screen — and in the game (grown nav) the spare height falls below
|
||||||
@@ -52,7 +58,11 @@
|
|||||||
coachmark overlay is up (app.coachActive) so the scrolling strip does not run behind the
|
coachmark overlay is up (app.coachActive) so the scrolling strip does not run behind the
|
||||||
dimmed onboarding layer; the engine keeps rotating (module scope) and the strip reappears,
|
dimmed onboarding layer; the engine keeps rotating (module scope) and the strip reappears,
|
||||||
per this same condition, once onboarding closes. -->
|
per this same condition, once onboarding closes. -->
|
||||||
{#if app.profile?.banner && app.profile.banner.campaigns.length && !app.coachActive}
|
{#if dictPreloadWarning.active}
|
||||||
|
<!-- A background dictionary preload for offline readiness failed (poor connection): a soft
|
||||||
|
notice takes the ad banner's slot until a later preload succeeds and clears it. -->
|
||||||
|
<p class="preload-warn" role="alert">{t('offline.preloadWarning')}</p>
|
||||||
|
{:else if app.profile?.banner && app.profile.banner.campaigns.length && !app.coachActive}
|
||||||
<AdBanner
|
<AdBanner
|
||||||
campaigns={app.profile.banner.campaigns}
|
campaigns={app.profile.banner.campaigns}
|
||||||
timings={app.profile.banner.timings}
|
timings={app.profile.banner.timings}
|
||||||
@@ -74,6 +84,12 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
/* Deliberate offline mode: a blue-tinted nav, mixed from the accent so it tracks the light/dark
|
||||||
|
theme (and any Telegram theme override) — the offline state is unmistakable at a glance. */
|
||||||
|
.nav.offline {
|
||||||
|
background: color-mix(in srgb, var(--accent) 20%, var(--bg-elev));
|
||||||
|
border-bottom-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||||
|
}
|
||||||
.nav.grow {
|
.nav.grow {
|
||||||
/* Grow into spare height (banner under the title, the board pinned to the bottom), but
|
/* Grow into spare height (banner under the title, the board pinned to the bottom), but
|
||||||
never shrink: on a short viewport the banner keeps its height and the board's own
|
never shrink: on a short viewport the banner keeps its height and the board's own
|
||||||
@@ -114,6 +130,34 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
/* The offline chip: a compact accent-tinted pill in the header's right slot. */
|
||||||
|
.chip {
|
||||||
|
min-width: 40px;
|
||||||
|
height: 24px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* The offline-readiness preload warning: a soft, muted strip in the ad banner's slot, sized like
|
||||||
|
the banner region so the bar does not jump when it appears. */
|
||||||
|
.preload-warn {
|
||||||
|
margin: 0;
|
||||||
|
padding: 7px var(--pad);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
.back {
|
.back {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Apple-lock-screen-style 4-digit PIN pad for offline pass-and-play (hotseat) games.
|
||||||
|
// Modes:
|
||||||
|
// set — enter a PIN, then repeat it to confirm; emits { kind: 'set', lock }.
|
||||||
|
// verify — enter a PIN; runs `verify(pin)` on the 4th digit; emits { kind: 'verified' }
|
||||||
|
// or shakes + clears on a wrong PIN.
|
||||||
|
// change — run `verify` on the current PIN, then offer "set a new PIN" (the set flow)
|
||||||
|
// or, when allowRemove, "remove PIN" ({ kind: 'removed' }).
|
||||||
|
// Verification is delegated to the caller's `verify` callback: at creation time it checks a
|
||||||
|
// UI-held lock (lib/pin verifyPin); in-game it delegates to the local source (which holds the
|
||||||
|
// stored lock). The pad never shows an OK button: entering the 4th digit is the action. PIN
|
||||||
|
// storage is a social lock, not cryptography — see lib/pin.ts.
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
import { newLock, type PinLock } from '../lib/pin';
|
||||||
|
|
||||||
|
/** PinResult is the outcome handed to onresult when the pad completes. */
|
||||||
|
export type PinResult =
|
||||||
|
| { kind: 'set'; lock: PinLock }
|
||||||
|
| { kind: 'verified' }
|
||||||
|
| { kind: 'removed' };
|
||||||
|
|
||||||
|
let {
|
||||||
|
mode,
|
||||||
|
title = '',
|
||||||
|
verify,
|
||||||
|
allowRemove = false,
|
||||||
|
onclose,
|
||||||
|
onresult,
|
||||||
|
}: {
|
||||||
|
mode: 'set' | 'verify' | 'change';
|
||||||
|
title?: string;
|
||||||
|
/** Verifies an entered PIN (required for 'verify' and 'change'). */
|
||||||
|
verify?: (pin: string) => Promise<boolean>;
|
||||||
|
allowRemove?: boolean;
|
||||||
|
onclose?: () => void;
|
||||||
|
onresult: (r: PinResult) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const LEN = 4;
|
||||||
|
|
||||||
|
// 'old' verifies the existing PIN (change); 'menu' offers set-new / remove (change);
|
||||||
|
// 'enter' collects a PIN; 'confirm' re-enters it (set / change→new). `mode` is fixed
|
||||||
|
// for the pad's lifetime (callers mount a fresh pad per operation), so capturing its
|
||||||
|
// initial value here is intentional.
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let phase = $state<'old' | 'menu' | 'enter' | 'confirm'>(mode === 'change' ? 'old' : 'enter');
|
||||||
|
let buffer = $state('');
|
||||||
|
let first = $state(''); // the first entry, awaiting confirmation
|
||||||
|
let error = $state(false); // drives the error prompt + shake
|
||||||
|
let shakeSeq = $state(0); // bumped on each error to replay the shake animation
|
||||||
|
let busy = $state(false); // guards the async check between the 4th digit and the outcome
|
||||||
|
|
||||||
|
function promptText(): string {
|
||||||
|
if (error) return phase === 'confirm' ? t('pin.mismatch') : t('pin.wrong');
|
||||||
|
switch (phase) {
|
||||||
|
case 'old':
|
||||||
|
return t('pin.enterCurrent');
|
||||||
|
case 'confirm':
|
||||||
|
return t('pin.repeat');
|
||||||
|
default:
|
||||||
|
return mode === 'verify' ? t('pin.enter') : t('pin.create');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(): void {
|
||||||
|
buffer = '';
|
||||||
|
error = true;
|
||||||
|
shakeSeq += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit(): Promise<void> {
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
// Let the 4th dot register before the verdict: a beat so the fill (and, on failure, the shake)
|
||||||
|
// reads as a deliberate response rather than an instant flicker.
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
if (phase === 'old') {
|
||||||
|
if (verify && (await verify(buffer))) {
|
||||||
|
buffer = '';
|
||||||
|
error = false;
|
||||||
|
phase = 'menu';
|
||||||
|
} else fail();
|
||||||
|
} else if (phase === 'enter' && mode === 'verify') {
|
||||||
|
if (verify && (await verify(buffer))) onresult({ kind: 'verified' });
|
||||||
|
else fail();
|
||||||
|
} else if (phase === 'enter') {
|
||||||
|
first = buffer;
|
||||||
|
buffer = '';
|
||||||
|
error = false;
|
||||||
|
phase = 'confirm';
|
||||||
|
} else if (phase === 'confirm') {
|
||||||
|
if (buffer === first) onresult({ kind: 'set', lock: await newLock(buffer) });
|
||||||
|
else {
|
||||||
|
first = '';
|
||||||
|
phase = 'enter';
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function press(d: string): void {
|
||||||
|
if (busy || phase === 'menu' || buffer.length >= LEN) return;
|
||||||
|
error = false;
|
||||||
|
buffer += d;
|
||||||
|
if (buffer.length === LEN) void commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function back(): void {
|
||||||
|
if (busy || phase === 'menu') return;
|
||||||
|
buffer = buffer.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNew(): void {
|
||||||
|
error = false;
|
||||||
|
first = '';
|
||||||
|
buffer = '';
|
||||||
|
phase = 'enter';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkey(e: KeyboardEvent): void {
|
||||||
|
if (e.key >= '0' && e.key <= '9') {
|
||||||
|
press(e.key);
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'Backspace') {
|
||||||
|
back();
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
onclose?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||||
|
const slots = Array.from({ length: LEN }, (_, i) => i);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onkey} />
|
||||||
|
|
||||||
|
<Modal {title} {onclose} overlayKeyboard>
|
||||||
|
<div class="pad">
|
||||||
|
{#if phase === 'menu'}
|
||||||
|
<div class="menu">
|
||||||
|
<button class="opt" onclick={startNew}>{t('pin.setNew')}</button>
|
||||||
|
{#if allowRemove}
|
||||||
|
<button class="opt danger" onclick={() => onresult({ kind: 'removed' })}>
|
||||||
|
{t('pin.remove')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="prompt" class:err={error}>{promptText()}</p>
|
||||||
|
{#key shakeSeq}
|
||||||
|
<div class="dots" class:shake={error} aria-hidden="true">
|
||||||
|
{#each slots as i}
|
||||||
|
<span class="dot" class:on={i < buffer.length}></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
<div class="grid">
|
||||||
|
{#each keys as k}
|
||||||
|
<button class="key" onclick={() => press(k)} aria-label={k}>{k}</button>
|
||||||
|
{/each}
|
||||||
|
<span class="key spacer"></span>
|
||||||
|
<button class="key" onclick={() => press('0')} aria-label="0">0</button>
|
||||||
|
<button class="key back" onclick={back} aria-label="⌫" disabled={buffer.length === 0}>⌫</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pad {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
.prompt {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--muted, var(--text));
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
.prompt.err {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--text);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.dot.on {
|
||||||
|
background: var(--text);
|
||||||
|
}
|
||||||
|
.shake {
|
||||||
|
animation: shake 0.32s ease;
|
||||||
|
}
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
20% { transform: translateX(-7px); }
|
||||||
|
40% { transform: translateX(7px); }
|
||||||
|
60% { transform: translateX(-5px); }
|
||||||
|
80% { transform: translateX(5px); }
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.key {
|
||||||
|
width: 62px;
|
||||||
|
height: 62px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.key:active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
.key.back {
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
.key.back:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
.key.spacer {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.opt {
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.opt.danger {
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
import { app, dismissToast } from '../lib/app.svelte';
|
import { app, dismissToast } from '../lib/app.svelte';
|
||||||
|
|
||||||
const dur = $derived(app.reduceMotion ? 0 : 260);
|
const dur = $derived(app.reduceMotion ? 0 : 260);
|
||||||
// An info bubble owns its whole 2s life through a CSS rise-and-fade animation, so it takes
|
// An info bubble owns its whole 3s life through a CSS animation — appear, a ~1s hold at rest,
|
||||||
// no Svelte enter/leave transition; an error keeps the fly-in / fade-out dwell behaviour.
|
// then rise-and-fade — so it takes no Svelte enter/leave transition; an error keeps the fly-in /
|
||||||
|
// fade-out dwell behaviour.
|
||||||
const isInfo = $derived(app.toast?.kind !== 'error');
|
const isInfo = $derived(app.toast?.kind !== 'error');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -50,17 +51,19 @@
|
|||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
/* An info bubble fades in, then drifts up by roughly the tab-bar height while fading out,
|
/* An info bubble fades in, holds for ~1s at rest, then drifts up by roughly the tab-bar height
|
||||||
all within 2s; the X centring is preserved across the rise. */
|
while fading out, all within 3s; the X centring is preserved across the rise. The 8%/41%
|
||||||
|
stops are the appear (~240ms) and the end of the ~1s hold. */
|
||||||
.toast.rise {
|
.toast.rise {
|
||||||
animation: toast-rise 2s ease-out forwards;
|
animation: toast-rise 3s ease-out forwards;
|
||||||
}
|
}
|
||||||
@keyframes toast-rise {
|
@keyframes toast-rise {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-50%) translateY(8px);
|
transform: translateX(-50%) translateY(8px);
|
||||||
}
|
}
|
||||||
12% {
|
8%,
|
||||||
|
41% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(-50%) translateY(0);
|
transform: translateX(-50%) translateY(0);
|
||||||
}
|
}
|
||||||
@@ -69,16 +72,16 @@
|
|||||||
transform: translateX(-50%) translateY(-56px);
|
transform: translateX(-50%) translateY(-56px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* Reduced motion: the same 2s life, fade only — no upward travel. */
|
/* Reduced motion: the same 3s life (appear, ~1s hold, fade), fade only — no upward travel. */
|
||||||
.toast.rise-reduced {
|
.toast.rise-reduced {
|
||||||
animation: toast-rise-reduced 2s ease-out forwards;
|
animation: toast-rise-reduced 3s ease-out forwards;
|
||||||
}
|
}
|
||||||
@keyframes toast-rise-reduced {
|
@keyframes toast-rise-reduced {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
12%,
|
8%,
|
||||||
70% {
|
41% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
|
|||||||
@@ -479,6 +479,11 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 5%;
|
top: 5%;
|
||||||
left: 8%;
|
left: 8%;
|
||||||
|
/* Chrome < 105 has no container-query units: a dropped `cqw` would inherit the .cell
|
||||||
|
`font-size: 0` and the glyph would vanish. Fall back to a viewport-relative size that tracks
|
||||||
|
the board (≈ the viewport-fitted query container) and the zoom (--z), so old Android System
|
||||||
|
WebViews still draw the tile glyphs. Chrome 105+ takes the exact `cqw` line below. */
|
||||||
|
font-size: calc(4.2vmin * var(--z, 1));
|
||||||
font-size: 4.2cqw;
|
font-size: 4.2cqw;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -487,12 +492,14 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: 5%;
|
right: 5%;
|
||||||
bottom: 3%;
|
bottom: 3%;
|
||||||
|
font-size: calc(2.4vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
|
||||||
font-size: 2.4cqw;
|
font-size: 2.4cqw;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
|
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
|
||||||
its ink centred on the same line as a neighbouring tile's value digit. */
|
its ink centred on the same line as a neighbouring tile's value digit. */
|
||||||
.blankmark {
|
.blankmark {
|
||||||
|
font-size: calc(2.8vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
|
||||||
font-size: 2.8cqw;
|
font-size: 2.8cqw;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
}
|
}
|
||||||
@@ -501,6 +508,7 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
font-size: calc(3.6vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
|
||||||
font-size: 3.6cqw;
|
font-size: 3.6cqw;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
@@ -509,6 +517,7 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
font-size: calc(2.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
|
||||||
font-size: 2.7cqw;
|
font-size: 2.7cqw;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
@@ -526,10 +535,12 @@
|
|||||||
padding: 0 1px;
|
padding: 0 1px;
|
||||||
}
|
}
|
||||||
.bt {
|
.bt {
|
||||||
|
font-size: calc(1.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
|
||||||
font-size: 1.7cqw;
|
font-size: 1.7cqw;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.bb {
|
.bb {
|
||||||
|
font-size: calc(1.9vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
|
||||||
font-size: 1.9cqw;
|
font-size: 1.9cqw;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
|
import { gameSource, isLocalGameId } from '../lib/gamesource';
|
||||||
import { handleError, showToast } from '../lib/app.svelte';
|
import { handleError, showToast } from '../lib/app.svelte';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import { alphabetLetters } from '../lib/alphabet';
|
import { alphabetLetters } from '../lib/alphabet';
|
||||||
@@ -20,8 +21,10 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
// Include the alphabet so input sanitising + the check accept the variant's letters.
|
// Include the alphabet so input sanitising + the check accept the variant's letters. Routed
|
||||||
const st = await gateway.gameState(id, true);
|
// through gameSource so an offline (local) game resolves its state from the device, not the
|
||||||
|
// network.
|
||||||
|
const st = await gameSource(id).gameState(id, true);
|
||||||
variant = st.game.variant;
|
variant = st.game.variant;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
@@ -41,7 +44,7 @@
|
|||||||
cooling = true;
|
cooling = true;
|
||||||
setTimeout(() => (cooling = false), 5000);
|
setTimeout(() => (cooling = false), 5000);
|
||||||
try {
|
try {
|
||||||
const r = await gateway.checkWord(id, w, variant);
|
const r = await gameSource(id).checkWord(id, w, variant);
|
||||||
checked.set(w, r.legal);
|
checked.set(w, r.legal);
|
||||||
result = { word: w, legal: r.legal };
|
result = { word: w, legal: r.legal };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -77,7 +80,11 @@
|
|||||||
: t('game.wordIllegal', { word: result.word })}
|
: t('game.wordIllegal', { word: result.word })}
|
||||||
</p>
|
</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
<!-- Complaints go to the admin over the network; a local (offline) game has no backend to
|
||||||
|
receive them, so the control is dropped there. -->
|
||||||
|
{#if !isLocalGameId(id)}
|
||||||
<button class="complain" onclick={complain}>{t('game.complain')}</button>
|
<button class="complain" onclick={complain}>{t('game.complain')}</button>
|
||||||
|
{/if}
|
||||||
{#if result.legal}
|
{#if result.legal}
|
||||||
<a
|
<a
|
||||||
class="lookup"
|
class="lookup"
|
||||||
|
|||||||
@@ -16,15 +16,21 @@
|
|||||||
// The game is rendered (and cached) before its comms open, so the cache tells us whether
|
// The game is rendered (and cached) before its comms open, so the cache tells us whether
|
||||||
// it is still active without another fetch; an unknown game keeps the Dictionary offered.
|
// it is still active without another fetch; an unknown game keeps the Dictionary offered.
|
||||||
const active = $derived(getCachedGame(id)?.view.game.status !== 'finished');
|
const active = $derived(getCachedGame(id)?.view.game.status !== 'finished');
|
||||||
// An honest-AI game has no chat at all, so its hub is Dictionary-only.
|
// A local game has NO chat: an honest-AI game (vs_ai) and an offline pass-and-play (hotseat) game.
|
||||||
const vsAi = $derived(getCachedGame(id)?.view.game.vsAi ?? false);
|
// Its hub is Dictionary-only.
|
||||||
|
const chatless = $derived(
|
||||||
|
(getCachedGame(id)?.view.game.vsAi ?? false) || (getCachedGame(id)?.view.game.hotseat ?? false),
|
||||||
|
);
|
||||||
// Seeded once from the entry route's tab, then owned locally. The effect keeps the tab valid:
|
// Seeded once from the entry route's tab, then owned locally. The effect keeps the tab valid:
|
||||||
// an AI game has only the Dictionary; a finished non-AI game has only Chat (a stale Dictionary
|
// a chatless game has only the Dictionary; a finished non-AI game has only Chat (a stale Dictionary
|
||||||
// deep-link falls back to Chat).
|
// deep-link falls back to Chat).
|
||||||
|
// A chatless game starts on the Dictionary regardless of the entry route — this keeps ChatScreen
|
||||||
|
// (which fetches chat over the network) from mounting even for a beat, which would otherwise raise
|
||||||
|
// an error toast in offline mode.
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let tab = $state<CommsTab>(initialTab);
|
let tab = $state<CommsTab>(chatless ? 'dictionary' : initialTab);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (vsAi) tab = 'dictionary';
|
if (chatless) tab = 'dictionary';
|
||||||
else if (tab === 'dictionary' && !active) tab = 'chat';
|
else if (tab === 'dictionary' && !active) tab = 'chat';
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -43,7 +49,7 @@
|
|||||||
|
|
||||||
{#snippet tabbar()}
|
{#snippet tabbar()}
|
||||||
<TabBar>
|
<TabBar>
|
||||||
{#if !vsAi}
|
{#if !chatless}
|
||||||
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
|
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
|
||||||
<span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span>
|
<span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+374
-31
@@ -4,24 +4,28 @@
|
|||||||
import TabBar from '../components/TabBar.svelte';
|
import TabBar from '../components/TabBar.svelte';
|
||||||
import TapConfirm from '../components/TapConfirm.svelte';
|
import TapConfirm from '../components/TapConfirm.svelte';
|
||||||
import Modal from '../components/Modal.svelte';
|
import Modal from '../components/Modal.svelte';
|
||||||
|
import PinPad from '../components/PinPad.svelte';
|
||||||
import DictWarmup from '../components/DictWarmup.svelte';
|
import DictWarmup from '../components/DictWarmup.svelte';
|
||||||
import Board from './Board.svelte';
|
import Board from './Board.svelte';
|
||||||
import Rack from './Rack.svelte';
|
import Rack from './Rack.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
|
import { gameSource, localSource, isLocalGameId } from '../lib/gamesource';
|
||||||
import { notePreviewLocal, notePreviewNetwork } from '../lib/localeval-metrics';
|
import { notePreviewLocal, notePreviewNetwork } from '../lib/localeval-metrics';
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
|
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
|
import { offlineMode } from '../lib/offline.svelte';
|
||||||
import { GatewayError } from '../lib/client';
|
import { GatewayError } from '../lib/client';
|
||||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
||||||
import { lastMoveCells, replay } from '../lib/board';
|
import { lastMoveCells, replay } from '../lib/board';
|
||||||
import { badgeKind } from '../lib/unread';
|
import { badgeKind } from '../lib/unread';
|
||||||
|
import { seatMedal } from '../lib/result';
|
||||||
import { historyGrid } from '../lib/history';
|
import { historyGrid } from '../lib/history';
|
||||||
import { centre, premiumGrid } from '../lib/premiums';
|
import { centre, premiumGrid } from '../lib/premiums';
|
||||||
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
|
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
|
||||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||||
import { hintsLeft } from '../lib/hints';
|
import { hintsLeft, hintGateRemainingMs, hintLockMinutes, HINT_GATE_MS } from '../lib/hints';
|
||||||
import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
|
import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
|
||||||
import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk';
|
import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk';
|
||||||
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
||||||
@@ -47,6 +51,18 @@
|
|||||||
|
|
||||||
let { id }: { id: string } = $props();
|
let { id }: { id: string } = $props();
|
||||||
|
|
||||||
|
// The game's source: the local engine for an offline game id, otherwise the network gateway. The
|
||||||
|
// screen calls the game-loop methods (state/history/submit/pass/exchange/resign/hint/evaluate/
|
||||||
|
// draft) through it, so the same UI drives a local vs_ai game and an online one alike.
|
||||||
|
const source = $derived(gameSource(id));
|
||||||
|
// A local (offline) game plays entirely on-device, so it is always ready; an online game needs a
|
||||||
|
// live connection — and offline mode's kill switch refuses its calls — so its network actions are
|
||||||
|
// disabled while disconnected or in offline mode (which also stops the "something went wrong"
|
||||||
|
// toasts a blocked call would otherwise raise).
|
||||||
|
const netReady = $derived(isLocalGameId(id) || (connection.online && !offlineMode.active));
|
||||||
|
// Unsubscribes from a local game's robot-reply events (offline only; null for a network game).
|
||||||
|
let localUnsub: (() => void) | null = null;
|
||||||
|
|
||||||
let view = $state<StateView | null>(null);
|
let view = $state<StateView | null>(null);
|
||||||
let moves = $state<MoveRecord[]>([]);
|
let moves = $state<MoveRecord[]>([]);
|
||||||
let placement = $state<Placement>(newPlacement([]));
|
let placement = $state<Placement>(newPlacement([]));
|
||||||
@@ -149,10 +165,42 @@
|
|||||||
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
|
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
|
||||||
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
|
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
|
||||||
const gameOver = $derived(!!view && view.game.status === 'finished');
|
const gameOver = $derived(!!view && view.game.status === 'finished');
|
||||||
|
// Offline hotseat: the seat to move is PIN-locked until its owner enters the seat PIN this turn.
|
||||||
|
// While locked the board stays visible but the rack is withheld and the move controls disabled;
|
||||||
|
// canMove folds the lock into isMyTurn (locked is always false for a network / vs_ai game).
|
||||||
|
const locked = $derived(!!view?.locked);
|
||||||
|
const canMove = $derived(isMyTurn && !locked);
|
||||||
// The hint badge: this game's allowance remaining plus the LIVE global wallet. Reading the
|
// The hint badge: this game's allowance remaining plus the LIVE global wallet. Reading the
|
||||||
// wallet from the profile (not the per-game view snapshot) keeps it correct after a wallet
|
// wallet from the profile (not the per-game view snapshot) keeps it correct after a wallet
|
||||||
// hint was spent in another game (see lib/hints).
|
// hint was spent in another game (see lib/hints).
|
||||||
const hintCount = $derived(hintsLeft(view, app.profile?.hintBalance ?? 0));
|
const hintCount = $derived(hintsLeft(view, app.profile?.hintBalance ?? 0));
|
||||||
|
// A vs_ai game's hint is unlimited and wallet-free, but idle-gated (an anti-frustration aid: it
|
||||||
|
// unlocks only after the player has been stuck ~30 min on a turn). The source (the SERVER clock
|
||||||
|
// online, the device clock offline) tells us the SECONDS LEFT (view.hintUnlockLeftSeconds); we
|
||||||
|
// anchor a MONOTONIC countdown (performance.now()) to it, so a client clock change cannot skew it.
|
||||||
|
// A fresh value arrives on load (armHintGate below); when the robot moves the wait is the full
|
||||||
|
// window. `monoNow` ticks so the 🔒 lifts live at the mark.
|
||||||
|
let hintGateStart = $state<number | null>(null); // performance.now() at the last anchor; null = open
|
||||||
|
let gateLeftMs = $state(0);
|
||||||
|
let monoNow = $state(performance.now());
|
||||||
|
const hintRemaining = $derived(hintGateRemainingMs(hintGateStart, gateLeftMs, monoNow));
|
||||||
|
const hintGated = $derived(hintRemaining > 0);
|
||||||
|
// Anchor the countdown to a freshly received seconds-left (or open the gate). Called on every state
|
||||||
|
// load from the view, and on the robot's move to the full window (the robot just moved).
|
||||||
|
function armHintGate(leftSeconds: number): void {
|
||||||
|
if (leftSeconds > 0) {
|
||||||
|
hintGateStart = performance.now();
|
||||||
|
gateLeftMs = leftSeconds * 1000;
|
||||||
|
} else {
|
||||||
|
hintGateStart = null;
|
||||||
|
gateLeftMs = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$effect(() => {
|
||||||
|
if (!view?.game.vsAi) return;
|
||||||
|
const iv = setInterval(() => (monoNow = performance.now()), 10_000);
|
||||||
|
return () => clearInterval(iv);
|
||||||
|
});
|
||||||
// RACK_SIZE mirrors the engine's rules.RackSize (7 for every current variant). The exchange
|
// RACK_SIZE mirrors the engine's rules.RackSize (7 for every current variant). The exchange
|
||||||
// gate is only a UX guard: the backend stays the source of truth and rejects an under-supplied
|
// gate is only a UX guard: the backend stays the source of truth and rejects an under-supplied
|
||||||
// exchange regardless (engine rejects when bag.Len() < rules.RackSize).
|
// exchange regardless (engine rejects when bag.Len() < rules.RackSize).
|
||||||
@@ -190,11 +238,13 @@
|
|||||||
// Fetch the saved draft alongside state and history (best-effort) so the composition is
|
// Fetch the saved draft alongside state and history (best-effort) so the composition is
|
||||||
// applied in the same tick the board appears — never as a second, visible rack→board step.
|
// applied in the same tick the board appears — never as a second, visible rack→board step.
|
||||||
const [st, hist, draft] = await Promise.all([
|
const [st, hist, draft] = await Promise.all([
|
||||||
gateway.gameState(id, includeAlphabet),
|
source.gameState(id, includeAlphabet),
|
||||||
gateway.gameHistory(id),
|
source.gameHistory(id),
|
||||||
gateway.draftGet(id).catch(() => ''),
|
source.draftGet(id).catch(() => ''),
|
||||||
]);
|
]);
|
||||||
view = st;
|
view = st;
|
||||||
|
// Anchor the vs_ai idle-hint countdown to the freshly fetched seconds-left (0 = open / non-vs_ai).
|
||||||
|
armHintGate(st.game.vsAi ? (st.hintUnlockLeftSeconds ?? 0) : 0);
|
||||||
syncWallet(st.walletBalance);
|
syncWallet(st.walletBalance);
|
||||||
// Seed the unread flag from the authoritative state (the live stream only raises it).
|
// Seed the unread flag from the authoritative state (the live stream only raises it).
|
||||||
seedChatUnread(id, st.game.unreadChat, st.game.unreadMessages);
|
seedChatUnread(id, st.game.unreadChat, st.game.unreadMessages);
|
||||||
@@ -220,7 +270,7 @@
|
|||||||
setCachedDraft(id, json);
|
setCachedDraft(id, json);
|
||||||
if (draftSaveTimer) clearTimeout(draftSaveTimer);
|
if (draftSaveTimer) clearTimeout(draftSaveTimer);
|
||||||
draftSaveTimer = setTimeout(() => {
|
draftSaveTimer = setTimeout(() => {
|
||||||
void gateway.draftSave(id, json).catch(() => {});
|
void source.draftSave(id, json).catch(() => {});
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
// applyDraft restores the player's saved composition over a freshly loaded state: the rack
|
// applyDraft restores the player's saved composition over a freshly loaded state: the rack
|
||||||
@@ -247,6 +297,9 @@
|
|||||||
if (cached) {
|
if (cached) {
|
||||||
view = cached.view;
|
view = cached.view;
|
||||||
moves = cached.moves;
|
moves = cached.moves;
|
||||||
|
// Arm the vs_ai idle-hint countdown from the cached seconds-left so the 🔒 shows at once on a
|
||||||
|
// warm open (no wait for load()); the cached value is a snapshot, refreshed by load() below.
|
||||||
|
armHintGate(cached.view.game.vsAi ? (cached.view.hintUnlockLeftSeconds ?? 0) : 0);
|
||||||
// Apply the cached draft synchronously so a warm/preloaded open paints the pending tiles
|
// Apply the cached draft synchronously so a warm/preloaded open paints the pending tiles
|
||||||
// already on the board (no full-rack-then-jump). load() then refreshes in the background.
|
// already on the board (no full-rack-then-jump). load() then refreshes in the background.
|
||||||
applyDraft(cached.view, cached.draft ?? '');
|
applyDraft(cached.view, cached.draft ?? '');
|
||||||
@@ -264,6 +317,12 @@
|
|||||||
dict = m;
|
dict = m;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// A local (offline) game has no live stream: route the source's robot-reply events through the
|
||||||
|
// same app event hub the network stream feeds, so the event effect above reacts to
|
||||||
|
// opponent_moved / game_over identically.
|
||||||
|
if (isLocalGameId(id)) {
|
||||||
|
localUnsub = localSource.events(id, (e) => (app.lastEvent = e));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Warm the game's dictionary for the local move preview once, when both the game and the
|
// Warm the game's dictionary for the local move preview once, when both the game and the
|
||||||
@@ -309,7 +368,11 @@
|
|||||||
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
|
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
|
||||||
// the move as a delta with no fetch.
|
// the move as a delta with no fetch.
|
||||||
if (placement.pending.length > 0) void load();
|
if (placement.pending.length > 0) void load();
|
||||||
else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen }));
|
else {
|
||||||
|
applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen }));
|
||||||
|
// The robot just moved (vs_ai) → the human's turn begins with the full idle window.
|
||||||
|
if (view?.game.vsAi) armHintGate(HINT_GATE_MS / 1000);
|
||||||
|
}
|
||||||
} else if (e.kind === 'your_turn' && e.gameId === id) {
|
} else if (e.kind === 'your_turn' && e.gameId === id) {
|
||||||
// The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch
|
// The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch
|
||||||
// only if we missed the move (our cached count trails the event's).
|
// only if we missed the move (our cached count trails the event's).
|
||||||
@@ -611,8 +674,13 @@
|
|||||||
// Flush a pending draft save so leaving mid-composition still persists it.
|
// Flush a pending draft save so leaving mid-composition still persists it.
|
||||||
if (draftSaveTimer) {
|
if (draftSaveTimer) {
|
||||||
clearTimeout(draftSaveTimer);
|
clearTimeout(draftSaveTimer);
|
||||||
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
|
void source.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
|
||||||
}
|
}
|
||||||
|
localUnsub?.();
|
||||||
|
// Leaving a hotseat game re-locks the current seat, so returning from the lobby re-prompts its
|
||||||
|
// PIN. Read the id from the loaded view, NOT the `id` prop: a $props() value reads back undefined
|
||||||
|
// during Svelte teardown, and isLocalGameId(undefined) would throw and abort this cleanup.
|
||||||
|
if (view?.game.hotseat) localSource.relock(view.game.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
function onCell(row: number, col: number) {
|
function onCell(row: number, col: number) {
|
||||||
@@ -717,8 +785,8 @@
|
|||||||
// rapid placements do not pile up requests on a slow link.
|
// rapid placements do not pile up requests on a slow link.
|
||||||
evalCtrl?.abort();
|
evalCtrl?.abort();
|
||||||
evalCtrl = null;
|
evalCtrl = null;
|
||||||
// Off-turn the composition is position-only: no score preview or evaluate.
|
// Off-turn (or a locked hotseat seat) the composition is position-only: no score preview.
|
||||||
if (!isMyTurn) return;
|
if (!canMove) return;
|
||||||
const sub = toSubmit(placement);
|
const sub = toSubmit(placement);
|
||||||
if (!sub) return;
|
if (!sub) return;
|
||||||
// Instant on-device preview when the game's dictionary is warm; the network otherwise.
|
// Instant on-device preview when the game's dictionary is warm; the network otherwise.
|
||||||
@@ -740,7 +808,7 @@
|
|||||||
evalCtrl = ctrl;
|
evalCtrl = ctrl;
|
||||||
previewTimer = setTimeout(async () => {
|
previewTimer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
preview = await gateway.evaluate(id, sub.tiles, variant, ctrl.signal);
|
preview = await source.evaluate(id, sub.tiles, variant, ctrl.signal);
|
||||||
notePreviewNetwork();
|
notePreviewNetwork();
|
||||||
} catch {
|
} catch {
|
||||||
/* best-effort (or aborted) */
|
/* best-effort (or aborted) */
|
||||||
@@ -776,12 +844,106 @@
|
|||||||
refreshRecent();
|
refreshRecent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- offline hotseat: seat unlock + host (referee) overrides -------------------
|
||||||
|
let unlockOpen = $state(false); // the current locked seat's owner enters their PIN to reveal the rack
|
||||||
|
// The host menu: 'pin' collects the master PIN, then 'menu' lists the overrides. hostPinEntered is
|
||||||
|
// the verified master PIN, reused to authorise the chosen action (the source re-checks it).
|
||||||
|
let hostMenuStep = $state<'closed' | 'pin' | 'menu'>('closed');
|
||||||
|
let hostPinEntered = $state('');
|
||||||
|
// The pending host action awaiting its confirm ("Skip X's turn?" etc.); null shows the menu list.
|
||||||
|
let hostConfirm = $state<{ action: 'skip' | 'resign' | 'terminate'; seat?: number; name?: string } | null>(null);
|
||||||
|
let hostDone = $state(false); // the transient success ✅ shown after an override
|
||||||
|
|
||||||
|
// hotseatName is the plain display name of a seat by index (hotseat seats are account-less local
|
||||||
|
// players, so no "you"/🤖 resolution is needed — unlike the seat-object seatName used elsewhere).
|
||||||
|
function hotseatName(i: number): string {
|
||||||
|
return view?.game.seats[i]?.displayName ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSkip(): void {
|
||||||
|
if (!view) return;
|
||||||
|
hostConfirm = { action: 'skip', seat: view.game.toMove, name: hotseatName(view.game.toMove) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashDone(): void {
|
||||||
|
hostDone = true;
|
||||||
|
setTimeout(() => (hostDone = false), 1100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceHotseat re-points the screen at the next seat after a hotseat move: the source has advanced
|
||||||
|
// the turn and re-locked, so a fresh state gives the next seat's rack (empty while it is locked).
|
||||||
|
async function advanceHotseat(): Promise<void> {
|
||||||
|
const st = await source.gameState(id, false);
|
||||||
|
view = st;
|
||||||
|
rackIds = st.rack.map((_, i) => i);
|
||||||
|
placement = newPlacement(st.rack);
|
||||||
|
selected = null;
|
||||||
|
preview = null;
|
||||||
|
setCachedGame(id, st, moves, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// onUnlock verifies the current seat's PIN through the source (which reveals the rack on success)
|
||||||
|
// and applies the unlocked state; handed to the PIN pad as its verifier.
|
||||||
|
async function onUnlock(pin: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const st = await localSource.unlockSeat(id, pin);
|
||||||
|
view = st;
|
||||||
|
rackIds = st.rack.map((_, i) => i);
|
||||||
|
placement = newPlacement(st.rack);
|
||||||
|
selected = null;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyHost captures the entered master PIN (reused to authorise the chosen action) and checks it.
|
||||||
|
async function verifyHost(pin: string): Promise<boolean> {
|
||||||
|
hostPinEntered = pin;
|
||||||
|
return localSource.verifyHostPin(id, pin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostConfirmText(c: { action: 'skip' | 'resign' | 'terminate'; name?: string }): string {
|
||||||
|
if (c.action === 'skip') return t('hotseat.askSkip', { name: c.name ?? '' });
|
||||||
|
if (c.action === 'resign') return t('hotseat.askExclude', { name: c.name ?? '' });
|
||||||
|
return t('hotseat.askTerminate');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runHostAction(): Promise<void> {
|
||||||
|
if (!hostConfirm) return;
|
||||||
|
const { action, seat } = hostConfirm;
|
||||||
|
hostConfirm = null;
|
||||||
|
hostMenuStep = 'closed';
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const st = await localSource.hostAction(id, hostPinEntered, action, seat);
|
||||||
|
if (st === null) {
|
||||||
|
navigate('/'); // terminated: the game is deleted, so return to the lobby
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
view = st;
|
||||||
|
rackIds = st.rack.map((_, i) => i);
|
||||||
|
placement = newPlacement(st.rack);
|
||||||
|
selected = null;
|
||||||
|
preview = null;
|
||||||
|
moves = (await source.gameHistory(id)).moves; // a skip / resign added a journal move
|
||||||
|
setCachedGame(id, st, moves, '');
|
||||||
|
flashDone();
|
||||||
|
} catch (e) {
|
||||||
|
handleError(e);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
hostPinEntered = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function commit() {
|
async function commit() {
|
||||||
const sub = toSubmit(placement);
|
const sub = toSubmit(placement);
|
||||||
if (!sub) return;
|
if (!sub) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant));
|
applyMoveResult(await source.submitPlay(id, sub.tiles, variant));
|
||||||
|
if (view?.game.hotseat) await advanceHotseat();
|
||||||
haptic('success');
|
haptic('success');
|
||||||
zoomed = false;
|
zoomed = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -800,7 +962,8 @@
|
|||||||
async function doPass() {
|
async function doPass() {
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
applyMoveResult(await gateway.pass(id));
|
applyMoveResult(await source.pass(id));
|
||||||
|
if (view?.game.hotseat) await advanceHotseat();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -821,7 +984,7 @@
|
|||||||
resignOpen = false;
|
resignOpen = false;
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
applyMoveResult(await gateway.resign(id));
|
applyMoveResult(await source.resign(id));
|
||||||
// Reveal the final board once the game is resigned: close the move-history drawer
|
// Reveal the final board once the game is resigned: close the move-history drawer
|
||||||
// (portrait — the landscape dock is unaffected) and zoom out if it was magnified.
|
// (portrait — the landscape dock is unaffected) and zoom out if it was magnified.
|
||||||
historyOpen = false;
|
historyOpen = false;
|
||||||
@@ -833,8 +996,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function doHint() {
|
async function doHint() {
|
||||||
|
// vs_ai: the idle gate replaces the wallet. While it is still closed, a tap only explains when the
|
||||||
|
// hint unlocks (no wallet is ever spent) — matching the 🔒 badge. Measured fresh at tap time.
|
||||||
|
if (view?.game.vsAi) {
|
||||||
|
const remaining = hintGateRemainingMs(hintGateStart, gateLeftMs, performance.now());
|
||||||
|
if (remaining > 0) {
|
||||||
|
showToast(t('game.hintLockedIn', { n: hintLockMinutes(remaining) }), 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const h = await gateway.hint(id);
|
const h = await source.hint(id);
|
||||||
if (h.move.tiles.length && view) {
|
if (h.move.tiles.length && view) {
|
||||||
placement = placementFromHint(h.move.tiles, view.rack);
|
placement = placementFromHint(h.move.tiles, view.rack);
|
||||||
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
|
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
|
||||||
@@ -869,6 +1041,12 @@
|
|||||||
// The backend does not spend a hint when there is no move.
|
// The backend does not spend a hint when there is no move.
|
||||||
if (e instanceof GatewayError && e.code === 'no_hint_available') {
|
if (e instanceof GatewayError && e.code === 'no_hint_available') {
|
||||||
showToast(t('game.noHintOptions'), 'info');
|
showToast(t('game.noHintOptions'), 'info');
|
||||||
|
} else if (e instanceof GatewayError && e.code === 'hint_locked') {
|
||||||
|
// The server-clock backstop refused a vs_ai hint the client thought was open (a rare client
|
||||||
|
// clock desync): re-sync the countdown from a fresh fetch and explain the gate.
|
||||||
|
await load();
|
||||||
|
const left = hintGateRemainingMs(hintGateStart, gateLeftMs, performance.now());
|
||||||
|
showToast(t('game.hintLockedIn', { n: hintLockMinutes(left || 60_000) }), 'info');
|
||||||
} else {
|
} else {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
}
|
}
|
||||||
@@ -906,7 +1084,8 @@
|
|||||||
exchangeOpen = false;
|
exchangeOpen = false;
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
applyMoveResult(await gateway.exchange(id, tiles, variant));
|
applyMoveResult(await source.exchange(id, tiles, variant));
|
||||||
|
if (view?.game.hotseat) await advanceHotseat();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -928,7 +1107,15 @@
|
|||||||
if (!view) return '';
|
if (!view) return '';
|
||||||
const me = view.game.seats[view.seat];
|
const me = view.game.seats[view.seat];
|
||||||
if (me?.isWinner) return t('game.won');
|
if (me?.isWinner) return t('game.won');
|
||||||
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
|
if (view.game.endReason === 'aborted') return t('game.tied'); // an abort is a draw for everyone
|
||||||
|
const myScore = me?.score ?? 0;
|
||||||
|
// A declared winner (incl. by resignation) that is not me, or anyone who scored higher → a loss.
|
||||||
|
if (view.game.seats.some((s) => s.isWinner) || view.game.seats.some((s) => !s.resigned && s.score > myScore)) {
|
||||||
|
return t('game.lost');
|
||||||
|
}
|
||||||
|
// No one above me and no declared winner: a tie for the lead is a (shared) win; only an
|
||||||
|
// all-level finish is a draw.
|
||||||
|
return view.game.seats.filter((s) => !s.resigned).every((s) => s.score === myScore) ? t('game.tied') : t('game.won');
|
||||||
}
|
}
|
||||||
|
|
||||||
// The finished-game export offers two formats — the GCG file and the server-rendered PNG
|
// The finished-game export offers two formats — the GCG file and the server-rendered PNG
|
||||||
@@ -1244,8 +1431,9 @@
|
|||||||
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested
|
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested
|
||||||
// opponent still shows it, but disabled).
|
// opponent still shows it, but disabled).
|
||||||
function canAddFriend(s: { accountId: string; seat: number }): boolean {
|
function canAddFriend(s: { accountId: string; seat: number }): boolean {
|
||||||
// Never offer add-friend against an AI opponent, an existing friend, or a blocked player.
|
// Never offer add-friend against an AI opponent, an existing friend, or a blocked player — nor in
|
||||||
if (view?.game.vsAi) return false;
|
// a local (offline) game, whose seats are account-less: vs_ai, and hotseat's synthetic seat ids.
|
||||||
|
if (view?.game.vsAi || isLocalGameId(id)) return false;
|
||||||
return (
|
return (
|
||||||
!!s.accountId &&
|
!!s.accountId &&
|
||||||
!app.profile?.isGuest &&
|
!app.profile?.isGuest &&
|
||||||
@@ -1259,7 +1447,7 @@
|
|||||||
// may still be blocked (the block overrides the friendship), so it omits the friend exclusion.
|
// may still be blocked (the block overrides the friendship), so it omits the friend exclusion.
|
||||||
// An already-blocked opponent hides it (both controls go, and the name is struck).
|
// An already-blocked opponent hides it (both controls go, and the name is struck).
|
||||||
function canBlock(s: { accountId: string; seat: number }): boolean {
|
function canBlock(s: { accountId: string; seat: number }): boolean {
|
||||||
if (view?.game.vsAi) return false;
|
if (view?.game.vsAi || isLocalGameId(id)) return false;
|
||||||
return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
|
return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1317,7 +1505,7 @@
|
|||||||
{#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if}
|
{#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if}
|
||||||
{#each view.game.seats as s (s.seat)}
|
{#each view.game.seats as s (s.seat)}
|
||||||
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
||||||
<div class="nm" class:struck={seatBlocked(s)}>{seatName(s)}</div>
|
<div class="nm" class:struck={seatBlocked(s)}>{#if gameOver && view.game.hotseat}<span class="medal" aria-hidden="true">{seatMedal(view.game, s.seat)}</span>{/if}{seatName(s)}</div>
|
||||||
<div class="sc" class:blockprompt={blockConfirm[s.seat]}>
|
<div class="sc" class:blockprompt={blockConfirm[s.seat]}>
|
||||||
{#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if}
|
{#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -1361,13 +1549,18 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<button class="hicon" onclick={onExportClick} aria-label={t('game.exportGcg')}>📤</button>
|
<button class="hicon" onclick={onExportClick} aria-label={t('game.exportGcg')}>📤</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if view.game.hotseat}
|
||||||
|
<!-- Hotseat resign is a host (referee) action, not self-serve; keep the slot empty. -->
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
||||||
<!-- A finished AI game has no comms at all (no chat, and the dictionary closes with the
|
<!-- The comms entry opens Chat + the word Dictionary. A local game (vs_ai or hotseat) has no
|
||||||
game), so the entry is dropped; an active AI game keeps it (it opens the dictionary). -->
|
chat, so its hub is Dictionary-only (CommsHub), and once finished the dictionary closes too
|
||||||
{#if !(gameOver && view.game.vsAi)}
|
— so drop the entry only when a local game is finished; an active local game keeps it for
|
||||||
|
the Dictionary. An online game always keeps it (chat outlives the game). -->
|
||||||
|
{#if !(gameOver && (view.game.vsAi || view.game.hotseat))}
|
||||||
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
||||||
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
|
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
|
||||||
</button>
|
</button>
|
||||||
@@ -1438,9 +1631,12 @@
|
|||||||
<div class="status">
|
<div class="status">
|
||||||
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
||||||
{#if gameOver}
|
{#if gameOver}
|
||||||
<strong class="over">{resultText()}</strong>
|
<!-- A hotseat game shows its result as per-seat medals on the plaques (below), not a
|
||||||
|
viewer-centric "you won/lost" — with 2-4 local players there is no single "you". A vs_ai
|
||||||
|
game keeps the "you won/lost" text (one human). -->
|
||||||
|
{#if !view.game.hotseat}<strong class="over">{resultText()}</strong>{/if}
|
||||||
{:else if placement.pending.length === 0}
|
{:else if placement.pending.length === 0}
|
||||||
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
<span class="turn-ind">{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="scores">
|
<span class="scores">
|
||||||
{#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
|
{#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
|
||||||
@@ -1454,6 +1650,11 @@
|
|||||||
a finished game shows the final rack greyed out and the controls disabled. -->
|
a finished game shows the final rack greyed out and the controls disabled. -->
|
||||||
<div class="rack-row" class:inert={gameOver} data-coach="game-rack">
|
<div class="rack-row" class:inert={gameOver} data-coach="game-rack">
|
||||||
<div class="rack-wrap">
|
<div class="rack-wrap">
|
||||||
|
{#if locked}
|
||||||
|
<!-- A PIN-locked hotseat seat: the rack is withheld until its owner unlocks it. The board
|
||||||
|
above stays visible; only this tray is gated. -->
|
||||||
|
<button class="unlock" onclick={() => (unlockOpen = true)}>🔒 {t('hotseat.unlock')}</button>
|
||||||
|
{:else}
|
||||||
<Rack
|
<Rack
|
||||||
slots={rackSlots}
|
slots={rackSlots}
|
||||||
{variant}
|
{variant}
|
||||||
@@ -1464,26 +1665,43 @@
|
|||||||
dropIndex={reorderTo}
|
dropIndex={reorderTo}
|
||||||
ondown={onRackDown}
|
ondown={onRackDown}
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
|
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
|
||||||
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
<button class="make" onclick={commit} disabled={busy || !canMove || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet controlButtons()}
|
{#snippet controlButtons()}
|
||||||
<button class="tab" disabled={busy || !isMyTurn || !connection.online} onclick={openExchange}>
|
<button class="tab" disabled={busy || !canMove || !netReady} onclick={openExchange}>
|
||||||
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
|
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
{#if view?.game.hotseat}
|
||||||
|
<!-- Hotseat: no hints. The freed slot becomes the host (referee) button — always available
|
||||||
|
(even on a locked seat: the host may skip a player who forgot their PIN). -->
|
||||||
|
<button class="tab" disabled={busy || gameOver} onclick={() => (hostMenuStep = 'pin')}>
|
||||||
|
<span class="sq">🔐</span><span class="lbl">{t('hotseat.host')}</span>
|
||||||
|
</button>
|
||||||
|
{:else if view?.game.vsAi}
|
||||||
|
<!-- vs_ai hints are unlimited and wallet-free, so no confirm and no count. A 🔒 badge marks the
|
||||||
|
idle gate while it is closed; tapping then only explains when it opens (doHint), and the lock
|
||||||
|
lifts live at the unlock moment. -->
|
||||||
|
<button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={doHint}>
|
||||||
|
<span class="sq" data-coach="game-hints">🛟{#if hintGated}<span class="lock" aria-hidden="true">🔒</span>{/if}</span>
|
||||||
|
<span class="lbl">{t('game.hint')}</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
<TapConfirm
|
<TapConfirm
|
||||||
triggerClass="tab"
|
triggerClass="tab"
|
||||||
label={t('game.hint')}
|
label={t('game.hint')}
|
||||||
disabled={busy || !isMyTurn || !connection.online || hintCount <= 0}
|
disabled={busy || !isMyTurn || !netReady || hintCount <= 0}
|
||||||
onconfirm={doHint}
|
onconfirm={doHint}
|
||||||
>
|
>
|
||||||
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
|
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
|
||||||
<span class="lbl">{t('game.hint')}</span>
|
<span class="lbl">{t('game.hint')}</span>
|
||||||
</TapConfirm>
|
</TapConfirm>
|
||||||
|
{/if}
|
||||||
{#if placement.pending.length > 0}
|
{#if placement.pending.length > 0}
|
||||||
<button class="tab" disabled={busy} onclick={resetPlacement}>
|
<button class="tab" disabled={busy} onclick={resetPlacement}>
|
||||||
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
|
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
|
||||||
@@ -1536,11 +1754,60 @@
|
|||||||
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
|
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
|
||||||
<div class="confirm-row">
|
<div class="confirm-row">
|
||||||
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
|
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
|
||||||
<button class="danger" onclick={doResign} disabled={!connection.online}>{t('game.dropGame')}</button>
|
<button class="danger" onclick={doResign} disabled={!netReady}>{t('game.dropGame')}</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Offline hotseat: unlock the current seat's rack. -->
|
||||||
|
{#if unlockOpen && view}
|
||||||
|
<PinPad
|
||||||
|
mode="verify"
|
||||||
|
title={t('hotseat.unlockTitle', { name: hotseatName(view.game.toMove) })}
|
||||||
|
verify={onUnlock}
|
||||||
|
onclose={() => (unlockOpen = false)}
|
||||||
|
onresult={() => (unlockOpen = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Offline hotseat: the host (referee) enters the master PIN, then picks an override. -->
|
||||||
|
{#if hostMenuStep === 'pin'}
|
||||||
|
<PinPad
|
||||||
|
mode="verify"
|
||||||
|
title={t('hotseat.host')}
|
||||||
|
verify={verifyHost}
|
||||||
|
onclose={() => (hostMenuStep = 'closed')}
|
||||||
|
onresult={() => (hostMenuStep = 'menu')}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if hostMenuStep === 'menu' && view}
|
||||||
|
<Modal title={t('hotseat.host')} onclose={() => { hostMenuStep = 'closed'; hostConfirm = null; }}>
|
||||||
|
{#if hostConfirm}
|
||||||
|
<p class="host-q">{hostConfirmText(hostConfirm)}</p>
|
||||||
|
<div class="confirm-row">
|
||||||
|
<button class="cancel" onclick={() => (hostConfirm = null)}>{t('common.cancel')}</button>
|
||||||
|
<button class="danger" onclick={runHostAction}>{t('common.ok')}</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="host-menu">
|
||||||
|
<button class="host-act" onclick={startSkip}>
|
||||||
|
{t('hotseat.skip')}
|
||||||
|
</button>
|
||||||
|
<div class="host-sub">{t('hotseat.exclude')}</div>
|
||||||
|
{#each view.game.seats as s (s.seat)}
|
||||||
|
<button class="host-seat" onclick={() => (hostConfirm = { action: 'resign', seat: s.seat, name: s.displayName })}>
|
||||||
|
{s.displayName}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<button class="host-act danger" onclick={() => (hostConfirm = { action: 'terminate' })}>{t('hotseat.terminate')}</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
{#if hostDone}
|
||||||
|
<div class="host-done" aria-hidden="true">✅</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if exportOpen}
|
{#if exportOpen}
|
||||||
<Modal title={t('game.exportGcg')} onclose={() => (exportOpen = false)}>
|
<Modal title={t('game.exportGcg')} onclose={() => (exportOpen = false)}>
|
||||||
<div class="export-opts">
|
<div class="export-opts">
|
||||||
@@ -1548,7 +1815,7 @@
|
|||||||
<button
|
<button
|
||||||
class="confirm"
|
class="confirm"
|
||||||
onclick={() => { exportOpen = false; void exportArtifact('png'); }}
|
onclick={() => { exportOpen = false; void exportArtifact('png'); }}
|
||||||
disabled={!connection.online}
|
disabled={!netReady}
|
||||||
>
|
>
|
||||||
{t('game.exportImageOpt')}
|
{t('game.exportImageOpt')}
|
||||||
</button>
|
</button>
|
||||||
@@ -1556,7 +1823,7 @@
|
|||||||
<button
|
<button
|
||||||
class={exportImageAvailable ? 'export-alt' : 'confirm'}
|
class={exportImageAvailable ? 'export-alt' : 'confirm'}
|
||||||
onclick={() => { exportOpen = false; void exportArtifact('gcg'); }}
|
onclick={() => { exportOpen = false; void exportArtifact('gcg'); }}
|
||||||
disabled={!connection.online}
|
disabled={!netReady}
|
||||||
>
|
>
|
||||||
{t('game.exportGcgOpt')}
|
{t('game.exportGcgOpt')}
|
||||||
</button>
|
</button>
|
||||||
@@ -1610,6 +1877,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
/* The finished-game place medal on a local (offline) seat plaque, left of the name. */
|
||||||
|
.medal {
|
||||||
|
margin-right: 3px;
|
||||||
|
}
|
||||||
.sc {
|
.sc {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
@@ -2031,4 +2302,76 @@
|
|||||||
.scoreboard.flat {
|
.scoreboard.flat {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
/* The vs_ai hint's idle lock: a small 🔒 at the icon-square corner (the .sq is positioned by the
|
||||||
|
shared TabBar rule). No count pill — the hint is unlimited; the lock alone marks the gate. */
|
||||||
|
.sq .lock {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
/* --- offline hotseat --- */
|
||||||
|
/* The locked-seat rack overlay: fills the rack tray with an "unlock" button, the board above
|
||||||
|
stays visible. */
|
||||||
|
.unlock {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.host-menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.host-act,
|
||||||
|
.host-seat {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.host-act.danger {
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.host-sub {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.host-q {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
/* The transient success tick after a host override — a centred ✅ that fades out on a timer. */
|
||||||
|
.host-done {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 4rem;
|
||||||
|
z-index: 50;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: host-done-fade 1.1s ease forwards;
|
||||||
|
}
|
||||||
|
@keyframes host-done-fade {
|
||||||
|
0% { opacity: 0; transform: scale(0.7); }
|
||||||
|
25% { opacity: 1; transform: scale(1); }
|
||||||
|
75% { opacity: 1; transform: scale(1); }
|
||||||
|
100% { opacity: 0; transform: scale(1); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export { ChatPostRequest } from './scrabblefb/chat-post-request.js';
|
|||||||
export { CheckWordRequest } from './scrabblefb/check-word-request.js';
|
export { CheckWordRequest } from './scrabblefb/check-word-request.js';
|
||||||
export { ComplaintRequest } from './scrabblefb/complaint-request.js';
|
export { ComplaintRequest } from './scrabblefb/complaint-request.js';
|
||||||
export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js';
|
export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js';
|
||||||
|
export { DictVersion } from './scrabblefb/dict-version.js';
|
||||||
export { DraftRequest } from './scrabblefb/draft-request.js';
|
export { DraftRequest } from './scrabblefb/draft-request.js';
|
||||||
export { DraftView } from './scrabblefb/draft-view.js';
|
export { DraftView } from './scrabblefb/draft-view.js';
|
||||||
export { EmailConfirmLinkRequest } from './scrabblefb/email-confirm-link-request.js';
|
export { EmailConfirmLinkRequest } from './scrabblefb/email-confirm-link-request.js';
|
||||||
|
|||||||
@@ -37,8 +37,50 @@ messagesLength():number {
|
|||||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
overrideBg():string|null
|
||||||
|
overrideBg(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
overrideBg(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
overrideFg():string|null
|
||||||
|
overrideFg(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
overrideFg(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
overrideLink():string|null
|
||||||
|
overrideLink(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
overrideLink(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 12);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
overrideBgDark():string|null
|
||||||
|
overrideBgDark(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
overrideBgDark(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 14);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
overrideFgDark():string|null
|
||||||
|
overrideFgDark(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
overrideFgDark(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 16);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
overrideLinkDark():string|null
|
||||||
|
overrideLinkDark(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
overrideLinkDark(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 18);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
static startBannerCampaign(builder:flatbuffers.Builder) {
|
static startBannerCampaign(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(2);
|
builder.startObject(8);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addWeight(builder:flatbuffers.Builder, weight:number) {
|
static addWeight(builder:flatbuffers.Builder, weight:number) {
|
||||||
@@ -61,15 +103,45 @@ static startMessagesVector(builder:flatbuffers.Builder, numElems:number) {
|
|||||||
builder.startVector(4, numElems, 4);
|
builder.startVector(4, numElems, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addOverrideBg(builder:flatbuffers.Builder, overrideBgOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(2, overrideBgOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addOverrideFg(builder:flatbuffers.Builder, overrideFgOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(3, overrideFgOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addOverrideLink(builder:flatbuffers.Builder, overrideLinkOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(4, overrideLinkOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addOverrideBgDark(builder:flatbuffers.Builder, overrideBgDarkOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(5, overrideBgDarkOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addOverrideFgDark(builder:flatbuffers.Builder, overrideFgDarkOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(6, overrideFgDarkOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addOverrideLinkDark(builder:flatbuffers.Builder, overrideLinkDarkOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(7, overrideLinkDarkOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endBannerCampaign(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endBannerCampaign(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createBannerCampaign(builder:flatbuffers.Builder, weight:number, messagesOffset:flatbuffers.Offset):flatbuffers.Offset {
|
static createBannerCampaign(builder:flatbuffers.Builder, weight:number, messagesOffset:flatbuffers.Offset, overrideBgOffset:flatbuffers.Offset, overrideFgOffset:flatbuffers.Offset, overrideLinkOffset:flatbuffers.Offset, overrideBgDarkOffset:flatbuffers.Offset, overrideFgDarkOffset:flatbuffers.Offset, overrideLinkDarkOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
BannerCampaign.startBannerCampaign(builder);
|
BannerCampaign.startBannerCampaign(builder);
|
||||||
BannerCampaign.addWeight(builder, weight);
|
BannerCampaign.addWeight(builder, weight);
|
||||||
BannerCampaign.addMessages(builder, messagesOffset);
|
BannerCampaign.addMessages(builder, messagesOffset);
|
||||||
|
BannerCampaign.addOverrideBg(builder, overrideBgOffset);
|
||||||
|
BannerCampaign.addOverrideFg(builder, overrideFgOffset);
|
||||||
|
BannerCampaign.addOverrideLink(builder, overrideLinkOffset);
|
||||||
|
BannerCampaign.addOverrideBgDark(builder, overrideBgDarkOffset);
|
||||||
|
BannerCampaign.addOverrideFgDark(builder, overrideFgDarkOffset);
|
||||||
|
BannerCampaign.addOverrideLinkDark(builder, overrideLinkDarkOffset);
|
||||||
return BannerCampaign.endBannerCampaign(builder);
|
return BannerCampaign.endBannerCampaign(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// automatically generated by the FlatBuffers compiler, do not modify
|
||||||
|
|
||||||
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
|
export class DictVersion {
|
||||||
|
bb: flatbuffers.ByteBuffer|null = null;
|
||||||
|
bb_pos = 0;
|
||||||
|
__init(i:number, bb:flatbuffers.ByteBuffer):DictVersion {
|
||||||
|
this.bb_pos = i;
|
||||||
|
this.bb = bb;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRootAsDictVersion(bb:flatbuffers.ByteBuffer, obj?:DictVersion):DictVersion {
|
||||||
|
return (obj || new DictVersion()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSizePrefixedRootAsDictVersion(bb:flatbuffers.ByteBuffer, obj?:DictVersion):DictVersion {
|
||||||
|
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||||
|
return (obj || new DictVersion()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
variant():string|null
|
||||||
|
variant(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
variant(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
version():string|null
|
||||||
|
version(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
version(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static startDictVersion(builder:flatbuffers.Builder) {
|
||||||
|
builder.startObject(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(0, variantOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addVersion(builder:flatbuffers.Builder, versionOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(1, versionOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static endDictVersion(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
|
const offset = builder.endObject();
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
static createDictVersion(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset, versionOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
|
DictVersion.startDictVersion(builder);
|
||||||
|
DictVersion.addVariant(builder, variantOffset);
|
||||||
|
DictVersion.addVersion(builder, versionOffset);
|
||||||
|
return DictVersion.endDictVersion(builder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,8 +41,13 @@ 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(3);
|
builder.startObject(4);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
|
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
|
||||||
@@ -57,16 +62,21 @@ 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):flatbuffers.Offset {
|
static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, languageOffset:flatbuffers.Offset, pwa:boolean):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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import * as flatbuffers from 'flatbuffers';
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
import { BannerInfo } from '../scrabblefb/banner-info.js';
|
import { BannerInfo } from '../scrabblefb/banner-info.js';
|
||||||
|
import { DictVersion } from '../scrabblefb/dict-version.js';
|
||||||
|
|
||||||
|
|
||||||
export class Profile {
|
export class Profile {
|
||||||
@@ -124,8 +125,18 @@ vkLinked():boolean {
|
|||||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dictVersions(index: number, obj?:DictVersion):DictVersion|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 36);
|
||||||
|
return offset ? (obj || new DictVersion()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dictVersionsLength():number {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 36);
|
||||||
|
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
static startProfile(builder:flatbuffers.Builder) {
|
static startProfile(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(16);
|
builder.startObject(17);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
|
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
|
||||||
@@ -204,6 +215,22 @@ static addVkLinked(builder:flatbuffers.Builder, vkLinked:boolean) {
|
|||||||
builder.addFieldInt8(15, +vkLinked, +false);
|
builder.addFieldInt8(15, +vkLinked, +false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addDictVersions(builder:flatbuffers.Builder, dictVersionsOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(16, dictVersionsOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static createDictVersionsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||||
|
builder.startVector(4, data.length, 4);
|
||||||
|
for (let i = data.length - 1; i >= 0; i--) {
|
||||||
|
builder.addOffset(data[i]!);
|
||||||
|
}
|
||||||
|
return builder.endVector();
|
||||||
|
}
|
||||||
|
|
||||||
|
static startDictVersionsVector(builder:flatbuffers.Builder, numElems:number) {
|
||||||
|
builder.startVector(4, numElems, 4);
|
||||||
|
}
|
||||||
|
|
||||||
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
|
|||||||
@@ -74,8 +74,13 @@ walletBalance():number {
|
|||||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hintUnlockLeftSeconds():number {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 18);
|
||||||
|
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
static startStateView(builder:flatbuffers.Builder) {
|
static startStateView(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(7);
|
builder.startObject(8);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
|
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
|
||||||
@@ -130,12 +135,16 @@ static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) {
|
|||||||
builder.addFieldInt32(6, walletBalance, 0);
|
builder.addFieldInt32(6, walletBalance, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addHintUnlockLeftSeconds(builder:flatbuffers.Builder, hintUnlockLeftSeconds:number) {
|
||||||
|
builder.addFieldInt32(7, hintUnlockLeftSeconds, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset, walletBalance:number):flatbuffers.Offset {
|
static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset, walletBalance:number, hintUnlockLeftSeconds:number):flatbuffers.Offset {
|
||||||
StateView.startStateView(builder);
|
StateView.startStateView(builder);
|
||||||
StateView.addGame(builder, gameOffset);
|
StateView.addGame(builder, gameOffset);
|
||||||
StateView.addSeat(builder, seat);
|
StateView.addSeat(builder, seat);
|
||||||
@@ -144,6 +153,7 @@ static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offse
|
|||||||
StateView.addHintsRemaining(builder, hintsRemaining);
|
StateView.addHintsRemaining(builder, hintsRemaining);
|
||||||
StateView.addAlphabet(builder, alphabetOffset);
|
StateView.addAlphabet(builder, alphabetOffset);
|
||||||
StateView.addWalletBalance(builder, walletBalance);
|
StateView.addWalletBalance(builder, walletBalance);
|
||||||
|
StateView.addHintUnlockLeftSeconds(builder, hintUnlockLeftSeconds);
|
||||||
return StateView.endStateView(builder);
|
return StateView.endStateView(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+199
-17
@@ -35,12 +35,17 @@ 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';
|
||||||
import {
|
import {
|
||||||
clearOnboarding,
|
clearOnboarding,
|
||||||
clearSession,
|
clearSession,
|
||||||
|
clearProfile,
|
||||||
|
loadProfile,
|
||||||
|
saveProfile,
|
||||||
loadOnboarding,
|
loadOnboarding,
|
||||||
loadPrefs,
|
loadPrefs,
|
||||||
loadSession,
|
loadSession,
|
||||||
@@ -50,7 +55,9 @@ import {
|
|||||||
savePrefs,
|
savePrefs,
|
||||||
} from './session';
|
} from './session';
|
||||||
import type { CoachSeries } from './coachmark';
|
import type { CoachSeries } from './coachmark';
|
||||||
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
import { connection, reportOffline, reportOnline, resetConnection, checkReachable } from './connection.svelte';
|
||||||
|
import { offlineMode, setOfflineMode } from './offline.svelte';
|
||||||
|
import { shouldBootOffline } from './offline';
|
||||||
import { isConnectionCode } from './retry';
|
import { isConnectionCode } from './retry';
|
||||||
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
|
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
|
||||||
import { advanceCached } from './gamedelta';
|
import { advanceCached } from './gamedelta';
|
||||||
@@ -71,6 +78,10 @@ export const app = $state<{
|
|||||||
* backend was down during a deploy). App.svelte then renders the boot-error retry screen
|
* backend was down during a deploy). App.svelte then renders the boot-error retry screen
|
||||||
* instead of the web login — a Mini App has no manual sign-in to fall back to. */
|
* instead of the web login — a Mini App has no manual sign-in to fall back to. */
|
||||||
bootError: boolean;
|
bootError: boolean;
|
||||||
|
/** True while the cold-start "no connection — go offline?" dialog is up (the reachability check
|
||||||
|
* timed out with the network interface reportedly online). App.svelte renders it over the splash;
|
||||||
|
* bootstrap awaits the choice via resolveOfflinePrompt. */
|
||||||
|
offlinePrompt: boolean;
|
||||||
/** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App
|
/** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App
|
||||||
* launch carried no sign-in data (empty initData). App.svelte then renders the compact
|
* launch carried no sign-in data (empty initData). App.svelte then renders the compact
|
||||||
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
|
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
|
||||||
@@ -141,6 +152,7 @@ export const app = $state<{
|
|||||||
}>({
|
}>({
|
||||||
ready: false,
|
ready: false,
|
||||||
bootError: false,
|
bootError: false,
|
||||||
|
offlinePrompt: false,
|
||||||
launchError: null,
|
launchError: null,
|
||||||
debugOpen: false,
|
debugOpen: false,
|
||||||
lobbyReady: false,
|
lobbyReady: false,
|
||||||
@@ -214,9 +226,9 @@ function goForeground(): void {
|
|||||||
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
|
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
|
||||||
app.toast = { kind, text, seq: ++toastSeq };
|
app.toast = { kind, text, seq: ++toastSeq };
|
||||||
if (toastTimer) clearTimeout(toastTimer);
|
if (toastTimer) clearTimeout(toastTimer);
|
||||||
// An info bubble lives exactly as long as its rise-and-fade animation (2s); an error
|
// An info bubble lives exactly as long as its appear + ~1s hold + rise-and-fade animation (3s);
|
||||||
// dwells longer (4s) so it is not missed.
|
// an error dwells longer (4s) so it is not missed.
|
||||||
toastTimer = setTimeout(() => (app.toast = null), kind === 'error' ? 4000 : 2000);
|
toastTimer = setTimeout(() => (app.toast = null), kind === 'error' ? 4000 : 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** dismissToast hides the current toast at once — a tap on the bubble dismisses it. */
|
/** dismissToast hides the current toast at once — a tap on the bubble dismisses it. */
|
||||||
@@ -292,7 +304,9 @@ export function handleError(err: unknown): void {
|
|||||||
void enterBlocked();
|
void enterBlocked();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isConnectionCode(code) || !connection.online) return;
|
// A blocked call in offline mode ('offline', the transport kill switch) is expected, not an error
|
||||||
|
// to surface — the offline chrome already signals the state; never a red toast.
|
||||||
|
if (isConnectionCode(code) || code === 'offline' || !connection.online) return;
|
||||||
haptic('error');
|
haptic('error');
|
||||||
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
|
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
|
||||||
}
|
}
|
||||||
@@ -458,6 +472,7 @@ export async function refreshProfile(): Promise<void> {
|
|||||||
if (!app.session) return;
|
if (!app.session) return;
|
||||||
try {
|
try {
|
||||||
app.profile = await gateway.profileGet();
|
app.profile = await gateway.profileGet();
|
||||||
|
void saveProfile($state.snapshot(app.profile)); // keep the offline-boot cache fresh (plain snapshot)
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort; the banner just stays as it was until the next fetch.
|
// Best-effort; the banner just stays as it was until the next fetch.
|
||||||
}
|
}
|
||||||
@@ -515,6 +530,11 @@ async function adoptSession(s: Session): Promise<void> {
|
|||||||
await saveSession(s);
|
await saveSession(s);
|
||||||
try {
|
try {
|
||||||
app.profile = await gateway.profileGet();
|
app.profile = await gateway.profileGet();
|
||||||
|
// Persist the profile so a later offline cold start can launch from it (its variant
|
||||||
|
// preferences, dictionary versions and display name) without a network fetch. Snapshot to a
|
||||||
|
// plain object first — the reactive $state proxy is not structured-cloneable, so it would fail
|
||||||
|
// the IndexedDB write and fall back to a localStorage entry that loadProfile never reads.
|
||||||
|
void saveProfile($state.snapshot(app.profile));
|
||||||
// The live interface language follows the device — the explicit local choice (saved in
|
// The live interface language follows the device — the explicit local choice (saved in
|
||||||
// prefs) or the system guess made at bootstrap — and is no longer overridden from the
|
// prefs) or the system guess made at bootstrap — and is no longer overridden from the
|
||||||
// account here: the Telegram bot a user signs in through must not dictate the UI, so a
|
// account here: the Telegram bot a user signs in through must not dictate the UI, so a
|
||||||
@@ -538,6 +558,74 @@ async function adoptSession(s: Session): Promise<void> {
|
|||||||
void refreshNotifications();
|
void refreshNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The cold-start "no connection — go offline?" dialog. bootstrap raises it and awaits the choice
|
||||||
|
// here; App.svelte's dialog buttons call resolveOfflinePrompt.
|
||||||
|
const BOOT_REACHABILITY_TIMEOUT_MS = 3000;
|
||||||
|
let offlinePromptResolve: ((goOffline: boolean) => void) | null = null;
|
||||||
|
|
||||||
|
/** resolveOfflinePrompt answers the cold-start "no connection — go offline?" dialog (App.svelte). */
|
||||||
|
export function resolveOfflinePrompt(goOffline: boolean): void {
|
||||||
|
app.offlinePrompt = false;
|
||||||
|
const resolve = offlinePromptResolve;
|
||||||
|
offlinePromptResolve = null;
|
||||||
|
resolve?.(goOffline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** promptOfflineChoice raises the dialog and resolves to the player's choice (true = go offline). */
|
||||||
|
function promptOfflineChoice(): Promise<boolean> {
|
||||||
|
app.offlinePrompt = true;
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
offlinePromptResolve = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* initNetworkReactivity reacts to mid-session network changes (e.g. the player toggling flight mode):
|
||||||
|
* losing the network interface enters offline mode automatically (session-only); regaining it
|
||||||
|
* verifies the gateway is really reachable (an interface being up does not guarantee it) and returns
|
||||||
|
* to online — but only if the offline was auto. A deliberate offline (the Settings toggle or the
|
||||||
|
* cold-start dialog) is left as the player's choice. These are passive OS events — no polling, no
|
||||||
|
* battery cost. Web-only and skipped in the mock (the e2e drives connectivity directly).
|
||||||
|
*/
|
||||||
|
// While in auto-offline, poll for the network to return so the app comes back online — robust to the
|
||||||
|
// unreliable `online` event (which often does not fire in an installed PWA). Each tick is cheap: it
|
||||||
|
// reads navigator.onLine (no radio) and only hits the network (a reachability check) when the
|
||||||
|
// interface is actually up, so while flight mode is on it costs nothing. The poll runs ONLY in
|
||||||
|
// auto-offline (a deliberate offline is the player's choice) and stops on returning online.
|
||||||
|
let recoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
export function scheduleRecovery(delayMs: number): void {
|
||||||
|
if (recoveryTimer) {
|
||||||
|
clearTimeout(recoveryTimer);
|
||||||
|
recoveryTimer = null;
|
||||||
|
}
|
||||||
|
if (typeof window === 'undefined' || !offlineMode.active || !offlineMode.auto) return;
|
||||||
|
recoveryTimer = setTimeout(async () => {
|
||||||
|
recoveryTimer = null;
|
||||||
|
if (!offlineMode.active || !offlineMode.auto) return;
|
||||||
|
const interfaceUp = typeof navigator === 'undefined' || navigator.onLine;
|
||||||
|
if (interfaceUp && (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS))) {
|
||||||
|
if (offlineMode.active && offlineMode.auto) setOfflineMode(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleRecovery(4000);
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initNetworkReactivity(): void {
|
||||||
|
if (typeof window === 'undefined' || import.meta.env.MODE === 'mock') return;
|
||||||
|
window.addEventListener('offline', () => {
|
||||||
|
if (!offlineMode.active) {
|
||||||
|
setOfflineMode(true, false); // auto (session)
|
||||||
|
scheduleRecovery(4000); // poll for the network to return
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// The online event, when it fires, just kicks an immediate reachability check; the poll above is
|
||||||
|
// the reliable fallback for platforms where it does not fire.
|
||||||
|
window.addEventListener('online', () => {
|
||||||
|
if (offlineMode.active && offlineMode.auto) scheduleRecovery(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* applyLinkResult applies a completed account link or merge: it adopts a
|
* applyLinkResult applies a completed account link or merge: it adopts a
|
||||||
* switched session (a guest initiator whose durable counterpart won, so the active
|
* switched session (a guest initiator whose durable counterpart won, so the active
|
||||||
@@ -637,17 +725,37 @@ function syncTelegramSafeArea(): void {
|
|||||||
root.classList.toggle('tg-fullscreen', top > 0);
|
root.classList.toggle('tg-fullscreen', top > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The soft-keyboard height threshold (px) that distinguishes an open keyboard from browser chrome
|
||||||
|
// (an address bar shrinks the visual viewport by far less), used to gate the focus-into-view scroll.
|
||||||
|
const KEYBOARD_MIN_PX = 120;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* syncViewportHeight mirrors the visual-viewport height into the --vvh CSS var so a screen can
|
* syncViewport mirrors the visual viewport into CSS vars so the pinned app-shell (app.css
|
||||||
* fit the visible area above an open soft keyboard (iOS does not shrink dvh for the keyboard).
|
* `html.app-shell body`) both FITS (`--vvh`) and FOLLOWS (`--vv-top`) the visible area above the soft
|
||||||
* On a screen whose input sits at the bottom (chat, word-check) this keeps the input visible
|
* keyboard. iOS Safari / WKWebView does not shrink the layout viewport for the keyboard (Android
|
||||||
* without the page scrolling, so the layout no longer jumps when the keyboard appears.
|
* Chrome does); instead the visual viewport shrinks AND offsets down to reveal the focused field, and
|
||||||
|
* those values do not always cleanly revert. Tracking only the height left the top-anchored shell
|
||||||
|
* misaligned on iOS — empty space below the content, worst on a repeat focus; tracking offsetTop too
|
||||||
|
* keeps the shell on the visible area on every platform. On the keyboard-OPEN transition it also
|
||||||
|
* scrolls the focused field into view, since iOS does not reliably scroll a pinned document to it.
|
||||||
*/
|
*/
|
||||||
function syncViewportHeight(): void {
|
let keyboardOpen = false;
|
||||||
|
function syncViewport(): void {
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
const win = typeof window !== 'undefined' ? window : null;
|
||||||
const h = vv ? vv.height : typeof window !== 'undefined' ? window.innerHeight : 0;
|
const vv = win ? win.visualViewport : null;
|
||||||
|
const h = vv ? vv.height : win ? win.innerHeight : 0;
|
||||||
|
const top = vv ? vv.offsetTop : 0;
|
||||||
if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`);
|
if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`);
|
||||||
|
document.documentElement.style.setProperty('--vv-top', `${top}px`);
|
||||||
|
const open = !!vv && !!win && win.innerHeight - h > KEYBOARD_MIN_PX;
|
||||||
|
if (open && !keyboardOpen) {
|
||||||
|
const el = document.activeElement;
|
||||||
|
if (el instanceof HTMLElement && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) {
|
||||||
|
requestAnimationFrame(() => el.scrollIntoView({ block: 'center' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keyboardOpen = open;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -707,11 +815,23 @@ export async function bootstrap(): Promise<void> {
|
|||||||
setLocale(guess);
|
setLocale(guess);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track the visual-viewport height so screens fit above an open soft keyboard (--vvh).
|
// Track the visual viewport (height + offset) so screens fit and follow the visible area above an
|
||||||
syncViewportHeight();
|
// open soft keyboard (--vvh / --vv-top). Both resize AND scroll fire on iOS when the keyboard moves
|
||||||
|
// the visual viewport.
|
||||||
|
syncViewport();
|
||||||
if (typeof window !== 'undefined' && window.visualViewport) {
|
if (typeof window !== 'undefined' && window.visualViewport) {
|
||||||
window.visualViewport.addEventListener('resize', syncViewportHeight);
|
window.visualViewport.addEventListener('resize', syncViewport);
|
||||||
window.visualViewport.addEventListener('scroll', syncViewportHeight);
|
window.visualViewport.addEventListener('scroll', syncViewport);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter on a single-line <input> dismisses the soft keyboard (blur): the keyboard's default
|
||||||
|
// return/"Go" key otherwise does nothing on a bare input, which reads as stuck. A <textarea> (the
|
||||||
|
// feedback form) is excluded so Enter still inserts a newline; a form's own Enter→submit still
|
||||||
|
// fires first (this listener is on the document, so it runs after the field's own handler).
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && e.target instanceof HTMLInputElement) e.target.blur();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no
|
// Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no
|
||||||
@@ -783,11 +903,65 @@ 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();
|
||||||
|
// React to mid-session network changes (e.g. flight mode) for the rest of the session.
|
||||||
|
initNetworkReactivity();
|
||||||
|
|
||||||
|
// Deliberate offline mode carried over from a prior session: with no network the session adoption
|
||||||
|
// + profile fetch below would hang the splash, so skip them and launch straight from the cached
|
||||||
|
// session + profile into the offline lobby. Without a cached profile (e.g. storage cleared) fall
|
||||||
|
// through to the normal online flow but KEEP the sticky flag — the mode is the player's choice, and
|
||||||
|
// an online boot re-persists the profile so the next launch goes offline. (A truly offline launch
|
||||||
|
// with no cached profile is unreachable: enabling offline requires a prior online session.)
|
||||||
|
if (offlineMode.active) {
|
||||||
|
const [offlineSession, offlineProfile] = await Promise.all([loadSession(), loadProfile()]);
|
||||||
|
if (shouldBootOffline({ offlineActive: true, hasSession: !!offlineSession, hasProfile: !!offlineProfile })) {
|
||||||
|
gateway.setToken(offlineSession!.token);
|
||||||
|
app.session = offlineSession!;
|
||||||
|
app.profile = offlineProfile!;
|
||||||
|
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
|
||||||
|
app.ready = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const saved = await loadSession();
|
const saved = await loadSession();
|
||||||
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
|
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
|
||||||
// from VK; capture and clear it here (it needs the restored session to link against).
|
// from VK; capture and clear it here (it needs the restored session to link against).
|
||||||
const vkcb = pendingVKLink();
|
const vkcb = pendingVKLink();
|
||||||
if (saved) {
|
if (saved) {
|
||||||
|
// Cold-start network auto-detect (deliberate offline is handled above). With a cached profile to
|
||||||
|
// fall back on and no pending VK-link, do not hang on adoptSession's retrying profile fetch when
|
||||||
|
// the network is down: a device with no network interface goes offline for the session (no
|
||||||
|
// dialog); an interface that is up but cannot reach the gateway within a short window asks the
|
||||||
|
// player. Web-only reaches here (the Mini-App branches returned above), so isStandalone gates it.
|
||||||
|
const cachedProfile = await loadProfile();
|
||||||
|
const canOffline = !!cachedProfile && !vkcb && isStandalone() && !!cachedProfile.email;
|
||||||
|
if (canOffline) {
|
||||||
|
const interfaceOffline = typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||||
|
gateway.setToken(saved.token);
|
||||||
|
const reachable = interfaceOffline ? false : await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS);
|
||||||
|
if (!reachable) {
|
||||||
|
// No network interface → go offline for the session (no dialog); interface up but the
|
||||||
|
// gateway is unreachable → ambiguous, so ask.
|
||||||
|
const goOffline = interfaceOffline ? true : await promptOfflineChoice();
|
||||||
|
if (goOffline) {
|
||||||
|
// A deliberate dialog choice is sticky; an auto (no-interface) offline is session-only.
|
||||||
|
setOfflineMode(true, !interfaceOffline);
|
||||||
|
if (interfaceOffline) scheduleRecovery(4000); // auto-offline: poll for the network to return
|
||||||
|
app.session = saved;
|
||||||
|
app.profile = cachedProfile;
|
||||||
|
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
|
||||||
|
app.ready = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// "Нет" — keep trying online: fall through to adoptSession (it retries; the connection
|
||||||
|
// watcher shows "Connecting…").
|
||||||
|
}
|
||||||
|
}
|
||||||
await adoptSession(saved);
|
await adoptSession(saved);
|
||||||
if (vkcb) {
|
if (vkcb) {
|
||||||
// The full-page redirect lost the in-app route, so hand the callback to Profile, which
|
// The full-page redirect lost the in-app route, so hand the callback to Profile, which
|
||||||
@@ -974,7 +1148,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);
|
await gateway.authEmailRequest(email, app.locale, isStandalone());
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err);
|
handleError(err);
|
||||||
@@ -1016,6 +1190,7 @@ export async function logout(): Promise<void> {
|
|||||||
app.splashDone = false;
|
app.splashDone = false;
|
||||||
gateway.setToken(null);
|
gateway.setToken(null);
|
||||||
await clearSession();
|
await clearSession();
|
||||||
|
await clearProfile();
|
||||||
app.session = null;
|
app.session = null;
|
||||||
app.profile = null;
|
app.profile = null;
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
@@ -1167,4 +1342,11 @@ if (import.meta.env.MODE === 'mock' && typeof window !== 'undefined') {
|
|||||||
closeStream();
|
closeStream();
|
||||||
app.blocked = { blocked: true, permanent: true, until: '', reason: '', ...b };
|
app.blocked = { blocked: true, permanent: true, until: '', reason: '', ...b };
|
||||||
};
|
};
|
||||||
|
// Expose the router so the e2e can assert navigate() updates the route synchronously (no
|
||||||
|
// hashchange lag): a route that trailed the hash for a frame let bootstrap's `app.ready` flip
|
||||||
|
// render the previous route under the new screen (the empty-hash lobby flashing under login).
|
||||||
|
(window as unknown as { __router?: { navigate(p: string): void; route(): string } }).__router = {
|
||||||
|
navigate,
|
||||||
|
route: () => router.route.name,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ describe('createScheduler', () => {
|
|||||||
{ weight: 7, messages: ['b'] },
|
{ weight: 7, messages: ['b'] },
|
||||||
]);
|
]);
|
||||||
expect(sched.total).toBe(2);
|
expect(sched.total).toBe(2);
|
||||||
const cycle = Array.from({ length: 10 }, () => sched.next());
|
const cycle = Array.from({ length: 10 }, () => sched.next().md);
|
||||||
const a = cycle.filter((m) => m === 'a').length;
|
const a = cycle.filter((m) => m === 'a').length;
|
||||||
const b = cycle.filter((m) => m === 'b').length;
|
const b = cycle.filter((m) => m === 'b').length;
|
||||||
expect(a).toBe(3);
|
expect(a).toBe(3);
|
||||||
@@ -42,7 +42,7 @@ describe('createScheduler', () => {
|
|||||||
it('round-robins messages within a campaign', () => {
|
it('round-robins messages within a campaign', () => {
|
||||||
const sched = createScheduler([{ weight: 1, messages: ['m0', 'm1', 'm2'] }]);
|
const sched = createScheduler([{ weight: 1, messages: ['m0', 'm1', 'm2'] }]);
|
||||||
expect(sched.total).toBe(3);
|
expect(sched.total).toBe(3);
|
||||||
expect([sched.next(), sched.next(), sched.next(), sched.next()]).toEqual(['m0', 'm1', 'm2', 'm0']);
|
expect([sched.next().md, sched.next().md, sched.next().md, sched.next().md]).toEqual(['m0', 'm1', 'm2', 'm0']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops empty and non-positive-weight campaigns', () => {
|
it('drops empty and non-positive-weight campaigns', () => {
|
||||||
@@ -52,13 +52,27 @@ describe('createScheduler', () => {
|
|||||||
{ weight: 2, messages: ['keep'] },
|
{ weight: 2, messages: ['keep'] },
|
||||||
]);
|
]);
|
||||||
expect(sched.total).toBe(1);
|
expect(sched.total).toBe(1);
|
||||||
expect(sched.next()).toBe('keep');
|
expect(sched.next().md).toBe('keep');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is empty for no campaigns', () => {
|
it('is empty for no campaigns', () => {
|
||||||
const sched = createScheduler([]);
|
const sched = createScheduler([]);
|
||||||
expect(sched.total).toBe(0);
|
expect(sched.total).toBe(0);
|
||||||
expect(sched.next()).toBe('');
|
expect(sched.next().md).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries each campaign colour override with its message', () => {
|
||||||
|
const red = { bg: '#aa0000', fg: '#ffffff', link: '#ffdd00' };
|
||||||
|
const darkRed = { bg: '#330000', fg: '#eeeeee', link: '#ffcc00' };
|
||||||
|
const sched = createScheduler([
|
||||||
|
{ weight: 1, messages: ['plain'] },
|
||||||
|
{ weight: 1, messages: ['branded'], overrideAll: red, overrideDark: darkRed },
|
||||||
|
]);
|
||||||
|
const two = [sched.next(), sched.next()];
|
||||||
|
const branded = two.find((it) => it.md === 'branded');
|
||||||
|
const plain = two.find((it) => it.md === 'plain');
|
||||||
|
expect(branded?.colors).toEqual({ all: red, dark: darkRed });
|
||||||
|
expect(plain?.colors).toEqual({ all: null, dark: null });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+21
-12
@@ -4,7 +4,7 @@
|
|||||||
// unit-testable with fake timers. The campaigns + timings come from the profile's
|
// unit-testable with fake timers. The campaigns + timings come from the profile's
|
||||||
// banner block (see lib/model, ARCHITECTURE §10); the host is AdBanner.svelte.
|
// banner block (see lib/model, ARCHITECTURE §10); the host is AdBanner.svelte.
|
||||||
|
|
||||||
import type { BannerCampaign, BannerTimings } from './model';
|
import type { BannerCampaign, BannerColors, BannerTimings } from './model';
|
||||||
|
|
||||||
/** Fallback display timings, matching the seeded ad_settings defaults. */
|
/** Fallback display timings, matching the seeded ad_settings defaults. */
|
||||||
export const defaultBannerTimings: BannerTimings = {
|
export const defaultBannerTimings: BannerTimings = {
|
||||||
@@ -16,9 +16,15 @@ export const defaultBannerTimings: BannerTimings = {
|
|||||||
fadeInMs: 1_000,
|
fadeInMs: 1_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** BannerItem is one scheduled message plus its campaign's colour override (for the strip). */
|
||||||
|
export interface BannerItem {
|
||||||
|
md: string;
|
||||||
|
colors: BannerColors;
|
||||||
|
}
|
||||||
|
|
||||||
/** Scheduler yields the next message to show, fairly across weighted campaigns. */
|
/** Scheduler yields the next message to show, fairly across weighted campaigns. */
|
||||||
export interface Scheduler {
|
export interface Scheduler {
|
||||||
next(): string;
|
next(): BannerItem;
|
||||||
/** Total messages across all (non-empty, positive-weight) campaigns. */
|
/** Total messages across all (non-empty, positive-weight) campaigns. */
|
||||||
readonly total: number;
|
readonly total: number;
|
||||||
}
|
}
|
||||||
@@ -33,15 +39,17 @@ export interface Scheduler {
|
|||||||
*/
|
*/
|
||||||
export function createScheduler(campaigns: BannerCampaign[]): Scheduler {
|
export function createScheduler(campaigns: BannerCampaign[]): Scheduler {
|
||||||
const cs = campaigns.filter((c) => c.weight > 0 && c.messages.length > 0);
|
const cs = campaigns.filter((c) => c.weight > 0 && c.messages.length > 0);
|
||||||
|
const colors: BannerColors[] = cs.map((c) => ({ all: c.overrideAll ?? null, dark: c.overrideDark ?? null }));
|
||||||
const current = cs.map(() => 0); // SWRR running weights
|
const current = cs.map(() => 0); // SWRR running weights
|
||||||
const cursor = cs.map(() => 0); // per-campaign round-robin position
|
const cursor = cs.map(() => 0); // per-campaign round-robin position
|
||||||
const totalWeight = cs.reduce((sum, c) => sum + c.weight, 0);
|
const totalWeight = cs.reduce((sum, c) => sum + c.weight, 0);
|
||||||
const total = cs.reduce((sum, c) => sum + c.messages.length, 0);
|
const total = cs.reduce((sum, c) => sum + c.messages.length, 0);
|
||||||
|
const empty: BannerColors = { all: null, dark: null };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total,
|
total,
|
||||||
next(): string {
|
next(): BannerItem {
|
||||||
if (cs.length === 0) return '';
|
if (cs.length === 0) return { md: '', colors: empty };
|
||||||
let best = 0;
|
let best = 0;
|
||||||
for (let i = 0; i < cs.length; i++) {
|
for (let i = 0; i < cs.length; i++) {
|
||||||
current[i] += cs[i].weight;
|
current[i] += cs[i].weight;
|
||||||
@@ -51,15 +59,16 @@ export function createScheduler(campaigns: BannerCampaign[]): Scheduler {
|
|||||||
const c = cs[best];
|
const c = cs[best];
|
||||||
const md = c.messages[cursor[best] % c.messages.length];
|
const md = c.messages[cursor[best] % c.messages.length];
|
||||||
cursor[best]++;
|
cursor[best]++;
|
||||||
return md;
|
return { md, colors: colors[best] };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The host the rotator drives; AdBanner.svelte supplies the DOM measurements and effects. */
|
/** The host the rotator drives; AdBanner.svelte supplies the DOM measurements and effects. */
|
||||||
export interface BannerHost {
|
export interface BannerHost {
|
||||||
/** Render md and fade it in (over fadeInMs), with the scroll reset to the start. */
|
/** Render md and fade it in (over fadeInMs), with the scroll reset to the start. colors is the
|
||||||
show(md: string): void;
|
* source campaign's colour override, applied to the strip (its neutral tokens when unset). */
|
||||||
|
show(md: string, colors: BannerColors): void;
|
||||||
/** Reset the scroll to the start without re-fading (to loop a long message). */
|
/** Reset the scroll to the start without re-fading (to loop a long message). */
|
||||||
resetScroll(): void;
|
resetScroll(): void;
|
||||||
/** Fade the current message out over durationMs. */
|
/** Fade the current message out over durationMs. */
|
||||||
@@ -98,7 +107,7 @@ export function createBannerRotator(
|
|||||||
const sched = createScheduler(campaigns);
|
const sched = createScheduler(campaigns);
|
||||||
let running = false;
|
let running = false;
|
||||||
let cycleStart = 0;
|
let cycleStart = 0;
|
||||||
let lastShown = ''; // the message currently presented, for restart() (re-measure)
|
let lastShown: BannerItem | null = null; // the item currently presented, for restart() (re-measure)
|
||||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
|
||||||
const at = (ms: number, fn: () => void) => {
|
const at = (ms: number, fn: () => void) => {
|
||||||
@@ -109,11 +118,11 @@ export function createBannerRotator(
|
|||||||
timers.length = 0;
|
timers.length = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
function present(md: string) {
|
function present(item: BannerItem) {
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
clear();
|
clear();
|
||||||
lastShown = md;
|
lastShown = item;
|
||||||
host.show(md);
|
host.show(item.md, item.colors);
|
||||||
at(config.fadeInMs, measure);
|
at(config.fadeInMs, measure);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +152,7 @@ export function createBannerRotator(
|
|||||||
// the same fade as a message change, not a hard jump.
|
// the same fade as a message change, not a hard jump.
|
||||||
host.hide(config.fadeOutMs);
|
host.hide(config.fadeOutMs);
|
||||||
at(config.fadeOutMs + config.gapMs, () => {
|
at(config.fadeOutMs + config.gapMs, () => {
|
||||||
host.show(lastShown);
|
if (lastShown) host.show(lastShown.md, lastShown.colors);
|
||||||
at(config.fadeInMs, () => scrollCycle(over));
|
at(config.fadeInMs, () => scrollCycle(over));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { derivedBorder, resolveBannerColors } from './bannerColors';
|
||||||
|
import type { BannerColors } from './model';
|
||||||
|
|
||||||
|
const red = { bg: '#aa0000', fg: '#ffffff', link: '#ffdd00' };
|
||||||
|
const darkRed = { bg: '#330000', fg: '#eeeeee', link: '#ffcc00' };
|
||||||
|
|
||||||
|
describe('resolveBannerColors', () => {
|
||||||
|
it('returns null when the campaign has no override (neutral tokens)', () => {
|
||||||
|
const none: BannerColors = { all: null, dark: null };
|
||||||
|
expect(resolveBannerColors(none, 'light')).toBeNull();
|
||||||
|
expect(resolveBannerColors(none, 'dark')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the all-themes set on both themes when no dark override', () => {
|
||||||
|
const c: BannerColors = { all: red, dark: null };
|
||||||
|
expect(resolveBannerColors(c, 'light')).toMatchObject({ bg: red.bg, fg: red.fg, link: red.link });
|
||||||
|
expect(resolveBannerColors(c, 'dark')).toMatchObject({ bg: red.bg, fg: red.fg, link: red.link });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the dark override on the dark theme, and the all set on light', () => {
|
||||||
|
const c: BannerColors = { all: red, dark: darkRed };
|
||||||
|
expect(resolveBannerColors(c, 'light')).toMatchObject({ bg: red.bg });
|
||||||
|
expect(resolveBannerColors(c, 'dark')).toMatchObject({ bg: darkRed.bg });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports a dark-only override (light keeps the neutral tokens)', () => {
|
||||||
|
const c: BannerColors = { all: null, dark: darkRed };
|
||||||
|
expect(resolveBannerColors(c, 'light')).toBeNull();
|
||||||
|
expect(resolveBannerColors(c, 'dark')).toMatchObject({ bg: darkRed.bg });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives the border from the resolved background', () => {
|
||||||
|
const c: BannerColors = { all: red, dark: null };
|
||||||
|
expect(resolveBannerColors(c, 'light')?.border).toBe(derivedBorder(red.bg));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('derivedBorder', () => {
|
||||||
|
it('darkens a light background and lightens a dark one', () => {
|
||||||
|
expect(derivedBorder('#ffffff')).toBe('#dbdbdb');
|
||||||
|
expect(derivedBorder('#000000')).toBe('#242424');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a malformed colour unchanged', () => {
|
||||||
|
expect(derivedBorder('nope')).toBe('nope');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Banner colour resolution. A campaign may carry two optional colour sets (see
|
||||||
|
// lib/model BannerColors): one for every theme and one that further overrides the
|
||||||
|
// dark theme. resolveBannerColors collapses them to the colours the strip paints on
|
||||||
|
// the currently-rendered theme, or null when the campaign keeps the neutral
|
||||||
|
// --ad-bg / --text-muted / --accent tokens. Pure and DOM-free, so it is unit-tested
|
||||||
|
// directly; AdBanner.svelte applies the result as inline CSS variables.
|
||||||
|
|
||||||
|
import type { BannerColors } from './model';
|
||||||
|
|
||||||
|
export type ThemeMode = 'light' | 'dark';
|
||||||
|
|
||||||
|
/** ResolvedBannerColors is the strip's colours for one theme, border already derived. */
|
||||||
|
export interface ResolvedBannerColors {
|
||||||
|
bg: string;
|
||||||
|
fg: string;
|
||||||
|
link: string;
|
||||||
|
border: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resolveBannerColors picks the colour set for the rendered theme — dark ← dark ?? all,
|
||||||
|
* light ← all — and returns null when there is no override for that theme (the strip then
|
||||||
|
* keeps the neutral tokens). The border is derived from the background so it stays subtle
|
||||||
|
* on any colour.
|
||||||
|
*/
|
||||||
|
export function resolveBannerColors(colors: BannerColors, mode: ThemeMode): ResolvedBannerColors | null {
|
||||||
|
const set = mode === 'dark' ? (colors.dark ?? colors.all) : colors.all;
|
||||||
|
if (!set) return null;
|
||||||
|
return { bg: set.bg, fg: set.fg, link: set.link, border: derivedBorder(set.bg) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* derivedBorder nudges the background 14% toward black on a light background and toward
|
||||||
|
* white on a dark one — a subtle edge that suits any override colour. It is computed in
|
||||||
|
* JS (no CSS color-mix) so it works on the old Android WebView floor (Chrome 67), and
|
||||||
|
* mirrors the admin console preview (banner_detail.gohtml). A malformed colour is
|
||||||
|
* returned unchanged.
|
||||||
|
*/
|
||||||
|
export function derivedBorder(bgHex: string): string {
|
||||||
|
const rgb = hexToRgb(bgHex);
|
||||||
|
if (!rgb) return bgHex;
|
||||||
|
const target: RGB = luminance(rgb) > 0.5 ? [0, 0, 0] : [255, 255, 255];
|
||||||
|
return rgbToHex(mix(rgb, target, 0.14));
|
||||||
|
}
|
||||||
|
|
||||||
|
type RGB = [number, number, number];
|
||||||
|
|
||||||
|
function hexToRgb(h: string): RGB | null {
|
||||||
|
const m = /^#([0-9a-fA-F]{6})$/.exec(h.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const n = m[1];
|
||||||
|
return [parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mix(a: RGB, b: RGB, t: number): RGB {
|
||||||
|
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
||||||
|
}
|
||||||
|
|
||||||
|
function luminance(r: RGB): number {
|
||||||
|
return (0.2126 * r[0] + 0.7152 * r[1] + 0.0722 * r[2]) / 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(x: number): string {
|
||||||
|
return Math.max(0, Math.min(255, Math.round(x))).toString(16).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHex(r: RGB): string {
|
||||||
|
return '#' + pad(r[0]) + pad(r[1]) + pad(r[2]);
|
||||||
|
}
|
||||||
@@ -4,11 +4,14 @@
|
|||||||
// independent of route transitions (the cycle is not restarted on navigation).
|
// independent of route transitions (the cycle is not restarted on navigation).
|
||||||
|
|
||||||
import { createBannerRotator, type BannerHost, type Rotator } from './banner';
|
import { createBannerRotator, type BannerHost, type Rotator } from './banner';
|
||||||
import type { BannerCampaign, BannerTimings } from './model';
|
import type { BannerCampaign, BannerColors, BannerTimings } from './model';
|
||||||
|
|
||||||
|
const noColors: BannerColors = { all: null, dark: null };
|
||||||
|
|
||||||
let rotator: Rotator | null = null;
|
let rotator: Rotator | null = null;
|
||||||
let mounted: BannerHost | null = null;
|
let mounted: BannerHost | null = null;
|
||||||
let current = '';
|
let current = '';
|
||||||
|
let currentColors: BannerColors = noColors;
|
||||||
let key = '';
|
let key = '';
|
||||||
// The in-flight horizontal scroll of the current message, so a view mounted by navigation can
|
// The in-flight horizontal scroll of the current message, so a view mounted by navigation can
|
||||||
// resume it from the same offset instead of restarting at the left. Null when not scrolling.
|
// resume it from the same offset instead of restarting at the left. Null when not scrolling.
|
||||||
@@ -17,10 +20,11 @@ let activeScroll: { toPx: number; dur: number; start: number } | null = null;
|
|||||||
// proxy is the rotator's host: it records the current message + scroll (so a freshly-mounted view
|
// proxy is the rotator's host: it records the current message + scroll (so a freshly-mounted view
|
||||||
// can resume them) and forwards every effect to the currently-attached DOM host, if any.
|
// can resume them) and forwards every effect to the currently-attached DOM host, if any.
|
||||||
const proxy: BannerHost = {
|
const proxy: BannerHost = {
|
||||||
show(md) {
|
show(md, colors) {
|
||||||
current = md;
|
current = md;
|
||||||
|
currentColors = colors;
|
||||||
activeScroll = null;
|
activeScroll = null;
|
||||||
mounted?.show(md);
|
mounted?.show(md, colors);
|
||||||
},
|
},
|
||||||
resetScroll() {
|
resetScroll() {
|
||||||
activeScroll = null;
|
activeScroll = null;
|
||||||
@@ -58,6 +62,7 @@ export function configureBanner(campaigns: BannerCampaign[], timings: BannerTimi
|
|||||||
key = k;
|
key = k;
|
||||||
rotator?.stop();
|
rotator?.stop();
|
||||||
current = '';
|
current = '';
|
||||||
|
currentColors = noColors;
|
||||||
rotator = createBannerRotator(campaigns, proxy, timings);
|
rotator = createBannerRotator(campaigns, proxy, timings);
|
||||||
rotator.start();
|
rotator.start();
|
||||||
}
|
}
|
||||||
@@ -71,6 +76,15 @@ export function bannerCurrent(): string {
|
|||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bannerCurrentColors returns the colour override of the campaign whose message is currently
|
||||||
|
* displayed (neutral tokens — both sets null — before the first message). A freshly-mounted view
|
||||||
|
* reads it alongside bannerCurrent so the strip paints the right colours immediately on mount.
|
||||||
|
*/
|
||||||
|
export function bannerCurrentColors(): BannerColors {
|
||||||
|
return currentColors;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* attachBannerHost connects a freshly-mounted view as the DOM host. It does NOT re-show the
|
* attachBannerHost connects a freshly-mounted view as the DOM host. It does NOT re-show the
|
||||||
* current message (the view renders it itself from bannerCurrent on mount): re-showing here would
|
* current message (the view renders it itself from bannerCurrent on mount): re-showing here would
|
||||||
|
|||||||
@@ -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): Promise<void>;
|
authEmailRequest(email: string, language: string, pwa: boolean): 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,10 +122,11 @@ 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')),
|
new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en', true)),
|
||||||
);
|
);
|
||||||
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(
|
||||||
@@ -647,7 +648,10 @@ describe('codec', () => {
|
|||||||
const uid = b.createString('u-1');
|
const uid = b.createString('u-1');
|
||||||
const m0 = b.createString('promo-en');
|
const m0 = b.createString('promo-en');
|
||||||
const msgs = fb.BannerCampaign.createMessagesVector(b, [m0]);
|
const msgs = fb.BannerCampaign.createMessagesVector(b, [m0]);
|
||||||
const camp = fb.BannerCampaign.createBannerCampaign(b, 3, msgs);
|
fb.BannerCampaign.startBannerCampaign(b);
|
||||||
|
fb.BannerCampaign.addWeight(b, 3);
|
||||||
|
fb.BannerCampaign.addMessages(b, msgs);
|
||||||
|
const camp = fb.BannerCampaign.endBannerCampaign(b);
|
||||||
const camps = fb.BannerInfo.createCampaignsVector(b, [camp]);
|
const camps = fb.BannerInfo.createCampaignsVector(b, [camp]);
|
||||||
const banner = fb.BannerInfo.createBannerInfo(b, camps, 60000, 5000, 40, 1000, 250, 1000);
|
const banner = fb.BannerInfo.createBannerInfo(b, camps, 60000, 5000, 40, 1000, 250, 1000);
|
||||||
fb.Profile.startProfile(b);
|
fb.Profile.startProfile(b);
|
||||||
@@ -656,7 +660,8 @@ describe('codec', () => {
|
|||||||
b.finish(fb.Profile.endProfile(b));
|
b.finish(fb.Profile.endProfile(b));
|
||||||
|
|
||||||
const p = decodeProfile(b.asUint8Array());
|
const p = decodeProfile(b.asUint8Array());
|
||||||
expect(p.banner?.campaigns).toEqual([{ weight: 3, messages: ['promo-en'] }]);
|
// A plain campaign carries no override (both sets null → the neutral theme tokens).
|
||||||
|
expect(p.banner?.campaigns).toEqual([{ weight: 3, messages: ['promo-en'], overrideAll: null, overrideDark: null }]);
|
||||||
expect(p.banner?.timings).toEqual({
|
expect(p.banner?.timings).toEqual({
|
||||||
holdMs: 60000,
|
holdMs: 60000,
|
||||||
edgePauseMs: 5000,
|
edgePauseMs: 5000,
|
||||||
@@ -667,6 +672,26 @@ describe('codec', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('decodes a campaign colour override (both sets)', () => {
|
||||||
|
const b = new Builder(256);
|
||||||
|
const uid = b.createString('u-1');
|
||||||
|
const m0 = b.createString('branded');
|
||||||
|
const msgs = fb.BannerCampaign.createMessagesVector(b, [m0]);
|
||||||
|
const s = (v: string) => b.createString(v);
|
||||||
|
const [obg, ofg, ol, obgd, ofgd, old] = ['#aa0000', '#ffffff', '#ffdd00', '#330000', '#eeeeee', '#ffcc00'].map(s);
|
||||||
|
const camp = fb.BannerCampaign.createBannerCampaign(b, 1, msgs, obg, ofg, ol, obgd, ofgd, old);
|
||||||
|
const camps = fb.BannerInfo.createCampaignsVector(b, [camp]);
|
||||||
|
const banner = fb.BannerInfo.createBannerInfo(b, camps, 60000, 5000, 40, 1000, 250, 1000);
|
||||||
|
fb.Profile.startProfile(b);
|
||||||
|
fb.Profile.addUserId(b, uid);
|
||||||
|
fb.Profile.addBanner(b, banner);
|
||||||
|
b.finish(fb.Profile.endProfile(b));
|
||||||
|
|
||||||
|
const p = decodeProfile(b.asUint8Array());
|
||||||
|
expect(p.banner?.campaigns[0].overrideAll).toEqual({ bg: '#aa0000', fg: '#ffffff', link: '#ffdd00' });
|
||||||
|
expect(p.banner?.campaigns[0].overrideDark).toEqual({ bg: '#330000', fg: '#eeeeee', link: '#ffcc00' });
|
||||||
|
});
|
||||||
|
|
||||||
it('leaves the profile banner undefined when absent', () => {
|
it('leaves the profile banner undefined when absent', () => {
|
||||||
const b = new Builder(64);
|
const b = new Builder(64);
|
||||||
const uid = b.createString('u-1');
|
const uid = b.createString('u-1');
|
||||||
@@ -687,6 +712,28 @@ describe('codec', () => {
|
|||||||
expect(decodeProfile(b.asUint8Array()).variantPreferences).toEqual(['erudit_ru', 'scrabble_en']);
|
expect(decodeProfile(b.asUint8Array()).variantPreferences).toEqual(['erudit_ru', 'scrabble_en']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('decodes the profile dictionary versions into a per-variant map', () => {
|
||||||
|
const b = new Builder(128);
|
||||||
|
const uid = b.createString('u-1');
|
||||||
|
const en = fb.DictVersion.createDictVersion(b, b.createString('scrabble_en'), b.createString('v1.3.0'));
|
||||||
|
const er = fb.DictVersion.createDictVersion(b, b.createString('erudit_ru'), b.createString('v1.2.0'));
|
||||||
|
const vec = fb.Profile.createDictVersionsVector(b, [en, er]);
|
||||||
|
fb.Profile.startProfile(b);
|
||||||
|
fb.Profile.addUserId(b, uid);
|
||||||
|
fb.Profile.addDictVersions(b, vec);
|
||||||
|
b.finish(fb.Profile.endProfile(b));
|
||||||
|
expect(decodeProfile(b.asUint8Array()).dictVersions).toEqual({ scrabble_en: 'v1.3.0', erudit_ru: 'v1.2.0' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes an absent dict_versions vector to an empty map', () => {
|
||||||
|
const b = new Builder(64);
|
||||||
|
const uid = b.createString('u-1');
|
||||||
|
fb.Profile.startProfile(b);
|
||||||
|
fb.Profile.addUserId(b, uid);
|
||||||
|
b.finish(fb.Profile.endProfile(b));
|
||||||
|
expect(decodeProfile(b.asUint8Array()).dictVersions).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
it('encodes the update-profile variant preferences', () => {
|
it('encodes the update-profile variant preferences', () => {
|
||||||
const buf = encodeUpdateProfile({
|
const buf = encodeUpdateProfile({
|
||||||
displayName: 'Kaya',
|
displayName: 'Kaya',
|
||||||
|
|||||||
+37
-2
@@ -19,6 +19,7 @@ import type {
|
|||||||
BestMoveTile,
|
BestMoveTile,
|
||||||
BlockStatus,
|
BlockStatus,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
|
ColorTriple,
|
||||||
EvalResult,
|
EvalResult,
|
||||||
FeedbackState,
|
FeedbackState,
|
||||||
FriendCode,
|
FriendCode,
|
||||||
@@ -213,7 +214,12 @@ 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(email: string, browserTz: string, language: string): Uint8Array {
|
export function encodeEmailRequest(
|
||||||
|
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);
|
||||||
@@ -222,6 +228,7 @@ export function encodeEmailRequest(email: string, browserTz: string, language: s
|
|||||||
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,9 +364,25 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
|||||||
email: s(p.email()),
|
email: s(p.email()),
|
||||||
telegramLinked: p.telegramLinked(),
|
telegramLinked: p.telegramLinked(),
|
||||||
vkLinked: p.vkLinked(),
|
vkLinked: p.vkLinked(),
|
||||||
|
dictVersions: decodeDictVersions(p),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decodeDictVersions reads the profile's per-variant current dictionary versions into a
|
||||||
|
// variant-keyed map, so the offline preloader can look up the version for each enabled
|
||||||
|
// variant. An entry with an unknown variant or empty version is skipped.
|
||||||
|
function decodeDictVersions(p: fb.Profile): Partial<Record<Variant, string>> {
|
||||||
|
const out: Partial<Record<Variant, string>> = {};
|
||||||
|
for (let i = 0; i < p.dictVersionsLength(); i++) {
|
||||||
|
const dv = p.dictVersions(i);
|
||||||
|
if (!dv) continue;
|
||||||
|
const variant = s(dv.variant()) as Variant;
|
||||||
|
const version = s(dv.version());
|
||||||
|
if (variant && version) out[variant] = version;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// decodeVariantPreferences reads the Profile.variant_preferences vector (the variants
|
// decodeVariantPreferences reads the Profile.variant_preferences vector (the variants
|
||||||
// the player enabled in Settings).
|
// the player enabled in Settings).
|
||||||
function decodeVariantPreferences(p: fb.Profile): Variant[] {
|
function decodeVariantPreferences(p: fb.Profile): Variant[] {
|
||||||
@@ -368,6 +391,12 @@ function decodeVariantPreferences(p: fb.Profile): Variant[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bannerTriple builds a colour override from the three wire fields, or null when the
|
||||||
|
// set is absent (any field empty/missing) — the strip then keeps the neutral tokens.
|
||||||
|
function bannerTriple(bg: string | null, fg: string | null, link: string | null): ColorTriple | null {
|
||||||
|
return bg && fg && link ? { bg, fg, link } : null;
|
||||||
|
}
|
||||||
|
|
||||||
// decodeBanner projects the optional advertising-banner block of a Profile, or
|
// decodeBanner projects the optional advertising-banner block of a Profile, or
|
||||||
// undefined when the viewer is not eligible (the field is absent).
|
// undefined when the viewer is not eligible (the field is absent).
|
||||||
function decodeBanner(p: fb.Profile): Banner | undefined {
|
function decodeBanner(p: fb.Profile): Banner | undefined {
|
||||||
@@ -379,7 +408,12 @@ function decodeBanner(p: fb.Profile): Banner | undefined {
|
|||||||
if (!c) continue;
|
if (!c) continue;
|
||||||
const messages: string[] = [];
|
const messages: string[] = [];
|
||||||
for (let j = 0; j < c.messagesLength(); j++) messages.push(c.messages(j));
|
for (let j = 0; j < c.messagesLength(); j++) messages.push(c.messages(j));
|
||||||
campaigns.push({ weight: c.weight(), messages });
|
campaigns.push({
|
||||||
|
weight: c.weight(),
|
||||||
|
messages,
|
||||||
|
overrideAll: bannerTriple(c.overrideBg(), c.overrideFg(), c.overrideLink()),
|
||||||
|
overrideDark: bannerTriple(c.overrideBgDark(), c.overrideFgDark(), c.overrideLinkDark()),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
campaigns,
|
campaigns,
|
||||||
@@ -427,6 +461,7 @@ function decodeStateViewTable(v: fb.StateView): StateView {
|
|||||||
bagLen: v.bagLen(),
|
bagLen: v.bagLen(),
|
||||||
hintsRemaining: v.hintsRemaining(),
|
hintsRemaining: v.hintsRemaining(),
|
||||||
walletBalance: v.walletBalance(),
|
walletBalance: v.walletBalance(),
|
||||||
|
hintUnlockLeftSeconds: v.hintUnlockLeftSeconds(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user