Compare commits
48 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 | |||
| ff486c80f8 | |||
| 42a6308261 | |||
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -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.
|
||||||
|
|||||||
@@ -163,13 +163,16 @@ type alphabetEntryDTO struct {
|
|||||||
// stateDTO is a player's view of a game. Rack carries wire alphabet indices (a
|
// stateDTO is a player's view of a game. Rack carries wire alphabet indices (a
|
||||||
// blank is engine.BlankIndex). Alphabet is present only when the request asked for it.
|
// blank is engine.BlankIndex). Alphabet is present only when the request asked for it.
|
||||||
type stateDTO struct {
|
type stateDTO struct {
|
||||||
Game gameDTO `json:"game"`
|
Game gameDTO `json:"game"`
|
||||||
Seat int `json:"seat"`
|
Seat int `json:"seat"`
|
||||||
Rack []int `json:"rack"`
|
Rack []int `json:"rack"`
|
||||||
BagLen int `json:"bag_len"`
|
BagLen int `json:"bag_len"`
|
||||||
HintsRemaining int `json:"hints_remaining"`
|
HintsRemaining int `json:"hints_remaining"`
|
||||||
WalletBalance int `json:"wallet_balance"`
|
WalletBalance int `json:"wallet_balance"`
|
||||||
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
|
// HintUnlockLeftSeconds is the vs_ai idle-hint gate: seconds until the hint unlocks (0 for a human
|
||||||
|
// game / first move / not your turn). The client anchors a monotonic countdown to it.
|
||||||
|
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
||||||
|
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchDTO reports whether the caller has been paired into a game.
|
// matchDTO reports whether the caller has been paired into a game.
|
||||||
@@ -315,12 +318,13 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
|
|||||||
return stateDTO{}, err
|
return stateDTO{}, err
|
||||||
}
|
}
|
||||||
dto := stateDTO{
|
dto := stateDTO{
|
||||||
Game: gameDTOFromGame(v.Game),
|
Game: gameDTOFromGame(v.Game),
|
||||||
Seat: v.Seat,
|
Seat: v.Seat,
|
||||||
Rack: rack,
|
Rack: rack,
|
||||||
BagLen: v.BagLen,
|
BagLen: v.BagLen,
|
||||||
HintsRemaining: v.HintsRemaining,
|
HintsRemaining: v.HintsRemaining,
|
||||||
WalletBalance: v.WalletBalance,
|
WalletBalance: v.WalletBalance,
|
||||||
|
HintUnlockLeftSeconds: v.HintUnlockLeftSeconds,
|
||||||
}
|
}
|
||||||
if includeAlphabet {
|
if includeAlphabet {
|
||||||
tab, err := engine.AlphabetTable(v.Game.Variant)
|
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||||
|
|||||||
@@ -239,6 +239,9 @@ func statusForError(err error) (int, string) {
|
|||||||
return http.StatusConflict, "no_hint_available"
|
return http.StatusConflict, "no_hint_available"
|
||||||
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft):
|
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft):
|
||||||
return http.StatusConflict, "hint_unavailable"
|
return http.StatusConflict, "hint_unavailable"
|
||||||
|
case errors.Is(err, game.ErrHintLocked):
|
||||||
|
// A vs_ai idle hint is still gated (the server-clock backstop); the client shows the lock.
|
||||||
|
return http.StatusConflict, "hint_locked"
|
||||||
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
|
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
|
||||||
return http.StatusUnprocessableEntity, "illegal_play"
|
return http.StatusUnprocessableEntity, "illegal_play"
|
||||||
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
|
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
|
||||||
|
|||||||
+32
-6
@@ -1242,17 +1242,43 @@ browser-language detection — crawlers render with arbitrary languages). The fa
|
|||||||
built from `ui/src/sw.ts` by **vite-plugin-pwa** (injectManifest strategy) — the client registers it
|
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
|
**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
|
**precaches the app shell and the hashed assets** (Workbox) so an installed PWA cold-launches with
|
||||||
no network, and falls every in-scope navigation back to the precached shell (the hash router
|
no network. Shell **navigations are network-first** — the fresh `no-cache` shell is fetched from the
|
||||||
resolves the route client-side); the landing page and the conditional polyfill bundle are excluded,
|
server (so a new deploy is picked up on the very next launch, not the one after), falling back to the
|
||||||
and the Connect stream and runtime API POSTs are never precached nor intercepted, so the live app is
|
precached shell only when the network is unreachable or too slow; the immutable hashed assets stay
|
||||||
never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
|
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
|
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
|
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
|
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
|
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
|
creates one through the in-browser engine — the same game screen then drives it, the robot replying
|
||||||
locally; online-only affordances (the Stats tab, the friend/random options in New Game) are disabled
|
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
|
||||||
or hidden. To have data ready before the switch, the **Profile advertises the current dictionary
|
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`,
|
version per variant** (`dict_versions`,
|
||||||
filled from the registry on the existing cold-start profile request — no extra round-trip), and an
|
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
|
eligible installed PWA (standalone web + confirmed email) **background-preloads** those dictionaries
|
||||||
|
|||||||
+26
-3
@@ -215,7 +215,16 @@ unlimited and offers a complaint on any result; for a word it finds, it also lin
|
|||||||
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
|
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
|
||||||
English) to look it up. Hints are governed per game —
|
English) to look it up. Hints are governed per game —
|
||||||
whether they are allowed and how many each player starts with — and draw on a
|
whether they are allowed and how many each player starts with — and draw on a
|
||||||
personal hint wallet once the per-game allowance is spent. The game ends when the
|
personal hint wallet once the per-game allowance is spent. Against the robot
|
||||||
|
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
|
||||||
|
anti-frustration aid: one unlocks only after the player has been **stuck ~30 minutes
|
||||||
|
on a turn** (timed from the robot's last move; the very first move, before the robot
|
||||||
|
has played, is exempt). While gated the hint button carries a small **🔒 lock** and a
|
||||||
|
tap shows how long remains; the lock lifts live at the mark. The same gate applies **online and
|
||||||
|
offline**: the countdown runs on a **steady in-app timer**, not the device clock, so changing the
|
||||||
|
clock cannot skew it, and a relaunch re-reads the remaining time so a stuck turn is not forgotten.
|
||||||
|
Online the **server enforces** it from its own clock (which the player cannot touch); a vs_ai hint
|
||||||
|
counts toward no hint statistic. The game ends when the
|
||||||
bag empties and a player clears their rack, after 6 consecutive scoreless turns,
|
bag empties and a player clears their rack, after 6 consecutive scoreless turns,
|
||||||
by resignation, or by the per-game move timeout (5 minutes to 24 hours, default
|
by resignation, or by the per-game move timeout (5 minutes to 24 hours, default
|
||||||
24 hours): a missed turn auto-resigns, except while the player is inside their
|
24 hours): a missed turn auto-resigns, except while the player is inside their
|
||||||
@@ -260,8 +269,8 @@ statistics.
|
|||||||
An installed web PWA signed in with a confirmed email can switch to a deliberate **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
|
(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
|
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are
|
||||||
disabled or hidden (the Stats tab; the *with friends* and *random opponent* options in New Game).
|
disabled or hidden (the Stats tab; the *random opponent* option in New Game).
|
||||||
Only **vs_ai** games are playable — New Game creates a device-local game against the robot, which
|
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
|
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,
|
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
|
the app quietly preloads the dictionaries for the player's enabled variants while still online, and
|
||||||
@@ -271,6 +280,20 @@ and waits briefly, and if they cannot be readied it stays online with a short *n
|
|||||||
(the download keeps running in the background, so a later switch is instant). The mode is
|
(the download keeps running in the background, so a later switch is instant). The mode is
|
||||||
device-scoped and sticky across launches.
|
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
|
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
|
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
|
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with
|
||||||
|
|||||||
+27
-3
@@ -222,7 +222,16 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
||||||
чтобы его посмотреть. Подсказки управляются настройками
|
чтобы его посмотреть. Подсказки управляются настройками
|
||||||
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
||||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия
|
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
|
||||||
|
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
|
||||||
|
анти-фрустрация: подсказка открывается, только когда игрок **застрял на ходу ~30 минут**
|
||||||
|
(отсчёт от последнего хода робота; самый первый ход, до хода робота, исключён). Пока гейт
|
||||||
|
закрыт, кнопка подсказки несёт маленький **🔒 замок**, а тап показывает, сколько осталось;
|
||||||
|
замок снимается вживую в нужный момент. Один и тот же гейт работает **онлайн и офлайн**: отсчёт
|
||||||
|
идёт по **ровному внутриигровому таймеру**, а не по системным часам, поэтому их перевод его не
|
||||||
|
собьёт, а повторный вход перечитывает остаток — застрявший ход не забывается. Онлайн гейт
|
||||||
|
**принуждает сервер** по своим часам (их игрок не тронет); vs_ai-подсказка не учитывается ни в
|
||||||
|
одной статистике подсказок. Партия
|
||||||
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
|
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
|
||||||
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
|
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
|
||||||
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
|
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
|
||||||
@@ -265,8 +274,8 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
|
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
|
||||||
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
|
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
|
||||||
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
|
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
|
||||||
отключены или скрыты (вкладка Статистика; варианты «с другом» и «случайный соперник» в Новой игре).
|
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
|
||||||
Играть можно только в **vs_ai** — Новая игра создаёт локальную игру против робота, который ходит
|
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
|
||||||
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
|
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
|
||||||
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
|
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
|
||||||
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
|
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
|
||||||
@@ -276,6 +285,21 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
|
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
|
||||||
сохраняется между запусками.
|
сохраняется между запусками.
|
||||||
|
|
||||||
|
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 2–4 человек
|
||||||
|
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||||
|
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
|
||||||
|
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
|
||||||
|
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
|
||||||
|
дать любому месту **свой необязательный PIN**. Во время игры доска видна всегда, но место с PIN
|
||||||
|
вместо стойки показывает кнопку **«Разблокировать»**, пока владелец не введёт PIN, — так устройство
|
||||||
|
передаётся из рук в руки, и только защищённое место прячет свои буквы; на каждом ходу замок
|
||||||
|
возвращается. **Ведущий** (кнопка ведущего в игре) может по паролю ведущего **пропустить** ход
|
||||||
|
текущего игрока, **исключить** игрока или **завершить игру досрочно** — досрочное завершение стирает
|
||||||
|
партию без победителей и проигравших. Подсказки и чат в игре по очереди отключены. Нормально
|
||||||
|
завершённая партия сохраняется в офлайн-лобби как любая другая; удаление любой игры по очереди —
|
||||||
|
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
|
||||||
|
своего хода.
|
||||||
|
|
||||||
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
|
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
|
||||||
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
|
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
|
||||||
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
|
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
|
||||||
|
|||||||
@@ -181,13 +181,14 @@ type AlphabetEntryJSON struct {
|
|||||||
// StateResp is a player's view of a game. Rack carries wire alphabet indices;
|
// StateResp is a player's view of a game. Rack carries wire alphabet indices;
|
||||||
// Alphabet is present only when the request asked for it.
|
// Alphabet is present only when the request asked for it.
|
||||||
type StateResp struct {
|
type StateResp struct {
|
||||||
Game GameResp `json:"game"`
|
Game GameResp `json:"game"`
|
||||||
Seat int `json:"seat"`
|
Seat int `json:"seat"`
|
||||||
Rack []int `json:"rack"`
|
Rack []int `json:"rack"`
|
||||||
BagLen int `json:"bag_len"`
|
BagLen int `json:"bag_len"`
|
||||||
HintsRemaining int `json:"hints_remaining"`
|
HintsRemaining int `json:"hints_remaining"`
|
||||||
WalletBalance int `json:"wallet_balance"`
|
WalletBalance int `json:"wallet_balance"`
|
||||||
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
||||||
|
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchResp reports an auto-match outcome.
|
// MatchResp reports an auto-match outcome.
|
||||||
|
|||||||
@@ -299,13 +299,14 @@ func toWireState(s backendclient.StateResp) wire.StateView {
|
|||||||
alphabet[i] = wire.AlphabetEntry{Index: e.Index, Letter: e.Letter, Value: e.Value}
|
alphabet[i] = wire.AlphabetEntry{Index: e.Index, Letter: e.Letter, Value: e.Value}
|
||||||
}
|
}
|
||||||
return wire.StateView{
|
return wire.StateView{
|
||||||
Game: toWireGame(s.Game),
|
Game: toWireGame(s.Game),
|
||||||
Seat: s.Seat,
|
Seat: s.Seat,
|
||||||
Rack: s.Rack,
|
Rack: s.Rack,
|
||||||
BagLen: s.BagLen,
|
BagLen: s.BagLen,
|
||||||
HintsRemaining: s.HintsRemaining,
|
HintsRemaining: s.HintsRemaining,
|
||||||
WalletBalance: s.WalletBalance,
|
WalletBalance: s.WalletBalance,
|
||||||
Alphabet: alphabet,
|
HintUnlockLeftSeconds: s.HintUnlockLeftSeconds,
|
||||||
|
Alphabet: alphabet,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
|||||||
if r.URL.Path != "/api/v1/user/games/g-1/state" {
|
if r.URL.Path != "/api/v1/user/games/g-1/state" {
|
||||||
t.Errorf("unexpected path %q", r.URL.Path)
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
}
|
}
|
||||||
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"wallet_balance":3}`))
|
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"wallet_balance":3,"hint_unlock_left_seconds":1200}`))
|
||||||
})
|
})
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
@@ -77,8 +77,8 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
|||||||
t.Fatalf("handler: %v", err)
|
t.Fatalf("handler: %v", err)
|
||||||
}
|
}
|
||||||
st := fb.GetRootAsStateView(payload, 0)
|
st := fb.GetRootAsStateView(payload, 0)
|
||||||
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 {
|
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 || st.HintUnlockLeftSeconds() != 1200 {
|
||||||
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance())
|
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d unlockLeft=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance(), st.HintUnlockLeftSeconds())
|
||||||
}
|
}
|
||||||
game := st.Game(nil)
|
game := st.Game(nil)
|
||||||
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
|
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
|
||||||
|
|||||||
@@ -316,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).
|
||||||
|
|||||||
@@ -156,8 +156,20 @@ func (rcv *StateView) MutateWalletBalance(n int32) bool {
|
|||||||
return rcv._tab.MutateInt32Slot(16, n)
|
return rcv._tab.MutateInt32Slot(16, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *StateView) HintUnlockLeftSeconds() int32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *StateView) MutateHintUnlockLeftSeconds(n int32) bool {
|
||||||
|
return rcv._tab.MutateInt32Slot(18, n)
|
||||||
|
}
|
||||||
|
|
||||||
func StateViewStart(builder *flatbuffers.Builder) {
|
func StateViewStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(7)
|
builder.StartObject(8)
|
||||||
}
|
}
|
||||||
func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
|
func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(game), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(game), 0)
|
||||||
@@ -186,6 +198,9 @@ func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) fl
|
|||||||
func StateViewAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) {
|
func StateViewAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) {
|
||||||
builder.PrependInt32Slot(6, walletBalance, 0)
|
builder.PrependInt32Slot(6, walletBalance, 0)
|
||||||
}
|
}
|
||||||
|
func StateViewAddHintUnlockLeftSeconds(builder *flatbuffers.Builder, hintUnlockLeftSeconds int32) {
|
||||||
|
builder.PrependInt32Slot(7, hintUnlockLeftSeconds, 0)
|
||||||
|
}
|
||||||
func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-7
@@ -87,13 +87,14 @@ type AlphabetEntry struct {
|
|||||||
// (wire alphabet indices), bag size and hint budget. Alphabet is set only when the
|
// (wire alphabet indices), bag size and hint budget. Alphabet is set only when the
|
||||||
// recipient may not have cached the variant's display table yet.
|
// recipient may not have cached the variant's display table yet.
|
||||||
type StateView struct {
|
type StateView struct {
|
||||||
Game GameView
|
Game GameView
|
||||||
Seat int
|
Seat int
|
||||||
Rack []int
|
Rack []int
|
||||||
BagLen int
|
BagLen int
|
||||||
HintsRemaining int
|
HintsRemaining int
|
||||||
WalletBalance int
|
WalletBalance int
|
||||||
Alphabet []AlphabetEntry
|
HintUnlockLeftSeconds int
|
||||||
|
Alphabet []AlphabetEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccountRef is a referenced account with its display name resolved.
|
// AccountRef is a referenced account with its display name resolved.
|
||||||
@@ -256,6 +257,7 @@ func BuildStateView(b *flatbuffers.Builder, s StateView) flatbuffers.UOffsetT {
|
|||||||
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
||||||
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
||||||
fb.StateViewAddWalletBalance(b, int32(s.WalletBalance))
|
fb.StateViewAddWalletBalance(b, int32(s.WalletBalance))
|
||||||
|
fb.StateViewAddHintUnlockLeftSeconds(b, int32(s.HintUnlockLeftSeconds))
|
||||||
if hasAlphabet {
|
if hasAlphabet {
|
||||||
fb.StateViewAddAlphabet(b, alphabet)
|
fb.StateViewAddAlphabet(b, alphabet)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// Offline pass-and-play (hotseat) is offered only in offline mode, which is gated to an installed
|
||||||
|
// standalone PWA with a confirmed email. The mock account has an email; force standalone so the
|
||||||
|
// Settings offline toggle shows.
|
||||||
|
async function forceStandalone(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const orig = window.matchMedia.bind(window);
|
||||||
|
window.matchMedia = (q: string) =>
|
||||||
|
(q.includes('display-mode: standalone')
|
||||||
|
? { matches: true, media: q, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent: () => false }
|
||||||
|
: orig(q)) as MediaQueryList;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterLobby(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /guest|гост/i }).first().click();
|
||||||
|
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goOffline(page: Page): Promise<void> {
|
||||||
|
await page.locator('button.tab').nth(2).click(); // Settings
|
||||||
|
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
|
||||||
|
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
|
||||||
|
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||||
|
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action).
|
||||||
|
// It first waits for the dots to be empty, so a call made right after a previous entry does not lose
|
||||||
|
// digits during the 250 ms verdict pause (the 4th digit → pause → clear/advance).
|
||||||
|
async function typePin(page: Page, digits: string): Promise<void> {
|
||||||
|
await expect(page.locator('.pad .dot.on')).toHaveCount(0);
|
||||||
|
for (const d of digits) await page.locator('.pad .key', { hasText: d }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('offline hotseat (pass-and-play)', () => {
|
||||||
|
test('create with a locked seat, unlock, host-skip, terminate from the lobby', async ({ page }) => {
|
||||||
|
await forceStandalone(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await enterLobby(page);
|
||||||
|
await goOffline(page);
|
||||||
|
|
||||||
|
// A known seed: the bag deals a full rack deterministically, AND the seeded seating shuffle keeps
|
||||||
|
// the roster order (Ann first) so the locked-seat assertions below are stable. (Seat order is
|
||||||
|
// randomised at start; the shuffle is seed-driven — seed '1' would seat Bob first, '2' Ann first.)
|
||||||
|
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('2'));
|
||||||
|
|
||||||
|
// New game -> the offline mode selector now offers "with friends" (hotseat).
|
||||||
|
await page.locator('button.tab').nth(0).click();
|
||||||
|
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
|
||||||
|
|
||||||
|
// The player rows are disabled until the mandatory host (master) PIN is set.
|
||||||
|
await expect(page.locator('.pname').first()).toBeDisabled();
|
||||||
|
|
||||||
|
// Set the host PIN (enter + confirm), then decline taking a seat.
|
||||||
|
await page.locator('.hostpin .plink').click();
|
||||||
|
await typePin(page, '9999');
|
||||||
|
await typePin(page, '9999');
|
||||||
|
await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
|
||||||
|
|
||||||
|
// Pick the English variant (the plaques mirror Quick Match; "Scrabble" is the Latin English name).
|
||||||
|
await page.locator('.variant', { hasText: 'Scrabble' }).click();
|
||||||
|
|
||||||
|
// Two players: Ann (PIN-locked) and Bob (open).
|
||||||
|
await page.locator('.pname').nth(0).fill('Ann');
|
||||||
|
await page.locator('.pname').nth(1).fill('Bob');
|
||||||
|
|
||||||
|
// Row-delete: a 3rd player's kebab asks the host PIN, which ARMS a ❌ (not a silent delete);
|
||||||
|
// tapping the ❌ removes the row.
|
||||||
|
await page.getByRole('button', { name: /Add player|Добавить/i }).click();
|
||||||
|
await expect(page.locator('.prow')).toHaveCount(3);
|
||||||
|
await page.locator('.prow').nth(2).locator('.pkebab').click();
|
||||||
|
await typePin(page, '9999');
|
||||||
|
const rowDel = page.locator('.prow').nth(2).locator('.prow-del');
|
||||||
|
await expect(rowDel).toBeVisible();
|
||||||
|
await rowDel.click();
|
||||||
|
await expect(page.locator('.prow')).toHaveCount(2);
|
||||||
|
|
||||||
|
// Lock Ann's seat with a PIN.
|
||||||
|
await page.locator('.prow').nth(0).locator('.plink').click();
|
||||||
|
await typePin(page, '1234');
|
||||||
|
await typePin(page, '1234');
|
||||||
|
|
||||||
|
await page.locator('button.invite').click();
|
||||||
|
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Ann is to move and PIN-locked: the board is visible but her rack is withheld behind Unlock.
|
||||||
|
await expect(page.locator('.unlock')).toBeVisible();
|
||||||
|
await expect(page.locator('.rack .tile')).toHaveCount(0);
|
||||||
|
|
||||||
|
// A wrong PIN keeps it locked; the right PIN reveals the full rack.
|
||||||
|
await page.locator('.unlock').click();
|
||||||
|
await typePin(page, '0000');
|
||||||
|
await expect(page.locator('.pad')).toBeVisible(); // still open after a wrong PIN
|
||||||
|
await typePin(page, '1234');
|
||||||
|
await expect(page.locator('.rack .tile')).toHaveCount(7);
|
||||||
|
|
||||||
|
// Opening the history reveals NO social controls on the seat plaques (a local game is
|
||||||
|
// account-less), and the Dictionary entry (the comms button) is kept for the active game.
|
||||||
|
await page.locator('.scoreboard').click();
|
||||||
|
await expect(page.locator('.fico')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.chat-ico')).toBeVisible();
|
||||||
|
await page.locator('.scoreboard').click();
|
||||||
|
|
||||||
|
// Host override: 🔐 -> master PIN -> skip the current turn -> confirm. The turn passes to Bob,
|
||||||
|
// whose seat is open, so his rack shows without a lock.
|
||||||
|
await page.locator('button.tab', { hasText: /Host|Ведущий/ }).click();
|
||||||
|
await typePin(page, '9999');
|
||||||
|
await page.getByRole('button', { name: /Skip|Пропустить/ }).click();
|
||||||
|
await page.getByRole('button', { name: /^(OK|ОК)$/ }).click();
|
||||||
|
await expect(page.locator('.unlock')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.rack .tile')).toHaveCount(7);
|
||||||
|
|
||||||
|
// Back in the lobby the active hotseat game has a kebab; terminating it needs the master PIN.
|
||||||
|
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||||
|
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
|
||||||
|
await expect(page.locator('.rowwrap')).toHaveCount(1);
|
||||||
|
await page.locator('.kebab').first().click();
|
||||||
|
const del = page.locator('.rowwrap.revealed .del');
|
||||||
|
await expect(del).toBeVisible();
|
||||||
|
await del.click();
|
||||||
|
await typePin(page, '9999');
|
||||||
|
await expect(page.locator('.rowwrap')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+15
-3
@@ -13,10 +13,12 @@ async function forceStandalone(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// After a load or reload, land in the lobby (dismiss the login screen if it is shown).
|
// 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> {
|
async function enterLobby(page: Page): Promise<void> {
|
||||||
const guest = page.getByRole('button', { name: /guest|гост/i });
|
await page.getByRole('button', { name: /guest|гост/i }).first().click();
|
||||||
if (await guest.count()) await guest.first().click();
|
|
||||||
// The lobby tab bar has three tabs in a fixed order: New (0), Stats (1), Settings (2). nth() is
|
// 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
|
// robust to locale, emoji variation selectors and the coachmark anchors (which the first-run
|
||||||
// onboarding strips after it completes).
|
// onboarding strips after it completes).
|
||||||
@@ -60,6 +62,9 @@ test.describe('offline mode', () => {
|
|||||||
await page.locator('button.invite').click();
|
await page.locator('button.invite').click();
|
||||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
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).
|
// The human plays WAY horizontally across the centre (7,5)-(7,7).
|
||||||
await placeTile(page, 'W', 7, 5);
|
await placeTile(page, 'W', 7, 5);
|
||||||
await placeTile(page, 'A', 7, 6);
|
await placeTile(page, 'A', 7, 6);
|
||||||
@@ -74,10 +79,17 @@ test.describe('offline mode', () => {
|
|||||||
}).toPass({ timeout: 15000 });
|
}).toPass({ timeout: 15000 });
|
||||||
const filled = await page.locator('[data-cell].filled').count();
|
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
|
// Reload: the hash router restores the /game/<id> route and the local game replays from
|
||||||
// IndexedDB with every committed tile intact.
|
// IndexedDB with every committed tile intact.
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||||
await expect(page.locator('[data-cell].filled')).toHaveCount(filled);
|
await expect(page.locator('[data-cell].filled')).toHaveCount(filled);
|
||||||
|
// The idle-hint gate is persisted (a wall-clock unlock time), so the 🔒 survives the reload —
|
||||||
|
// the wait was not reset by relaunching.
|
||||||
|
await expect(page.locator('.lock')).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { expect, test } from './fixtures';
|
||||||
|
|
||||||
|
// The hash router must update its reactive `route` rune synchronously inside navigate(), not defer it
|
||||||
|
// to the asynchronous `hashchange` event. Bootstrap flips `app.ready` in the same tick right after
|
||||||
|
// `navigate('/login')` on an unauthenticated cold start; a route that trailed the hash for one frame
|
||||||
|
// let App.svelte render the stale route (the empty-hash lobby) under the new login screen — a visible
|
||||||
|
// lobby-shell flash plus a doomed games.list. It also made the offline e2e flaky: enterLobby could
|
||||||
|
// latch that transient lobby tab-bar instead of clicking through the login screen.
|
||||||
|
|
||||||
|
test('navigate updates the reactive route synchronously (no hashchange lag)', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// The bundle has loaded and installed the mock __router seam once the login screen is up.
|
||||||
|
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Read the route in the SAME microtask as the navigate: the fix makes it the new route at once;
|
||||||
|
// the pre-fix code returned the previous route (it waited for the async hashchange).
|
||||||
|
const routeAfterNavigate = await page.evaluate(() => {
|
||||||
|
const r = (window as unknown as { __router: { navigate(p: string): void; route(): string } }).__router;
|
||||||
|
r.navigate('/settings');
|
||||||
|
return r.route();
|
||||||
|
});
|
||||||
|
expect(routeAfterNavigate).toBe('settings');
|
||||||
|
|
||||||
|
// The hash was written too, so a reload/back still resolves the same route.
|
||||||
|
expect(new URL(page.url()).hash).toBe('#/settings');
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// Playwright's WebKit has no real soft keyboard, so emulate iOS's visual-viewport behaviour: replace
|
||||||
|
// window.visualViewport with a controllable fake BEFORE the app boots. The app's syncViewport
|
||||||
|
// (app.svelte.ts) reads visualViewport.height/offsetTop and mirrors them into --vvh / --vv-top, which
|
||||||
|
// position the pinned app-shell (app.css html.app-shell body). Driving the fake exercises the exact
|
||||||
|
// code path the real keyboard triggers on iOS — where the layout viewport does NOT shrink and the
|
||||||
|
// visual viewport instead offsets down toward the focused field.
|
||||||
|
async function installFakeViewport(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const fake = new EventTarget() as EventTarget & { height: number; offsetTop: number; width: number };
|
||||||
|
fake.height = window.innerHeight;
|
||||||
|
fake.offsetTop = 0;
|
||||||
|
fake.width = window.innerWidth;
|
||||||
|
Object.defineProperty(window, 'visualViewport', { configurable: true, value: fake });
|
||||||
|
(window as unknown as { __vv: typeof fake }).__vv = fake;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setViewport(page: Page, height: number, offsetTop: number): Promise<void> {
|
||||||
|
await page.evaluate(
|
||||||
|
([h, t]) => {
|
||||||
|
const vv = (window as unknown as { __vv: { height: number; offsetTop: number; dispatchEvent(e: Event): boolean } }).__vv;
|
||||||
|
vv.height = h;
|
||||||
|
vv.offsetTop = t;
|
||||||
|
vv.dispatchEvent(new Event('resize'));
|
||||||
|
vv.dispatchEvent(new Event('scroll'));
|
||||||
|
},
|
||||||
|
[height, offsetTop],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shell(page: Page): Promise<{ vvh: string; top: string; bodyTop: string }> {
|
||||||
|
return page.evaluate(() => ({
|
||||||
|
vvh: getComputedStyle(document.documentElement).getPropertyValue('--vvh').trim(),
|
||||||
|
top: getComputedStyle(document.documentElement).getPropertyValue('--vv-top').trim(),
|
||||||
|
bodyTop: getComputedStyle(document.body).top,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('visual-viewport shell (soft-keyboard alignment)', () => {
|
||||||
|
test('the pinned shell follows the visual viewport height AND offset', async ({ page }) => {
|
||||||
|
await installFakeViewport(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('html.app-shell')).toBeAttached();
|
||||||
|
const innerH = await page.evaluate(() => window.innerHeight);
|
||||||
|
|
||||||
|
// Keyboard closed: full height, no offset.
|
||||||
|
await setViewport(page, innerH, 0);
|
||||||
|
let s = await shell(page);
|
||||||
|
expect(s.top).toBe('0px');
|
||||||
|
expect(s.bodyTop).toBe('0px');
|
||||||
|
|
||||||
|
// Keyboard open + iOS offset: the visual viewport shrinks AND offsets down. The pinned shell must
|
||||||
|
// follow BOTH — the body's top must equal the offset (not stay at 0), else the top-anchored
|
||||||
|
// content shows empty space below (the iOS bug this fixes).
|
||||||
|
await setViewport(page, innerH - 300, 180);
|
||||||
|
s = await shell(page);
|
||||||
|
expect(s.vvh).toBe(`${innerH - 300}px`);
|
||||||
|
expect(s.top).toBe('180px');
|
||||||
|
expect(s.bodyTop).toBe('180px');
|
||||||
|
|
||||||
|
// Keyboard closed again: the offset reverts to 0 — no stale shift left behind.
|
||||||
|
await setViewport(page, innerH, 0);
|
||||||
|
s = await shell(page);
|
||||||
|
expect(s.top).toBe('0px');
|
||||||
|
expect(s.bodyTop).toBe('0px');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,12 +20,16 @@ import { join } from 'node:path';
|
|||||||
const DIST = 'dist';
|
const DIST = 'dist';
|
||||||
|
|
||||||
// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview
|
// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview
|
||||||
// wiring, to 112 for the PWA install feature, to 113 for the offline-mode wiring, then to 114 for
|
// wiring, to 112 for the PWA install feature, to 113 for the offline-mode wiring, to 114 for
|
||||||
// the offline auto-detect: the cold-start reachability check and the "no connection" dialog live in
|
// the offline auto-detect (the cold-start reachability check and the "no connection" dialog live in
|
||||||
// the boot path (app.svelte.ts / the App shell), which cannot be lazy-loaded. The heavy parts — the
|
// the boot path), then to 115 for the vs_ai idle-hint gate — its monotonic clock, lock badge and
|
||||||
// dict loader, the move generator and the preload orchestration — still stay in lazy chunks. Scoped
|
// countdown toast live in the always-loaded game screen (Game.svelte) — and to 120 for the offline
|
||||||
// CSS lands in the CSS chunk, not this JS budget.
|
// pass-and-play (hotseat) mode: the on-screen PIN pad, the creation roster and the in-game host menu
|
||||||
const BUDGET = { app: 114, shared: 30, landing: 5 };
|
// 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.
|
||||||
|
|||||||
+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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -16,18 +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).
|
||||||
// An honest-AI game has only the Dictionary, so start there regardless of the entry route — this
|
// A chatless game starts on the Dictionary regardless of the entry route — this keeps ChatScreen
|
||||||
// keeps ChatScreen (which fetches chat over the network) from mounting even for a beat, which would
|
// (which fetches chat over the network) from mounting even for a beat, which would otherwise raise
|
||||||
// otherwise raise an error toast in offline mode.
|
// an error toast in offline mode.
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let tab = $state<CommsTab>(vsAi ? 'dictionary' : 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>
|
||||||
@@ -46,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>
|
||||||
|
|||||||
+357
-35
@@ -4,6 +4,7 @@
|
|||||||
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';
|
||||||
@@ -19,11 +20,12 @@
|
|||||||
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';
|
||||||
@@ -163,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).
|
||||||
@@ -209,6 +243,8 @@
|
|||||||
source.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);
|
||||||
@@ -261,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 ?? '');
|
||||||
@@ -329,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).
|
||||||
@@ -634,6 +677,10 @@
|
|||||||
void source.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
|
void source.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
|
||||||
}
|
}
|
||||||
localUnsub?.();
|
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) {
|
||||||
@@ -738,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.
|
||||||
@@ -797,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 source.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) {
|
||||||
@@ -822,6 +963,7 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
applyMoveResult(await source.pass(id));
|
applyMoveResult(await source.pass(id));
|
||||||
|
if (view?.game.hotseat) await advanceHotseat();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -854,6 +996,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 source.hint(id);
|
const h = await source.hint(id);
|
||||||
if (h.move.tiles.length && view) {
|
if (h.move.tiles.length && view) {
|
||||||
@@ -890,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);
|
||||||
}
|
}
|
||||||
@@ -928,6 +1085,7 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
applyMoveResult(await source.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 {
|
||||||
@@ -949,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
|
||||||
@@ -1265,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 &&
|
||||||
@@ -1280,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>
|
||||||
@@ -1338,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>
|
||||||
@@ -1382,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>
|
||||||
@@ -1459,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}
|
||||||
@@ -1475,36 +1650,58 @@
|
|||||||
a finished game shows the final rack greyed out and the controls disabled. -->
|
a finished game shows the final rack greyed out and the controls disabled. -->
|
||||||
<div class="rack-row" class:inert={gameOver} data-coach="game-rack">
|
<div class="rack-row" class:inert={gameOver} data-coach="game-rack">
|
||||||
<div class="rack-wrap">
|
<div class="rack-wrap">
|
||||||
<Rack
|
{#if locked}
|
||||||
slots={rackSlots}
|
<!-- A PIN-locked hotseat seat: the rack is withheld until its owner unlocks it. The board
|
||||||
{variant}
|
above stays visible; only this tray is gated. -->
|
||||||
{selected}
|
<button class="unlock" onclick={() => (unlockOpen = true)}>🔒 {t('hotseat.unlock')}</button>
|
||||||
{landscape}
|
{:else}
|
||||||
shuffling={shuffling && !app.reduceMotion}
|
<Rack
|
||||||
draggingId={reorderDragId}
|
slots={rackSlots}
|
||||||
dropIndex={reorderTo}
|
{variant}
|
||||||
ondown={onRackDown}
|
{selected}
|
||||||
/>
|
{landscape}
|
||||||
|
shuffling={shuffling && !app.reduceMotion}
|
||||||
|
draggingId={reorderDragId}
|
||||||
|
dropIndex={reorderTo}
|
||||||
|
ondown={onRackDown}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
|
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
|
||||||
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !netReady || !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 || !netReady} onclick={openExchange}>
|
<button class="tab" disabled={busy || !canMove || !netReady} onclick={openExchange}>
|
||||||
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
|
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
|
||||||
</button>
|
</button>
|
||||||
<TapConfirm
|
{#if view?.game.hotseat}
|
||||||
triggerClass="tab"
|
<!-- Hotseat: no hints. The freed slot becomes the host (referee) button — always available
|
||||||
label={t('game.hint')}
|
(even on a locked seat: the host may skip a player who forgot their PIN). -->
|
||||||
disabled={busy || !isMyTurn || !netReady || hintCount <= 0}
|
<button class="tab" disabled={busy || gameOver} onclick={() => (hostMenuStep = 'pin')}>
|
||||||
onconfirm={doHint}
|
<span class="sq">🔐</span><span class="lbl">{t('hotseat.host')}</span>
|
||||||
>
|
</button>
|
||||||
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
|
{:else if view?.game.vsAi}
|
||||||
<span class="lbl">{t('game.hint')}</span>
|
<!-- vs_ai hints are unlimited and wallet-free, so no confirm and no count. A 🔒 badge marks the
|
||||||
</TapConfirm>
|
idle gate while it is closed; tapping then only explains when it opens (doHint), and the lock
|
||||||
|
lifts live at the unlock moment. -->
|
||||||
|
<button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={doHint}>
|
||||||
|
<span class="sq" data-coach="game-hints">🛟{#if hintGated}<span class="lock" aria-hidden="true">🔒</span>{/if}</span>
|
||||||
|
<span class="lbl">{t('game.hint')}</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<TapConfirm
|
||||||
|
triggerClass="tab"
|
||||||
|
label={t('game.hint')}
|
||||||
|
disabled={busy || !isMyTurn || !netReady || hintCount <= 0}
|
||||||
|
onconfirm={doHint}
|
||||||
|
>
|
||||||
|
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
|
||||||
|
<span class="lbl">{t('game.hint')}</span>
|
||||||
|
</TapConfirm>
|
||||||
|
{/if}
|
||||||
{#if placement.pending.length > 0}
|
{#if placement.pending.length > 0}
|
||||||
<button class="tab" disabled={busy} onclick={resetPlacement}>
|
<button class="tab" disabled={busy} onclick={resetPlacement}>
|
||||||
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
|
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
|
||||||
@@ -1562,6 +1759,55 @@
|
|||||||
</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">
|
||||||
@@ -1631,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;
|
||||||
@@ -2052,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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-11
@@ -725,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -795,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
|
||||||
@@ -1310,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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -461,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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ export const localSource = {
|
|||||||
create: (opts) => load().then((s) => s.create(opts)),
|
create: (opts) => load().then((s) => s.create(opts)),
|
||||||
list: () => load().then((s) => s.list()),
|
list: () => load().then((s) => s.list()),
|
||||||
delete: (id) => load().then((s) => s.delete(id)),
|
delete: (id) => load().then((s) => s.delete(id)),
|
||||||
|
// Offline hotseat controls (no network equivalent): reveal a PIN-locked seat, and the host
|
||||||
|
// (referee) overrides gated by the master PIN.
|
||||||
|
unlockSeat: (id, pin) => load().then((s) => s.unlockSeat(id, pin)),
|
||||||
|
verifyHostPin: (id, pin) => load().then((s) => s.verifyHostPin(id, pin)),
|
||||||
|
hostAction: (id, pin, action, targetSeat) => load().then((s) => s.hostAction(id, pin, action, targetSeat)),
|
||||||
|
// relock is fire-and-forget: the engine is already loaded (the game was open), and re-locking a
|
||||||
|
// cached game before the screen is left needs no result.
|
||||||
|
relock: (id) => void load().then((s) => s.relock(id)),
|
||||||
// events must return the unsubscribe synchronously (the screen subscribes in onMount), so it wires
|
// events must return the unsubscribe synchronously (the screen subscribes in onMount), so it wires
|
||||||
// the real subscription once the engine loads and, until then, cancels a pending subscribe.
|
// the real subscription once the engine loads and, until then, cancels a pending subscribe.
|
||||||
events: (id, onEvent) => {
|
events: (id, onEvent) => {
|
||||||
@@ -54,7 +62,8 @@ export const localSource = {
|
|||||||
unsub();
|
unsub();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
} satisfies GameLoopSource & Pick<LocalSource, 'events' | 'create' | 'list' | 'delete'>;
|
} satisfies GameLoopSource &
|
||||||
|
Pick<LocalSource, 'events' | 'create' | 'list' | 'delete' | 'unlockSeat' | 'verifyHostPin' | 'hostAction' | 'relock'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* gameSource returns the source that runs the game with the given id: the local engine for a local
|
* gameSource returns the source that runs the game with the given id: the local engine for a local
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { hintsLeft } from './hints';
|
import { hintsLeft, hintGateRemainingMs, hintLockMinutes } from './hints';
|
||||||
|
|
||||||
// view carries only the two fields hintsLeft reads.
|
// view carries only the two fields hintsLeft reads.
|
||||||
const view = (hintsRemaining: number, walletBalance: number) => ({ hintsRemaining, walletBalance });
|
const view = (hintsRemaining: number, walletBalance: number) => ({ hintsRemaining, walletBalance });
|
||||||
@@ -30,3 +30,32 @@ describe('hintsLeft', () => {
|
|||||||
expect(hintsLeft(view(1, 0), -5)).toBe(1);
|
expect(hintsLeft(view(1, 0), -5)).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('hintGateRemainingMs (the vs_ai idle-hint gate, monotonic)', () => {
|
||||||
|
it('is 0 when the gate is open — a null anchor (the human first move, or a non-gated game)', () => {
|
||||||
|
expect(hintGateRemainingMs(null, 60_000, 5000)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is the window remaining minus the monotonic time elapsed since the anchor', () => {
|
||||||
|
// anchored at mono 1000 with 60s left; 20s of monotonic time later -> 40s left.
|
||||||
|
expect(hintGateRemainingMs(1000, 60_000, 1000 + 20_000)).toBe(40_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is 0 once the elapsed monotonic time reaches (or passes) the window', () => {
|
||||||
|
expect(hintGateRemainingMs(1000, 60_000, 1000 + 60_000)).toBe(0);
|
||||||
|
expect(hintGateRemainingMs(1000, 60_000, 1000 + 90_000)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hintLockMinutes (the toast N, rounded up)', () => {
|
||||||
|
it('rounds up to whole minutes, so a still-closed gate never reads 0', () => {
|
||||||
|
expect(hintLockMinutes(1)).toBe(1);
|
||||||
|
expect(hintLockMinutes(60_000)).toBe(1);
|
||||||
|
expect(hintLockMinutes(61_000)).toBe(2);
|
||||||
|
expect(hintLockMinutes(29 * 60_000)).toBe(29);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is 0 when nothing remains', () => {
|
||||||
|
expect(hintLockMinutes(0)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,3 +24,27 @@ export function hintsLeft(
|
|||||||
const allowance = Math.max(0, view.hintsRemaining - view.walletBalance);
|
const allowance = Math.max(0, view.hintsRemaining - view.walletBalance);
|
||||||
return allowance + Math.max(0, walletBalance);
|
return allowance + Math.max(0, walletBalance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** HINT_GATE_MS is the idle time a vs_ai player must be stuck on a turn before a hint unlocks. */
|
||||||
|
export const HINT_GATE_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hintGateRemainingMs returns how long (in milliseconds) until a vs_ai game's idle hint unlocks — 0
|
||||||
|
* once it is available. It counts down from a MONOTONIC clock (performance.now()), so a client clock
|
||||||
|
* change cannot skew it: gateStartMono is performance.now() captured when the source's "seconds left"
|
||||||
|
* was received, gateLeftMs is that seconds-left in ms, and monoNow is performance.now() now; the
|
||||||
|
* remaining is gateLeftMs minus the monotonic time elapsed since the anchor. gateStartMono null means
|
||||||
|
* the gate is open (the human's first move, or a non-gated game). The duration comes from the source
|
||||||
|
* (the SERVER clock online, the device clock offline) and is re-fetched on every load, so the wait
|
||||||
|
* both persists across a relaunch and stays immune to a live clock change.
|
||||||
|
*/
|
||||||
|
export function hintGateRemainingMs(gateStartMono: number | null, gateLeftMs: number, monoNow: number): number {
|
||||||
|
if (gateStartMono === null) return 0;
|
||||||
|
return Math.max(0, gateLeftMs - (monoNow - gateStartMono));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** hintLockMinutes rounds a remaining-milliseconds gap up to whole minutes for the "available in N
|
||||||
|
* min." toast, so a gate that is still closed never reads 0. */
|
||||||
|
export function hintLockMinutes(remainingMs: number): number {
|
||||||
|
return Math.ceil(remainingMs / 60000);
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export const en = {
|
|||||||
'game.draw': 'Exchange/Pass',
|
'game.draw': 'Exchange/Pass',
|
||||||
'game.shuffle': 'Shuffle',
|
'game.shuffle': 'Shuffle',
|
||||||
'game.hint': 'Hint',
|
'game.hint': 'Hint',
|
||||||
|
'game.hintLockedIn': 'Available in {n} min.',
|
||||||
'game.chat': 'Chat',
|
'game.chat': 'Chat',
|
||||||
'game.checkWord': 'Check word',
|
'game.checkWord': 'Check word',
|
||||||
'game.dictionary': 'Dictionary',
|
'game.dictionary': 'Dictionary',
|
||||||
@@ -388,6 +389,35 @@ export const en = {
|
|||||||
'error.game_active': 'Available only after the game is finished.',
|
'error.game_active': 'Available only after the game is finished.',
|
||||||
'error.invalid_profile': 'Some profile fields are invalid.',
|
'error.invalid_profile': 'Some profile fields are invalid.',
|
||||||
'error.already_confirmed': 'This email is already confirmed.',
|
'error.already_confirmed': 'This email is already confirmed.',
|
||||||
|
|
||||||
|
'pin.enter': 'Enter PIN',
|
||||||
|
'pin.create': 'Create a PIN',
|
||||||
|
'pin.repeat': 'Repeat the PIN',
|
||||||
|
'pin.enterCurrent': 'Enter current PIN',
|
||||||
|
'pin.mismatch': "PINs don't match",
|
||||||
|
'pin.wrong': 'Wrong PIN',
|
||||||
|
'pin.setNew': 'Set a new PIN',
|
||||||
|
'pin.remove': 'Remove PIN',
|
||||||
|
|
||||||
|
'hotseat.hostPin': 'Host password',
|
||||||
|
'hotseat.setPin': 'Password',
|
||||||
|
'hotseat.changePin': 'Change',
|
||||||
|
'hotseat.playerName': 'Player name',
|
||||||
|
'hotseat.addPlayer': 'Add player',
|
||||||
|
'hotseat.removePlayer': 'Remove player',
|
||||||
|
'hotseat.hostPlaysTitle': 'Are you playing too?',
|
||||||
|
'hotseat.hostPlaysYes': 'Yes, I play',
|
||||||
|
'hotseat.hostPlaysNo': 'No',
|
||||||
|
'hotseat.unlock': 'Unlock',
|
||||||
|
'hotseat.unlockTitle': 'Unlock: {name}',
|
||||||
|
'hotseat.turnOf': 'Turn: {name}',
|
||||||
|
'hotseat.host': 'Host',
|
||||||
|
'hotseat.skip': 'Skip the current turn',
|
||||||
|
'hotseat.exclude': 'Exclude a player:',
|
||||||
|
'hotseat.terminate': 'End the game',
|
||||||
|
'hotseat.askSkip': "Skip {name}'s turn?",
|
||||||
|
'hotseat.askExclude': 'Remove {name} from the game?',
|
||||||
|
'hotseat.askTerminate': 'End the game with no result?',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type MessageKey = keyof typeof en;
|
export type MessageKey = keyof typeof en;
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'game.draw': 'Обмен/Пас',
|
'game.draw': 'Обмен/Пас',
|
||||||
'game.shuffle': 'Перемешать',
|
'game.shuffle': 'Перемешать',
|
||||||
'game.hint': 'Подсказка',
|
'game.hint': 'Подсказка',
|
||||||
|
'game.hintLockedIn': 'Будет доступно через {n} мин.',
|
||||||
'game.chat': 'Чат',
|
'game.chat': 'Чат',
|
||||||
'game.checkWord': 'Проверить слово',
|
'game.checkWord': 'Проверить слово',
|
||||||
'game.dictionary': 'Словарь',
|
'game.dictionary': 'Словарь',
|
||||||
@@ -388,4 +389,33 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'error.game_active': 'Доступно только после завершения игры.',
|
'error.game_active': 'Доступно только после завершения игры.',
|
||||||
'error.invalid_profile': 'Некоторые поля профиля некорректны.',
|
'error.invalid_profile': 'Некоторые поля профиля некорректны.',
|
||||||
'error.already_confirmed': 'Эта почта уже подтверждена.',
|
'error.already_confirmed': 'Эта почта уже подтверждена.',
|
||||||
|
|
||||||
|
'pin.enter': 'Введите PIN',
|
||||||
|
'pin.create': 'Придумайте PIN',
|
||||||
|
'pin.repeat': 'Повторите PIN',
|
||||||
|
'pin.enterCurrent': 'Введите текущий PIN',
|
||||||
|
'pin.mismatch': 'PIN не совпадает',
|
||||||
|
'pin.wrong': 'Неверный PIN',
|
||||||
|
'pin.setNew': 'Задать новый PIN',
|
||||||
|
'pin.remove': 'Удалить пароль',
|
||||||
|
|
||||||
|
'hotseat.hostPin': 'Пароль ведущего',
|
||||||
|
'hotseat.setPin': 'Пароль',
|
||||||
|
'hotseat.changePin': 'Изменить',
|
||||||
|
'hotseat.playerName': 'Имя игрока',
|
||||||
|
'hotseat.addPlayer': 'Добавить игрока',
|
||||||
|
'hotseat.removePlayer': 'Удалить игрока',
|
||||||
|
'hotseat.hostPlaysTitle': 'Принимаете участие в игре?',
|
||||||
|
'hotseat.hostPlaysYes': 'Да, играю',
|
||||||
|
'hotseat.hostPlaysNo': 'Нет',
|
||||||
|
'hotseat.unlock': 'Разблокировать',
|
||||||
|
'hotseat.unlockTitle': 'Разблокировать: {name}',
|
||||||
|
'hotseat.turnOf': 'Ход: {name}',
|
||||||
|
'hotseat.host': 'Ведущий',
|
||||||
|
'hotseat.skip': 'Пропустить ход',
|
||||||
|
'hotseat.exclude': 'Исключить игрока:',
|
||||||
|
'hotseat.terminate': 'Отменить партию',
|
||||||
|
'hotseat.askSkip': 'Пропустить ход игрока {name}?',
|
||||||
|
'hotseat.askExclude': 'Удалить {name} из игры?',
|
||||||
|
'hotseat.askTerminate': 'Отменить партию без результата?',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -240,6 +240,10 @@ export class LocalGame {
|
|||||||
handOf(player: number): number[] {
|
handOf(player: number): number[] {
|
||||||
return [...this.hands[player]];
|
return [...this.hands[player]];
|
||||||
}
|
}
|
||||||
|
/** resignedOf reports whether a seat has resigned or been excluded by the host. */
|
||||||
|
resignedOf(player: number): boolean {
|
||||||
|
return this.resigned[player] ?? false;
|
||||||
|
}
|
||||||
/** winnerIndex is the finished game's winner, or -1 (tie / in progress / aborted). */
|
/** winnerIndex is the finished game's winner, or -1 (tie / in progress / aborted). */
|
||||||
get winnerIndex(): number {
|
get winnerIndex(): number {
|
||||||
return winner(this.over, this.reason, this.scores, this.resigned);
|
return winner(this.over, this.reason, this.scores, this.resigned);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function makeGame(seed: bigint): LocalGame {
|
|||||||
return new LocalGame({ variant: 'scrabble_en', version: 'sample', seed, players: 2, dawg, multipleWords: true });
|
return new LocalGame({ variant: 'scrabble_en', version: 'sample', seed, players: 2, dawg, multipleWords: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = { id: 'g1', seats, createdAtUnix: 1, updatedAtUnix: 2, robotLastMoveAtUnix: 3 };
|
const meta = { id: 'g1', seats, createdAtUnix: 1, updatedAtUnix: 2, hintUnlockAtMs: 0 };
|
||||||
|
|
||||||
describe('local game serialize + replay', () => {
|
describe('local game serialize + replay', () => {
|
||||||
it('reconstructs a mid-game position by replay', () => {
|
it('reconstructs a mid-game position by replay', () => {
|
||||||
@@ -83,4 +83,20 @@ describe('local game serialize + replay', () => {
|
|||||||
expect(rec.journal.length).toBe(g.moveCount);
|
expect(rec.journal.length).toBe(g.moveCount);
|
||||||
expect(replayGame(rec, dawg).seed).toBe(9999999999n);
|
expect(replayGame(rec, dawg).seed).toBe(9999999999n);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serialises hotseat metadata: the mode, master PIN and per-seat PINs', async () => {
|
||||||
|
const { newLock } = await import('../pin');
|
||||||
|
const hostPin = await newLock('9999');
|
||||||
|
const seatPin = await newLock('1234');
|
||||||
|
const hotseatSeats: Seat[] = [
|
||||||
|
{ kind: 'human', name: 'Ann', pin: seatPin },
|
||||||
|
{ kind: 'human', name: 'Bob' },
|
||||||
|
];
|
||||||
|
const g = makeGame(5n);
|
||||||
|
const rec = serializeGame(g, { id: 'h1', seats: hotseatSeats, hotseat: true, hostPin, createdAtUnix: 1, updatedAtUnix: 2, hintUnlockAtMs: 0 });
|
||||||
|
expect(rec.hotseat).toBe(true);
|
||||||
|
expect(rec.hostPin).toEqual(hostPin);
|
||||||
|
expect(rec.seats[0].pin).toEqual(seatPin);
|
||||||
|
expect(rec.seats[1].pin).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import { LocalGame, type LocalMove, type DropoutTiles, type LocalGameOptions } from './engine';
|
import { LocalGame, type LocalMove, type DropoutTiles, type LocalGameOptions } from './engine';
|
||||||
import type { Dawg } from '../dict/dawg';
|
import type { Dawg } from '../dict/dawg';
|
||||||
import type { Variant } from '../model';
|
import type { Variant } from '../model';
|
||||||
|
import type { PinLock } from '../pin';
|
||||||
|
|
||||||
/** Seat describes one player of a local game (forward-compatible with a 2-4 hotseat). */
|
/** Seat describes one player of a local game (forward-compatible with a 2-4 hotseat). */
|
||||||
export interface Seat {
|
export interface Seat {
|
||||||
@@ -20,6 +21,9 @@ export interface Seat {
|
|||||||
/** The player's real account id for a human seat, so the client identifies "you" and the correct
|
/** The player's real account id for a human seat, so the client identifies "you" and the correct
|
||||||
* turn exactly as in an online game; absent for the robot (a synthetic id is derived). */
|
* turn exactly as in an online game; absent for the robot (a synthetic id is derived). */
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
/** Offline hotseat only: the seat's optional PIN lock. When set, the seat's rack stays hidden
|
||||||
|
* until the PIN is entered (a social lock for a shared device — see lib/pin). */
|
||||||
|
pin?: PinLock;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** LocalGameRecord is the durable form of one local game — everything needed to reconstruct it. */
|
/** LocalGameRecord is the durable form of one local game — everything needed to reconstruct it. */
|
||||||
@@ -36,19 +40,30 @@ export interface LocalGameRecord {
|
|||||||
/** The dictionary-independent, alphabet-index-space move journal. */
|
/** The dictionary-independent, alphabet-index-space move journal. */
|
||||||
journal: LocalMove[];
|
journal: LocalMove[];
|
||||||
status: 'active' | 'finished';
|
status: 'active' | 'finished';
|
||||||
|
/** Offline pass-and-play (hotseat): 2-4 humans share one device. Absent on vs_ai records (and on
|
||||||
|
* older records predating the feature), which the source reads as false. */
|
||||||
|
hotseat?: boolean;
|
||||||
|
/** Offline hotseat only: the mandatory master (host/referee) PIN lock, needed to skip/resign a
|
||||||
|
* seat, exclude a player or terminate the game (see lib/pin). Absent on vs_ai records. */
|
||||||
|
hostPin?: PinLock;
|
||||||
createdAtUnix: number;
|
createdAtUnix: number;
|
||||||
updatedAtUnix: number;
|
updatedAtUnix: number;
|
||||||
/** Unix seconds of the robot's most recent move — the local hint gate (>30 min) reads this. */
|
/** Wall-clock ms at which the vs_ai idle hint unlocks (the robot's last move + the window), or 0
|
||||||
robotLastMoveAtUnix: number;
|
* while open (no robot move yet). Persisted so the wait survives leaving and reopening the app;
|
||||||
|
* read through a sanitiser (cap at now + window) so a device clock set back cannot freeze it —
|
||||||
|
* see lib/hints. */
|
||||||
|
hintUnlockAtMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The record fields the caller owns (the engine supplies the rest). */
|
/** The record fields the caller owns (the engine supplies the rest). */
|
||||||
export interface RecordMeta {
|
export interface RecordMeta {
|
||||||
id: string;
|
id: string;
|
||||||
seats: Seat[];
|
seats: Seat[];
|
||||||
|
hotseat?: boolean;
|
||||||
|
hostPin?: PinLock;
|
||||||
createdAtUnix: number;
|
createdAtUnix: number;
|
||||||
updatedAtUnix: number;
|
updatedAtUnix: number;
|
||||||
robotLastMoveAtUnix: number;
|
hintUnlockAtMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,9 +83,11 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor
|
|||||||
seats: meta.seats,
|
seats: meta.seats,
|
||||||
journal: game.history,
|
journal: game.history,
|
||||||
status: game.isOver ? 'finished' : 'active',
|
status: game.isOver ? 'finished' : 'active',
|
||||||
|
hotseat: meta.hotseat,
|
||||||
|
hostPin: meta.hostPin,
|
||||||
createdAtUnix: meta.createdAtUnix,
|
createdAtUnix: meta.createdAtUnix,
|
||||||
updatedAtUnix: meta.updatedAtUnix,
|
updatedAtUnix: meta.updatedAtUnix,
|
||||||
robotLastMoveAtUnix: meta.robotLastMoveAtUnix,
|
hintUnlockAtMs: meta.hintUnlockAtMs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import type { StateView } from '../model';
|
||||||
|
|
||||||
|
// getDawg is mocked to the committed sample dictionary (the store's IndexedDB is absent under node,
|
||||||
|
// so games live in the source's in-memory cache — created once, then driven without reload).
|
||||||
|
vi.mock('../dict', () => ({
|
||||||
|
getDawg: async () => {
|
||||||
|
const { Dawg } = await import('../dict/dawg');
|
||||||
|
const { readFileSync } = await import('node:fs');
|
||||||
|
return new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { LocalSource } from './source';
|
||||||
|
import type { Seat } from './serialize';
|
||||||
|
import { newLock } from '../pin';
|
||||||
|
|
||||||
|
async function hotseat(id: string, seats: Seat[], hostDigits = '9999'): Promise<LocalSource> {
|
||||||
|
const src = new LocalSource();
|
||||||
|
await src.create({
|
||||||
|
id,
|
||||||
|
variant: 'scrabble_en',
|
||||||
|
dictVersion: 'sample',
|
||||||
|
seed: 3n,
|
||||||
|
multipleWords: true,
|
||||||
|
seats,
|
||||||
|
hotseat: true,
|
||||||
|
hostPin: await newLock(hostDigits),
|
||||||
|
});
|
||||||
|
return src;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LocalSource hotseat', () => {
|
||||||
|
it('creates a pass-and-play game: not vs_ai, board visible, first seat to move', async () => {
|
||||||
|
const src = await hotseat('local:h1', [
|
||||||
|
{ kind: 'human', name: 'Ann' },
|
||||||
|
{ kind: 'human', name: 'Bob' },
|
||||||
|
]);
|
||||||
|
const st = await src.gameState('local:h1');
|
||||||
|
expect(st.game.vsAi).toBe(false);
|
||||||
|
expect(st.game.hotseat).toBe(true);
|
||||||
|
expect(st.game.toMove).toBe(0);
|
||||||
|
expect(st.locked).toBeFalsy(); // seat 0 has no PIN
|
||||||
|
expect(st.rack.length).toBe(7);
|
||||||
|
expect(st.game.seats.map((s) => s.displayName)).toEqual(['Ann', 'Bob']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the rack of a PIN-locked seat until the right PIN unlocks it', async () => {
|
||||||
|
const src = await hotseat('local:h2', [
|
||||||
|
{ kind: 'human', name: 'Ann', pin: await newLock('1234') },
|
||||||
|
{ kind: 'human', name: 'Bob' },
|
||||||
|
]);
|
||||||
|
const st = await src.gameState('local:h2');
|
||||||
|
expect(st.locked).toBe(true);
|
||||||
|
expect(st.rack).toEqual([]);
|
||||||
|
|
||||||
|
await expect(src.unlockSeat('local:h2', '0000')).rejects.toMatchObject({ code: 'wrong_pin' });
|
||||||
|
|
||||||
|
const opened = await src.unlockSeat('local:h2', '1234');
|
||||||
|
expect(opened.locked).toBe(false);
|
||||||
|
expect(opened.rack.length).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-locks the seat when the turn advances and again when it comes back', async () => {
|
||||||
|
const src = await hotseat('local:h3', [
|
||||||
|
{ kind: 'human', name: 'A', pin: await newLock('1111') },
|
||||||
|
{ kind: 'human', name: 'B' },
|
||||||
|
{ kind: 'human', name: 'C' },
|
||||||
|
]);
|
||||||
|
await src.unlockSeat('local:h3', '1111');
|
||||||
|
await src.pass('local:h3'); // seat 0 -> 1
|
||||||
|
let st = await src.gameState('local:h3');
|
||||||
|
expect(st.game.toMove).toBe(1);
|
||||||
|
expect(st.locked).toBeFalsy(); // seat 1 has no PIN
|
||||||
|
|
||||||
|
await src.pass('local:h3'); // 1 -> 2
|
||||||
|
await src.pass('local:h3'); // 2 -> 0
|
||||||
|
st = await src.gameState('local:h3');
|
||||||
|
expect(st.game.toMove).toBe(0);
|
||||||
|
expect(st.locked).toBe(true); // seat 0 needs re-unlocking
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the host skip the current turn, gated by the master PIN', async () => {
|
||||||
|
const src = await hotseat('local:h4', [
|
||||||
|
{ kind: 'human', name: 'A' },
|
||||||
|
{ kind: 'human', name: 'B' },
|
||||||
|
], '4321');
|
||||||
|
expect((await src.gameState('local:h4')).game.toMove).toBe(0);
|
||||||
|
await expect(src.hostAction('local:h4', '0000', 'skip')).rejects.toMatchObject({ code: 'wrong_pin' });
|
||||||
|
const st = await src.hostAction('local:h4', '4321', 'skip');
|
||||||
|
expect(st).not.toBeNull();
|
||||||
|
expect((st as StateView).game.toMove).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the host resign a seat (ending a two-player game)', async () => {
|
||||||
|
const src = await hotseat('local:h5', [
|
||||||
|
{ kind: 'human', name: 'A' },
|
||||||
|
{ kind: 'human', name: 'B' },
|
||||||
|
], '4321');
|
||||||
|
const st = await src.hostAction('local:h5', '4321', 'resign', 1);
|
||||||
|
expect((st as StateView).game.status).toBe('finished');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a host-excluded seat as resigned in the view (for the medal ranking)', async () => {
|
||||||
|
const src = await hotseat('local:hR', [
|
||||||
|
{ kind: 'human', name: 'A' },
|
||||||
|
{ kind: 'human', name: 'B' },
|
||||||
|
{ kind: 'human', name: 'C' },
|
||||||
|
], '4321');
|
||||||
|
await src.hostAction('local:hR', '4321', 'resign', 1); // exclude seat 1 (game continues: 2 active)
|
||||||
|
const st = await src.gameState('local:hR');
|
||||||
|
expect(st.game.seats[1].resigned).toBe(true);
|
||||||
|
expect(st.game.seats[0].resigned).toBe(false);
|
||||||
|
expect(st.game.seats[2].resigned).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the host terminate the game, deleting it', async () => {
|
||||||
|
const src = await hotseat('local:h6', [
|
||||||
|
{ kind: 'human', name: 'A' },
|
||||||
|
{ kind: 'human', name: 'B' },
|
||||||
|
], '4321');
|
||||||
|
const r = await src.hostAction('local:h6', '4321', 'terminate');
|
||||||
|
expect(r).toBeNull();
|
||||||
|
await expect(src.gameState('local:h6')).rejects.toMatchObject({ code: 'game_not_found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-locks an unlocked seat on relock (returning to the lobby)', async () => {
|
||||||
|
const src = await hotseat('local:h9', [
|
||||||
|
{ kind: 'human', name: 'Ann', pin: await newLock('1234') },
|
||||||
|
{ kind: 'human', name: 'Bob' },
|
||||||
|
]);
|
||||||
|
const opened = await src.unlockSeat('local:h9', '1234');
|
||||||
|
expect(opened.locked).toBe(false);
|
||||||
|
src.relock('local:h9');
|
||||||
|
const st = await src.gameState('local:h9');
|
||||||
|
expect(st.locked).toBe(true); // returning to the game re-prompts the seat PIN
|
||||||
|
expect(st.rack).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies the master PIN', async () => {
|
||||||
|
const src = await hotseat('local:h7', [
|
||||||
|
{ kind: 'human', name: 'A' },
|
||||||
|
{ kind: 'human', name: 'B' },
|
||||||
|
], '4321');
|
||||||
|
expect(await src.verifyHostPin('local:h7', '4321')).toBe(true);
|
||||||
|
expect(await src.verifyHostPin('local:h7', '0000')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists a naturally finished game (scoreless run) as finished, and unlocked', async () => {
|
||||||
|
const src = await hotseat('local:h8', [
|
||||||
|
{ kind: 'human', name: 'A', pin: await newLock('1111') },
|
||||||
|
{ kind: 'human', name: 'B', pin: await newLock('2222') },
|
||||||
|
]);
|
||||||
|
for (let i = 0; i < 6; i++) await src.pass('local:h8');
|
||||||
|
const st = await src.gameState('local:h8');
|
||||||
|
expect(st.game.status).toBe('finished');
|
||||||
|
expect(st.locked).toBeFalsy(); // a finished game shows its final rack, never an unlock button
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -72,9 +72,15 @@ describe('LocalSource', () => {
|
|||||||
expect(events.some((e) => e.kind === 'opponent_moved')).toBe(true);
|
expect(events.some((e) => e.kind === 'opponent_moved')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gates the hint until 30 minutes since the robot last moved', async () => {
|
it('does not wall-clock-gate the hint (the idle gate is client-side and monotonic)', async () => {
|
||||||
const src = await newSource('local:g3', 7n);
|
const src = await newSource('local:g3', 7n);
|
||||||
await expect(src.hint('local:g3')).rejects.toMatchObject({ code: 'hint_not_ready' });
|
// hint() serves the engine's top move (or no_hint when the tiny sample dictionary has none for
|
||||||
|
// this rack), but never the old timestamp gate 'hint_not_ready' — that moved to the client
|
||||||
|
// (lib/hints, measured with performance.now()) so a device clock change cannot defeat it.
|
||||||
|
await src.hint('local:g3').then(
|
||||||
|
(h) => expect(h.move).toBeDefined(),
|
||||||
|
(e) => expect((e as { code: string }).code).toBe('no_hint'),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('records decoded moves in the history', async () => {
|
it('records decoded moves in the history', async () => {
|
||||||
|
|||||||
+114
-17
@@ -14,8 +14,10 @@ import { getLocalGame, saveLocalGame, listLocalGames, deleteLocalGame } from './
|
|||||||
import { RULESETS } from './ruleset';
|
import { RULESETS } from './ruleset';
|
||||||
import { getDawg } from '../dict';
|
import { getDawg } from '../dict';
|
||||||
import { setAlphabet, hasAlphabet } from '../alphabet';
|
import { setAlphabet, hasAlphabet } from '../alphabet';
|
||||||
|
import { HINT_GATE_MS } from '../hints';
|
||||||
import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
|
import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
|
||||||
import { decide } from '../robot/strategy';
|
import { decide } from '../robot/strategy';
|
||||||
|
import { verifyPin, type PinLock } from '../pin';
|
||||||
import { GatewayError, type PlacedTile } from '../client';
|
import { GatewayError, type PlacedTile } from '../client';
|
||||||
import type {
|
import type {
|
||||||
EvalResult,
|
EvalResult,
|
||||||
@@ -35,9 +37,6 @@ import type { Move } from '../dict/validate';
|
|||||||
|
|
||||||
export { LOCAL_ID_PREFIX, isLocalGameId };
|
export { LOCAL_ID_PREFIX, isLocalGameId };
|
||||||
|
|
||||||
/** HINT_GATE_MS is the idle time since the robot's last move before an offline hint unlocks. */
|
|
||||||
export const HINT_GATE_MS = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GameLoopSource is the subset of GatewayClient the game screen calls to run a game. The real
|
* GameLoopSource is the subset of GatewayClient the game screen calls to run a game. The real
|
||||||
* gateway satisfies it structurally; the local source implements it for offline play.
|
* gateway satisfies it structurally; the local source implements it for offline play.
|
||||||
@@ -89,6 +88,9 @@ function encodePlacements(variant: Variant, tiles: PlacedTile[]): { row: number;
|
|||||||
interface Live {
|
interface Live {
|
||||||
game: LocalGame;
|
game: LocalGame;
|
||||||
record: LocalGameRecord;
|
record: LocalGameRecord;
|
||||||
|
/** Offline hotseat only: which seat is currently unlocked (its PIN was entered this turn), or null.
|
||||||
|
* Ephemeral — never persisted; cleared on every turn advance so each turn re-locks. */
|
||||||
|
unlockedSeat: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,7 +101,8 @@ export class LocalSource implements GameLoopSource {
|
|||||||
private readonly live = new Map<string, Live>();
|
private readonly live = new Map<string, Live>();
|
||||||
private readonly listeners = new Map<string, Set<(e: PushEvent) => void>>();
|
private readonly listeners = new Map<string, Set<(e: PushEvent) => void>>();
|
||||||
|
|
||||||
/** create starts a new local vs_ai game, persists it and returns its state. */
|
/** create starts a new local game — a vs_ai game or an offline pass-and-play (hotseat) game when
|
||||||
|
* hotseat is set — persists it and returns its state. hostPin is the hotseat master PIN. */
|
||||||
async create(opts: {
|
async create(opts: {
|
||||||
id: string;
|
id: string;
|
||||||
variant: Variant;
|
variant: Variant;
|
||||||
@@ -107,6 +110,8 @@ export class LocalSource implements GameLoopSource {
|
|||||||
seed: bigint;
|
seed: bigint;
|
||||||
multipleWords: boolean;
|
multipleWords: boolean;
|
||||||
seats: LocalSeat[];
|
seats: LocalSeat[];
|
||||||
|
hotseat?: boolean;
|
||||||
|
hostPin?: PinLock;
|
||||||
}): Promise<StateView> {
|
}): Promise<StateView> {
|
||||||
const dawg = await getDawg(opts.variant, opts.dictVersion);
|
const dawg = await getDawg(opts.variant, opts.dictVersion);
|
||||||
if (!dawg) throw new GatewayError('dict_unavailable');
|
if (!dawg) throw new GatewayError('dict_unavailable');
|
||||||
@@ -121,12 +126,14 @@ export class LocalSource implements GameLoopSource {
|
|||||||
const now = nowUnix();
|
const now = nowUnix();
|
||||||
const record = serializeGame(game, {
|
const record = serializeGame(game, {
|
||||||
id: opts.id,
|
id: opts.id,
|
||||||
seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name, accountId: s.accountId })),
|
seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name, accountId: s.accountId, pin: s.pin })),
|
||||||
|
hotseat: opts.hotseat,
|
||||||
|
hostPin: opts.hostPin,
|
||||||
createdAtUnix: now,
|
createdAtUnix: now,
|
||||||
updatedAtUnix: now,
|
updatedAtUnix: now,
|
||||||
robotLastMoveAtUnix: now,
|
hintUnlockAtMs: 0, // no robot move yet: a human-first opening's hint is open at once
|
||||||
});
|
});
|
||||||
const entry: Live = { game, record };
|
const entry: Live = { game, record, unlockedSeat: null };
|
||||||
this.live.set(opts.id, entry);
|
this.live.set(opts.id, entry);
|
||||||
await saveLocalGame(record);
|
await saveLocalGame(record);
|
||||||
return this.stateView(entry);
|
return this.stateView(entry);
|
||||||
@@ -187,15 +194,72 @@ export class LocalSource implements GameLoopSource {
|
|||||||
const entry = await this.load(gameId);
|
const entry = await this.load(gameId);
|
||||||
const seat = entry.game.currentPlayer;
|
const seat = entry.game.currentPlayer;
|
||||||
const move = entry.game.resign();
|
const move = entry.game.resign();
|
||||||
|
entry.unlockedSeat = null;
|
||||||
await this.persist(entry);
|
await this.persist(entry);
|
||||||
return this.moveResult(entry, move, seat);
|
return this.moveResult(entry, move, seat);
|
||||||
}
|
}
|
||||||
|
|
||||||
async hint(gameId: string): Promise<HintResult> {
|
/**
|
||||||
|
* unlockSeat reveals the current hotseat seat's rack once its PIN is entered. It verifies pin
|
||||||
|
* against the seat's stored lock (a seat with no lock unlocks with any pin) and, on success, marks
|
||||||
|
* the seat unlocked for this turn only — a subsequent move re-locks it. Throws 'wrong_pin' on a
|
||||||
|
* mismatch. Returns the freshly unlocked state (rack revealed).
|
||||||
|
*/
|
||||||
|
async unlockSeat(gameId: string, pin: string): Promise<StateView> {
|
||||||
const entry = await this.load(gameId);
|
const entry = await this.load(gameId);
|
||||||
if (nowUnix() * 1000 - entry.record.robotLastMoveAtUnix * 1000 < HINT_GATE_MS) {
|
const seat = entry.game.currentPlayer;
|
||||||
throw new GatewayError('hint_not_ready');
|
const lock = entry.record.seats[seat]?.pin;
|
||||||
|
if (lock && !(await verifyPin(pin, lock))) throw new GatewayError('wrong_pin');
|
||||||
|
entry.unlockedSeat = seat;
|
||||||
|
return this.stateView(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** relock drops any per-turn seat unlock, so re-opening a hotseat game re-prompts the current
|
||||||
|
* seat's PIN. Called when the game screen is left (returning to the lobby). No-op when the game is
|
||||||
|
* not cached, or a seat with no PIN (which is never withheld anyway). */
|
||||||
|
relock(gameId: string): void {
|
||||||
|
const entry = this.live.get(gameId);
|
||||||
|
if (entry) entry.unlockedSeat = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** verifyHostPin reports whether pin matches the hotseat master (host/referee) PIN. */
|
||||||
|
async verifyHostPin(gameId: string, pin: string): Promise<boolean> {
|
||||||
|
const entry = await this.load(gameId);
|
||||||
|
return !!entry.record.hostPin && (await verifyPin(pin, entry.record.hostPin));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hostAction runs a host (referee) override on a hotseat game, gated by the master PIN:
|
||||||
|
* 'skip' — pass the current seat's turn;
|
||||||
|
* 'resign' — resign seat targetSeat (exclude a player / concede on their behalf);
|
||||||
|
* 'terminate' — end the game with no result and delete it from the store.
|
||||||
|
* Returns the updated StateView, or null when the game was terminated. Throws 'wrong_pin' on a bad
|
||||||
|
* PIN.
|
||||||
|
*/
|
||||||
|
async hostAction(
|
||||||
|
gameId: string,
|
||||||
|
pin: string,
|
||||||
|
action: 'skip' | 'resign' | 'terminate',
|
||||||
|
targetSeat?: number,
|
||||||
|
): Promise<StateView | null> {
|
||||||
|
const entry = await this.load(gameId);
|
||||||
|
if (!entry.record.hostPin || !(await verifyPin(pin, entry.record.hostPin))) throw new GatewayError('wrong_pin');
|
||||||
|
if (action === 'terminate') {
|
||||||
|
await this.delete(gameId);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
if (action === 'skip') entry.game.pass();
|
||||||
|
else entry.game.resignSeat(targetSeat ?? entry.game.currentPlayer);
|
||||||
|
entry.unlockedSeat = null;
|
||||||
|
await this.persist(entry);
|
||||||
|
return this.stateView(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async hint(gameId: string): Promise<HintResult> {
|
||||||
|
// The idle gate is enforced client-side with a monotonic clock (see Game.svelte / lib/hints):
|
||||||
|
// a wall-clock timestamp here could be defeated by changing the device clock. This just serves
|
||||||
|
// the engine's top-ranked move; the client only calls it once the gate is open.
|
||||||
|
const entry = await this.load(gameId);
|
||||||
const top = this.topMove(entry.game);
|
const top = this.topMove(entry.game);
|
||||||
if (!top) throw new GatewayError('no_hint');
|
if (!top) throw new GatewayError('no_hint');
|
||||||
return { move: this.moveFromGen(entry.record.variant, entry.game.currentPlayer, top), hintsRemaining: 1, walletBalance: 0 };
|
return { move: this.moveFromGen(entry.record.variant, entry.game.currentPlayer, top), hintsRemaining: 1, walletBalance: 0 };
|
||||||
@@ -248,7 +312,7 @@ export class LocalSource implements GameLoopSource {
|
|||||||
if (!record) throw new GatewayError('game_not_found');
|
if (!record) throw new GatewayError('game_not_found');
|
||||||
const dawg = await getDawg(record.variant, record.dictVersion);
|
const dawg = await getDawg(record.variant, record.dictVersion);
|
||||||
if (!dawg) throw new GatewayError('dict_unavailable');
|
if (!dawg) throw new GatewayError('dict_unavailable');
|
||||||
const entry: Live = { game: replayGame(record, dawg), record };
|
const entry: Live = { game: replayGame(record, dawg), record, unlockedSeat: null };
|
||||||
this.live.set(gameId, entry);
|
this.live.set(gameId, entry);
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
@@ -260,6 +324,7 @@ export class LocalSource implements GameLoopSource {
|
|||||||
const entry = await this.load(gameId);
|
const entry = await this.load(gameId);
|
||||||
const seat = entry.game.currentPlayer;
|
const seat = entry.game.currentPlayer;
|
||||||
const move = act(entry.game);
|
const move = act(entry.game);
|
||||||
|
entry.unlockedSeat = null; // the turn has advanced; a hotseat seat re-locks (no effect for vs_ai)
|
||||||
const robotMoves = this.runRobots(entry);
|
const robotMoves = this.runRobots(entry);
|
||||||
await this.persist(entry);
|
await this.persist(entry);
|
||||||
const result = this.moveResult(entry, move, seat);
|
const result = this.moveResult(entry, move, seat);
|
||||||
@@ -281,8 +346,11 @@ export class LocalSource implements GameLoopSource {
|
|||||||
if (dec.kind === 'play') out.push(g.play(dec.move.dir, dec.move.tiles));
|
if (dec.kind === 'play') out.push(g.play(dec.move.dir, dec.move.tiles));
|
||||||
else if (dec.kind === 'exchange' && g.bagLength >= RULESETS[entry.record.variant].rackSize) out.push(g.exchange(dec.exchange));
|
else if (dec.kind === 'exchange' && g.bagLength >= RULESETS[entry.record.variant].rackSize) out.push(g.exchange(dec.exchange));
|
||||||
else out.push(g.pass());
|
else out.push(g.pass());
|
||||||
entry.record.robotLastMoveAtUnix = nowUnix();
|
|
||||||
}
|
}
|
||||||
|
// Arm the idle hint gate from the robot's reply: a wall-clock unlock time, persisted so the wait
|
||||||
|
// survives a relaunch. Read back through a sanitiser (lib/hints / stateView) so a clock set back
|
||||||
|
// cannot freeze it.
|
||||||
|
if (out.length > 0) entry.record.hintUnlockAtMs = Date.now() + HINT_GATE_MS;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,9 +373,11 @@ export class LocalSource implements GameLoopSource {
|
|||||||
entry.record = serializeGame(entry.game, {
|
entry.record = serializeGame(entry.game, {
|
||||||
id: entry.record.id,
|
id: entry.record.id,
|
||||||
seats: entry.record.seats,
|
seats: entry.record.seats,
|
||||||
|
hotseat: entry.record.hotseat,
|
||||||
|
hostPin: entry.record.hostPin,
|
||||||
createdAtUnix: entry.record.createdAtUnix,
|
createdAtUnix: entry.record.createdAtUnix,
|
||||||
updatedAtUnix: nowUnix(),
|
updatedAtUnix: nowUnix(),
|
||||||
robotLastMoveAtUnix: entry.record.robotLastMoveAtUnix,
|
hintUnlockAtMs: entry.record.hintUnlockAtMs,
|
||||||
});
|
});
|
||||||
await saveLocalGame(entry.record);
|
await saveLocalGame(entry.record);
|
||||||
}
|
}
|
||||||
@@ -340,10 +410,35 @@ export class LocalSource implements GameLoopSource {
|
|||||||
|
|
||||||
// --- shape builders --------------------------------------------------------
|
// --- shape builders --------------------------------------------------------
|
||||||
|
|
||||||
|
// The vs_ai idle-hint seconds left, from the stored unlock instant (device wall clock, persisted
|
||||||
|
// for the offline record) minus now — capped at the window so a device clock set back cannot push
|
||||||
|
// it far ahead and freeze the gate, ceiled to whole seconds and floored at 0. The client anchors a
|
||||||
|
// monotonic countdown to this; 0 while open (a human-first opening, no robot move yet).
|
||||||
|
private hintUnlockLeft(entry: Live): number {
|
||||||
|
if (!entry.record.hintUnlockAtMs) return 0;
|
||||||
|
const leftMs = Math.min(HINT_GATE_MS, entry.record.hintUnlockAtMs - Date.now());
|
||||||
|
return leftMs > 0 ? Math.ceil(leftMs / 1000) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// stateView is the seated player's private view. For a vs_ai game that is the single human seat; for
|
||||||
|
// a hotseat game it is the seat to move — and when that seat carries a PIN, the rack is withheld
|
||||||
|
// (empty, locked=true) until unlockSeat is called this turn.
|
||||||
private stateView(entry: Live): StateView {
|
private stateView(entry: Live): StateView {
|
||||||
const seat = this.humanSeat(entry);
|
const hotseat = !!entry.record.hotseat;
|
||||||
const rack = entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t));
|
const seat = hotseat ? entry.game.currentPlayer : this.humanSeat(entry);
|
||||||
return { game: this.gameView(entry), seat, rack, bagLen: entry.game.bagLength, hintsRemaining: 1, walletBalance: 0 };
|
// A finished game is never locked — its final rack is shown for review, not an unlock button.
|
||||||
|
const locked = !entry.game.isOver && hotseat && !!entry.record.seats[seat]?.pin && entry.unlockedSeat !== seat;
|
||||||
|
const rack = locked ? [] : entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t));
|
||||||
|
return {
|
||||||
|
game: this.gameView(entry),
|
||||||
|
seat,
|
||||||
|
rack,
|
||||||
|
bagLen: entry.game.bagLength,
|
||||||
|
hintsRemaining: 1,
|
||||||
|
walletBalance: 0,
|
||||||
|
hintUnlockLeftSeconds: this.hintUnlockLeft(entry),
|
||||||
|
locked,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Offline there is no server to send the per-variant alphabet table (letters + tile values) the
|
// Offline there is no server to send the per-variant alphabet table (letters + tile values) the
|
||||||
@@ -369,6 +464,7 @@ export class LocalSource implements GameLoopSource {
|
|||||||
score: game.scoreOf(i),
|
score: game.scoreOf(i),
|
||||||
hintsUsed: 0,
|
hintsUsed: 0,
|
||||||
isWinner: game.isOver && i === game.winnerIndex,
|
isWinner: game.isOver && i === game.winnerIndex,
|
||||||
|
resigned: game.resignedOf(i),
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
@@ -382,7 +478,8 @@ export class LocalSource implements GameLoopSource {
|
|||||||
moveCount: game.moveCount,
|
moveCount: game.moveCount,
|
||||||
endReason: game.isOver ? game.endReason : '',
|
endReason: game.isOver ? game.endReason : '',
|
||||||
lastActivityUnix: record.updatedAtUnix,
|
lastActivityUnix: record.updatedAtUnix,
|
||||||
vsAi: true,
|
vsAi: !record.hotseat,
|
||||||
|
hotseat: !!record.hotseat,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
seats,
|
seats,
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export interface Seat {
|
|||||||
score: number;
|
score: number;
|
||||||
hintsUsed: number;
|
hintsUsed: number;
|
||||||
isWinner: boolean;
|
isWinner: boolean;
|
||||||
|
/** Offline hotseat only: the seat resigned or was excluded by the host. Set by the local source so
|
||||||
|
* the finished-game medal ranking can place it last (no medal); undefined for online seats. */
|
||||||
|
resigned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameView {
|
export interface GameView {
|
||||||
@@ -47,6 +50,10 @@ export interface GameView {
|
|||||||
lastActivityUnix: number;
|
lastActivityUnix: number;
|
||||||
/** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */
|
/** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */
|
||||||
vsAi: boolean;
|
vsAi: boolean;
|
||||||
|
/** true = a local offline pass-and-play (hotseat) game: 2-4 humans share one device. Set only by
|
||||||
|
* the local source; the online gateway never sets it. Drives the per-seat PIN-lock UI and the host
|
||||||
|
* menu (which replaces the hint control). A hotseat game is never vsAi. */
|
||||||
|
hotseat?: boolean;
|
||||||
/** Per-viewer flag: the requesting player has at least one unread chat entry (message or
|
/** Per-viewer flag: the requesting player has at least one unread chat entry (message or
|
||||||
* nudge) in this game. Set on the authoritative REST views (lobby list, game state,
|
* nudge) in this game. Set on the authoritative REST views (lobby list, game state,
|
||||||
* move result); the live-event GameView leaves it false (events bump unread instead). */
|
* move result); the live-event GameView leaves it false (events bump unread instead). */
|
||||||
@@ -82,6 +89,14 @@ export interface StateView {
|
|||||||
bagLen: number;
|
bagLen: number;
|
||||||
hintsRemaining: number;
|
hintsRemaining: number;
|
||||||
walletBalance: number;
|
walletBalance: number;
|
||||||
|
/** For a vs_ai game, the seconds until the idle hint unlocks (computed by the source: the SERVER
|
||||||
|
* clock online, the device clock offline) — 0/undefined while open (the human's first move, or a
|
||||||
|
* non-vs_ai game). The client anchors a MONOTONIC countdown (performance.now()) to it on receipt,
|
||||||
|
* so a client clock change cannot skew it; a relaunch re-fetches a fresh value. See lib/hints. */
|
||||||
|
hintUnlockLeftSeconds?: number;
|
||||||
|
/** Offline hotseat only: the seat to move is PIN-locked, so its rack is withheld (empty) until the
|
||||||
|
* seat's PIN is entered (see LocalSource.unlockSeat). Absent/false everywhere else. */
|
||||||
|
locked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MoveResult {
|
export interface MoveResult {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { hashPin, newLock, newSalt, verifyPin } from './pin';
|
||||||
|
|
||||||
|
describe('hashPin', () => {
|
||||||
|
it('is deterministic for the same pin and salt', async () => {
|
||||||
|
const salt = 'fixedsalt';
|
||||||
|
expect(await hashPin('1234', salt)).toBe(await hashPin('1234', salt));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('differs when the salt differs', async () => {
|
||||||
|
expect(await hashPin('1234', 'saltA')).not.toBe(await hashPin('1234', 'saltB'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('differs when the pin differs', async () => {
|
||||||
|
const salt = 'fixedsalt';
|
||||||
|
expect(await hashPin('1234', salt)).not.toBe(await hashPin('4321', salt));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('newSalt', () => {
|
||||||
|
it('returns a non-empty value', () => {
|
||||||
|
expect(newSalt().length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a fresh value each call', () => {
|
||||||
|
expect(newSalt()).not.toBe(newSalt());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('newLock / verifyPin', () => {
|
||||||
|
it('produces a lock that verifies its own pin', async () => {
|
||||||
|
const lock = await newLock('1234');
|
||||||
|
expect(lock.hash.length).toBeGreaterThan(0);
|
||||||
|
expect(lock.salt.length).toBeGreaterThan(0);
|
||||||
|
expect(await verifyPin('1234', lock)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a wrong pin', async () => {
|
||||||
|
const lock = await newLock('1234');
|
||||||
|
expect(await verifyPin('0000', lock)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('salts each lock independently (same pin, different hashes)', async () => {
|
||||||
|
const a = await newLock('1234');
|
||||||
|
const b = await newLock('1234');
|
||||||
|
expect(a.salt).not.toBe(b.salt);
|
||||||
|
expect(a.hash).not.toBe(b.hash);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Seat / host PIN locks for offline pass-and-play (hotseat) games.
|
||||||
|
//
|
||||||
|
// A PinLock is a salted SHA-256 digest of a 4-digit PIN. This is a SOCIAL lock for a
|
||||||
|
// shared device — not cryptographic protection. A 4-digit PIN has only 10^4
|
||||||
|
// combinations and the whole game runs on the client, so a determined peek reads the
|
||||||
|
// racks straight from memory regardless. The salt only keeps the PIN itself out of a
|
||||||
|
// casual devtools glance and defeats precomputed tables; it does not make the PIN
|
||||||
|
// brute-force resistant.
|
||||||
|
|
||||||
|
/** PinLock is a stored, salted SHA-256 digest of a PIN (see the file header caveat). */
|
||||||
|
export interface PinLock {
|
||||||
|
hash: string;
|
||||||
|
salt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// b64url encodes bytes as base64url without padding (matches the idiom in vkid.ts).
|
||||||
|
function b64url(bytes: Uint8Array): string {
|
||||||
|
let s = '';
|
||||||
|
for (const b of bytes) s += String.fromCharCode(b);
|
||||||
|
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** newSalt returns a fresh random base64url salt for a PinLock. */
|
||||||
|
export function newSalt(): string {
|
||||||
|
const a = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(a);
|
||||||
|
return b64url(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** hashPin returns the base64url SHA-256 digest binding salt to pin. */
|
||||||
|
export async function hashPin(pin: string, salt: string): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`${salt}:${pin}`));
|
||||||
|
return b64url(new Uint8Array(digest));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** newLock builds a PinLock for pin with a fresh salt. */
|
||||||
|
export async function newLock(pin: string): Promise<PinLock> {
|
||||||
|
const salt = newSalt();
|
||||||
|
return { salt, hash: await hashPin(pin, salt) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** verifyPin reports whether pin matches the digest stored in lock. */
|
||||||
|
export async function verifyPin(pin: string, lock: PinLock): Promise<boolean> {
|
||||||
|
return (await hashPin(pin, lock.salt)) === lock.hash;
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { resultBadge } from './result';
|
import { resultBadge, seatMedal } from './result';
|
||||||
import type { GameView, Seat } from './model';
|
import type { GameView, Seat } from './model';
|
||||||
|
|
||||||
const seat = (s: number, accountId: string, score: number, isWinner = false): Seat => ({
|
const seat = (s: number, accountId: string, score: number, isWinner = false, resigned = false): Seat => ({
|
||||||
seat: s,
|
seat: s,
|
||||||
accountId,
|
accountId,
|
||||||
displayName: accountId,
|
displayName: accountId,
|
||||||
score,
|
score,
|
||||||
hintsUsed: 0,
|
hintsUsed: 0,
|
||||||
isWinner,
|
isWinner,
|
||||||
|
resigned,
|
||||||
});
|
});
|
||||||
|
|
||||||
function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
|
function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
|
||||||
@@ -74,4 +75,46 @@ describe('resultBadge', () => {
|
|||||||
const second = game([seat(0, 'me', 300), seat(1, 'a', 400, true), seat(2, 'b', 200), seat(3, 'c', 100)]);
|
const second = game([seat(0, 'me', 300), seat(1, 'a', 400, true), seat(2, 'b', 200), seat(3, 'c', 100)]);
|
||||||
expect(resultBadge(second, 'me')).toEqual({ key: 'result.place2', emoji: '🥈' });
|
expect(resultBadge(second, 'me')).toEqual({ key: 'result.place2', emoji: '🥈' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('tie for the lead (3-4p): a shared victory for the leaders, a placed finish for those below', () => {
|
||||||
|
// The reported case, viewer-side: two seats tie for the lead (-8), one is last (-14) — no single
|
||||||
|
// winner(), so this must NOT read as a full draw for everyone.
|
||||||
|
expect(resultBadge(game([seat(0, 'me', -8), seat(1, 'a', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.victory', emoji: '🏆' });
|
||||||
|
expect(resultBadge(game([seat(0, 'x', -8), seat(1, 'me', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.place3', emoji: '🥉' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an aborted game reads as a draw for everyone, whatever the scores', () => {
|
||||||
|
const g = { ...game([seat(0, 'me', 50), seat(1, 'a', 30)]), endReason: 'aborted' };
|
||||||
|
expect(resultBadge(g, 'me')).toEqual({ key: 'result.draw', emoji: '🏅' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('seatMedal', () => {
|
||||||
|
it('is empty for a game still in progress', () => {
|
||||||
|
expect(seatMedal(game([seat(0, 'a', 5), seat(1, 'b', 3)], 'active'), 0)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finished: places each seat by final score', () => {
|
||||||
|
const g = game([seat(0, 'a', 100), seat(1, 'b', 400, true), seat(2, 'c', 300), seat(3, 'd', 200)]);
|
||||||
|
expect(seatMedal(g, 1)).toBe('🏆'); // 400 → 1st
|
||||||
|
expect(seatMedal(g, 2)).toBe('🥈'); // 300 → 2nd
|
||||||
|
expect(seatMedal(g, 3)).toBe('🥉'); // 200 → 3rd
|
||||||
|
expect(seatMedal(g, 0)).toBe('🏅'); // 100 → 4th
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a tie for the lead shares first place; the rest place below (competition ranking)', () => {
|
||||||
|
// The owner's case: a scoreless end deducted racks to -8 / -14 / -8, tying seats 0 and 2 for the
|
||||||
|
// lead — they must SHARE the trophy, not all show the last-place medal (the reported bug).
|
||||||
|
const g = game([seat(0, 'a', -8), seat(1, 'b', -14), seat(2, 'c', -8)]);
|
||||||
|
expect(seatMedal(g, 0)).toBe('🏆');
|
||||||
|
expect(seatMedal(g, 2)).toBe('🏆');
|
||||||
|
expect(seatMedal(g, 1)).toBe('🥉'); // two seats rank above it → 3rd
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an excluded / resigned seat gets no medal and does not push the others down', () => {
|
||||||
|
const g = game([seat(0, 'a', 50), seat(1, 'b', 900, false, true), seat(2, 'c', 30)]);
|
||||||
|
expect(seatMedal(g, 1)).toBe(''); // resigned: no medal, though its frozen score is the highest
|
||||||
|
expect(seatMedal(g, 0)).toBe('🏆'); // ranked among the rest: highest → 1st
|
||||||
|
expect(seatMedal(g, 2)).toBe('🥈');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+41
-9
@@ -9,6 +9,13 @@ export interface ResultBadge {
|
|||||||
emoji: string;
|
emoji: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** placeBadge maps a 1-based finishing rank to its label + medal (2nd is a defeat in a duel). */
|
||||||
|
function placeBadge(rank: number, players: number): ResultBadge {
|
||||||
|
if (rank === 2) return players === 2 ? { key: 'result.defeat', emoji: '🥈' } : { key: 'result.place2', emoji: '🥈' };
|
||||||
|
if (rank === 3) return { key: 'result.place3', emoji: '🥉' };
|
||||||
|
return { key: 'result.place4', emoji: '🏅' };
|
||||||
|
}
|
||||||
|
|
||||||
export function resultBadge(game: GameView, myId: string): ResultBadge {
|
export function resultBadge(game: GameView, myId: string): ResultBadge {
|
||||||
const me = game.seats.find((s) => s.accountId === myId);
|
const me = game.seats.find((s) => s.accountId === myId);
|
||||||
|
|
||||||
@@ -19,14 +26,39 @@ export function resultBadge(game: GameView, myId: string): ResultBadge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (me?.isWinner) return { key: 'result.victory', emoji: '🏆' };
|
if (me?.isWinner) return { key: 'result.victory', emoji: '🏆' };
|
||||||
if (!game.seats.some((s) => s.isWinner)) return { key: 'result.draw', emoji: '🏅' };
|
|
||||||
|
|
||||||
// Someone else won and it is not me, so I did not win — even when scores are level (a
|
// No single winner: a tie for the lead, a full draw, or an aborted game. An abort or an all-level
|
||||||
// win by resignation or timeout can leave the winner at or below my score). The winner
|
// finish is a draw; a tie for the lead is a SHARED victory for the top scorers, and a placed finish
|
||||||
// takes rank 1; place me among the remaining seats by score, starting at rank 2.
|
// for those below. (winner() is -1 on ANY tie for the lead, so isWinner alone cannot tell a
|
||||||
const ahead = game.seats.filter((s) => !s.isWinner && s.accountId !== myId && s.score > (me?.score ?? 0)).length;
|
// three-way "two share first, one is last" apart from a genuine draw — the reported bug.)
|
||||||
const rank = 2 + ahead;
|
if (!game.seats.some((s) => s.isWinner)) {
|
||||||
if (rank === 2) return game.players === 2 ? { key: 'result.defeat', emoji: '🥈' } : { key: 'result.place2', emoji: '🥈' };
|
const myScore = me?.score ?? 0;
|
||||||
if (rank === 3) return { key: 'result.place3', emoji: '🥉' };
|
const higher = game.seats.filter((s) => !s.resigned && s.score > myScore).length;
|
||||||
return { key: 'result.place4', emoji: '🏅' };
|
const allLevel = game.seats.filter((s) => !s.resigned).every((s) => s.score === myScore);
|
||||||
|
if (game.endReason === 'aborted' || (higher === 0 && allLevel)) return { key: 'result.draw', emoji: '🏅' };
|
||||||
|
return higher === 0 ? { key: 'result.victory', emoji: '🏆' } : placeBadge(1 + higher, game.players);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A winner exists but it is not me — even when scores are level (a win by resignation or timeout
|
||||||
|
// can leave the winner at or below my score). The winner takes rank 1; place me among the remaining
|
||||||
|
// (non-resigned) seats by score, starting at rank 2.
|
||||||
|
const ahead = game.seats.filter((s) => !s.isWinner && !s.resigned && s.accountId !== myId && s.score > (me?.score ?? 0)).length;
|
||||||
|
return placeBadge(2 + ahead, game.players);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* seatMedal returns a per-SEAT place emoji for a finished game (empty while it is still in progress),
|
||||||
|
* so a hotseat game can show each player's medal on their plaque instead of a single viewer-centric
|
||||||
|
* "you won/lost" (which is meaningless with 2-4 local players). It ranks by the FINAL score with
|
||||||
|
* competition ranking (equal scores share a place), so a tie for the lead SHARES the trophy rather
|
||||||
|
* than reading as a full draw. A resigned / host-excluded seat places last, with no medal, and does
|
||||||
|
* not push the remaining players down. Ranked by score, not by the engine's single-winner flag
|
||||||
|
* (which is -1 on any tie for the lead — the reported all-last-place bug).
|
||||||
|
*/
|
||||||
|
export function seatMedal(game: GameView, seat: number): string {
|
||||||
|
if (game.status !== 'finished') return '';
|
||||||
|
const s = game.seats.find((x) => x.seat === seat);
|
||||||
|
if (!s || s.resigned) return '';
|
||||||
|
const rank = 1 + game.seats.filter((x) => !x.resigned && x.score > s.score).length;
|
||||||
|
return rank === 1 ? '🏆' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '🏅';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildSeats, commitName, rosterReady, shuffleSeeded, type RosterRow } from './roster';
|
||||||
|
|
||||||
|
describe('commitName (keep-last-valid)', () => {
|
||||||
|
it('commits a valid, trimmed name', () => {
|
||||||
|
expect(commitName('Ann', '')).toBe('Ann');
|
||||||
|
expect(commitName(' Ann ', '')).toBe('Ann');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the previous valid name when the input goes invalid', () => {
|
||||||
|
expect(commitName('', 'Ann')).toBe('Ann');
|
||||||
|
expect(commitName(' ', 'Ann')).toBe('Ann');
|
||||||
|
expect(commitName('a'.repeat(33), 'Ann')).toBe('Ann'); // over the 32-rune cap
|
||||||
|
expect(commitName('_bad', 'Ann')).toBe('Ann'); // leading separator
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces the committed name with a newer valid one', () => {
|
||||||
|
expect(commitName('Bob', 'Ann')).toBe('Bob');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rosterReady', () => {
|
||||||
|
const row = (committed: string): RosterRow => ({ name: committed, committed });
|
||||||
|
|
||||||
|
it('needs 2-4 rows, each with a committed name', () => {
|
||||||
|
expect(rosterReady([row('A'), row('B')])).toBe(true);
|
||||||
|
expect(rosterReady([row('A'), row('B'), row('C'), row('D')])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects fewer than 2, more than 4, or an empty row', () => {
|
||||||
|
expect(rosterReady([row('A')])).toBe(false);
|
||||||
|
expect(rosterReady([row('A'), row('B'), row('C'), row('D'), row('E')])).toBe(false);
|
||||||
|
expect(rosterReady([row('A'), row('')])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSeats', () => {
|
||||||
|
it('maps committed rows to account-less human seats, carrying the PIN', () => {
|
||||||
|
const lock = { hash: 'h', salt: 's' };
|
||||||
|
const seats = buildSeats([
|
||||||
|
{ name: 'Ann', committed: 'Ann', pin: lock },
|
||||||
|
{ name: 'x', committed: 'Bob' },
|
||||||
|
]);
|
||||||
|
expect(seats).toEqual([
|
||||||
|
{ kind: 'human', name: 'Ann', pin: lock },
|
||||||
|
{ kind: 'human', name: 'Bob', pin: undefined },
|
||||||
|
]);
|
||||||
|
expect(seats.every((s) => !('accountId' in s) || s.accountId === undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shuffleSeeded', () => {
|
||||||
|
it('is deterministic for the same seed', () => {
|
||||||
|
expect(shuffleSeeded([0, 1, 2, 3], 42n)).toEqual(shuffleSeeded([0, 1, 2, 3], 42n));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every element (a permutation) and does not mutate the input', () => {
|
||||||
|
const input = [0, 1, 2, 3];
|
||||||
|
const out = shuffleSeeded(input, 7n);
|
||||||
|
expect([...out].sort((a, b) => a - b)).toEqual([0, 1, 2, 3]);
|
||||||
|
expect(input).toEqual([0, 1, 2, 3]); // input untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it('varies the order across seeds (not always the identity)', () => {
|
||||||
|
const orders = new Set([1n, 2n, 3n, 4n, 5n, 6n, 7n, 8n].map((s) => shuffleSeeded([0, 1, 2, 3], s).join('')));
|
||||||
|
expect(orders.size).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Pure helpers for the offline hotseat (pass-and-play) creation roster: keeping a last-valid name as
|
||||||
|
// a row is edited, and turning the finished roster into engine seats. Kept out of the component so it
|
||||||
|
// is unit-testable; the form itself is exercised by the e2e layer.
|
||||||
|
|
||||||
|
import { validDisplayName } from './profileValidation';
|
||||||
|
import type { PinLock } from './pin';
|
||||||
|
import type { Seat } from './localgame/serialize';
|
||||||
|
|
||||||
|
/** RosterRow is one editable player row: the live input, the last valid name kept when the input
|
||||||
|
* goes invalid, and the optional per-seat PIN lock. */
|
||||||
|
export interface RosterRow {
|
||||||
|
name: string;
|
||||||
|
committed: string;
|
||||||
|
pin?: PinLock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** commitName returns the new last-valid name: the trimmed input when it is a valid display name,
|
||||||
|
* otherwise prevCommitted unchanged — so editing a set name into an invalid one keeps the old one. */
|
||||||
|
export function commitName(input: string, prevCommitted: string): string {
|
||||||
|
return validDisplayName(input) ? input.trim() : prevCommitted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** rosterReady reports whether the roster can start a game: 2-4 rows, each with a committed name. */
|
||||||
|
export function rosterReady(rows: RosterRow[]): boolean {
|
||||||
|
return rows.length >= 2 && rows.length <= 4 && rows.every((r) => r.committed !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** buildSeats turns committed roster rows into engine seats — all human, account-less (local
|
||||||
|
* players sharing a device), carrying each row's optional PIN. */
|
||||||
|
export function buildSeats(rows: RosterRow[]): Seat[] {
|
||||||
|
return rows.map((r): Seat => ({ kind: 'human', name: r.committed, pin: r.pin }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* shuffleSeeded returns a deterministic shuffle of items driven by seed — a Fisher-Yates over a small
|
||||||
|
* in-house PRNG (a distinct seed derivation from the tile bag, so the seating is not correlated with
|
||||||
|
* the tile draws). Used to randomise the hotseat seating at game start, so the first mover is random;
|
||||||
|
* being seed-driven keeps it reproducible for replay and tests.
|
||||||
|
*/
|
||||||
|
export function shuffleSeeded<T>(items: readonly T[], seed: bigint): T[] {
|
||||||
|
const out = [...items];
|
||||||
|
let a = (Number(BigInt.asUintN(32, seed ^ 0x9e3779b9n)) >>> 0) || 1;
|
||||||
|
const rand = (): number => {
|
||||||
|
a = (a + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
for (let i = out.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(rand() * (i + 1));
|
||||||
|
[out[i], out[j]] = [out[j], out[i]];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -17,12 +17,15 @@ if (typeof window !== 'undefined') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** navigate switches the hash route (and forces a re-parse if it is unchanged). */
|
/** navigate switches the hash route (and forces a re-parse if it is unchanged). The route rune is
|
||||||
|
* updated synchronously — not left to the asynchronous `hashchange` — so a caller that renders off
|
||||||
|
* the route in the same tick (notably bootstrap flipping `app.ready` right after `navigate('/login')`)
|
||||||
|
* never sees `router.route` lag the hash for a frame, which would briefly render the previous route
|
||||||
|
* (the empty-hash lobby) under the new screen. The later `hashchange` re-parses to the same value. */
|
||||||
export function navigate(path: string): void {
|
export function navigate(path: string): void {
|
||||||
const target = '#' + path;
|
const target = '#' + path;
|
||||||
if (location.hash === target) {
|
router.route = parse(target);
|
||||||
router.route = parse(target);
|
if (location.hash !== target) {
|
||||||
} else {
|
|
||||||
location.hash = path;
|
location.hash = path;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-11
@@ -4,6 +4,7 @@
|
|||||||
import Screen from '../components/Screen.svelte';
|
import Screen from '../components/Screen.svelte';
|
||||||
import TabBar from '../components/TabBar.svelte';
|
import TabBar from '../components/TabBar.svelte';
|
||||||
import Modal from '../components/Modal.svelte';
|
import Modal from '../components/Modal.svelte';
|
||||||
|
import PinPad from '../components/PinPad.svelte';
|
||||||
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
|
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
@@ -186,6 +187,9 @@
|
|||||||
// revealed by swiping the row left (touch) or tapping its kebab (any pointer); the action is
|
// revealed by swiping the row left (touch) or tapping its kebab (any pointer); the action is
|
||||||
// per-account and irreversible. Only one row is revealed at a time.
|
// per-account and irreversible. Only one row is revealed at a time.
|
||||||
let revealedId = $state<string | null>(null);
|
let revealedId = $state<string | null>(null);
|
||||||
|
// A hotseat game's deletion is gated by the host (master) PIN — both active (terminate the game)
|
||||||
|
// and finished; pinTarget holds the game id while its PIN pad is open.
|
||||||
|
let pinTarget = $state<string | null>(null);
|
||||||
let drag: { id: string; x0: number; y0: number } | null = null;
|
let drag: { id: string; x0: number; y0: number } | null = null;
|
||||||
// A horizontal swipe must not also count as a tap that opens the game; armed on swipe,
|
// A horizontal swipe must not also count as a tap that opens the game; armed on swipe,
|
||||||
// consumed by the next tap, and reset on the next pointerdown so a later tap is never eaten.
|
// consumed by the next tap, and reset on the next pointerdown so a later tap is never eaten.
|
||||||
@@ -196,13 +200,14 @@
|
|||||||
if (e.pointerType === 'mouse') return; // desktop reveals via the kebab, not a swipe
|
if (e.pointerType === 'mouse') return; // desktop reveals via the kebab, not a swipe
|
||||||
drag = { id, x0: e.clientX, y0: e.clientY };
|
drag = { id, x0: e.clientX, y0: e.clientY };
|
||||||
}
|
}
|
||||||
function onRowUp(e: PointerEvent, finished: boolean): void {
|
function onRowUp(e: PointerEvent, revealable: boolean): void {
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
const dx = e.clientX - drag.x0;
|
const dx = e.clientX - drag.x0;
|
||||||
const dy = e.clientY - drag.y0;
|
const dy = e.clientY - drag.y0;
|
||||||
// Only a finished row reveals on a horizontal swipe; that swipe then suppresses the tap so it
|
// Only a revealable row (a finished game, or any hotseat game) reveals its delete on a horizontal
|
||||||
// does not also open the game. Active rows ignore swipes and stay plain tap-to-open.
|
// swipe; that swipe then suppresses the tap so it does not also open the game. Other active rows
|
||||||
if (finished && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
|
// ignore swipes and stay plain tap-to-open.
|
||||||
|
if (revealable && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
|
||||||
swiped = true;
|
swiped = true;
|
||||||
revealedId = dx < 0 ? drag.id : null;
|
revealedId = dx < 0 ? drag.id : null;
|
||||||
}
|
}
|
||||||
@@ -240,6 +245,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startDelete routes the row's delete: a hotseat game (active or finished) is gated behind the host
|
||||||
|
// master PIN (so the last mover cannot instantly wipe the game); any other finished game deletes at
|
||||||
|
// once.
|
||||||
|
function startDelete(g: GameView): void {
|
||||||
|
if (g.hotseat) pinTarget = g.id;
|
||||||
|
else void hide(g.id);
|
||||||
|
}
|
||||||
|
|
||||||
async function acceptInvite(inv: Invitation) {
|
async function acceptInvite(inv: Invitation) {
|
||||||
try {
|
try {
|
||||||
const r = await gateway.invitationAccept(inv.id);
|
const r = await gateway.invitationAccept(inv.id);
|
||||||
@@ -315,15 +328,15 @@
|
|||||||
<div class="list">
|
<div class="list">
|
||||||
{#each group.list as g (g.id)}
|
{#each group.list as g (g.id)}
|
||||||
{@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
|
{@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
|
||||||
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}>
|
<div class="rowwrap" class:revealed={(group.finished || g.hotseat) && revealedId === g.id}>
|
||||||
{#if group.finished}
|
{#if group.finished || g.hotseat}
|
||||||
<button class="del" onclick={() => hide(g.id)} disabled={!connection.online} aria-label={t('lobby.hideGame')}>❌</button>
|
<button class="del" onclick={() => startDelete(g)} disabled={!isLocalGameId(g.id) && !connection.online} aria-label={t('lobby.hideGame')}>❌</button>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button
|
<button
|
||||||
class="open"
|
class="open"
|
||||||
onpointerdown={(e) => onRowDown(e, g.id)}
|
onpointerdown={(e) => onRowDown(e, g.id)}
|
||||||
onpointerup={(e) => onRowUp(e, group.finished)}
|
onpointerup={(e) => onRowUp(e, group.finished || !!g.hotseat)}
|
||||||
onclick={() => openGame(g)}
|
onclick={() => openGame(g)}
|
||||||
>
|
>
|
||||||
<span class="info">
|
<span class="info">
|
||||||
@@ -340,12 +353,15 @@
|
|||||||
>{/each}</span
|
>{/each}</span
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
<!-- Re-key on the per-card blink nonce so a repeat blink restarts the fade. -->
|
<!-- Re-key on the per-card blink nonce so a repeat blink restarts the fade. A
|
||||||
|
hotseat game shows no lobby medal — its result lives on the in-game seat
|
||||||
|
plaques, and resultBadge is viewer-centric (no seat matches the account). A
|
||||||
|
vs_ai game keeps its medal (one human, a straightforward win/loss). -->
|
||||||
{#key blinkNonce.get(g.id) ?? 0}
|
{#key blinkNonce.get(g.id) ?? 0}
|
||||||
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{resultBadge(g, myId).emoji}</span>
|
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{g.hotseat ? '' : resultBadge(g, myId).emoji}</span>
|
||||||
{/key}
|
{/key}
|
||||||
</button>
|
</button>
|
||||||
{#if group.finished}
|
{#if group.finished || g.hotseat}
|
||||||
<button class="kebab" onclick={() => toggleReveal(g.id)} aria-label={t('lobby.hideGame')}>⋮</button>
|
<button class="kebab" onclick={() => toggleReveal(g.id)} aria-label={t('lobby.hideGame')}>⋮</button>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- A visual duplicate of the row's tap target: opens the game on tap, but kept out
|
<!-- A visual duplicate of the row's tap target: opens the game on tap, but kept out
|
||||||
@@ -378,6 +394,21 @@
|
|||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Deleting a hotseat game (active or finished) is gated by the host master PIN. -->
|
||||||
|
{#if pinTarget}
|
||||||
|
<PinPad
|
||||||
|
mode="verify"
|
||||||
|
title={t('hotseat.hostPin')}
|
||||||
|
verify={(p) => localSource.verifyHostPin(pinTarget ?? '', p)}
|
||||||
|
onclose={() => (pinTarget = null)}
|
||||||
|
onresult={() => {
|
||||||
|
const id = pinTarget;
|
||||||
|
pinTarget = null;
|
||||||
|
if (id) void hide(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#snippet tabbar()}
|
{#snippet tabbar()}
|
||||||
<TabBar>
|
<TabBar>
|
||||||
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
|
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
|
||||||
|
|||||||
@@ -17,10 +17,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function signIn() {
|
async function signIn() {
|
||||||
|
if (busy || code.trim().length === 0) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
await loginEmail(email.trim(), code.trim());
|
await loginEmail(email.trim(), code.trim());
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track the code and auto-submit once the 6-digit code is complete (Enter submits too).
|
||||||
|
function onCode(v: string): void {
|
||||||
|
code = v;
|
||||||
|
if (code.trim().length === 6) void signIn();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="login">
|
<main class="login">
|
||||||
@@ -46,10 +53,16 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<p class="muted">{t('login.codeSent', { email })}</p>
|
<p class="muted">{t('login.codeSent', { email })}</p>
|
||||||
<input
|
<input
|
||||||
|
class="codein"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
autocomplete="one-time-code"
|
autocomplete="one-time-code"
|
||||||
|
maxlength="6"
|
||||||
placeholder={t('login.codePlaceholder')}
|
placeholder={t('login.codePlaceholder')}
|
||||||
bind:value={code}
|
value={code}
|
||||||
|
oninput={(e) => onCode(e.currentTarget.value)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter') void signIn();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button class="secondary" disabled={busy || !code.trim()} onclick={signIn}>
|
<button class="secondary" disabled={busy || !code.trim()} onclick={signIn}>
|
||||||
{t('login.signIn')}
|
{t('login.signIn')}
|
||||||
@@ -98,6 +111,11 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
/* The email code: the same spread-digit look as the friend-code input elsewhere. */
|
||||||
|
.codein {
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
button {
|
button {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
|
|||||||
+333
-15
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Screen from '../components/Screen.svelte';
|
import Screen from '../components/Screen.svelte';
|
||||||
|
import Modal from '../components/Modal.svelte';
|
||||||
|
import PinPad, { type PinResult } from '../components/PinPad.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
import { app, handleError, showToast } from '../lib/app.svelte';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
@@ -9,6 +11,9 @@
|
|||||||
import { newLocalGameId, randomSeed } from '../lib/localgame/id';
|
import { newLocalGameId, randomSeed } from '../lib/localgame/id';
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
|
import { validDisplayName } from '../lib/profileValidation';
|
||||||
|
import { verifyPin, type PinLock } from '../lib/pin';
|
||||||
|
import { commitName, rosterReady, buildSeats, shuffleSeeded, type RosterRow } from '../lib/roster';
|
||||||
import type { AccountRef, Variant } from '../lib/model';
|
import type { AccountRef, Variant } from '../lib/model';
|
||||||
import {
|
import {
|
||||||
availableVariants,
|
availableVariants,
|
||||||
@@ -144,13 +149,143 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- offline hotseat (pass-and-play) ---
|
||||||
|
// A device-local 2-4 human game. The host (referee) sets a mandatory master PIN first — it gates
|
||||||
|
// the roster and, in-game, the host overrides; each player row optionally locks its seat with a PIN.
|
||||||
|
let hostPin = $state<PinLock | undefined>(undefined);
|
||||||
|
let rows = $state<RosterRow[]>([
|
||||||
|
{ name: '', committed: '' },
|
||||||
|
{ name: '', committed: '' },
|
||||||
|
]);
|
||||||
|
let askHostPlays = $state(false); // the "do you take a seat?" prompt shown once the host PIN is set
|
||||||
|
|
||||||
|
// The PIN-pad overlay target: what a completed PIN applies to.
|
||||||
|
type PadTarget =
|
||||||
|
| { kind: 'host-set' }
|
||||||
|
| { kind: 'host-change' }
|
||||||
|
| { kind: 'seat-set'; index: number }
|
||||||
|
| { kind: 'seat-change'; index: number }
|
||||||
|
| { kind: 'row-delete'; index: number };
|
||||||
|
let pad = $state<PadTarget | null>(null);
|
||||||
|
// The row whose delete cross is armed: tapping a row's kebab asks the host PIN, and a correct PIN
|
||||||
|
// arms its ❌ (mirroring the lobby delete) rather than removing it silently. Tapping the kebab again
|
||||||
|
// (or arming another row) clears the authorisation.
|
||||||
|
let deleteRow = $state<number | null>(null);
|
||||||
|
|
||||||
|
const padMode = (p: PadTarget): 'set' | 'verify' | 'change' =>
|
||||||
|
p.kind === 'host-set' || p.kind === 'seat-set' ? 'set' : p.kind === 'row-delete' ? 'verify' : 'change';
|
||||||
|
function padVerify(p: PadTarget): ((pin: string) => Promise<boolean>) | undefined {
|
||||||
|
if (p.kind === 'host-change' || p.kind === 'row-delete') return (pin) => verifyPin(pin, hostPin!);
|
||||||
|
if (p.kind === 'seat-change') return (pin) => verifyPin(pin, rows[p.index].pin!);
|
||||||
|
return undefined; // set flows need no verifier
|
||||||
|
}
|
||||||
|
const padTitle = (p: PadTarget): string =>
|
||||||
|
p.kind === 'host-set' || p.kind === 'host-change' || p.kind === 'row-delete'
|
||||||
|
? t('hotseat.hostPin')
|
||||||
|
: t('hotseat.setPin');
|
||||||
|
|
||||||
|
function onPad(r: PinResult): void {
|
||||||
|
const target = pad;
|
||||||
|
pad = null;
|
||||||
|
if (!target) return;
|
||||||
|
switch (target.kind) {
|
||||||
|
case 'host-set':
|
||||||
|
if (r.kind === 'set') {
|
||||||
|
hostPin = r.lock;
|
||||||
|
askHostPlays = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'host-change':
|
||||||
|
if (r.kind === 'set') hostPin = r.lock; // the host PIN is mandatory — no remove option
|
||||||
|
break;
|
||||||
|
case 'seat-set':
|
||||||
|
if (r.kind === 'set') rows[target.index].pin = r.lock;
|
||||||
|
break;
|
||||||
|
case 'seat-change':
|
||||||
|
if (r.kind === 'set') rows[target.index].pin = r.lock;
|
||||||
|
else if (r.kind === 'removed') rows[target.index].pin = undefined;
|
||||||
|
break;
|
||||||
|
case 'row-delete':
|
||||||
|
if (r.kind === 'verified') deleteRow = target.index; // arm the ❌; the actual delete is a tap on it
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// onKebab toggles a row's delete authorisation: a second tap (already armed) closes it and resets
|
||||||
|
// the host authorisation; otherwise it asks the host PIN (which, on success, arms the ❌).
|
||||||
|
function onKebab(i: number): void {
|
||||||
|
if (deleteRow === i) {
|
||||||
|
deleteRow = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteRow = null; // only one row armed at a time
|
||||||
|
pad = { kind: 'row-delete', index: i };
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(i: number): void {
|
||||||
|
rows = rows.filter((_, j) => j !== i);
|
||||||
|
deleteRow = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow(): void {
|
||||||
|
rows = [...rows, { name: '', committed: '' }];
|
||||||
|
deleteRow = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHostPlays(yes: boolean): void {
|
||||||
|
askHostPlays = false;
|
||||||
|
if (!yes) return;
|
||||||
|
// Seat the host at row 0, its name reused from the profile (a normal seat — its optional seat PIN
|
||||||
|
// is separate from the master PIN).
|
||||||
|
const committed = commitName(app.profile?.displayName ?? '', '');
|
||||||
|
rows[0] = { name: committed, committed };
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSeatPin(i: number): void {
|
||||||
|
pad = rows[i].pin ? { kind: 'seat-change', index: i } : { kind: 'seat-set', index: i };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startHotseat(): Promise<void> {
|
||||||
|
if (starting || !hostPin || !inviteVariant || !rosterReady(rows)) return;
|
||||||
|
starting = true;
|
||||||
|
try {
|
||||||
|
const version = app.profile?.dictVersions?.[inviteVariant];
|
||||||
|
if (!version) {
|
||||||
|
handleError(new Error('dict_unavailable'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = newLocalGameId();
|
||||||
|
const seed = randomSeed();
|
||||||
|
// Randomise the seating so the FIRST mover is random (the engine starts at seat 0, so shuffling
|
||||||
|
// the seats picks a random starter and turn order). Seed-driven, so replay is consistent.
|
||||||
|
// Snapshot the roster + PINs to plain objects: the rows/pins are $state proxies, and a proxy
|
||||||
|
// fails structured-clone when the record is persisted to IndexedDB (silently, best-effort store)
|
||||||
|
// — so the game would vanish on reload. See lib/localgame/store.
|
||||||
|
await localSource.create({
|
||||||
|
id,
|
||||||
|
variant: inviteVariant,
|
||||||
|
dictVersion: version,
|
||||||
|
seed,
|
||||||
|
multipleWords: multipleWordsForRequest(inviteVariant, multipleWords),
|
||||||
|
seats: $state.snapshot(shuffleSeeded(buildSeats(rows), seed)),
|
||||||
|
hotseat: true,
|
||||||
|
hostPin: hostPin ? $state.snapshot(hostPin) : undefined,
|
||||||
|
});
|
||||||
|
navigate(`/game/${id}`);
|
||||||
|
} catch (e) {
|
||||||
|
handleError(e);
|
||||||
|
} finally {
|
||||||
|
starting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Screen title={t('new.title')} back="/">
|
<Screen title={t('new.title')} back="/">
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<!-- Offline mode offers only a local vs_ai game: the friends flow needs the network, so the
|
<!-- The auto/friends selector. Online it is hidden for guests (no friends to invite). Offline
|
||||||
auto/friends selector is hidden and the auto (vs_ai) flow shows on its own. -->
|
it is always shown: "quick" is the local vs_ai flow, "with friends" is the local
|
||||||
{#if !guest && !offlineMode.active}
|
pass-and-play (hotseat) flow. -->
|
||||||
|
{#if !guest || offlineMode.active}
|
||||||
<div class="seg modes">
|
<div class="seg modes">
|
||||||
<button class="opt" class:active={mode === 'auto'} onclick={() => (mode = 'auto')}>{t('new.auto')}</button>
|
<button class="opt" class:active={mode === 'auto'} onclick={() => (mode = 'auto')}>{t('new.auto')}</button>
|
||||||
<button class="opt" class:active={mode === 'friends'} onclick={() => (mode = 'friends')}>{t('new.withFriends')}</button>
|
<button class="opt" class:active={mode === 'friends'} onclick={() => (mode = 'friends')}>{t('new.withFriends')}</button>
|
||||||
@@ -190,7 +325,6 @@
|
|||||||
<input type="checkbox" bind:checked={multipleWords} />
|
<input type="checkbox" bind:checked={multipleWords} />
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="grow"></div>
|
|
||||||
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
|
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
|
||||||
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
|
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
|
||||||
<button
|
<button
|
||||||
@@ -198,6 +332,78 @@
|
|||||||
disabled={!selectedAuto || (!connection.online && !offlineMode.active) || starting}
|
disabled={!selectedAuto || (!connection.online && !offlineMode.active) || starting}
|
||||||
onclick={() => selectedAuto && find(selectedAuto)}
|
onclick={() => selectedAuto && find(selectedAuto)}
|
||||||
>{t('new.start')}</button>
|
>{t('new.start')}</button>
|
||||||
|
{:else if offlineMode.active}
|
||||||
|
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master
|
||||||
|
PIN first (it gates the roster); each seat may add its own PIN. -->
|
||||||
|
<div class="hostpin">
|
||||||
|
<span class="ftitle">{t('hotseat.hostPin')}</span>
|
||||||
|
<button class="plink" onclick={() => (pad = hostPin ? { kind: 'host-change' } : { kind: 'host-set' })}>
|
||||||
|
{hostPin ? t('hotseat.changePin') : t('hotseat.setPin')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Variant plaques + the multiple-words toggle, identical to Quick Match. -->
|
||||||
|
<div class="variants">
|
||||||
|
{#each variants as v (v.id)}
|
||||||
|
<button class="variant" class:selected={inviteVariant === v.id} onclick={() => (inviteVariant = v.id)}>
|
||||||
|
<span class="vmain">
|
||||||
|
<span class="vname">{t(v.label)}</span>
|
||||||
|
{#if VARIANT_FLAG[v.id]}
|
||||||
|
<span class="vflag">{VARIANT_FLAG[v.id]}</span>
|
||||||
|
{:else}
|
||||||
|
<img class="vflag-img" src="flag-ussr.svg" alt="" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="vrules">{t(VARIANT_RULES[v.id])}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if variants.some((v) => supportsMultipleWordsToggle(v.id))}
|
||||||
|
<label class="toggle">
|
||||||
|
<span>{t('new.multipleWordsPerTurn')}</span>
|
||||||
|
<input type="checkbox" bind:checked={multipleWords} />
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
<div class="roster">
|
||||||
|
{#each rows as row, i (i)}
|
||||||
|
<div class="prow">
|
||||||
|
<input
|
||||||
|
class="pname"
|
||||||
|
class:invalid={row.name.trim() !== '' && !validDisplayName(row.name)}
|
||||||
|
bind:value={row.name}
|
||||||
|
disabled={!hostPin}
|
||||||
|
placeholder={t('hotseat.playerName')}
|
||||||
|
maxlength="40"
|
||||||
|
oninput={() => (row.committed = commitName(row.name, row.committed))}
|
||||||
|
onblur={() => (row.name = row.committed)}
|
||||||
|
/>
|
||||||
|
<button class="plink" disabled={!hostPin || row.committed === ''} onclick={() => openSeatPin(i)}>
|
||||||
|
{row.pin ? t('hotseat.changePin') : t('hotseat.setPin')}
|
||||||
|
</button>
|
||||||
|
{#if rows.length > 2}
|
||||||
|
{#if deleteRow === i}
|
||||||
|
<button class="prow-del" aria-label={t('hotseat.removePlayer')} onclick={() => removeRow(i)}>❌</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="pkebab"
|
||||||
|
class:armed={deleteRow === i}
|
||||||
|
disabled={!hostPin}
|
||||||
|
aria-label={t('hotseat.removePlayer')}
|
||||||
|
onclick={() => onKebab(i)}
|
||||||
|
>⋮</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if rows.length < 4}
|
||||||
|
<button class="addrow" disabled={!hostPin} onclick={addRow}>
|
||||||
|
+ {t('hotseat.addPlayer')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="invite"
|
||||||
|
disabled={!hostPin || !inviteVariant || !rosterReady(rows) || starting}
|
||||||
|
onclick={startHotseat}
|
||||||
|
>{t('new.start')}</button>
|
||||||
{:else if friends.length === 0}
|
{:else if friends.length === 0}
|
||||||
<p class="subtitle">{t('new.noFriends')}</p>
|
<p class="subtitle">{t('new.noFriends')}</p>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -245,16 +451,37 @@
|
|||||||
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
|
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if pad}
|
||||||
|
<PinPad
|
||||||
|
mode={padMode(pad)}
|
||||||
|
title={padTitle(pad)}
|
||||||
|
verify={padVerify(pad)}
|
||||||
|
allowRemove={pad.kind === 'seat-change'}
|
||||||
|
onclose={() => (pad = null)}
|
||||||
|
onresult={onPad}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if askHostPlays}
|
||||||
|
<Modal title={t('hotseat.hostPlaysTitle')} onclose={() => (askHostPlays = false)}>
|
||||||
|
<div class="hostplays">
|
||||||
|
<button class="ghost" onclick={() => setHostPlays(false)}>{t('hotseat.hostPlaysNo')}</button>
|
||||||
|
<button class="primary" onclick={() => setHostPlays(true)}>{t('hotseat.hostPlaysYes')}</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Screen>
|
</Screen>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Natural-flow content (matching the Profile sub-view): no forced height and no pinned CTA, so the
|
||||||
|
screen's own scroll (Screen .content + --vvh) handles overflow and a focused input scrolls into
|
||||||
|
view above the soft keyboard with a single, clean scroll container. */
|
||||||
.page {
|
.page {
|
||||||
padding: var(--pad);
|
padding: var(--pad);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
height: 100%;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.subtitle {
|
.subtitle {
|
||||||
@@ -339,8 +566,6 @@
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
.fg {
|
.fg {
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -363,9 +588,6 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
.friends-scroll {
|
.friends-scroll {
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -426,11 +648,6 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
/* Pushes the auto-match start cluster (move-limit hint + Start button) to the bottom,
|
|
||||||
mirroring the friend-game invite button pinned by the .fg scroll area. */
|
|
||||||
.grow {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.invite {
|
.invite {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
@@ -450,4 +667,105 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
/* --- offline hotseat roster --- */
|
||||||
|
.hostpin {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 11px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.roster {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.prow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pname {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 11px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.pname:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.pname.invalid {
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
.plink {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.plink:disabled {
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.pkebab {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.pkebab.armed {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
/* The delete cross a correct host PIN arms next to a player row (mirrors the lobby game delete). */
|
||||||
|
.prow-del {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.addrow {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: none;
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.addrow:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.hostplays {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.hostplays button {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.hostplays .primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+13
-5
@@ -24,17 +24,19 @@ clientsClaim();
|
|||||||
// Drop precaches left by a previous SW revision (including the old install-only shell cache).
|
// Drop precaches left by a previous SW revision (including the old install-only shell cache).
|
||||||
cleanupOutdatedCaches();
|
cleanupOutdatedCaches();
|
||||||
|
|
||||||
// Precache the shell + hashed assets injected at build time — exactly what a cold offline launch
|
|
||||||
// needs. The landing page and the old-engine polyfill bundle are excluded via the build globs.
|
|
||||||
precacheAndRoute(self.__WB_MANIFEST);
|
|
||||||
|
|
||||||
// Navigations are network-first so a new deploy loads immediately when online: fetch the fresh
|
// Navigations are network-first so a new deploy loads immediately when online: fetch the fresh
|
||||||
// (no-cache) shell from the server — it references the new hashed assets, which are then fetched
|
// (no-cache) shell from the server — it references the new hashed assets, which are then fetched
|
||||||
// fresh — and only fall back to the precached shell when the network is unreachable or too slow (a
|
// fresh — and only fall back to the precached shell when the network is unreachable or too slow (a
|
||||||
// short timeout). This keeps the app up to date online while still cold-launching offline. Only the
|
// short timeout). This keeps the app up to date online while still cold-launching offline. Only the
|
||||||
// tiny HTML is re-fetched; the immutable hashed assets stay cache-first (precacheAndRoute above) and
|
// tiny HTML is re-fetched; the immutable hashed assets stay cache-first (precacheAndRoute below) and
|
||||||
// re-download only when their hash — i.e. the version — changes. Deny-list the RPC path and the
|
// re-download only when their hash — i.e. the version — changes. Deny-list the RPC path and the
|
||||||
// admin console so those are never resolved to the app shell.
|
// admin console so those are never resolved to the app shell.
|
||||||
|
//
|
||||||
|
// This route MUST be registered BEFORE precacheAndRoute: Workbox matches routes in registration
|
||||||
|
// order, and the precache route matches shell navigations too (its directoryIndex is index.html), so
|
||||||
|
// registering it first would shadow this handler and serve the shell CACHE-FIRST — i.e. the old
|
||||||
|
// build's version until the next load. NavigationRoute matches only request.mode === 'navigate', so
|
||||||
|
// hashed-asset requests still fall through to the cache-first precache route below.
|
||||||
const NAV_TIMEOUT_MS = 3000;
|
const NAV_TIMEOUT_MS = 3000;
|
||||||
async function freshShell({ request }: { request: Request }): Promise<Response> {
|
async function freshShell({ request }: { request: Request }): Promise<Response> {
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
@@ -50,3 +52,9 @@ async function freshShell({ request }: { request: Request }): Promise<Response>
|
|||||||
return (await matchPrecache('index.html')) ?? Response.error();
|
return (await matchPrecache('index.html')) ?? Response.error();
|
||||||
}
|
}
|
||||||
registerRoute(new NavigationRoute(freshShell, { denylist: [/^\/scrabble\.edge\.v1\.Gateway/, /^\/_gm\//] }));
|
registerRoute(new NavigationRoute(freshShell, { denylist: [/^\/scrabble\.edge\.v1\.Gateway/, /^\/_gm\//] }));
|
||||||
|
|
||||||
|
// Precache the shell + hashed assets injected at build time — exactly what a cold offline launch
|
||||||
|
// needs (the precached index.html is also the offline fallback matchPrecache serves above). The
|
||||||
|
// immutable hashed assets stay cache-first here; the landing page and the old-engine polyfill bundle
|
||||||
|
// are excluded via the build globs.
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST);
|
||||||
|
|||||||
Reference in New Issue
Block a user