Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6badc20078 | |||
| a241e43d79 | |||
| 8ce986922a | |||
| 3469278260 | |||
| c66bf1eceb | |||
| 7b4d2421e2 | |||
| bf46b9492d | |||
| 1e1117c28e | |||
| 77a690fcf6 | |||
| 0ca01133b5 | |||
| 2683103fc1 | |||
| 2d2dd2bc47 | |||
| 45f0b34881 | |||
| df9eace09f | |||
| 0a0a9e5a8d | |||
| 0c9678c42b | |||
| e40adfb0c7 | |||
| 3306a016a0 | |||
| 18785efc8c | |||
| 780ff68ec2 | |||
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -99,6 +99,14 @@ jobs:
|
|||||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
||||||
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
||||||
|
# Robokassa direct-rail (backend BACKEND_ROBOKASSA_*): the prod shop login + the pass phrases
|
||||||
|
# that sign the launch request / verify the Result callback, and the test-mode flag — a var so
|
||||||
|
# go-live is a flag flip, not a secret redeploy ("1" runs test payments against the test
|
||||||
|
# passwords; empty/"0" is live). An empty login leaves the direct rail disabled.
|
||||||
|
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
|
||||||
|
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
|
||||||
|
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
|
||||||
|
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
|
||||||
# VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at
|
# VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at
|
||||||
# runtime) + the app's protected key. Both shared across contours. The redirect URL is
|
# runtime) + the app's protected key. Both shared across contours. The redirect URL is
|
||||||
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ jobs:
|
|||||||
# the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL
|
# the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL
|
||||||
# and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
# and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||||
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
||||||
|
# Robokassa direct-rail: the rollback re-renders the same runtime env (write-prod-env.sh), so
|
||||||
|
# it must carry the same credentials or the direct rail goes dark after a rollback.
|
||||||
|
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
|
||||||
|
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
|
||||||
|
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
|
||||||
|
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
|
||||||
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||||
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ status — without re-deriving decisions.
|
|||||||
| E5 | Payment intake | 2 | DONE |
|
| E5 | Payment intake | 2 | DONE |
|
||||||
| E6 | Ads | 2 | DONE |
|
| E6 | Ads | 2 | DONE |
|
||||||
| E7 | Admin, reports & catalog | 2 | DONE |
|
| E7 | Admin, reports & catalog | 2 | DONE |
|
||||||
| E8 | Guest limits | — | WIP |
|
| E8 | Guest limits | — | DONE |
|
||||||
| E9 | Tournament fee | future | TODO |
|
| E9 | Tournament fee | future | TODO |
|
||||||
|
|
||||||
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
|
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
|
||||||
@@ -782,7 +782,7 @@ become bootstrap-only.
|
|||||||
|
|
||||||
## E8 — Guest limits
|
## E8 — Guest limits
|
||||||
|
|
||||||
**Status:** WIP · **standalone** (game-behaviour change) · depends on: none · mechanics: PAYMENTS §6.
|
**Status:** DONE · **standalone** (game-behaviour change) · depends on: none · mechanics: PAYMENTS §6.
|
||||||
|
|
||||||
**Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today UI-only),
|
**Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today UI-only),
|
||||||
with configurable per-tier × per-kind limits so the pressure tunes without a release.
|
with configurable per-tier × per-kind limits so the pressure tunes without a release.
|
||||||
@@ -833,12 +833,19 @@ payments mixed in.
|
|||||||
`game.Service.AtGameLimit`; the **`internal/gamelimits` config + hot cache** (loaded at boot,
|
`game.Service.AtGameLimit`; the **`internal/gamelimits` config + hot cache** (loaded at boot,
|
||||||
invalidated on edit); the admin **kind column** in both game lists (`/_gm/games` + the user card)
|
invalidated on edit); the admin **kind column** in both game lists (`/_gm/games` + the user card)
|
||||||
and a **config editor** (`/_gm/limits`) for the six limits.
|
and a **config editor** (`/_gm/limits`) for the six limits.
|
||||||
- **PR2 — wire + client:** `Profile.game_limits` (the caller's tier) + `GameView.kind` (FBS +
|
- ~~**PR2 — wire + client** — DONE.~~ `Profile.game_limits` (the caller's tier) + `GameView.kind` (FBS
|
||||||
transcode + codec); the client per-kind active count from the lobby; the **lock badge** on a capped
|
+ gateway transcode + client codec, committed regen); the client counts active games per kind from
|
||||||
new-game start button, with two messages by tier — a **guest** login-funnel modal ("Войдите или
|
the lobby cache (`gamelimits.ts`) and locks a capped new-game start — an **outline 🔒** button that
|
||||||
создайте учётную запись…", Отмена / Вход→profile) and, for a **durable** account at its cap, a plain
|
opens `GameLimitModal` instead of a game, native (Telegram `showPopup`) or the in-app `Modal`
|
||||||
notice ("Вы достигли лимита одновременных игр, сначала завершите текущие", ОК; **native** popup in
|
elsewhere, with two messages by tier: a **guest** sign-in funnel ("Войдите или создайте учётную
|
||||||
Telegram — no gesture-gated API follows it); the profile re-fetch after a guest→durable upgrade.
|
запись…", Отмена / Вход→`/settings`) and, for a **durable** account at its cap, a plain notice
|
||||||
|
("Вы достигли лимита одновременных игр, сначала завершите текущие", ОК). The lock lifts via the
|
||||||
|
existing profile re-fetch after a guest→durable upgrade.
|
||||||
|
- **Decision (owner-agreed): the lobby's old `at_game_limit` New-Game tab-disable + notice is
|
||||||
|
removed.** The `at_game_limit` flag (now the random-kind cap) conflicted with the per-kind lock —
|
||||||
|
it hid the New-Game screen where the lock lives, and wrongly blocked starting an unfulfilled kind.
|
||||||
|
The tab is always enabled; the per-kind lock on the start button is the only gate. The wire field
|
||||||
|
`GameList.at_game_limit` stays (unused by the client) for a later cleanup.
|
||||||
|
|
||||||
**Tests.**
|
**Tests.**
|
||||||
|
|
||||||
@@ -846,8 +853,10 @@ payments mixed in.
|
|||||||
(domain + HTTP 403); guest blocked from a 2nd vs_ai / 2nd random (+ `at_game_limit`); durable on the
|
(domain + HTTP 403); guest blocked from a 2nd vs_ai / 2nd random (+ `at_game_limit`); durable on the
|
||||||
higher tier; the durable friends cap + config cache reflecting an admin edit; accept stays exempt.
|
higher tier; the durable friends cap + config cache reflecting an admin edit; accept stays exempt.
|
||||||
- unit (PR1, done): the per-tier/kind limit resolution (`Cap`, `LimitsFor`).
|
- unit (PR1, done): the per-tier/kind limit resolution (`Cap`, `LimitsFor`).
|
||||||
- UI (PR2): the lock + the two modals on a capped button (mock); the profile re-fetch lifts the lock
|
- unit + UI (PR2, done): the client lock logic (`gamelimits.ts` — count/cap/lock), the codec kind +
|
||||||
after register.
|
game_limits roundtrip, the gateway transcode game_limits encode, the popup builders, and a mock e2e
|
||||||
|
(`gamelimit.spec.ts`) — a capped start shows 🔒, opens the modal without navigating, and the lock
|
||||||
|
clears when the profile refetch lifts the cap.
|
||||||
|
|
||||||
**Done-criteria.** A guest is server-capped (default 1 vs_ai + 1 random, configurable) and cannot
|
**Done-criteria.** A guest is server-capped (default 1 vs_ai + 1 random, configurable) and cannot
|
||||||
friend/invite; a durable account is capped at 10 per kind (configurable); the client shows the lock +
|
friend/invite; a durable account is capped at 10 per kind (configurable); the client shows the lock +
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
||||||
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
|
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
|
||||||
<li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li>
|
<li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li>
|
||||||
|
<li><b>Word rule</b> {{if .MultipleWordsPerTurn}}multiple words per turn{{else}}single word per turn{{end}}</li>
|
||||||
<li><b>Players</b> {{.Players}}</li>
|
<li><b>Players</b> {{.Players}}</li>
|
||||||
<li><b>To move</b> seat {{.ToMove}}</li>
|
<li><b>To move</b> seat {{.ToMove}}</li>
|
||||||
<li><b>Moves</b> {{.MoveCount}}</li>
|
<li><b>Moves</b> {{.MoveCount}}</li>
|
||||||
|
|||||||
@@ -349,6 +349,10 @@ type GameDetailView struct {
|
|||||||
FinishedAt string
|
FinishedAt string
|
||||||
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
|
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
|
||||||
VsAI bool
|
VsAI bool
|
||||||
|
// MultipleWordsPerTurn is the game's cross-word rule: true = standard Scrabble (every cross-word
|
||||||
|
// is validated and scored), false = the single-word rule (only the main word along the play
|
||||||
|
// direction counts). Shown in the summary so an operator can tell the rule at a glance.
|
||||||
|
MultipleWordsPerTurn bool
|
||||||
Seats []SeatRow
|
Seats []SeatRow
|
||||||
// HasRobot is true when any seat is a robot, gating the robot-target caption;
|
// HasRobot is true when any seat is a robot, gating the robot-target caption;
|
||||||
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ func gameSummary(g Game, names []string) notify.GameSummary {
|
|||||||
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||||
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||||
VsAI: g.VsAI,
|
VsAI: g.VsAI,
|
||||||
|
Kind: int(g.Kind),
|
||||||
MoveCount: g.MoveCount,
|
MoveCount: g.MoveCount,
|
||||||
EndReason: g.EndReason,
|
EndReason: g.EndReason,
|
||||||
Seats: seats,
|
Seats: seats,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ func toWireGame(g GameSummary) wire.GameView {
|
|||||||
TurnTimeoutSecs: g.TurnTimeoutSecs,
|
TurnTimeoutSecs: g.TurnTimeoutSecs,
|
||||||
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||||
VsAI: g.VsAI,
|
VsAI: g.VsAI,
|
||||||
|
Kind: g.Kind,
|
||||||
MoveCount: g.MoveCount,
|
MoveCount: g.MoveCount,
|
||||||
EndReason: g.EndReason,
|
EndReason: g.EndReason,
|
||||||
Seats: seats,
|
Seats: seats,
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ func TestGameOverPayloadRoundTrips(t *testing.T) {
|
|||||||
func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
|
func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
|
||||||
uid, gid := uuid.New(), uuid.New()
|
uid, gid := uuid.New(), uuid.New()
|
||||||
move := engine.MoveRecord{Player: 1, Action: engine.ActionPlay, Words: []string{"STOOL"}, Score: 24, Total: 130}
|
move := engine.MoveRecord{Player: 1, Action: engine.ActionPlay, Words: []string{"STOOL"}, Score: 24, Total: 130}
|
||||||
summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}}
|
summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Kind: 2, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}}
|
||||||
in := notify.OpponentMoved(uid, gid, move, summary, 42)
|
in := notify.OpponentMoved(uid, gid, move, summary, 42)
|
||||||
if in.Kind != notify.KindOpponentMoved {
|
if in.Kind != notify.KindOpponentMoved {
|
||||||
t.Fatalf("kind = %q", in.Kind)
|
t.Fatalf("kind = %q", in.Kind)
|
||||||
@@ -132,7 +132,7 @@ func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
|
|||||||
if m == nil || m.Player() != 1 || string(m.Action()) != "play" || m.Total() != 130 {
|
if m == nil || m.Player() != 1 || string(m.Action()) != "play" || m.Total() != 130 {
|
||||||
t.Fatalf("move wrong: %+v", m)
|
t.Fatalf("move wrong: %+v", m)
|
||||||
}
|
}
|
||||||
if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 {
|
if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 || g.Kind() != 2 {
|
||||||
t.Fatalf("game summary wrong: %+v", ev.Game(nil))
|
t.Fatalf("game summary wrong: %+v", ev.Game(nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ type GameSummary struct {
|
|||||||
EndReason string
|
EndReason string
|
||||||
Seats []SeatStanding
|
Seats []SeatStanding
|
||||||
LastActivityUnix int64
|
LastActivityUnix int64
|
||||||
|
// Kind is the game's origin for the active-game limits (0 unknown, 1 vs_ai, 2 random, 3 friends);
|
||||||
|
// it rides live events so a lobby patch keeps the per-kind count correct.
|
||||||
|
Kind int
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an
|
// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi
|
|||||||
}
|
}
|
||||||
r.Banner = s.bannerFor(ctx, acc, cxt, present)
|
r.Banner = s.bannerFor(ctx, acc, cxt, present)
|
||||||
r.Ads = s.adsFor(ctx, acc, cxt, present)
|
r.Ads = s.adsFor(ctx, acc, cxt, present)
|
||||||
|
r.GameLimits = s.gameLimitsDTOFor(acc.IsGuest)
|
||||||
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
||||||
r.DictVersions = s.currentDictVersions()
|
r.DictVersions = s.currentDictVersions()
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -78,6 +78,18 @@ type profileResponse struct {
|
|||||||
// Filled outside the pure projection (it reads the dictionary registry), so it is empty
|
// Filled outside the pure projection (it reads the dictionary registry), so it is empty
|
||||||
// for callers that build the DTO without a Server. See Server.profileResponse.
|
// for callers that build the DTO without a Server. See Server.profileResponse.
|
||||||
DictVersions []dictVersion `json:"dict_versions,omitempty"`
|
DictVersions []dictVersion `json:"dict_versions,omitempty"`
|
||||||
|
// GameLimits carries the caller's tier active-game caps per kind (-1 = unlimited); the client
|
||||||
|
// counts its active games per kind from the lobby and locks a capped New-Game start. Filled
|
||||||
|
// outside the pure projection (it reads the limits config). See Server.profileResponse.
|
||||||
|
GameLimits *gameLimitsDTO `json:"game_limits,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// gameLimitsDTO is the caller's tier active-game caps per kind (-1 = unlimited), for the client's
|
||||||
|
// per-kind New-Game lock. profileResponse.GameLimits carries it.
|
||||||
|
type gameLimitsDTO struct {
|
||||||
|
VsAI int `json:"vs_ai"`
|
||||||
|
Random int `json:"random"`
|
||||||
|
Friends int `json:"friends"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// dictVersion pairs a game variant's stable label (engine.Variant.String) with its current
|
// dictVersion pairs a game variant's stable label (engine.Variant.String) with its current
|
||||||
@@ -135,6 +147,10 @@ type gameDTO struct {
|
|||||||
// VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
|
// VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
|
||||||
// are suppressed in the client.
|
// are suppressed in the client.
|
||||||
VsAI bool `json:"vs_ai"`
|
VsAI bool `json:"vs_ai"`
|
||||||
|
// Kind is the game's origin for the active-game limits (game.Kind): 0 unknown (a pre-existing
|
||||||
|
// game), 1 vs_ai, 2 random, 3 friends. The lobby counts active games per kind to lock a capped
|
||||||
|
// New-Game start.
|
||||||
|
Kind int `json:"kind"`
|
||||||
MoveCount int `json:"move_count"`
|
MoveCount int `json:"move_count"`
|
||||||
EndReason string `json:"end_reason"`
|
EndReason string `json:"end_reason"`
|
||||||
// LastActivityUnix is the lobby sort key: the current turn's start for an active
|
// LastActivityUnix is the lobby sort key: the current turn's start for an active
|
||||||
@@ -277,6 +293,7 @@ func gameDTOFromGame(g game.Game) gameDTO {
|
|||||||
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||||
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||||
VsAI: g.VsAI,
|
VsAI: g.VsAI,
|
||||||
|
Kind: int(g.Kind),
|
||||||
MoveCount: g.MoveCount,
|
MoveCount: g.MoveCount,
|
||||||
EndReason: g.EndReason,
|
EndReason: g.EndReason,
|
||||||
LastActivityUnix: last.Unix(),
|
LastActivityUnix: last.Unix(),
|
||||||
|
|||||||
@@ -566,6 +566,7 @@ func (s *Server) consoleGameDetail(c *gin.Context) {
|
|||||||
Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
|
Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
|
||||||
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
|
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
|
||||||
FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI,
|
FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI,
|
||||||
|
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||||
}
|
}
|
||||||
// Resolve seats and detect robot seats; capture the human opponent's timezone, which
|
// Resolve seats and detect robot seats; capture the human opponent's timezone, which
|
||||||
// anchors the robot's sleep window for the next-move ETA.
|
// anchors the robot's sleep window for the next-move ETA.
|
||||||
|
|||||||
@@ -9,6 +9,16 @@ import (
|
|||||||
"scrabble/backend/internal/gamelimits"
|
"scrabble/backend/internal/gamelimits"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// gameLimitsDTOFor resolves the caller's tier active-game caps into the profile DTO, so the client
|
||||||
|
// can lock a capped New-Game start per kind. It returns nil when the limits config is not wired.
|
||||||
|
func (s *Server) gameLimitsDTOFor(isGuest bool) *gameLimitsDTO {
|
||||||
|
if s.gamelimits == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
l := s.gamelimits.LimitsFor(isGuest)
|
||||||
|
return &gameLimitsDTO{VsAI: l.VsAI, Random: l.Random, Friends: l.Friends}
|
||||||
|
}
|
||||||
|
|
||||||
// consoleLimits renders the per-tier, per-kind active-game limit form (backend.config), read from
|
// consoleLimits renders the per-tier, per-kind active-game limit form (backend.config), read from
|
||||||
// the in-memory cache.
|
// the in-memory cache.
|
||||||
func (s *Server) consoleLimits(c *gin.Context) {
|
func (s *Server) consoleLimits(c *gin.Context) {
|
||||||
|
|||||||
@@ -125,6 +125,17 @@ GATEWAY_VK_APP_SECRET=
|
|||||||
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
|
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
|
||||||
GATEWAY_VK_ID_CLIENT_SECRET=
|
GATEWAY_VK_ID_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# --- Payments: Robokassa (direct RUB rail) ----------------------------------
|
||||||
|
# The shop's merchant login + the two pass phrases (Password1 signs the launch request,
|
||||||
|
# Password2 signs/verifies the Result callback). An empty login leaves the direct rail off.
|
||||||
|
# ROBOKASSA_TEST=1 runs test payments against the shop's TEST pass phrases (no real money);
|
||||||
|
# empty/0 is live. Mapped in compose to BACKEND_ROBOKASSA_*; Gitea TEST_/PROD_ secrets, with
|
||||||
|
# PROD_BACKEND_ROBOKASSA_TEST a variable so go-live is a flag flip, not a secret redeploy.
|
||||||
|
ROBOKASSA_MERCHANT_LOGIN=
|
||||||
|
ROBOKASSA_PASSWORD1=
|
||||||
|
ROBOKASSA_PASSWORD2=
|
||||||
|
ROBOKASSA_TEST=
|
||||||
|
|
||||||
# --- Gateway anti-abuse ------------------------------------------------------
|
# --- Gateway anti-abuse ------------------------------------------------------
|
||||||
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
|
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
|
||||||
# where the IP ban is on (prod), logs + a ban metric otherwise (test). Plant the value
|
# where the IP ban is on (prod), logs + a ban metric otherwise (test). Plant the value
|
||||||
|
|||||||
+7
-2
@@ -76,6 +76,9 @@ compose binds from this directory.
|
|||||||
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
|
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
|
||||||
| `TELEGRAM_MINIAPP_URL` | derived | The Mini App URL the bot hands out in deep links / buttons. The deploy derives `PUBLIC_BASE_URL + /telegram/`; set it directly only for a local run (compose still `:?`-requires it). |
|
| `TELEGRAM_MINIAPP_URL` | derived | The Mini App URL the bot hands out in deep links / buttons. The deploy derives `PUBLIC_BASE_URL + /telegram/`; set it directly only for a local run (compose still `:?`-requires it). |
|
||||||
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
|
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
|
||||||
|
| `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | secret | Robokassa shop login for the direct RUB rail. Empty leaves the rail off. |
|
||||||
|
| `BACKEND_ROBOKASSA_PASSWORD1` / `…_PASSWORD2` | secret | Robokassa pass phrases: Password1 signs the launch request, Password2 signs/verifies the Result callback. Use the shop's **test** pair while `…_ROBOKASSA_TEST=1`, the **live** pair for real money. (Password3 — Robokassa's JWT-invoice API — is unused.) |
|
||||||
|
| `BACKEND_ROBOKASSA_TEST` | **variable** | `1` runs test payments against the test pass phrases (no real money); empty/`0` is live. A variable, not a secret, so go-live is a flag flip + redeploy, not a secret rotation. |
|
||||||
|
|
||||||
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
||||||
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
|
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
|
||||||
@@ -252,8 +255,10 @@ shipping or redeploying this stack does **not** start archiving — the artifact
|
|||||||
armed, which is why an un-armed prod deploy can never pile WAL onto the disk. The base-backup
|
armed, which is why an un-armed prod deploy can never pile WAL onto the disk. The base-backup
|
||||||
timer is provisioned by the Ansible `main` role behind `pitr_enabled` (also default off). Two
|
timer is provisioned by the Ansible `main` role behind `pitr_enabled` (also default off). Two
|
||||||
Grafana alerts watch health: `WAL archiving failing` (`pg_stat_archiver_failed_count` rising)
|
Grafana alerts watch health: `WAL archiving failing` (`pg_stat_archiver_failed_count` rising)
|
||||||
and `WAL archiving stalled` (`pg_stat_archiver_last_archive_age` over 30 min) — both
|
and `WAL archiving stalled` (last archive over 30 min old **while `pg_wal_size_bytes` is growing**
|
||||||
absent/NaN-safe, so they stay quiet until archiving is armed.
|
— the pg_wal-growth guard keeps an idle database, which archives nothing because it writes nothing,
|
||||||
|
from false-triggering during quiet hours) — both absent/NaN-safe, so they stay quiet until
|
||||||
|
archiving is armed.
|
||||||
|
|
||||||
**Assessment (owner-reviewed; the gate before the first real money).** Measured on prod
|
**Assessment (owner-reviewed; the gate before the first real money).** Measured on prod
|
||||||
`pg_stat_wal`: WAL is generated at **~0.77 MB/day** and the database is **~9.6 MB**. At
|
`pg_stat_wal`: WAL is generated at **~0.77 MB/day** and the database is **~9.6 MB**. At
|
||||||
|
|||||||
@@ -213,6 +213,20 @@
|
|||||||
loop:
|
loop:
|
||||||
- ""
|
- ""
|
||||||
- config
|
- config
|
||||||
- certs
|
|
||||||
- dumps
|
- dumps
|
||||||
- images
|
- images
|
||||||
|
|
||||||
|
# The certs dir holds the reverse-mTLS bot-link keypair, bind-mounted into the gateway (and
|
||||||
|
# backend) which run as the distroless nonroot UID 65532 — not the deploy user. The dir must be
|
||||||
|
# traversable by "other" (0755) or the nonroot process cannot open the 0644 keypair and crash-loops
|
||||||
|
# at startup ("mtls: load server keypair: ... permission denied") — a latent failure that only bites
|
||||||
|
# on a container restart (e.g. a host reboot), not while a long-running container holds the keypair
|
||||||
|
# in memory. The keys themselves are 0644 by design (see the gateway compose); the host is
|
||||||
|
# single-tenant and SSH-access-controlled, so a traversable certs dir adds no meaningful exposure.
|
||||||
|
- name: Create the scrabble certs directory (traversable by the nonroot gateway UID)
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ scrabble_base_dir }}/certs"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ deploy_user }}"
|
||||||
|
group: "{{ deploy_user }}"
|
||||||
|
mode: "0755"
|
||||||
|
|||||||
@@ -245,8 +245,14 @@ groups:
|
|||||||
conditions:
|
conditions:
|
||||||
- evaluator: { type: gt, params: [0] }
|
- evaluator: { type: gt, params: [0] }
|
||||||
labels: { severity: critical }
|
labels: { severity: critical }
|
||||||
annotations: { summary: 'pgBackRest archive_command is failing — PITR is degrading and pg_wal can fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble check`.' }
|
annotations: { summary: 'pgBackRest archive_command is failing — PITR is degrading and pg_wal can fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check`.' }
|
||||||
|
|
||||||
|
# Fires only when the last archive is >30 min old AND pg_wal is actually growing (WAL is being
|
||||||
|
# produced but not archived). A genuinely idle database archives nothing — Postgres does not
|
||||||
|
# force-switch an empty segment on archive_timeout — so `last_archive_age` alone false-triggers
|
||||||
|
# during quiet hours; the pg_wal-growth guard removes that. `and on()` bridges the differing
|
||||||
|
# label sets (last_archive_age has a server label, the pg_wal gauge does not); delta() (not
|
||||||
|
# increase) suits the pg_wal_size_bytes gauge. Real archive failures are caught by pg_archive_failing.
|
||||||
- uid: pg_archive_stalled
|
- uid: pg_archive_stalled
|
||||||
title: WAL archiving stalled
|
title: WAL archiving stalled
|
||||||
condition: C
|
condition: C
|
||||||
@@ -255,11 +261,11 @@ groups:
|
|||||||
execErrState: OK
|
execErrState: OK
|
||||||
data:
|
data:
|
||||||
- refId: A
|
- refId: A
|
||||||
relativeTimeRange: { from: 300, to: 0 }
|
relativeTimeRange: { from: 2100, to: 0 }
|
||||||
datasourceUid: prometheus
|
datasourceUid: prometheus
|
||||||
model:
|
model:
|
||||||
refId: A
|
refId: A
|
||||||
expr: pg_stat_archiver_last_archive_age
|
expr: (pg_stat_archiver_last_archive_age > 1800) and on() (delta(pg_wal_size_bytes[35m]) > 16777216)
|
||||||
instant: true
|
instant: true
|
||||||
- refId: C
|
- refId: C
|
||||||
datasourceUid: __expr__
|
datasourceUid: __expr__
|
||||||
@@ -270,4 +276,4 @@ groups:
|
|||||||
conditions:
|
conditions:
|
||||||
- evaluator: { type: gt, params: [1800] }
|
- evaluator: { type: gt, params: [1800] }
|
||||||
labels: { severity: critical }
|
labels: { severity: critical }
|
||||||
annotations: { summary: 'No WAL segment archived for over 30 minutes (archive_timeout is 5m) — archiving is stalled; the PITR recovery point is frozen and pg_wal may grow.' }
|
annotations: { summary: 'No WAL archived for over 30 minutes while pg_wal keeps growing — archiving is genuinely stalled; the PITR recovery point is frozen and pg_wal will fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check`.' }
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export APP_VERSION='$APP_VERSION'
|
|||||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||||
|
export ROBOKASSA_MERCHANT_LOGIN='$ROBOKASSA_MERCHANT_LOGIN'
|
||||||
|
export ROBOKASSA_PASSWORD1='$ROBOKASSA_PASSWORD1'
|
||||||
|
export ROBOKASSA_PASSWORD2='$ROBOKASSA_PASSWORD2'
|
||||||
|
export ROBOKASSA_TEST='$ROBOKASSA_TEST'
|
||||||
export VITE_VK_APP_ID='$VITE_VK_APP_ID'
|
export VITE_VK_APP_ID='$VITE_VK_APP_ID'
|
||||||
export VITE_VK_ID_REDIRECT_URL='$VITE_VK_ID_REDIRECT_URL'
|
export VITE_VK_ID_REDIRECT_URL='$VITE_VK_ID_REDIRECT_URL'
|
||||||
export GATEWAY_VK_ID_CLIENT_SECRET='$GATEWAY_VK_ID_CLIENT_SECRET'
|
export GATEWAY_VK_ID_CLIENT_SECRET='$GATEWAY_VK_ID_CLIENT_SECRET'
|
||||||
|
|||||||
+13
-6
@@ -435,7 +435,11 @@ Key points:
|
|||||||
through a **session-gated `GET /dict/{variant}/{version}`** edge route
|
through a **session-gated `GET /dict/{variant}/{version}`** edge route
|
||||||
(immutable; cached in IndexedDB best-effort) and reused across sessions; any
|
(immutable; cached in IndexedDB best-effort) and reused across sessions; any
|
||||||
miss, storage eviction or a bad-connection breaker falls back to the network
|
miss, storage eviction or a bad-connection breaker falls back to the network
|
||||||
`evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`.
|
`evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`. The **on-board rendering** of a staged
|
||||||
|
play — the legality tint, the cells a formed word covers, and where the score badge anchors — is a
|
||||||
|
separate **pure geometry helper** (`ui/src/lib/formed.ts`) derived from the board and the staged
|
||||||
|
tiles, so it works regardless of which eval path produced the preview (the badge's number still
|
||||||
|
comes from the preview's score): legality and score stay with the eval, geometry with the board.
|
||||||
|
|
||||||
## 6. Game rules
|
## 6. Game rules
|
||||||
|
|
||||||
@@ -666,11 +670,14 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
|
|||||||
refuses a **guest** outright with **403** (guests cannot use friends — the same guest gate
|
refuses a **guest** outright with **403** (guests cannot use friends — the same guest gate
|
||||||
covers friend requests and friend-code redemption). **Accepting** an invitation
|
covers friend requests and friend-code redemption). **Accepting** an invitation
|
||||||
(`POST /invitations/:id/accept`) is never gated, so friend games are capped only at
|
(`POST /invitations/:id/accept`) is never gated, so friend games are capped only at
|
||||||
initiation. The lobby learns the random-kind state from a boolean **`at_game_limit`** on
|
initiation. The client counts its lobby games per kind against the per-tier caps it reads on
|
||||||
the `games.list` response — already re-fetched on entry and every game event; the client
|
the profile (`Profile.game_limits`, carrying `GameView.kind`), and locks a capped kind's
|
||||||
additionally counts the listed games per kind against the per-tier caps it reads in the
|
New-Game start — a 🔒 outline button that opens a prompt instead of a game: a sign-in funnel
|
||||||
profile, locking a kind's New-Game entry (a login funnel for a guest, an already-at-limit
|
for a guest, a "finish a current game first" notice for a durable account (native inside
|
||||||
note for a durable account) when that kind is full.
|
Telegram, an in-app modal elsewhere), lifting when the profile is re-fetched after a
|
||||||
|
guest→durable upgrade. The `games.list` `at_game_limit` boolean (now the random-kind cap) is
|
||||||
|
still carried but no longer drives the UI — the per-kind start lock superseded the old
|
||||||
|
New-Game-tab disable.
|
||||||
- **Friends**: two add paths over one `friendships` table. A **one-time
|
- **Friends**: two add paths over one `friendships` table. A **one-time
|
||||||
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
|
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
|
||||||
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
|
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
|
||||||
|
|||||||
+42
-25
@@ -190,24 +190,31 @@ Mini App, the link only opens the app and the recipient enters the copied code b
|
|||||||
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
|
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
|
||||||
expires after seven days.
|
expires after seven days.
|
||||||
|
|
||||||
**Simultaneous-game cap.** A player may hold at most **10 active quick games** at once —
|
**Active-game caps (per kind).** A player's simultaneous unfinished games are capped **per kind** —
|
||||||
counting both in-progress and still-searching auto-match/AI games, but **not** friend games
|
against the AI, in a random match, and by friend invitation — with separate limits for a **guest**
|
||||||
created by invitation. At the cap the **New Game** button is disabled (greyed) and a plain,
|
and a signed-in (durable) account, tuned by the operator without a release. A **guest** may hold **1**
|
||||||
low-emphasis line under the lists reads *"You've reached the simultaneous games limit."*; both
|
game against the AI and **1** random match at once and cannot start friend games at all; a signed-in
|
||||||
clear automatically once an active game finishes and the count drops below ten. The cap blocks
|
account holds up to **10** of each kind. Only in-progress and still-searching games count; a finished
|
||||||
**starting** any game (a quick game or a friend invitation, which share the New Game entry), but
|
game frees its slot, and games already in progress are never interrupted. On the **New Game** screen a
|
||||||
**accepting an incoming invitation is never blocked** — so friend games are capped from the other
|
start whose kind is at its cap shows a **🔒** and, when tapped, opens a short prompt instead of a game:
|
||||||
end (you cannot initiate one while at the cap) while you can always join a game you are invited to.
|
a **guest** is invited to sign in or create an account (to play several games at once), a signed-in
|
||||||
|
player is told to finish a current game first. **Accepting an incoming invitation is never blocked** —
|
||||||
|
friend games are capped only when you start one. The server enforces every cap regardless of the
|
||||||
|
client, and refuses a guest's friend request, friend code or invitation outright.
|
||||||
|
|
||||||
### Playing a game
|
### Playing a game
|
||||||
Place tiles, pass, exchange, or resign. Pass and exchange share one control —
|
Place tiles, pass, exchange, or resign. Pass and exchange share one control —
|
||||||
choosing no tiles passes the turn, choosing tiles exchanges them. Tiles are laid
|
choosing no tiles passes the turn, choosing tiles exchanges them. The tiles left in the
|
||||||
|
bag are shown as a count on that control and at the foot of the move table. Tiles are laid
|
||||||
without choosing a direction — the game infers the play's orientation, so a single
|
without choosing a direction — the game infers the play's orientation, so a single
|
||||||
tile that extends
|
tile that extends
|
||||||
an existing word (down a column or across a row) is accepted. A play is validated
|
an existing word (down a column or across a row) is accepted. A play is validated
|
||||||
against the game's dictionary at submit time and scored; an unlimited preview
|
against the game's dictionary at submit time and scored; an unlimited preview shows
|
||||||
reports the word(s) a tentative move would form and its score, or that it is not
|
**on the board itself** whether the tentative move is legal — the staged tiles turn a light
|
||||||
legal, and the move is offered for submission only once it is confirmed legal. The preview is
|
green once they form a word, a shade darker on the board tiles the word runs through (in a
|
||||||
|
one-word-per-turn game only the main word), or a calm pink when they form none — and an
|
||||||
|
**orange badge** on the main word carries the move's
|
||||||
|
score. The confirm control is offered only once the move is legal. The preview is
|
||||||
computed **on-device** for an instant response once the game's dictionary has loaded — a
|
computed **on-device** for an instant response once the game's dictionary has loaded — a
|
||||||
brief warm-up shows on the first open otherwise — and falls back to the server whenever the
|
brief warm-up shows on the first open otherwise — and falls back to the server whenever the
|
||||||
local dictionary is unavailable. The dictionary check tool is
|
local dictionary is unavailable. The dictionary check tool is
|
||||||
@@ -369,7 +376,9 @@ an email or Telegram and merging accounts are covered under "Accounts, linking &
|
|||||||
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
|
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
|
||||||
entry that opens this screen, and your display preferences (theme, board-label style and
|
entry that opens this screen, and your display preferences (theme, board-label style and
|
||||||
reduce-motion — not the interface language, which follows your account) sync across your
|
reduce-motion — not the interface language, which follows your account) sync across your
|
||||||
Telegram devices.
|
Telegram devices. On a touch device the settings also offer a **Zoom the board** toggle (on by
|
||||||
|
default): with it off, dropping a tile no longer auto-magnifies the board toward it. It is a
|
||||||
|
per-device preference and is hidden on desktop, where the board already fits and never auto-zooms.
|
||||||
|
|
||||||
**Preferences (which variants you can be matched into).** A profile setting picks the game
|
**Preferences (which variants you can be matched into).** A profile setting picks the game
|
||||||
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
||||||
@@ -525,21 +534,29 @@ shown defensively (text escaped, attachments downloaded rather than rendered).
|
|||||||
### Wallet
|
### Wallet
|
||||||
|
|
||||||
Durable players have a **Wallet** — a tab in the Settings hub, between Friends and About; guests have
|
Durable players have a **Wallet** — a tab in the Settings hub, between Friends and About; guests have
|
||||||
none. It shows their **chips** (the in-game currency, split by where they were funded — VK, Telegram
|
none. A compact **balance** leads the screen: the running context's own **chips** (the in-game
|
||||||
or the web), their **active benefits** (ads off until a date or forever, and the available hint
|
currency) first, then any linked other-platform chips behind that platform's logo — VK, Telegram or
|
||||||
count), and a **store**. What is visible and spendable depends on **where the app runs**, by the
|
the web, split by where they were funded. What is visible and spendable depends on **where the app
|
||||||
store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that store's chips are
|
runs**, by the store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that
|
||||||
usable; on the open web all attached chips are, drawn web → VK → Telegram; on VK-iOS the balance is
|
store's chips are usable; on the open web all attached chips are, drawn web → VK → Telegram; on
|
||||||
shown but frozen for spending.
|
VK-iOS the balance is shown but frozen for spending.
|
||||||
|
|
||||||
|
Below the balance an **Active** line lists what the player currently holds — the remaining hint count
|
||||||
|
and, if bought, the ad-free end date (or "forever"); it is hidden when nothing is active. The
|
||||||
|
**store** then splits, by a two-way toggle, into **Buy chips** and **Spend chips**:
|
||||||
|
|
||||||
|
- **Buy chips** — the **chip packs** bought with real money, priced in the running context's currency
|
||||||
|
(VK Votes, Telegram Stars, or roubles on the web), plus, at the top, a **watch-an-ad-for-chips**
|
||||||
|
option where it is offered. Paying opens the provider's payment page (accepting the public offer).
|
||||||
|
- **Spend chips** — the **values** bought with chips (extra hints, days without ads) at a fixed chip
|
||||||
|
price. Their action reads **Exchange**, and a value the balance cannot cover is disabled. Tapping
|
||||||
|
Exchange asks the player to **confirm** (the spend is instant, with no provider window to stand in
|
||||||
|
for a confirmation); that same dialog also carries the store-compliance **warning** when a web
|
||||||
|
spend would draw VK/Telegram chips — the benefit then works only on the web and in the app.
|
||||||
|
|
||||||
The **store** lists **values** bought with chips (extra hints, days without ads) at a fixed chip
|
|
||||||
price shown in every context, and **chip packs** bought with real money, priced in the running
|
|
||||||
context's currency (VK Votes, Telegram Stars, or roubles on the web). Buying a value applies its
|
|
||||||
benefit at once. Before a web purchase that would draw VK/Telegram chips, the app **warns** that the
|
|
||||||
benefit will then work only on the web and in the app — a store rule — and asks the player to confirm.
|
|
||||||
On the **Google Play** build the money purchases are hidden behind a note pointing to the RuStore
|
On the **Google Play** build the money purchases are hidden behind a note pointing to the RuStore
|
||||||
build (Google's in-app-currency rule); spending already-earned chips still works. The wallet keeps
|
build (Google's in-app-currency rule); spending already-earned chips still works. The wallet keeps
|
||||||
**no purchase history** — only the current balances, benefits and store.
|
**no purchase history** — only the current balances, active benefits and store.
|
||||||
|
|
||||||
### Advertising banner
|
### Advertising banner
|
||||||
|
|
||||||
|
|||||||
+47
-28
@@ -197,25 +197,32 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
||||||
ответа приглашение протухает через семь дней.
|
ответа приглашение протухает через семь дней.
|
||||||
|
|
||||||
**Лимит одновременных партий.** Игрок может держать одновременно не более **10 активных
|
**Лимит одновременных партий (по видам).** Число одновременных незавершённых партий игрока
|
||||||
быстрых партий** — считаются и идущие, и ещё ищущие соперника авто-подбор/AI-партии, но **не**
|
ограничено **по видам** — против AI, в случайном подборе и по приглашению друзей — с раздельными
|
||||||
игры с друзьями, созданные приглашением. На лимите кнопка **«Новая игра»** становится неактивной
|
лимитами для **гостя** и для вошедшего (постоянного) аккаунта; оператор настраивает их без релиза.
|
||||||
(серой), а под списками появляется ненавязчивая строка *«Вы достигли лимита одновременных
|
**Гость** может держать **1** партию против AI и **1** случайную одновременно и вовсе не может
|
||||||
партий»*; и кнопка, и надпись снимаются автоматически, как только активная партия завершается и
|
создавать игры с друзьями; вошедший аккаунт — до **10** каждого вида. Считаются только идущие и ещё
|
||||||
счётчик опускается ниже десяти. Лимит запрещает **создавать** любую партию (быструю или
|
ищущие соперника партии; завершённая освобождает слот, а уже идущие партии никогда не прерываются.
|
||||||
приглашение в игру с друзьями — у них общий вход «Новая игра»), но **принимать входящее
|
На экране **«Новая игра»** старт того вида, что достиг лимита, показывает **🔒** и по нажатию
|
||||||
приглашение никогда не запрещено** — так игры с друзьями ограничиваются «с другого конца» (на
|
открывает короткое сообщение вместо партии: **гостю** предлагают войти или создать учётную запись
|
||||||
лимите их нельзя инициировать), а присоединиться к партии по приглашению можно всегда.
|
(чтобы играть несколько партий сразу), вошедшему — сначала завершить текущую. **Принимать входящее
|
||||||
|
приглашение никогда не запрещено** — игры с друзьями ограничиваются только при создании. Сервер
|
||||||
|
применяет все лимиты независимо от клиента и напрямую отклоняет заявку в друзья, код друга или
|
||||||
|
приглашение от гостя.
|
||||||
|
|
||||||
### Игровой процесс
|
### Игровой процесс
|
||||||
Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления:
|
Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления:
|
||||||
без выбранных фишек — пас, с выбранными — обмен. Фишки кладутся без выбора
|
без выбранных фишек — пас, с выбранными — обмен. Число оставшихся в мешке фишек
|
||||||
|
показано счётчиком на этом элементе и внизу таблицы ходов. Фишки кладутся без выбора
|
||||||
направления — игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая
|
направления — игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая
|
||||||
уже лежащее
|
уже лежащее
|
||||||
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
|
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
|
||||||
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
|
сдаче и считается; безлимитный предпросмотр показывает **прямо на доске**, допустим ли
|
||||||
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
|
предполагаемый ход: выложенные фишки становятся светло-зелёными, когда образуют слово
|
||||||
отправить только после подтверждения, что он допустим. Предпросмотр считается **на устройстве**
|
(и чуть темнее — фишки доски, через которые проходит слово; в партии «одно слово за ход»
|
||||||
|
подсвечивается только основное слово), или спокойно-розовыми, когда слова нет, — а
|
||||||
|
**оранжевый бейдж** на основном слове несёт очки хода. Кнопка
|
||||||
|
подтверждения появляется только когда ход допустим. Предпросмотр считается **на устройстве**
|
||||||
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
|
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
|
||||||
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
||||||
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
||||||
@@ -378,7 +385,10 @@ UTC; при создании аккаунта она подставляется
|
|||||||
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
|
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
|
||||||
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
|
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
|
||||||
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
|
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
|
||||||
Telegram.
|
Telegram. На сенсорном устройстве в настройках также есть переключатель **«Приближать
|
||||||
|
доску»** (по умолчанию включён): если выключить, при кидании фишки доска больше не
|
||||||
|
приближается к ней автоматически. Это настройка на устройство, и она скрыта на десктопе,
|
||||||
|
где доска и так помещается целиком и авто-зума нет.
|
||||||
|
|
||||||
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
||||||
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
||||||
@@ -538,21 +548,30 @@ high-rate флага. С карточки пользователя операт
|
|||||||
### Кошелёк
|
### Кошелёк
|
||||||
|
|
||||||
У постоянных (не гостевых) игроков есть **Кошелёк** — вкладка в разделе настроек, между «Друзьями» и
|
У постоянных (не гостевых) игроков есть **Кошелёк** — вкладка в разделе настроек, между «Друзьями» и
|
||||||
«О программе»; у гостей его нет. Он показывает **фишки** (внутриигровую валюту, разделённую по тому,
|
«О программе»; у гостей его нет. Экран открывает компактный **баланс**: сначала собственные **фишки**
|
||||||
где она была пополнена — VK, Telegram или веб), **активные блага** (реклама выключена до даты или
|
(внутриигровая валюта) текущего контекста, затем привязанные фишки других платформ за их логотипом —
|
||||||
навсегда, и число доступных подсказок) и **магазин**. Что видно и что можно потратить, зависит от
|
VK, Telegram или веб, разделённые по тому, где они были пополнены. Что видно и что можно потратить,
|
||||||
того, **где запущено приложение**, по правилам соответствия магазинам из [`PAYMENTS.md`](PAYMENTS.md):
|
зависит от того, **где запущено приложение**, по правилам соответствия магазинам из
|
||||||
внутри магазина доступны только его фишки; в открытом вебе — все привязанные, списываются в порядке
|
[`PAYMENTS.md`](PAYMENTS.md): внутри магазина доступны только его фишки; в открытом вебе — все
|
||||||
веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена.
|
привязанные, списываются в порядке веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена.
|
||||||
|
|
||||||
**Магазин** перечисляет **ценности**, покупаемые за фишки (дополнительные подсказки, дни без рекламы),
|
Под балансом строка **«Активные»** перечисляет, что у игрока есть сейчас — остаток подсказок и, если
|
||||||
по фиксированной цене в фишках, одинаковой во всех контекстах, и **наборы фишек**, покупаемые за
|
куплено, дату окончания «без рекламы» (или «навсегда»); она скрыта, когда активного ничего нет. Далее
|
||||||
настоящие деньги, в валюте текущего контекста (голоса VK, звёзды Telegram или рубли в вебе). Покупка
|
**магазин** двухпозиционным переключателем делится на **«Купить фишки»** и **«Потратить фишки»**:
|
||||||
ценности сразу применяет её благо. Перед покупкой в вебе, которая списала бы фишки VK/Telegram,
|
|
||||||
приложение **предупреждает**, что благо будет работать только в вебе и в приложении — это правило
|
- **Купить фишки** — **наборы фишек** за настоящие деньги, в валюте текущего контекста (голоса VK,
|
||||||
магазинов — и просит подтверждения. В сборке для **Google Play** покупки за деньги скрыты за подсказкой
|
звёзды Telegram или рубли в вебе), а сверху — вариант **«ролик за фишки»**, где он предлагается.
|
||||||
установить сборку из RuStore (правило Google о внутриигровой валюте); трата уже заработанных фишек
|
Оплата открывает страницу провайдера (с принятием публичной оферты).
|
||||||
по-прежнему работает. Кошелёк **не хранит историю покупок** — только текущие балансы, блага и магазин.
|
- **Потратить фишки** — **ценности** за фишки (дополнительные подсказки, дни без рекламы) по
|
||||||
|
фиксированной цене в фишках. Их действие называется **«Обмен»**, а ценность, на которую не хватает
|
||||||
|
баланса, недоступна. Тап по «Обмену» просит **подтверждения** (трата мгновенна, окна провайдера,
|
||||||
|
которое сошло бы за подтверждение, нет); это же окно несёт и **предупреждение** соответствия
|
||||||
|
магазинам, когда трата в вебе списала бы фишки VK/Telegram — благо тогда работает только в вебе и в
|
||||||
|
приложении.
|
||||||
|
|
||||||
|
В сборке для **Google Play** покупки за деньги скрыты за подсказкой установить сборку из RuStore
|
||||||
|
(правило Google о внутриигровой валюте); трата уже заработанных фишек по-прежнему работает. Кошелёк
|
||||||
|
**не хранит историю покупок** — только текущие балансы, активные блага и магазин.
|
||||||
|
|
||||||
### Рекламный баннер
|
### Рекламный баннер
|
||||||
|
|
||||||
|
|||||||
+42
-26
@@ -179,16 +179,16 @@ e2e and the screenshots.
|
|||||||
back near the edges. It **recentres only on a zoom-in** — placing a 2nd+ tile or
|
back near the edges. It **recentres only on a zoom-in** — placing a 2nd+ tile or
|
||||||
hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
|
hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
|
||||||
in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
|
in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
|
||||||
the first time. A **swipe down on the zoom-out board** opens the history, but only when the
|
the first time — both gated by the **Zoom the board** setting (on by default; the toggle is
|
||||||
|
shown only on a touch device). A **swipe down on the zoom-out board** opens the history, but only when the
|
||||||
board is scrolled to its top so it never fights the stage's own vertical scroll (the conflict
|
board is scrolled to its top so it never fights the stage's own vertical scroll (the conflict
|
||||||
that once retired this gesture) — and it is suppressed on the zoomed board, where the
|
that once retired this gesture) — and it is suppressed on the zoomed board, where the
|
||||||
one-finger drag pans (and on desktop / the landscape iframe, where a mouse cannot drag-scroll the
|
one-finger drag pans (and on desktop / the landscape iframe, where a mouse cannot drag-scroll the
|
||||||
viewport natively, a **drag-to-pan** handler moves the zoomed board instead — active only while
|
viewport natively, a **drag-to-pan** handler moves the zoomed board instead — active only while
|
||||||
zoomed, off pending tiles, past a small threshold, swallowing the trailing click). History also
|
zoomed, off pending tiles, past a small threshold, swallowing the trailing click). History also
|
||||||
opens on a **tap of the score bar** and closes on a tap or
|
opens on a **tap of the score bar** and closes on a tap or
|
||||||
an **upward swipe** of the then-inert board (below). A **hint** auto-zooms centred on the
|
an **upward swipe** of the then-inert board (below). Taking a **hint** while zoomed in **zooms
|
||||||
hint's placement, not
|
out** to the whole board, so the hint's word — highlighted (below) — is never left off-screen.
|
||||||
the top-left.
|
|
||||||
- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by
|
- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by
|
||||||
dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty
|
dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty
|
||||||
cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a
|
cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a
|
||||||
@@ -224,7 +224,7 @@ e2e and the screenshots.
|
|||||||
are pinned to the card's edges.
|
are pinned to the card's edges.
|
||||||
Unread chat is also badged on the score bar itself, so it shows with the history closed.
|
Unread chat is also badged on the score bar itself, so it shows with the history closed.
|
||||||
- **Vertical fit & keyboard**: when the game does not fit the viewport, only the
|
- **Vertical fit & keyboard**: when the game does not fit the viewport, only the
|
||||||
board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab
|
board area scrolls vertically (`Screen` `column` mode; the turn strip, score bar, rack and tab
|
||||||
bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in
|
bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in
|
||||||
`Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard
|
`Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard
|
||||||
overlays the empty area below, so the layout doesn't resize/jank; other modals stay
|
overlays the empty area below, so the layout doesn't resize/jank; other modals stay
|
||||||
@@ -235,23 +235,32 @@ e2e and the screenshots.
|
|||||||
(`min(100cqw, 100cqh)`, height-driven in `Board.svelte`'s `.scaler.land`) inside a
|
(`min(100cqw, 100cqh)`, height-driven in `Board.svelte`'s `.scaler.land`) inside a
|
||||||
`container-type: size` pane, shrinking by width when the column is narrow — the board has the
|
`container-type: size` pane, shrinking by width when the column is narrow — the board has the
|
||||||
**lowest priority**, so the left panel is never squeezed. The **left panel** stacks, top to
|
**lowest priority**, so the left panel is never squeezed. The **left panel** stacks, top to
|
||||||
bottom: the score plaques, the **always-open** history (docked and scrolling with its header
|
bottom: the **turn/result strip**, the score plaques, the **always-open** history (docked and
|
||||||
sticky — no slide-down drawer, no score-bar toggle), the status line (bag · turn · score
|
scrolling with its header sticky and the **bag count** pinned at its foot — no slide-down drawer,
|
||||||
preview), the rack (+ the ✅ make control) and, pinned at the bottom, the controls tab bar. Board
|
no score-bar toggle), the rack (with the ✅ confirm control in its fixed 7th slot) and, pinned at
|
||||||
|
the bottom, the controls tab bar. Board
|
||||||
**zoom works as in portrait** (double-tap / pinch / placement auto-zoom), but the viewport is the
|
**zoom works as in portrait** (double-tap / pinch / placement auto-zoom), but the viewport is the
|
||||||
full pane: zoom-out shows the height-fitted square centred, and zoom-in magnifies the board past
|
full pane: zoom-out shows the height-fitted square centred, and zoom-in magnifies the board past
|
||||||
the pane and pans within it, occupying the full width up to the left panel (the focus-centred
|
the pane and pans within it, occupying the full width up to the left panel (the focus-centred
|
||||||
scroll is set directly, without the portrait width-progress tween). Only the history open/close
|
scroll is set directly, without the portrait width-progress tween). Only the history open/close
|
||||||
swipes are dropped (it is always open) and the nav bar does not grow (`growNav` off). The
|
swipes are dropped (it is always open) and the nav bar does not grow (`growNav` off). The
|
||||||
portrait layout is byte-for-byte unchanged; both layouts render from the same snippets, so the
|
portrait layout is byte-for-byte unchanged; both layouts render from the same snippets, so the
|
||||||
behaviour and markup stay single-sourced. The rack tiles size to the (fixed-width) panel rather
|
behaviour and markup stay single-sourced. The rack is a **seven-column grid** filling the tray
|
||||||
than the viewport width so seven tiles never overflow the column.
|
width in both layouts, so the tiles are square and as large as the row allows (the confirm ✅ takes
|
||||||
- **Highlights**: pending tiles use a slightly darker tile background (no outline). The
|
the fixed 7th slot while a play is staged).
|
||||||
|
- **Highlights**: while composing a play the staged tiles are **tinted by legality** — a light
|
||||||
|
green once they form a word (a shade darker on the committed board tiles the word runs through —
|
||||||
|
in a **single-word game** only the main word lights up, since the engine ignores cross words),
|
||||||
|
a calm pink when they form none — and an **orange score badge** sits on a corner of the main
|
||||||
|
word's tile, its digit sized like a tile's point value (so it scales with the zoom) and clamped
|
||||||
|
to stay on the board. Off-turn or with no preview the staged tiles keep the plain pending fill.
|
||||||
|
The colours are theme tokens in `app.css` (`--tile-pending-legal` / `-illegal`, `--tile-formed`,
|
||||||
|
`--score-badge`); the geometry — which cells a word covers and where the badge anchors — is a
|
||||||
|
pure client-side helper (`lib/formed.ts`), independent of the move evaluator. The
|
||||||
last completed word keeps the normal tile background; instead its letters — not the point
|
last completed word keeps the normal tile background; instead its letters — not the point
|
||||||
values — are drawn in the recent-move colour, in both themes. It is static while it is the
|
values — are drawn in the recent-move colour, in both themes. It is static while it is the
|
||||||
opponent's turn (our word), and a 1 s flash (the letter pulses between its normal colour
|
opponent's turn (our word), and a 1 s flash (the letter pulses between its normal colour
|
||||||
and the recent-move colour) when it is our turn (their word). While placing, only the
|
and the recent-move colour) when it is our turn (their word).
|
||||||
pending tiles are highlighted.
|
|
||||||
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
|
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
|
||||||
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
|
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
|
||||||
nothing. Default **beginner**.
|
nothing. Default **beginner**.
|
||||||
@@ -267,18 +276,22 @@ e2e and the screenshots.
|
|||||||
otherwise it reverts. Used by the **Hint** tab (the icon morphs to ✅, no label — replacing
|
otherwise it reverts. Used by the **Hint** tab (the icon morphs to ✅, no label — replacing
|
||||||
the old press-and-hold popover) and the in-game **add-friend 🤝** and **block ✖️** card
|
the old press-and-hold popover) and the in-game **add-friend 🤝** and **block ✖️** card
|
||||||
controls. The **Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action).
|
controls. The **Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action).
|
||||||
- **MakeMove / Reset**: when ≥1 tile is pending the rack collapses its used slots
|
- **Confirm / Reset**: while ≥1 tile is pending a **borderless ✅ icon button** (styled like a
|
||||||
and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent
|
tab, not a filled accent button) takes the rack's **fixed 7th slot** and commits the move — no
|
||||||
button) beside the rack commits the move — no popover, and disabled while the pending word
|
popover, and disabled while the pending word is known illegal; the 🔀 Shuffle tab is replaced by
|
||||||
is known illegal; the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab.
|
a **↩️ Reset** tab.
|
||||||
- **Game tab bar**: 🔄 Exchange/Pass (opens a dialog — pick tiles to **Exchange N**, or pick
|
- **Game tab bar**: 🔄 Exchange/Pass (a **bag-count badge** on its icon while the bag is non-empty;
|
||||||
|
opens a dialog — pick tiles to **Exchange N**, or pick
|
||||||
none to **Pass without exchanging**; pass is always available on your turn, exchange only
|
none to **Pass without exchanging**; pass is always available on your turn, exchange only
|
||||||
while the bag still holds a full rack, below which the tiles disable and only the pass
|
while the bag still holds a full rack, below which the tiles disable and only the pass
|
||||||
remains), 🛟 Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label,
|
remains), 🛟 Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label,
|
||||||
no confirm), which
|
no confirm), which
|
||||||
**animates** — tiles hop along a low parabola to their new slots (duration scaled by the
|
**animates** — tiles hop along a low parabola to their new slots (duration scaled by the
|
||||||
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. The
|
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. A **thin strip
|
||||||
under-board slot shows the **Scores: N** preview. The screen **title** is the variant's
|
above the score plaques** reads, while composing a legal play, the formed word(s) and the move
|
||||||
|
score ("WORD+WORD = N"); otherwise whose turn it is, or the viewer's result once the game ends. The
|
||||||
|
move score also rides the board badge (above), never an under-board caption.
|
||||||
|
The screen **title** is the variant's
|
||||||
display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".
|
display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".
|
||||||
|
|
||||||
## Advertising banner (`components/AdBanner.svelte` in `components/Header.svelte`, `lib/banner.ts` + `lib/bannerEngine.ts`)
|
## Advertising banner (`components/AdBanner.svelte` in `components/Header.svelte`, `lib/banner.ts` + `lib/bannerEngine.ts`)
|
||||||
@@ -347,12 +360,15 @@ its entrance (`components/Toast.svelte`, re-keyed on a per-message sequence). An
|
|||||||
lives ~2 s, drifting up by about the tab-bar height as it fades (a plain fade, no travel, under
|
lives ~2 s, drifting up by about the tab-bar height as it fades (a plain fade, no travel, under
|
||||||
reduce-motion); an **error** toast dwells longer (~4 s). Either dismisses instantly on tap.
|
reduce-motion); an **error** toast dwells longer (~4 s). Either dismisses instantly on tap.
|
||||||
|
|
||||||
**Simultaneous-game cap.** When the player is at the active-quick-game cap (`games.list`
|
**Active-game caps.** A player's simultaneous unfinished games are capped per kind (vs AI / random /
|
||||||
`at_game_limit`), the lobby's **New Game** tab is **disabled** (greyed via the shared
|
friends) against the caller's per-tier limits (`Profile.game_limits`), counted client-side from the
|
||||||
`.tab:disabled` opacity) and a plain, low-emphasis line — muted, centred, no accent — sits
|
lobby games. The lobby's **New Game** tab is **always enabled** — the lock lives on the per-kind
|
||||||
under the lists with the localized "limit reached" notice. Both follow the flag carried in the
|
start, not the tab. On the **New Game** screen a start whose kind is at its cap renders as an
|
||||||
cached lobby snapshot, so they render in their last-known state on entry (the button stays
|
**outline** button with a **🔒** (not the filled accent CTA), so it reads as gated rather than ready;
|
||||||
enabled on the first, uncached load) and flip in place when an event refreshes the lobby.
|
tapped, it opens a short prompt instead of a game — inside Telegram a **native popup**, elsewhere the
|
||||||
|
in-app **Modal**. A **guest** sees a sign-in funnel (Cancel / Sign in → the account screen); a
|
||||||
|
signed-in account at its cap sees a plain "finish a current game first" notice (OK). The lock lifts in
|
||||||
|
place when the profile is re-fetched after a guest→durable upgrade.
|
||||||
|
|
||||||
## Social, account & history surfaces
|
## Social, account & history surfaces
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ type ProfileResp struct {
|
|||||||
// DictVersions is the current dictionary version per game variant, forwarded verbatim
|
// DictVersions is the current dictionary version per game variant, forwarded verbatim
|
||||||
// into the Profile payload so an offline-capable client preloads the matching dawg.
|
// into the Profile payload so an offline-capable client preloads the matching dawg.
|
||||||
DictVersions []DictVersion `json:"dict_versions,omitempty"`
|
DictVersions []DictVersion `json:"dict_versions,omitempty"`
|
||||||
|
// GameLimits is the caller's tier active-game caps per kind (-1 = unlimited), forwarded verbatim
|
||||||
|
// into the Profile payload for the client's per-kind New-Game lock.
|
||||||
|
GameLimits *GameLimitsResp `json:"game_limits,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameLimitsResp is the caller's tier active-game caps per kind (-1 = unlimited) in the profile.
|
||||||
|
type GameLimitsResp struct {
|
||||||
|
VsAI int `json:"vs_ai"`
|
||||||
|
Random int `json:"random"`
|
||||||
|
Friends int `json:"friends"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdsResp is the post-move interstitial config in the profile: the client-mirrored cooldowns
|
// AdsResp is the post-move interstitial config in the profile: the client-mirrored cooldowns
|
||||||
@@ -170,6 +180,7 @@ type GameResp struct {
|
|||||||
Seats []SeatResp `json:"seats"`
|
Seats []SeatResp `json:"seats"`
|
||||||
UnreadChat bool `json:"unread_chat"`
|
UnreadChat bool `json:"unread_chat"`
|
||||||
UnreadMessages bool `json:"unread_messages"`
|
UnreadMessages bool `json:"unread_messages"`
|
||||||
|
Kind int `json:"kind"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as
|
// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as
|
||||||
|
|||||||
@@ -194,6 +194,15 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
fb.AdsInfoAddSuppressed(b, p.Ads.Suppressed)
|
fb.AdsInfoAddSuppressed(b, p.Ads.Suppressed)
|
||||||
ads = fb.AdsInfoEnd(b)
|
ads = fb.AdsInfoEnd(b)
|
||||||
}
|
}
|
||||||
|
// The active-game limits table (scalars only), built before Profile is opened.
|
||||||
|
var gameLimits flatbuffers.UOffsetT
|
||||||
|
if p.GameLimits != nil {
|
||||||
|
fb.GameLimitsStart(b)
|
||||||
|
fb.GameLimitsAddVsAi(b, int32(p.GameLimits.VsAI))
|
||||||
|
fb.GameLimitsAddRandom(b, int32(p.GameLimits.Random))
|
||||||
|
fb.GameLimitsAddFriends(b, int32(p.GameLimits.Friends))
|
||||||
|
gameLimits = fb.GameLimitsEnd(b)
|
||||||
|
}
|
||||||
fb.ProfileStart(b)
|
fb.ProfileStart(b)
|
||||||
fb.ProfileAddUserId(b, uid)
|
fb.ProfileAddUserId(b, uid)
|
||||||
fb.ProfileAddDisplayName(b, name)
|
fb.ProfileAddDisplayName(b, name)
|
||||||
@@ -219,6 +228,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
if p.Ads != nil {
|
if p.Ads != nil {
|
||||||
fb.ProfileAddAds(b, ads)
|
fb.ProfileAddAds(b, ads)
|
||||||
}
|
}
|
||||||
|
if p.GameLimits != nil {
|
||||||
|
fb.ProfileAddGameLimits(b, gameLimits)
|
||||||
|
}
|
||||||
b.Finish(fb.ProfileEnd(b))
|
b.Finish(fb.ProfileEnd(b))
|
||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
}
|
}
|
||||||
@@ -624,6 +636,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView {
|
|||||||
LastActivityUnix: g.LastActivityUnix,
|
LastActivityUnix: g.LastActivityUnix,
|
||||||
UnreadChat: g.UnreadChat,
|
UnreadChat: g.UnreadChat,
|
||||||
UnreadMessages: g.UnreadMessages,
|
UnreadMessages: g.UnreadMessages,
|
||||||
|
Kind: g.Kind,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package transcode_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"scrabble/gateway/internal/transcode"
|
||||||
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestProfileGetEncodesGameLimits verifies the gateway forwards the backend's per-kind active-game
|
||||||
|
// caps into the Profile payload — the caps the client's New-Game lock reads. The encode is not
|
||||||
|
// exercised by the mock e2e (it bypasses the codec), so a dropped field would only surface here.
|
||||||
|
func TestProfileGetEncodesGameLimits(t *testing.T) {
|
||||||
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/user/profile" {
|
||||||
|
t.Errorf("unexpected %s %q", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en",` +
|
||||||
|
`"game_limits":{"vs_ai":1,"random":1,"friends":0}}`))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil)
|
||||||
|
op, ok := reg.Lookup(transcode.MsgProfileGet)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("profile.get not registered")
|
||||||
|
}
|
||||||
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gl := fb.GetRootAsProfile(payload, 0).GameLimits(nil)
|
||||||
|
if gl == nil {
|
||||||
|
t.Fatal("profile carries no game_limits block")
|
||||||
|
}
|
||||||
|
if gl.VsAi() != 1 || gl.Random() != 1 || gl.Friends() != 0 {
|
||||||
|
t.Errorf("game limits = %d/%d/%d, want 1/1/0", gl.VsAi(), gl.Random(), gl.Friends())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProfileGetNoGameLimits verifies a profile without a game_limits block encodes none (the
|
||||||
|
// backend omits it only when the limits config is unwired; normally the block is present).
|
||||||
|
func TestProfileGetNoGameLimits(t *testing.T) {
|
||||||
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en"}`))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil)
|
||||||
|
op, _ := reg.Lookup(transcode.MsgProfileGet)
|
||||||
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler: %v", err)
|
||||||
|
}
|
||||||
|
if p := fb.GetRootAsProfile(payload, 0); p.GameLimits(nil) != nil {
|
||||||
|
t.Error("profile without a game_limits block unexpectedly carries one")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,6 +80,11 @@ table GameView {
|
|||||||
// message (not just a nudge). With unread_chat it colours the badge — both set is the regular
|
// message (not just a nudge). With unread_chat it colours the badge — both set is the regular
|
||||||
// badge, unread_chat alone is the softer nudge-only badge.
|
// badge, unread_chat alone is the softer nudge-only badge.
|
||||||
unread_messages:bool;
|
unread_messages:bool;
|
||||||
|
// kind is the game's origin for the active-game limits: 0 unknown (a pre-existing game, never
|
||||||
|
// gated), 1 vs_ai, 2 random, 3 friends. The lobby counts active games per kind against the
|
||||||
|
// caller's per-kind limits (Profile.game_limits) to lock a capped New-Game start (added
|
||||||
|
// trailing — backward-compatible).
|
||||||
|
kind:int;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveRecord is one decoded move (a committed play, or a hint preview).
|
// MoveRecord is one decoded move (a committed play, or a hint preview).
|
||||||
@@ -267,6 +272,10 @@ table Profile {
|
|||||||
// ads carries the post-move interstitial config (cooldowns + suppressed) for the client-mirrored
|
// ads carries the post-move interstitial config (cooldowns + suppressed) for the client-mirrored
|
||||||
// gate (added trailing — backward-compatible).
|
// gate (added trailing — backward-compatible).
|
||||||
ads:AdsInfo;
|
ads:AdsInfo;
|
||||||
|
// game_limits carries the caller's tier active-game caps per kind (-1 = unlimited); the client
|
||||||
|
// counts its active games per kind from the lobby and locks a capped New-Game start (added
|
||||||
|
// trailing — backward-compatible).
|
||||||
|
game_limits:GameLimits;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdsInfo is the post-move interstitial config: the client-mirrored cooldowns (seconds) and whether
|
// AdsInfo is the post-move interstitial config: the client-mirrored cooldowns (seconds) and whether
|
||||||
@@ -278,6 +287,15 @@ table AdsInfo {
|
|||||||
suppressed:bool;
|
suppressed:bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GameLimits is the caller's tier active-game caps per kind (-1 = unlimited), resolved to the
|
||||||
|
// guest or durable tier server-side. It rides Profile.game_limits for the client's per-kind
|
||||||
|
// New-Game lock.
|
||||||
|
table GameLimits {
|
||||||
|
vs_ai:int;
|
||||||
|
random:int;
|
||||||
|
friends:int;
|
||||||
|
}
|
||||||
|
|
||||||
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation
|
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation
|
||||||
// returns the "account_blocked" result code, then replaces every screen with the terminal blocked
|
// returns the "account_blocked" result code, then replaces every screen with the terminal blocked
|
||||||
// screen and stops all push/poll. permanent is false for a temporary block; until is an RFC3339
|
// screen and stops all push/poll. permanent is false for a temporary block; until is an RFC3339
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package scrabblefb
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GameLimits struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsGameLimits(buf []byte, offset flatbuffers.UOffsetT) *GameLimits {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &GameLimits{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishGameLimitsBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.Finish(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsGameLimits(buf []byte, offset flatbuffers.UOffsetT) *GameLimits {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &GameLimits{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishSizePrefixedGameLimitsBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.FinishSizePrefixed(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameLimits) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameLimits) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameLimits) VsAi() int32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameLimits) MutateVsAi(n int32) bool {
|
||||||
|
return rcv._tab.MutateInt32Slot(4, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameLimits) Random() int32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameLimits) MutateRandom(n int32) bool {
|
||||||
|
return rcv._tab.MutateInt32Slot(6, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameLimits) Friends() int32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameLimits) MutateFriends(n int32) bool {
|
||||||
|
return rcv._tab.MutateInt32Slot(8, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GameLimitsStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(3)
|
||||||
|
}
|
||||||
|
func GameLimitsAddVsAi(builder *flatbuffers.Builder, vsAi int32) {
|
||||||
|
builder.PrependInt32Slot(0, vsAi, 0)
|
||||||
|
}
|
||||||
|
func GameLimitsAddRandom(builder *flatbuffers.Builder, random int32) {
|
||||||
|
builder.PrependInt32Slot(1, random, 0)
|
||||||
|
}
|
||||||
|
func GameLimitsAddFriends(builder *flatbuffers.Builder, friends int32) {
|
||||||
|
builder.PrependInt32Slot(2, friends, 0)
|
||||||
|
}
|
||||||
|
func GameLimitsEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
@@ -209,8 +209,20 @@ func (rcv *GameView) MutateUnreadMessages(n bool) bool {
|
|||||||
return rcv._tab.MutateBoolSlot(32, n)
|
return rcv._tab.MutateBoolSlot(32, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *GameView) Kind() int32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(34))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameView) MutateKind(n int32) bool {
|
||||||
|
return rcv._tab.MutateInt32Slot(34, n)
|
||||||
|
}
|
||||||
|
|
||||||
func GameViewStart(builder *flatbuffers.Builder) {
|
func GameViewStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(15)
|
builder.StartObject(16)
|
||||||
}
|
}
|
||||||
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
|
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0)
|
||||||
@@ -260,6 +272,9 @@ func GameViewAddUnreadChat(builder *flatbuffers.Builder, unreadChat bool) {
|
|||||||
func GameViewAddUnreadMessages(builder *flatbuffers.Builder, unreadMessages bool) {
|
func GameViewAddUnreadMessages(builder *flatbuffers.Builder, unreadMessages bool) {
|
||||||
builder.PrependBoolSlot(14, unreadMessages, false)
|
builder.PrependBoolSlot(14, unreadMessages, false)
|
||||||
}
|
}
|
||||||
|
func GameViewAddKind(builder *flatbuffers.Builder, kind int32) {
|
||||||
|
builder.PrependInt32Slot(15, kind, 0)
|
||||||
|
}
|
||||||
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,8 +244,21 @@ func (rcv *Profile) Ads(obj *AdsInfo) *AdsInfo {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *Profile) GameLimits(obj *GameLimits) *GameLimits {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(40))
|
||||||
|
if o != 0 {
|
||||||
|
x := rcv._tab.Indirect(o + rcv._tab.Pos)
|
||||||
|
if obj == nil {
|
||||||
|
obj = new(GameLimits)
|
||||||
|
}
|
||||||
|
obj.Init(rcv._tab.Bytes, x)
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func ProfileStart(builder *flatbuffers.Builder) {
|
func ProfileStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(18)
|
builder.StartObject(19)
|
||||||
}
|
}
|
||||||
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
|
||||||
@@ -307,6 +320,9 @@ func ProfileStartDictVersionsVector(builder *flatbuffers.Builder, numElems int)
|
|||||||
func ProfileAddAds(builder *flatbuffers.Builder, ads flatbuffers.UOffsetT) {
|
func ProfileAddAds(builder *flatbuffers.Builder, ads flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(17, flatbuffers.UOffsetT(ads), 0)
|
builder.PrependUOffsetTSlot(17, flatbuffers.UOffsetT(ads), 0)
|
||||||
}
|
}
|
||||||
|
func ProfileAddGameLimits(builder *flatbuffers.Builder, gameLimits flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(18, flatbuffers.UOffsetT(gameLimits), 0)
|
||||||
|
}
|
||||||
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ type GameView struct {
|
|||||||
// UnreadMessages is a per-viewer flag: at least one unread entry is a real message (not just
|
// UnreadMessages is a per-viewer flag: at least one unread entry is a real message (not just
|
||||||
// a nudge), so the badge can be coloured apart from the nudge-only case.
|
// a nudge), so the badge can be coloured apart from the nudge-only case.
|
||||||
UnreadMessages bool
|
UnreadMessages bool
|
||||||
|
// Kind is the game's origin for the active-game limits: 0 unknown (a pre-existing game), 1 vs_ai,
|
||||||
|
// 2 random, 3 friends. The lobby counts active games per kind to lock a capped New-Game start.
|
||||||
|
Kind int
|
||||||
}
|
}
|
||||||
|
|
||||||
// TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank
|
// TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank
|
||||||
@@ -169,6 +172,7 @@ func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT {
|
|||||||
fb.GameViewAddVsAi(b, g.VsAI)
|
fb.GameViewAddVsAi(b, g.VsAI)
|
||||||
fb.GameViewAddUnreadChat(b, g.UnreadChat)
|
fb.GameViewAddUnreadChat(b, g.UnreadChat)
|
||||||
fb.GameViewAddUnreadMessages(b, g.UnreadMessages)
|
fb.GameViewAddUnreadMessages(b, g.UnreadMessages)
|
||||||
|
fb.GameViewAddKind(b, int32(g.Kind))
|
||||||
return fb.GameViewEnd(b)
|
return fb.GameViewEnd(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -261,7 +261,7 @@ test('dropping the game ends it and shows the result', async ({ page }) => {
|
|||||||
await page.locator('.scoreboard').click(); // open the history
|
await page.locator('.scoreboard').click(); // open the history
|
||||||
await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁 in the history header
|
await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁 in the history header
|
||||||
await page.locator('button.danger').click(); // confirm in the modal
|
await page.locator('button.danger').click(); // confirm in the modal
|
||||||
await expect(page.locator('.status .over')).toBeVisible();
|
await expect(page.locator('.turnstrip.result')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resigning reveals the full board: closes the history and zooms out', async ({ page }) => {
|
test('resigning reveals the full board: closes the history and zooms out', async ({ page }) => {
|
||||||
@@ -283,7 +283,7 @@ test('resigning reveals the full board: closes the history and zooms out', async
|
|||||||
await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁
|
await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁
|
||||||
await page.locator('button.danger').click(); // confirm
|
await page.locator('button.danger').click(); // confirm
|
||||||
|
|
||||||
await expect(page.locator('.status .over')).toBeVisible(); // the game ended
|
await expect(page.locator('.turnstrip.result')).toBeVisible(); // the game ended
|
||||||
await expect(page.locator('.history')).toHaveCount(0); // history auto-closed (portrait)
|
await expect(page.locator('.history')).toHaveCount(0); // history auto-closed (portrait)
|
||||||
await expect(page.locator('.viewport.zoomed')).toHaveCount(0); // board zoomed out
|
await expect(page.locator('.viewport.zoomed')).toHaveCount(0); // board zoomed out
|
||||||
});
|
});
|
||||||
|
|||||||
+31
-22
@@ -1,31 +1,40 @@
|
|||||||
import { expect, test } from './fixtures';
|
import { expect, test } from './fixtures';
|
||||||
|
|
||||||
// The simultaneous quick-game cap. When the backend reports the player is at the limit
|
// The active-game limit lock on the New Game screen. When a start's kind is at the caller's per-kind
|
||||||
// (games.list at_game_limit), the lobby disables the "New Game" tab and shows a plain,
|
// cap (Profile.game_limits), the start button shows a 🔒 and opens a funnel modal instead of a game;
|
||||||
// low-emphasis notice under the lists; when it clears, the button re-enables and the
|
// when the cap lifts (the profile refetch a guest→durable upgrade would trigger), the lock clears and
|
||||||
// notice goes. Driven by the mock transport's __mock.setGameLimit seam (no backend), which
|
// the button starts a game again. Driven by the mock's __mock.setGameLimits seam (no backend). The
|
||||||
// also nudges the lobby to re-fetch (the lobby refreshes on any live event).
|
// native Telegram popup path is verified on the contour; here the cross-platform in-app modal shows
|
||||||
|
// (the mock profile is a durable account, so it is the "finish a current game first" notice).
|
||||||
|
|
||||||
test('lobby: New Game disables and a notice shows at the simultaneous-game limit', async ({ page }) => {
|
type Limits = { vsAi: number; random: number; friends: number };
|
||||||
|
function setLimits(l: Limits) {
|
||||||
|
(window as unknown as { __mock: { setGameLimits(l: Limits): void } }).__mock.setGameLimits(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('new game: a start locks at its per-kind cap and the funnel modal opens', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByRole('button', { name: /guest/i }).click();
|
await page.getByRole('button', { name: /guest/i }).click();
|
||||||
|
|
||||||
// Below the limit: the New Game tab is enabled and no notice is shown.
|
// The New Game button always opens the screen now — the per-kind lock lives on the start button.
|
||||||
const newGame = page.getByRole('button', { name: /🎲/ });
|
await page.getByRole('button', { name: /🎲/ }).click();
|
||||||
await expect(newGame).toBeEnabled();
|
|
||||||
await expect(page.getByText(/reached the simultaneous games limit/i)).toHaveCount(0);
|
|
||||||
|
|
||||||
// Reach the limit: the button greys out (disabled) and the notice appears under the lists.
|
const start = page.getByRole('button', { name: /start game/i });
|
||||||
await page.evaluate(() =>
|
await expect(start).not.toContainText('🔒');
|
||||||
(window as unknown as { __mock: { setGameLimit(v: boolean): void } }).__mock.setGameLimit(true),
|
|
||||||
);
|
|
||||||
await expect(newGame).toBeDisabled();
|
|
||||||
await expect(page.getByText(/reached the simultaneous games limit/i)).toBeVisible();
|
|
||||||
|
|
||||||
// Drop back below the limit (a game finished): the button re-enables and the notice clears.
|
// Cap every kind: the AI start (the default opponent) locks.
|
||||||
await page.evaluate(() =>
|
await page.evaluate(setLimits, { vsAi: 0, random: 0, friends: 0 });
|
||||||
(window as unknown as { __mock: { setGameLimit(v: boolean): void } }).__mock.setGameLimit(false),
|
await expect(start).toContainText('🔒');
|
||||||
);
|
|
||||||
await expect(newGame).toBeEnabled();
|
// Tapping the locked start opens the modal instead of a game, and does not navigate away.
|
||||||
await expect(page.getByText(/reached the simultaneous games limit/i)).toHaveCount(0);
|
await start.click();
|
||||||
|
const notice = page.getByText(/reached the limit/i);
|
||||||
|
await expect(notice).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/new$/);
|
||||||
|
await page.getByRole('button', { name: /^ok$/i }).click();
|
||||||
|
await expect(notice).toHaveCount(0);
|
||||||
|
|
||||||
|
// Lift the cap (the same profile-refetch path a registration takes): the lock clears.
|
||||||
|
await page.evaluate(setLimits, { vsAi: 10, random: 10, friends: 10 });
|
||||||
|
await expect(start).not.toContainText('🔒');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { expect, test } from './fixtures';
|
||||||
|
|
||||||
|
// The in-game composition UX after the under-board status strip was removed:
|
||||||
|
// - whose turn (or the final result) shows in a thin strip above the score plaques;
|
||||||
|
// - the tiles left in the bag ride the exchange control as a badge and repeat at the foot of the
|
||||||
|
// move table;
|
||||||
|
// - staging a play tints its tiles on the board and shows the orange move-score badge, and the
|
||||||
|
// confirm control sits in the rack's fixed 7th slot.
|
||||||
|
// Mock transport only: the mock has no dictionary (Game.svelte skips the local evaluator under the
|
||||||
|
// mock mode) and its network evaluator accepts any placement, so a staged tile always reads as legal.
|
||||||
|
|
||||||
|
test('in-game UX: turn strip, bag badge + table footer, staged-play highlight and score badge', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /guest/i }).click();
|
||||||
|
await page.getByRole('button', { name: /🎲/ }).click();
|
||||||
|
await page.getByRole('button', { name: 'Random player' }).click();
|
||||||
|
await page.locator('.variant').first().click();
|
||||||
|
await page.getByRole('button', { name: /Start game/i }).click();
|
||||||
|
// Attach the opponent deterministically, then it is the player's turn.
|
||||||
|
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
|
||||||
|
await expect(page.getByText('Robo')).toBeVisible();
|
||||||
|
|
||||||
|
// The old under-board status line is gone; a thin turn strip sits above the plaques instead.
|
||||||
|
await expect(page.locator('.turnstrip')).toBeVisible();
|
||||||
|
await expect(page.locator('.status')).toHaveCount(0);
|
||||||
|
|
||||||
|
// The bag count rides the exchange control (the first tab) as a badge, and repeats at the foot of
|
||||||
|
// the move table, out of the scrolling grid.
|
||||||
|
await expect(page.locator('.tab').first().locator('.badge')).toBeVisible();
|
||||||
|
await page.locator('.scoreboard').click(); // open the history drawer
|
||||||
|
await expect(page.locator('.hbagfoot')).toContainText(/bag/i);
|
||||||
|
await page.locator('.scoreboard').click(); // close it again
|
||||||
|
|
||||||
|
// Stage a play: the pending tile reads as legal (green), the orange move-score badge shows on the
|
||||||
|
// board, and the confirm control takes the rack's 7th slot.
|
||||||
|
await page.locator('.rack .tile').first().click();
|
||||||
|
await page.locator('[data-cell]:not(.filled)').nth(112).click(); // centre (row 7, col 7)
|
||||||
|
await expect(page.locator('[data-cell].pending.legal')).toHaveCount(1);
|
||||||
|
await expect(page.locator('.scorebadge')).toBeVisible();
|
||||||
|
await expect(page.locator('.make')).toBeVisible();
|
||||||
|
});
|
||||||
@@ -75,7 +75,7 @@ test('AI game: after it ends, no GCG export and no comms entry', async ({ page }
|
|||||||
await page.locator('.scoreboard').click();
|
await page.locator('.scoreboard').click();
|
||||||
await page.getByRole('button', { name: 'Drop game' }).click();
|
await page.getByRole('button', { name: 'Drop game' }).click();
|
||||||
await page.locator('button.danger').click();
|
await page.locator('button.danger').click();
|
||||||
await expect(page.locator('.status .over')).toBeVisible();
|
await expect(page.locator('.turnstrip.result')).toBeVisible();
|
||||||
|
|
||||||
// Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and
|
// Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and
|
||||||
// no comms entry at all.
|
// no comms entry at all.
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ test('guest reaches a board and previews a placement', async ({ page }) => {
|
|||||||
await rackTile.click();
|
await rackTile.click();
|
||||||
await page.locator('[data-cell]:not(.filled)').nth(30).click();
|
await page.locator('[data-cell]:not(.filled)').nth(30).click();
|
||||||
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
|
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
|
||||||
// The score preview appears where the hints count used to be.
|
// The move-score badge appears on the board for the staged play.
|
||||||
await expect(page.locator('.scores')).toContainText(/\d/);
|
await expect(page.locator('.scorebadge')).toContainText(/\d/);
|
||||||
|
|
||||||
// The contextual MakeMove control (✅) appears once a tile is pending.
|
// The contextual MakeMove control (✅) appears once a tile is pending.
|
||||||
await expect(page.locator('.make')).toBeVisible();
|
await expect(page.locator('.make')).toBeVisible();
|
||||||
|
|||||||
+41
-24
@@ -1,9 +1,10 @@
|
|||||||
import { expect, test, type Page } from './fixtures';
|
import { expect, test, type Page } from './fixtures';
|
||||||
|
|
||||||
// The Wallet section against the mock transport (no backend). The mock seeds a web/native (direct)
|
// The Wallet section against the mock transport (no backend). The mock seeds a web/native (direct)
|
||||||
// account holding a direct + vk chip segment and a small catalog (two chip-priced values and one
|
// account holding a direct (120) + vk (400) chip segment and a small catalog (two chip-priced values
|
||||||
// rouble-priced chip pack — see lib/mock/client.ts), so the storefront, the store-compliance
|
// and one rouble-priced chip pack — see lib/mock/client.ts), so the compact balance, the split
|
||||||
// warning and the Google Play stub are all exercisable without a backend.
|
// storefront, the exchange-confirm dialog (with its store-compliance warning) and the Google Play
|
||||||
|
// stub are all exercisable without a backend.
|
||||||
|
|
||||||
async function loginLobby(page: Page): Promise<void> {
|
async function loginLobby(page: Page): Promise<void> {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
@@ -22,24 +23,33 @@ async function openWallet(page: Page): Promise<void> {
|
|||||||
await expect(page.getByTestId('wallet')).toBeVisible();
|
await expect(page.getByTestId('wallet')).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
test('wallet: balances, benefits and the storefront render for a durable account', async ({ page }) => {
|
test('wallet: balance, active benefits and the split storefront render for a durable account', async ({ page }) => {
|
||||||
await loginLobby(page);
|
await loginLobby(page);
|
||||||
await openWallet(page);
|
await openWallet(page);
|
||||||
|
|
||||||
// Balances: the seeded direct ("Web") and vk segments.
|
// The compact balance leads with the context's own (direct/"Web") chips, then the linked vk
|
||||||
await expect(page.getByRole('heading', { name: 'Balance' })).toBeVisible();
|
// segment behind its logo.
|
||||||
await expect(page.getByText('Web', { exact: true })).toBeVisible();
|
const balance = page.getByTestId('balance');
|
||||||
await expect(page.getByText('VK', { exact: true })).toBeVisible();
|
await expect(balance).toContainText('120');
|
||||||
|
await expect(balance).toContainText('400');
|
||||||
|
await expect(balance.locator('.plogo')).toHaveCount(1); // the one linked (vk) platform
|
||||||
|
|
||||||
// Benefits + the storefront: two values priced in chips, one pack priced in roubles.
|
// The active benefits (the seeded 5 hints) sit above the store.
|
||||||
await expect(page.getByRole('heading', { name: 'Benefits' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Active' })).toBeVisible();
|
||||||
|
await expect(page.getByTestId('active')).toContainText('Hints: 5');
|
||||||
await expect(page.getByRole('heading', { name: 'Store' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Store' })).toBeVisible();
|
||||||
await expect(page.getByTestId('product')).toHaveCount(3);
|
|
||||||
const pack = page.locator('[data-kind="pack"]');
|
// Default tab — Buy chips: one rouble-priced pack + the public offer, and no values.
|
||||||
await expect(pack).toContainText('₽');
|
await expect(page.locator('[data-kind="pack"]')).toHaveCount(1);
|
||||||
// The pack purchase is wired to money intake: an enabled Buy action, with the public-offer link.
|
await expect(page.locator('[data-kind="pack"]')).toContainText('₽');
|
||||||
await expect(pack.getByTestId('buy-pack')).toBeEnabled();
|
await expect(page.locator('[data-kind="pack"]').getByTestId('buy-pack')).toBeEnabled();
|
||||||
await expect(page.getByTestId('offer')).toContainText('Public offer');
|
await expect(page.getByTestId('offer')).toContainText('Public offer');
|
||||||
|
await expect(page.locator('[data-kind="value"]')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Spend chips tab: the two chip-priced values, exchanged (not bought) with the "Exchange" button.
|
||||||
|
await page.getByTestId('tab-spend').click();
|
||||||
|
await expect(page.locator('[data-kind="value"]')).toHaveCount(2);
|
||||||
|
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toHaveText('Exchange');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wallet: buying a chip pack opens the provider payment page', async ({ page }) => {
|
test('wallet: buying a chip pack opens the provider payment page', async ({ page }) => {
|
||||||
@@ -87,32 +97,39 @@ test('wallet: the Google Play build hides the money purchases behind the RuStore
|
|||||||
await expect(page.getByText('Your turn')).toBeVisible();
|
await expect(page.getByText('Your turn')).toBeVisible();
|
||||||
await openWallet(page);
|
await openWallet(page);
|
||||||
|
|
||||||
// The chip pack is gone; the stub points at the RuStore build. Spending earned chips still works.
|
// Buy chips: the chip pack is gone; the stub points at the RuStore build.
|
||||||
await expect(page.getByTestId('gp-stub')).toBeVisible();
|
await expect(page.getByTestId('gp-stub')).toBeVisible();
|
||||||
await expect(page.locator('[data-kind="pack"]')).toHaveCount(0);
|
await expect(page.locator('[data-kind="pack"]')).toHaveCount(0);
|
||||||
|
// Spending earned chips still works — the values live in the Spend tab.
|
||||||
|
await page.getByTestId('tab-spend').click();
|
||||||
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toBeVisible();
|
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wallet: a web spend that would draw store chips warns first, then completes', async ({ page }) => {
|
test('wallet: a web spend that would draw store chips confirms with a warning, then completes', async ({ page }) => {
|
||||||
await loginLobby(page);
|
await loginLobby(page);
|
||||||
await openWallet(page);
|
await openWallet(page);
|
||||||
|
await page.getByTestId('tab-spend').click();
|
||||||
|
|
||||||
// The 300-chip value drains direct (120) then reaches into vk (180) → a store segment → warn.
|
// The 300-chip value drains direct (120) then reaches into vk (180) → a store segment → the confirm
|
||||||
|
// dialog carries the cross-platform warning.
|
||||||
await page.locator('[data-pid="val-noads-30"]').getByTestId('buy').click();
|
await page.locator('[data-pid="val-noads-30"]').getByTestId('buy').click();
|
||||||
await expect(page.getByTestId('warn')).toBeVisible();
|
await expect(page.getByTestId('warn')).toBeVisible();
|
||||||
await page.getByTestId('warn-confirm').click();
|
await page.getByTestId('spend-confirm').click();
|
||||||
await expect(page.getByTestId('warn')).toBeHidden();
|
// The spend applied: the no-ads benefit now shows an end date in the Active section.
|
||||||
// The spend applied: the no-ads benefit now shows an end date.
|
|
||||||
await expect(page.getByTestId('wallet')).toContainText('No ads until');
|
await expect(page.getByTestId('wallet')).toContainText('No ads until');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wallet: a spend the direct segment covers alone does not warn', async ({ page }) => {
|
test('wallet: a spend the direct segment covers alone confirms without a warning', async ({ page }) => {
|
||||||
await loginLobby(page);
|
await loginLobby(page);
|
||||||
await openWallet(page);
|
await openWallet(page);
|
||||||
|
await page.getByTestId('tab-spend').click();
|
||||||
|
|
||||||
// The 100-chip value is covered by the direct segment (120) alone → no store draw, no warning.
|
// The 100-chip value is covered by the direct segment (120) alone → the confirm dialog shows, but
|
||||||
|
// with no cross-platform warning.
|
||||||
await page.locator('[data-pid="val-hints-50"]').getByTestId('buy').click();
|
await page.locator('[data-pid="val-hints-50"]').getByTestId('buy').click();
|
||||||
|
await expect(page.getByTestId('spend-body')).toBeVisible();
|
||||||
await expect(page.getByTestId('warn')).toBeHidden();
|
await expect(page.getByTestId('warn')).toBeHidden();
|
||||||
|
await page.getByTestId('spend-confirm').click();
|
||||||
// The hints benefit rose from 5 to 55.
|
// The hints benefit rose from 5 to 55.
|
||||||
await expect(page.getByTestId('wallet')).toContainText('Hints 55');
|
await expect(page.getByTestId('wallet')).toContainText('Hints: 55');
|
||||||
});
|
});
|
||||||
|
|||||||
+12
-14
@@ -74,11 +74,11 @@ test.describe('touch placement', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// A hint taken while the board is ALREADY zoomed in must still scroll to the played word — the
|
// A hint taken while the board is ALREADY zoomed in now zooms OUT to the whole board, so the played
|
||||||
// zoom state does not change, so without an explicit recenter the board stays parked where the
|
// word — highlighted while composing — is guaranteed visible rather than parked off-screen under the
|
||||||
// player was looking (the reported rough edge). The mock hint plays at the centre star (7,7), so
|
// old zoom (the owner changed this from the former recentre-on-the-word behaviour, so the word can
|
||||||
// zooming into the top-left corner first and then hinting must pan the board toward the centre.
|
// never be lost off-screen). The mock hint plays at the centre star (7,7).
|
||||||
test('a hint recentres an already-zoomed board on the played word', async ({ page }) => {
|
test('a hint zooms out an already-zoomed board so the played word is visible', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByRole('button', { name: /guest/i }).click();
|
await page.getByRole('button', { name: /guest/i }).click();
|
||||||
await page.getByRole('button', { name: /Ann/ }).click();
|
await page.getByRole('button', { name: /Ann/ }).click();
|
||||||
@@ -91,19 +91,17 @@ test('a hint recentres an already-zoomed board on the played word', async ({ pag
|
|||||||
el.click();
|
el.click();
|
||||||
el.click();
|
el.click();
|
||||||
});
|
});
|
||||||
const viewport = page.locator('.viewport.zoomed');
|
await expect(page.locator('.viewport.zoomed')).toBeVisible();
|
||||||
await expect(viewport).toBeVisible();
|
|
||||||
await page.waitForTimeout(400); // let the zoom-in settle near the top-left corner
|
|
||||||
const before = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop }));
|
|
||||||
|
|
||||||
// Take a hint (the control confirms on a second tap), which plays at the centre.
|
// Take a hint (the control confirms on a second tap), which plays at the centre.
|
||||||
const hint = page.getByRole('button', { name: 'Hint' });
|
const hint = page.getByRole('button', { name: 'Hint' });
|
||||||
await hint.click();
|
await hint.click();
|
||||||
await hint.click();
|
await hint.click();
|
||||||
await page.waitForTimeout(300); // the board pans to the hint word
|
|
||||||
|
|
||||||
const after = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop }));
|
// The board zoomed out — no zoomed viewport — with the hint's play staged (the confirm control
|
||||||
// The board panned from the top-left toward the centre word: both offsets grew.
|
// shows). The mock hint plays on the centre star, which the seeded game already occupies, so the
|
||||||
expect(after.left).toBeGreaterThan(before.left + 20);
|
// staged tile hides under the committed one; the confirm control is the reliable "a play is
|
||||||
expect(after.top).toBeGreaterThan(before.top + 20);
|
// staged" signal.
|
||||||
|
await expect(page.locator('.viewport.zoomed')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.make')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,9 +32,13 @@ const DIST = 'dist';
|
|||||||
// storefront logic and the catalog codec load with the always-mounted settings hub (its i18n lands
|
// storefront logic and the catalog codec load with the always-mounted settings hub (its i18n lands
|
||||||
// in the shared chunk) — and to 125 for the payment intake rails: the wallet order flow, the
|
// in the shared chunk) — and to 125 for the payment intake rails: the wallet order flow, the
|
||||||
// Telegram Stars openInvoice / VK order-box launch and the per-button in-flight state ride the same
|
// Telegram Stars openInvoice / VK order-box launch and the per-button in-flight state ride the same
|
||||||
|
// always-loaded Wallet screen, then to 126 for the in-game board-feedback UX — the staged-play
|
||||||
|
// highlight geometry (lib/formed.ts), the on-board score badge and the bag / turn-strip / board-zoom
|
||||||
|
// wiring live in the always-loaded Game / Board / Rack screens, then to 127 for the wallet storefront
|
||||||
|
// redesign — its buy/spend toggle, compact balance and exchange-confirm dialog ride the same
|
||||||
// always-loaded Wallet screen. The heavy parts — the dict loader, the move generator and the preload
|
// always-loaded Wallet screen. 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.
|
// orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
|
||||||
const BUDGET = { app: 125, shared: 31, landing: 5 };
|
const BUDGET = { app: 127, shared: 31, 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.
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
locale: lc,
|
locale: lc,
|
||||||
reduceMotion: prefs.reduceMotion ?? false,
|
reduceMotion: prefs.reduceMotion ?? false,
|
||||||
boardLabels: prefs.boardLabels ?? 'beginner',
|
boardLabels: prefs.boardLabels ?? 'beginner',
|
||||||
|
zoomBoard: prefs.zoomBoard ?? true,
|
||||||
});
|
});
|
||||||
prefs = { ...prefs, locale: lc };
|
prefs = { ...prefs, locale: lc };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,14 @@
|
|||||||
--tile-edge: #d8c190;
|
--tile-edge: #d8c190;
|
||||||
--tile-text: #2a2113;
|
--tile-text: #2a2113;
|
||||||
--tile-pending: #f2cf73;
|
--tile-pending: #f2cf73;
|
||||||
|
/* In-composition highlight of the staged play (see game/Board.svelte): a calm reddish-pink
|
||||||
|
when the tiles form no word, a light green for the player's own tiles once they do, a
|
||||||
|
deeper green for the board tiles a formed word runs through, and the orange move-score
|
||||||
|
badge. Tuned per theme; refined on the contour. */
|
||||||
|
--tile-pending-illegal: #f2c7bf;
|
||||||
|
--tile-pending-legal: #c9e7bd;
|
||||||
|
--tile-formed: #a5cf93;
|
||||||
|
--score-badge: #e5811d;
|
||||||
/* Last-word highlight letter — a lighter burgundy than the dark theme on purpose: against
|
/* Last-word highlight letter — a lighter burgundy than the dark theme on purpose: against
|
||||||
the lighter tile the perceived contrast needs it, so the two are tuned per theme. */
|
the lighter tile the perceived contrast needs it, so the two are tuned per theme. */
|
||||||
--tile-recent: #9c5849;
|
--tile-recent: #9c5849;
|
||||||
@@ -84,6 +92,10 @@
|
|||||||
--tile-edge: #b6a473;
|
--tile-edge: #b6a473;
|
||||||
--tile-text: #20190d;
|
--tile-text: #20190d;
|
||||||
--tile-pending: #d8b75e;
|
--tile-pending: #d8b75e;
|
||||||
|
--tile-pending-illegal: #d8a99f;
|
||||||
|
--tile-pending-legal: #a9cf94;
|
||||||
|
--tile-formed: #83b571;
|
||||||
|
--score-badge: #e88f31;
|
||||||
--tile-recent: #8c4a3c;
|
--tile-recent: #8c4a3c;
|
||||||
--prem-tw: #9c3f34; /* 3x word: a touch darker red */
|
--prem-tw: #9c3f34; /* 3x word: a touch darker red */
|
||||||
--prem-dw: #a8636b; /* 2x word: softer, pinker */
|
--prem-dw: #a8636b; /* 2x word: softer, pinker */
|
||||||
@@ -115,6 +127,10 @@
|
|||||||
--tile-edge: #b6a473;
|
--tile-edge: #b6a473;
|
||||||
--tile-text: #20190d;
|
--tile-text: #20190d;
|
||||||
--tile-pending: #f0d98f;
|
--tile-pending: #f0d98f;
|
||||||
|
--tile-pending-illegal: #d8a99f;
|
||||||
|
--tile-pending-legal: #a9cf94;
|
||||||
|
--tile-formed: #83b571;
|
||||||
|
--score-badge: #e88f31;
|
||||||
/* Last-word highlight letter — a warm burgundy whose red hue stays distinct from both the
|
/* Last-word highlight letter — a warm burgundy whose red hue stays distinct from both the
|
||||||
near-black glyph and the warm tile. The light theme uses a lighter burgundy (tuned per
|
near-black glyph and the warm tile. The light theme uses a lighter burgundy (tuned per
|
||||||
theme; perceived contrast depends on the surrounding board). */
|
theme; perceived contrast depends on the surrounding board). */
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
import { insideTelegram, telegramDialogsAvailable, telegramShowPopup } from '../lib/telegram';
|
||||||
|
import { LOGIN_BUTTON_ID, gameLimitGuestPopup, gameLimitDurablePopup } from '../lib/nativedialogs';
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
|
||||||
|
// The New-Game screen raises this when the player taps a start whose kind is at its cap. guest
|
||||||
|
// picks the message: a sign-in funnel (a guest plays more by registering) vs a durable account's
|
||||||
|
// plain "finish a current game first" notice. onclose dismisses; onlogin routes a guest to sign-in.
|
||||||
|
let { open, guest, onclose, onlogin }: { open: boolean; guest: boolean; onclose: () => void; onlogin: () => void } = $props();
|
||||||
|
|
||||||
|
const title = $derived(guest ? t('new.limitGuestTitle') : t('new.limitDurableTitle'));
|
||||||
|
const body = $derived(guest ? t('new.limitGuestBody') : t('new.limitDurableBody'));
|
||||||
|
|
||||||
|
// Native path: inside the Mini App with native dialogs, present Telegram's own popup instead of
|
||||||
|
// the in-app modal (evaluated at fire time — the SDK loads after mount). A guest popup's login
|
||||||
|
// button routes to sign-in; any other dismissal closes. The message here is a plain notice with
|
||||||
|
// no gesture-gated follow-up, so a native popup is safe (unlike share/clipboard flows).
|
||||||
|
let shown = false;
|
||||||
|
$effect(() => {
|
||||||
|
if (open && !shown && insideTelegram() && telegramDialogsAvailable()) {
|
||||||
|
shown = true;
|
||||||
|
const params = guest
|
||||||
|
? gameLimitGuestPopup(title, body, t('common.cancel'), t('new.limitLogin'))
|
||||||
|
: gameLimitDurablePopup(title, body, t('common.ok'));
|
||||||
|
void telegramShowPopup(params).then((id) => (guest && id === LOGIN_BUTTON_ID ? onlogin() : onclose()));
|
||||||
|
} else if (!open) {
|
||||||
|
shown = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open && !(insideTelegram() && telegramDialogsAvailable())}
|
||||||
|
<Modal {title} onclose={onclose}>
|
||||||
|
<p class="msg">{body}</p>
|
||||||
|
<div class="actions">
|
||||||
|
{#if guest}
|
||||||
|
<button class="cancel" onclick={onclose}>{t('common.cancel')}</button>
|
||||||
|
<button class="primary" onclick={onlogin}>{t('new.limitLogin')}</button>
|
||||||
|
{:else}
|
||||||
|
<button class="primary" onclick={onclose}>{t('common.ok')}</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.msg {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.actions button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
.cancel {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,12 +3,16 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
title = '',
|
title = '',
|
||||||
|
titleAside = '',
|
||||||
onclose,
|
onclose,
|
||||||
overlayKeyboard = false,
|
overlayKeyboard = false,
|
||||||
bottomSheet = false,
|
bottomSheet = false,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
/** Optional secondary text pinned to the right of the title row, in a lighter (non-bold)
|
||||||
|
* muted style — e.g. the bag count beside the "Exchange or pass" title. */
|
||||||
|
titleAside?: string;
|
||||||
onclose?: () => void;
|
onclose?: () => void;
|
||||||
overlayKeyboard?: boolean;
|
overlayKeyboard?: boolean;
|
||||||
bottomSheet?: boolean;
|
bottomSheet?: boolean;
|
||||||
@@ -65,7 +69,12 @@
|
|||||||
style:--kb={bottomSheet ? `${kb}px` : null}
|
style:--kb={bottomSheet ? `${kb}px` : null}
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{#if title}<h2>{title}</h2>{/if}
|
{#if title}
|
||||||
|
<div class="titlerow">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{#if titleAside}<span class="aside">{titleAside}</span>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,8 +130,21 @@
|
|||||||
max-height: 86dvh;
|
max-height: 86dvh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
.titlerow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
}
|
}
|
||||||
|
/* Secondary title-row text (e.g. the bag count): lighter and muted so the title still leads. */
|
||||||
|
.aside {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -85,7 +85,9 @@
|
|||||||
color: var(--accent-text);
|
color: var(--accent-text);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
min-width: 15px;
|
min-width: 15px;
|
||||||
padding: 0 3px;
|
/* Enough horizontal room that a two/three-digit count (e.g. the bag count) does not touch the
|
||||||
|
pill's rounded ends. */
|
||||||
|
padding: 0 5px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
focus,
|
focus,
|
||||||
recenter,
|
recenter,
|
||||||
dropTarget,
|
dropTarget,
|
||||||
|
previewLegal,
|
||||||
|
formed,
|
||||||
|
scoreBadge,
|
||||||
oncell,
|
oncell,
|
||||||
ontogglezoom,
|
ontogglezoom,
|
||||||
onrecall,
|
onrecall,
|
||||||
@@ -48,6 +51,15 @@
|
|||||||
recenter: number;
|
recenter: number;
|
||||||
/** The cell a dragged tile is currently aimed at, highlighted as a drop target. */
|
/** The cell a dragged tile is currently aimed at, highlighted as a drop target. */
|
||||||
dropTarget: { row: number; col: number } | null;
|
dropTarget: { row: number; col: number } | null;
|
||||||
|
/** While composing: true when the staged tiles form a legal play, false when not, null when
|
||||||
|
* there is no preview (off-turn, nothing staged, or a recall drag) — tints the pending tiles
|
||||||
|
* green vs a calm pink. */
|
||||||
|
previewLegal: boolean | null;
|
||||||
|
/** "row,col" keys of every committed board cell a formed word runs through, greened a shade
|
||||||
|
* darker than the player's own staged tiles. */
|
||||||
|
formed: Set<string>;
|
||||||
|
/** The orange move-score badge for the legal play being composed, or null. */
|
||||||
|
scoreBadge: { row: number; col: number; corner: 'tr' | 'bl'; score: number } | null;
|
||||||
oncell: (row: number, col: number) => void;
|
oncell: (row: number, col: number) => void;
|
||||||
ontogglezoom: (row: number, col: number) => void;
|
ontogglezoom: (row: number, col: number) => void;
|
||||||
/** Recall the pending tile at (row, col) — fired on a double-tap of a pending cell. */
|
/** Recall the pending tile at (row, col) — fired on a double-tap of a pending cell. */
|
||||||
@@ -60,6 +72,20 @@
|
|||||||
const z = $derived(zoomed ? Z : 1);
|
const z = $derived(zoomed ? Z : 1);
|
||||||
const premClass: Record<Premium, string> = { '': '', TW: 'tw', DW: 'dw', TL: 'tl', DL: 'dl' };
|
const premClass: Record<Premium, string> = { '': '', TW: 'tw', DW: 'dw', TL: 'tl', DL: 'dl' };
|
||||||
|
|
||||||
|
// The score badge sits on a corner of its anchor cell, clamped so the whole pill stays on the
|
||||||
|
// board. Its position is a percentage of the (15-cell) board; CSS centres the pill on the point.
|
||||||
|
const BADGE_MARGIN = 2.4;
|
||||||
|
const badgePos = $derived.by(() => {
|
||||||
|
const b = scoreBadge;
|
||||||
|
if (!b) return null;
|
||||||
|
const cell = 100 / 15;
|
||||||
|
let x = b.corner === 'tr' ? (b.col + 1) * cell : b.col * cell;
|
||||||
|
let y = b.corner === 'tr' ? b.row * cell : (b.row + 1) * cell;
|
||||||
|
x = Math.max(BADGE_MARGIN, Math.min(100 - BADGE_MARGIN, x));
|
||||||
|
y = Math.max(BADGE_MARGIN, Math.min(100 - BADGE_MARGIN, y));
|
||||||
|
return { x, y };
|
||||||
|
});
|
||||||
|
|
||||||
let viewport = $state<HTMLElement>();
|
let viewport = $state<HTMLElement>();
|
||||||
|
|
||||||
// Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll
|
// Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll
|
||||||
@@ -315,6 +341,9 @@
|
|||||||
class="cell {premClass[premium[r][c]]}"
|
class="cell {premClass[premium[r][c]]}"
|
||||||
class:filled={!!cell}
|
class:filled={!!cell}
|
||||||
class:pending={!!p && !cell}
|
class:pending={!!p && !cell}
|
||||||
|
class:legal={!!p && !cell && previewLegal === true}
|
||||||
|
class:illegal={!!p && !cell && previewLegal === false}
|
||||||
|
class:formed={!!cell && formed.has(key(r, c))}
|
||||||
class:hl={!!cell && highlight.has(key(r, c)) && !flash}
|
class:hl={!!cell && highlight.has(key(r, c)) && !flash}
|
||||||
class:flash={!!cell && flash && highlight.has(key(r, c))}
|
class:flash={!!cell && flash && highlight.has(key(r, c))}
|
||||||
class:dark={premium[r][c] === '' && !cell && !p && (r + c) % 2 === 1}
|
class:dark={premium[r][c] === '' && !cell && !p && (r + c) % 2 === 1}
|
||||||
@@ -342,6 +371,9 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if scoreBadge && badgePos}
|
||||||
|
<span class="scorebadge" style="left:{badgePos.x}%; top:{badgePos.y}%">{scoreBadge.score}</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -397,6 +429,8 @@
|
|||||||
gap: 0;
|
gap: 0;
|
||||||
background: var(--board-bg);
|
background: var(--board-bg);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
/* Anchor for the absolutely-positioned score badge (its percentages resolve against the board). */
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.cell {
|
.cell {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -445,6 +479,19 @@
|
|||||||
board) instead of the touch starting a board pan. */
|
board) instead of the touch starting a board pan. */
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
/* While composing, the staged play recolours its tiles: a calm reddish-pink when they form no
|
||||||
|
word, a light green for the player's own tiles once they do; committed board tiles a formed
|
||||||
|
word runs through go a shade darker (see lib/formed + Game.svelte). .formed sits after .filled
|
||||||
|
so it wins on committed cells (equal specificity). */
|
||||||
|
.cell.pending.illegal {
|
||||||
|
background: var(--tile-pending-illegal);
|
||||||
|
}
|
||||||
|
.cell.pending.legal {
|
||||||
|
background: var(--tile-pending-legal);
|
||||||
|
}
|
||||||
|
.cell.formed {
|
||||||
|
background: var(--tile-formed);
|
||||||
|
}
|
||||||
.cell.droptarget {
|
.cell.droptarget {
|
||||||
/* The cell a carried tile is aimed at: an accent ring plus a light accent wash, so the
|
/* The cell a carried tile is aimed at: an accent ring plus a light accent wash, so the
|
||||||
target reads clearly while dragging without obscuring the bonus label underneath. */
|
target reads clearly while dragging without obscuring the bonus label underneath. */
|
||||||
@@ -545,4 +592,25 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
/* The move-score badge for the legal play being composed: an orange pill centred on a corner of
|
||||||
|
the main word's tile (see lib/formed.badgePlacement + Game.svelte), its digit sized like a
|
||||||
|
tile's point value so it scales with the zoom. Decorative — no pointer events — and drawn above
|
||||||
|
the tiles. */
|
||||||
|
.scorebadge {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 5;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--score-badge);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 2px 5px; /* px fallback for Chrome < 105 (no cqw) */
|
||||||
|
padding: 0.4cqw 0.9cqw;
|
||||||
|
font-size: calc(2.4vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
|
||||||
|
font-size: 2.4cqw;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+118
-100
@@ -49,6 +49,7 @@
|
|||||||
type Placement,
|
type Placement,
|
||||||
} from '../lib/placement';
|
} from '../lib/placement';
|
||||||
import { liveDraftTiles, parseDraft, serializeDraft, validRackOrder } from '../lib/draft';
|
import { liveDraftTiles, parseDraft, serializeDraft, validRackOrder } from '../lib/draft';
|
||||||
|
import { badgePlacement, formedGeometry } from '../lib/formed';
|
||||||
|
|
||||||
let { id }: { id: string } = $props();
|
let { id }: { id: string } = $props();
|
||||||
|
|
||||||
@@ -483,6 +484,23 @@
|
|||||||
// move's ✅ confirm and its preview caption are hidden so they don't sit over the opening gap;
|
// move's ✅ confirm and its preview caption are hidden so they don't sit over the opening gap;
|
||||||
// they return the moment the tile is dragged back out over the board.
|
// they return the moment the tile is dragged back out over the board.
|
||||||
const recallOverRack = $derived(draggingPend != null && reorderTo != null);
|
const recallOverRack = $derived(draggingPend != null && reorderTo != null);
|
||||||
|
// The composed play's word geometry (client-side, independent of the move evaluator): the cells a
|
||||||
|
// formed word runs through — greened on the board — and the main word that anchors the score
|
||||||
|
// badge. Derived only for a legal preview, and never while dragging a staged tile back over the
|
||||||
|
// rack (recallOverRack), so the highlight and badge clear the instant a recall begins.
|
||||||
|
const EMPTY_FORMED = new Set<string>();
|
||||||
|
const formedGeom = $derived(
|
||||||
|
preview?.legal && !recallOverRack
|
||||||
|
? formedGeometry(board, placement.pending, !!view?.game.multipleWordsPerTurn)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
const formedCells = $derived(formedGeom?.cells ?? EMPTY_FORMED);
|
||||||
|
const boardBadge = $derived(
|
||||||
|
formedGeom && preview?.legal ? { ...badgePlacement(formedGeom.main), score: preview.score } : null,
|
||||||
|
);
|
||||||
|
// The pending-tile tint: null (default colour) with no preview or during a recall drag, otherwise
|
||||||
|
// the play's legality — green when legal, a calm pink when not.
|
||||||
|
const previewLegal: boolean | null = $derived(recallOverRack ? null : preview ? preview.legal : null);
|
||||||
|
|
||||||
let dragPointerId = -1;
|
let dragPointerId = -1;
|
||||||
function beginDrag(src: DragSrc, e: PointerEvent) {
|
function beginDrag(src: DragSrc, e: PointerEvent) {
|
||||||
@@ -610,7 +628,7 @@
|
|||||||
? setTimeout(() => {
|
? setTimeout(() => {
|
||||||
// Still holding the tile over this cell: magnify into it. Only the first
|
// Still holding the tile over this cell: magnify into it. Only the first
|
||||||
// (zoom-in) hold centres; once zoomed we never move the board on hover.
|
// (zoom-in) hold centres; once zoomed we never move the board on hover.
|
||||||
if (drag && isCoarse() && !landscape && !zoomed) {
|
if (drag && isCoarse() && !landscape && !zoomed && app.zoomBoard) {
|
||||||
focus = c;
|
focus = c;
|
||||||
zoomed = true;
|
zoomed = true;
|
||||||
haptic('light');
|
haptic('light');
|
||||||
@@ -729,8 +747,8 @@
|
|||||||
if (pendingMap.has(`${row},${col}`)) return;
|
if (pendingMap.has(`${row},${col}`)) return;
|
||||||
focus = { row, col };
|
focus = { row, col };
|
||||||
// Auto-zoom is portrait-only: landscape fits the whole board, magnifying it there
|
// Auto-zoom is portrait-only: landscape fits the whole board, magnifying it there
|
||||||
// only hides the rest of the position.
|
// only hides the rest of the position. The "Zoom the board" setting can turn it off.
|
||||||
if (isCoarse() && !landscape && !zoomed) zoomed = true;
|
if (isCoarse() && !landscape && !zoomed && app.zoomBoard) zoomed = true;
|
||||||
if (placement.rack[index] === BLANK) {
|
if (placement.rack[index] === BLANK) {
|
||||||
blankPrompt = { rackIndex: index, row, col };
|
blankPrompt = { rackIndex: index, row, col };
|
||||||
return;
|
return;
|
||||||
@@ -1030,23 +1048,11 @@
|
|||||||
// Mark the turn as hinted: the interstitial fires when the player CONFIRMS the move (commit),
|
// Mark the turn as hinted: the interstitial fires when the player CONFIRMS the move (commit),
|
||||||
// not now — showing it here would interrupt placing the preview and revert the board on close.
|
// not now — showing it here would interrupt placing the preview and revert the board on close.
|
||||||
hintUsedThisTurn = true;
|
hintUsedThisTurn = true;
|
||||||
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
|
// A hint just landed: if the board was zoomed in, zoom OUT to the whole board so the hint's
|
||||||
// focus the centre of the laid tiles' bounding box.
|
// word — highlighted green while composing — is guaranteed visible rather than possibly
|
||||||
const p = placement.pending;
|
// parked off-screen under the old zoom. A full board needs no scroll-to-word, so the former
|
||||||
if (p.length) {
|
// focus/recenter step is gone with the zoom-in.
|
||||||
const rows = p.map((tt) => tt.row);
|
if (zoomed) zoomed = false;
|
||||||
const cols = p.map((tt) => tt.col);
|
|
||||||
focus = {
|
|
||||||
row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
|
|
||||||
col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2),
|
|
||||||
};
|
|
||||||
// Ask the board to scroll to the hint word. A zoomed-out board zooms in (and centres)
|
|
||||||
// on the next line (portrait only); this nonce also recentres a board that is already
|
|
||||||
// zoomed in, where the unchanged zoom state would otherwise leave it parked where the
|
|
||||||
// player was looking.
|
|
||||||
recenter++;
|
|
||||||
}
|
|
||||||
if (isCoarse() && !landscape) zoomed = true;
|
|
||||||
view = { ...view, hintsRemaining: h.hintsRemaining };
|
view = { ...view, hintsRemaining: h.hintsRemaining };
|
||||||
syncWallet(h.walletBalance);
|
syncWallet(h.walletBalance);
|
||||||
// The hint is the engine's own top-ranked, fully scored legal move: reuse it as the
|
// The hint is the engine's own top-ranked, fully scored legal move: reuse it as the
|
||||||
@@ -1486,15 +1492,16 @@
|
|||||||
{#if landscape}
|
{#if landscape}
|
||||||
<div class="game-land">
|
<div class="game-land">
|
||||||
<div class="leftpane">
|
<div class="leftpane">
|
||||||
|
{@render turnStrip()}
|
||||||
{@render scoreboardBlock()}
|
{@render scoreboardBlock()}
|
||||||
<div class="history land">{@render historyBody()}</div>
|
<div class="history land">{@render historyBody()}</div>
|
||||||
{@render statusBlock()}
|
|
||||||
{@render rackRow()}
|
{@render rackRow()}
|
||||||
<TabBar>{@render controlButtons()}</TabBar>
|
<TabBar>{@render controlButtons()}</TabBar>
|
||||||
</div>
|
</div>
|
||||||
<div class="rightpane">{@render boardBlock()}</div>
|
<div class="rightpane">{@render boardBlock()}</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
{@render turnStrip()}
|
||||||
{@render scoreboardBlock()}
|
{@render scoreboardBlock()}
|
||||||
<div class="stage" class:histopen={historyOpen} bind:this={stageEl}>
|
<div class="stage" class:histopen={historyOpen} bind:this={stageEl}>
|
||||||
{#if historyOpen}
|
{#if historyOpen}
|
||||||
@@ -1502,7 +1509,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{@render boardBlock()}
|
{@render boardBlock()}
|
||||||
</div>
|
</div>
|
||||||
{@render statusBlock()}
|
|
||||||
{@render rackRow()}
|
{@render rackRow()}
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
@@ -1517,6 +1523,24 @@
|
|||||||
|
|
||||||
<!-- Reusable game-screen pieces, arranged differently by the portrait and landscape branches
|
<!-- Reusable game-screen pieces, arranged differently by the portrait and landscape branches
|
||||||
above so the markup and logic stay single-sourced (see the {#if landscape} split). -->
|
above so the markup and logic stay single-sourced (see the {#if landscape} split). -->
|
||||||
|
{#snippet turnStrip()}
|
||||||
|
<!-- A thin line above the score plaques: while composing a legal play it reads the formed
|
||||||
|
word(s) and the move score ("WORD+WORD = N"); otherwise whose turn it is during play, or the
|
||||||
|
viewer's result once the game ends. A hotseat game shows its result as per-seat medals on the
|
||||||
|
plaques (2-4 local players have no single "you"), so the strip is hidden when it is over. -->
|
||||||
|
{#if view && !(gameOver && view.game.hotseat)}
|
||||||
|
<div class="turnstrip" class:result={gameOver}>
|
||||||
|
{#if gameOver}
|
||||||
|
{resultText()}
|
||||||
|
{:else if preview?.legal && !recallOverRack}
|
||||||
|
{preview.words.map((w) => w.toUpperCase()).join('+')} = {preview.score}
|
||||||
|
{:else}
|
||||||
|
{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet scoreboardBlock()}
|
{#snippet scoreboardBlock()}
|
||||||
{#if view}
|
{#if view}
|
||||||
{@const badge = badgeKind(app.chatUnread[id] ?? false, app.messageUnread[id] ?? false)}
|
{@const badge = badgeKind(app.chatUnread[id] ?? false, app.messageUnread[id] ?? false)}
|
||||||
@@ -1603,10 +1627,11 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{#if view.game.endReason === 'aborted'}
|
{#if view.game.endReason === 'aborted'}
|
||||||
<p class="horganizer">{t('game.abortedNote')}</p>
|
<p class="horganizer">{t('game.abortedNote')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="hbagfoot">{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
@@ -1639,6 +1664,9 @@
|
|||||||
{focus}
|
{focus}
|
||||||
{recenter}
|
{recenter}
|
||||||
{dropTarget}
|
{dropTarget}
|
||||||
|
{previewLegal}
|
||||||
|
formed={formedCells}
|
||||||
|
scoreBadge={boardBadge}
|
||||||
oncell={onCell}
|
oncell={onCell}
|
||||||
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
|
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
|
||||||
onrecall={onRecall}
|
onrecall={onRecall}
|
||||||
@@ -1647,25 +1675,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet statusBlock()}
|
|
||||||
{#if view}
|
|
||||||
<div class="status">
|
|
||||||
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
|
||||||
{#if gameOver}
|
|
||||||
<!-- 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}
|
|
||||||
<span class="turn-ind">{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
|
||||||
{/if}
|
|
||||||
<span class="scores">
|
|
||||||
{#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet rackRow()}
|
{#snippet rackRow()}
|
||||||
<!-- The footer is drawn even when the game is over (rack + controls), but inert:
|
<!-- The footer is drawn even when the game is over (rack + controls), but inert:
|
||||||
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. -->
|
||||||
@@ -1680,23 +1689,26 @@
|
|||||||
slots={rackSlots}
|
slots={rackSlots}
|
||||||
{variant}
|
{variant}
|
||||||
{selected}
|
{selected}
|
||||||
{landscape}
|
|
||||||
shuffling={shuffling && !app.reduceMotion}
|
shuffling={shuffling && !app.reduceMotion}
|
||||||
draggingId={reorderDragId}
|
draggingId={reorderDragId}
|
||||||
dropIndex={reorderTo}
|
dropIndex={reorderTo}
|
||||||
|
confirm={!gameOver && placement.pending.length > 0 && !recallOverRack ? confirmBtn : undefined}
|
||||||
ondown={onRackDown}
|
ondown={onRackDown}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
|
|
||||||
<button class="make" onclick={commit} disabled={busy || !canMove || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet confirmBtn()}
|
||||||
|
<!-- The confirm-move control lives in the rack's fixed 7th slot (see Rack). Shown only while a
|
||||||
|
play is staged; disabled until the preview says it is legal. -->
|
||||||
|
<button class="make" onclick={commit} disabled={busy || !canMove || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet controlButtons()}
|
{#snippet controlButtons()}
|
||||||
<button class="tab" disabled={busy || !canMove || !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">🔄{#if view && view.bagLen > 0}<span class="badge">{view.bagLen}</span>{/if}</span><span class="lbl">{t('game.draw')}</span>
|
||||||
</button>
|
</button>
|
||||||
{#if view?.game.hotseat}
|
{#if view?.game.hotseat}
|
||||||
<!-- Hotseat: no hints. The freed slot becomes the host (referee) button — always available
|
<!-- Hotseat: no hints. The freed slot becomes the host (referee) button — always available
|
||||||
@@ -1757,7 +1769,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if exchangeOpen && view}
|
{#if exchangeOpen && view}
|
||||||
<Modal title={t('game.exchangeTitle')} onclose={() => (exchangeOpen = false)}>
|
<Modal
|
||||||
|
title={t('game.exchangeTitle')}
|
||||||
|
titleAside={view.bagLen === 0 ? t('game.bagEmpty') : t('game.bagCount', { n: view.bagLen })}
|
||||||
|
onclose={() => (exchangeOpen = false)}
|
||||||
|
>
|
||||||
<div class="exch">
|
<div class="exch">
|
||||||
{#each view.rack as letter, i (i)}
|
{#each view.rack as letter, i (i)}
|
||||||
<button class="etile" class:sel={exchangeSel.includes(i)} disabled={!canExchange} onclick={() => toggleExch(i)}>
|
<button class="etile" class:sel={exchangeSel.includes(i)} disabled={!canExchange} onclick={() => toggleExch(i)}>
|
||||||
@@ -1906,6 +1922,25 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
/* The thin turn/result line above the score plaques (see turnStrip): whose turn it is during
|
||||||
|
play, accented for the viewer's win/lose result once the game ends. */
|
||||||
|
.turnstrip {
|
||||||
|
flex: none;
|
||||||
|
/* A smaller bottom pad than top: the score plaques below carry their own top padding, so an
|
||||||
|
even strip read as too tall with the text pushed up. */
|
||||||
|
padding: 4px var(--pad) 2px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.turnstrip.result {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
.stage {
|
.stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
/* The board is the only part that scrolls vertically when the game does not fit;
|
/* The board is the only part that scrolls vertically when the game does not fit;
|
||||||
@@ -1924,16 +1959,12 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0 0 auto 0;
|
inset: 0 0 auto 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
/* A fixed-height drawer matching the board's slid offset, so the bottom border
|
/* A fixed-height drawer matching the board's slid offset, so the bottom border and its shadow
|
||||||
and its shadow pin to the board immediately instead of tracking the table as
|
pin to the board immediately instead of tracking the table as moves accumulate. It is a flex
|
||||||
moves accumulate. scrollbar-gutter reserves the scrollbar so the centred word
|
column: the header and the pinned bag footer stay put while the grid (.hgridwrap) scrolls. */
|
||||||
column does not jump left/right when the list overflows. */
|
|
||||||
height: 62%;
|
height: 62%;
|
||||||
overflow: auto;
|
display: flex;
|
||||||
/* No iOS rubber-band inside the drawer: the moves list does not elastically bounce past its
|
flex-direction: column;
|
||||||
ends (the document is already pinned; this stops the inner scroller's own bounce). */
|
|
||||||
overscroll-behavior: none;
|
|
||||||
scrollbar-gutter: stable;
|
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
|
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
@@ -1945,8 +1976,26 @@
|
|||||||
The wrapper's horizontal padding matches the scoreboard so the columns line up under the
|
The wrapper's horizontal padding matches the scoreboard so the columns line up under the
|
||||||
plaques. */
|
plaques. */
|
||||||
.hgridwrap {
|
.hgridwrap {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
/* No iOS rubber-band inside the scroller: the moves list does not elastically bounce past its
|
||||||
|
ends (the document is already pinned; this stops the inner scroller's own bounce). */
|
||||||
|
overscroll-behavior: none;
|
||||||
|
/* Reserve the scrollbar so the centred word column does not jump left/right on overflow. */
|
||||||
|
scrollbar-gutter: stable;
|
||||||
padding: 8px var(--pad);
|
padding: 8px var(--pad);
|
||||||
}
|
}
|
||||||
|
/* The bag count pinned bottom-left of the move table, out of the scrolling grid, so it stays put
|
||||||
|
as moves accumulate. The same count also rides the exchange control as a badge (see
|
||||||
|
controlButtons), which is the always-visible indicator when the table is closed. */
|
||||||
|
.hbagfoot {
|
||||||
|
flex: none;
|
||||||
|
padding: 6px var(--pad);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
.hgrid {
|
.hgrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
@@ -1991,28 +2040,6 @@
|
|||||||
.boardwrap.slid :global(.viewport) {
|
.boardwrap.slid :global(.viewport) {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.status {
|
|
||||||
display: flex;
|
|
||||||
flex: none;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 2px var(--pad) 6px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.turn-ind {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.over {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
.scores {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ok);
|
|
||||||
min-width: 64px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
/* The single-word-rule label centred in the history header between its two icons. */
|
/* The single-word-rule label centred in the history header between its two icons. */
|
||||||
.oneword-label {
|
.oneword-label {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -2033,40 +2060,31 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
/* Landscape: the rack sits directly under the docked history (no board between them), so give it a
|
||||||
|
small gap above; portrait gets its spacing from the board's own padding. */
|
||||||
|
.game-land .rack-row {
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
.rack-wrap {
|
.rack-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
/* A borderless icon button (like the tab bar), not a filled accent button — and disabled
|
/* The confirm-move control occupies the rack's fixed 7th column while a play is staged (a
|
||||||
while the pending word is known to be illegal. It is an overlay, NOT a flex sibling of the
|
borderless icon button, like the tab bar). It is rendered inside the rack grid (see Rack), so it
|
||||||
rack: the rack keeps the full row width with a fixed tile size (no reflow or tile resize
|
lines up with the tiles and stays the rightmost slot no matter how many tiles remain. */
|
||||||
when a tile is staged), and the button is absolutely pinned over the slots a staged tile
|
|
||||||
frees on the right. Its right edge sits at var(--pad) — directly under the right edge of the
|
|
||||||
preview caption above (.status shares that padding). */
|
|
||||||
.make {
|
.make {
|
||||||
position: absolute;
|
grid-column: 7;
|
||||||
right: var(--pad);
|
align-self: stretch;
|
||||||
top: 0;
|
display: grid;
|
||||||
bottom: 6px;
|
place-items: center;
|
||||||
width: 56px;
|
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: none;
|
border: none;
|
||||||
display: grid;
|
|
||||||
place-items: center end;
|
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
}
|
}
|
||||||
.make:disabled {
|
.make:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
/* Landscape: the rack fills a narrow fixed-width panel with exactly seven tile slots, so the 56px
|
|
||||||
button (sized for the roomy portrait rack) is wider than the single slot a staged tile frees and
|
|
||||||
overlaps the now-rightmost tile. Match the button to one landscape tile slot (its width formula),
|
|
||||||
so it sits inside the freed slot with its right edge level with the rack's right edge — the
|
|
||||||
mirror of the first tile's left edge. */
|
|
||||||
.game-land .make {
|
|
||||||
width: calc((100% - 2 * var(--pad) - 6 * 5px) / 7);
|
|
||||||
}
|
|
||||||
/* The move-history header: leave (active) / export (finished) on the left, comms on the
|
/* The move-history header: leave (active) / export (finished) on the left, comms on the
|
||||||
right, icon-only. Sticky so it stays atop the scrolling move list. */
|
right, icon-only. Sticky so it stays atop the scrolling move list. */
|
||||||
.hhead {
|
.hhead {
|
||||||
|
|||||||
+23
-43
@@ -4,15 +4,16 @@
|
|||||||
import { valueForLetter } from '../lib/alphabet';
|
import { valueForLetter } from '../lib/alphabet';
|
||||||
import type { Variant } from '../lib/model';
|
import type { Variant } from '../lib/model';
|
||||||
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
|
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
slots,
|
slots,
|
||||||
variant,
|
variant,
|
||||||
selected,
|
selected,
|
||||||
landscape = false,
|
|
||||||
shuffling = false,
|
shuffling = false,
|
||||||
draggingId = null,
|
draggingId = null,
|
||||||
dropIndex = null,
|
dropIndex = null,
|
||||||
|
confirm,
|
||||||
ondown,
|
ondown,
|
||||||
}: {
|
}: {
|
||||||
// Each slot carries a stable id that travels with its tile through a shuffle, so the
|
// Each slot carries a stable id that travels with its tile through a shuffle, so the
|
||||||
@@ -20,14 +21,14 @@
|
|||||||
slots: (RackSlot & { id: number })[];
|
slots: (RackSlot & { id: number })[];
|
||||||
variant: Variant;
|
variant: Variant;
|
||||||
selected: number | null;
|
selected: number | null;
|
||||||
/** Landscape layout: size the tiles to share the (narrow) left-panel width instead of the
|
|
||||||
* viewport-width measure, so seven tiles never overflow the panel. */
|
|
||||||
landscape?: boolean;
|
|
||||||
shuffling?: boolean;
|
shuffling?: boolean;
|
||||||
// While a rack tile is being dragged to reorder it, draggingId is its id (hidden here —
|
// While a rack tile is being dragged to reorder it, draggingId is its id (hidden here —
|
||||||
// the drag ghost stands in) and dropIndex is the slot where a gap opens.
|
// the drag ghost stands in) and dropIndex is the slot where a gap opens.
|
||||||
draggingId?: number | null;
|
draggingId?: number | null;
|
||||||
dropIndex?: number | null;
|
dropIndex?: number | null;
|
||||||
|
/** The confirm-move control, rendered as the fixed 7th slot of the rack while a play is staged
|
||||||
|
* (the parent owns the button and its enablement; the rack only positions it). */
|
||||||
|
confirm?: Snippet;
|
||||||
ondown: (e: PointerEvent, index: number) => void;
|
ondown: (e: PointerEvent, index: number) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rack" class:reordering={draggingId != null || dropIndex != null} class:landscape data-rack>
|
<div class="rack" class:reordering={draggingId != null || dropIndex != null} data-rack>
|
||||||
{#each shown as slot, i (slot.id)}
|
{#each shown as slot, i (slot.id)}
|
||||||
<button
|
<button
|
||||||
class="tile"
|
class="tile"
|
||||||
@@ -75,21 +76,26 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
{@render confirm?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Seven equal columns filling the whole tray width, so the tiles are square and as large as the
|
||||||
|
row allows — the same in portrait and landscape. The tile glyphs use cqw against the rack width
|
||||||
|
(the query container) so they track the tile size, the way the board sizes its labels on its
|
||||||
|
.scaler. */
|
||||||
.rack {
|
.rack {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
align-items: center;
|
align-items: start;
|
||||||
/* Reserve one tile's height so an empty rack (e.g. a finished game) keeps the
|
container-type: inline-size;
|
||||||
footer the same size as during play — no layout jump between states. */
|
/* Reserve about one tile's height so an empty rack (e.g. a finished game) keeps the footer the
|
||||||
|
same size as during play — no layout jump between states. */
|
||||||
min-height: min(12.5vw, 46px);
|
min-height: min(12.5vw, 46px);
|
||||||
}
|
}
|
||||||
.tile {
|
.tile {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 0 0 auto;
|
|
||||||
width: min(12.5vw, 46px);
|
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
background: var(--tile-bg);
|
background: var(--tile-bg);
|
||||||
color: var(--tile-text);
|
color: var(--tile-text);
|
||||||
@@ -97,7 +103,6 @@
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
box-shadow: inset 0 -3px 0 var(--tile-edge);
|
box-shadow: inset 0 -3px 0 var(--tile-edge);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.4rem;
|
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
@@ -109,35 +114,6 @@
|
|||||||
outline: 3px solid var(--accent);
|
outline: 3px solid var(--accent);
|
||||||
outline-offset: -3px;
|
outline-offset: -3px;
|
||||||
}
|
}
|
||||||
/* Landscape: the rack sits in a fixed-width left panel, so the tiles share the panel width
|
|
||||||
(capped at the portrait size) and shrink below it when the panel is narrow, instead of the
|
|
||||||
viewport-width measure that would overflow seven tiles in a column. */
|
|
||||||
.rack.landscape {
|
|
||||||
/* Size the tile glyphs relative to the rack (the board sizes its labels the same way, on its
|
|
||||||
.scaler container). The tile is a flex + aspect-ratio item whose OWN container-query size
|
|
||||||
resolves unreliably, so the rack — which has a definite width — is the query container. A
|
|
||||||
fixed-rem letter overflowed the tile once it shrank below 46px in a narrow left panel and
|
|
||||||
dropped into the bottom-left corner. The cqw values track the rack≈7×tile relation. */
|
|
||||||
container-type: inline-size;
|
|
||||||
}
|
|
||||||
.rack.landscape .tile {
|
|
||||||
/* Fixed tile size (1/7 of the fixed-width rack, accounting for the six 5px gaps), NOT flex-grow:
|
|
||||||
so placing a tile leaves the remaining ones put instead of growing them to refill the row —
|
|
||||||
matching the portrait rack. The constant rack width also keeps the cqw glyphs above steady as
|
|
||||||
tiles leave. */
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: calc((100% - 6 * 5px) / 7);
|
|
||||||
max-width: 46px;
|
|
||||||
}
|
|
||||||
.rack.landscape .letter {
|
|
||||||
font-size: 6cqw;
|
|
||||||
}
|
|
||||||
.rack.landscape .val {
|
|
||||||
font-size: 3.1cqw;
|
|
||||||
}
|
|
||||||
.rack.landscape .star {
|
|
||||||
font-size: 7.6cqw;
|
|
||||||
}
|
|
||||||
/* While reordering, tiles at/after the drop slot slide right to open a gap there (one
|
/* While reordering, tiles at/after the drop slot slide right to open a gap there (one
|
||||||
tile width plus the rack gap), so the drop position is visible. */
|
tile width plus the rack gap), so the drop position is visible. */
|
||||||
.rack.reordering .tile {
|
.rack.reordering .tile {
|
||||||
@@ -150,12 +126,15 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8%;
|
top: 8%;
|
||||||
left: 14%;
|
left: 14%;
|
||||||
|
font-size: 6vmin; /* cqw fallback for Chrome < 105 — approximates the portrait rack ≈ viewport width */
|
||||||
|
font-size: 6cqw;
|
||||||
}
|
}
|
||||||
.val {
|
.val {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 4px;
|
right: 4px;
|
||||||
bottom: 1px;
|
bottom: 1px;
|
||||||
font-size: 0.7rem;
|
font-size: 3.1vmin; /* cqw fallback for Chrome < 105 */
|
||||||
|
font-size: 3.1cqw;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
/* Erudit's blank ("звёздочка") shows its star horizontally centred on the otherwise empty
|
/* Erudit's blank ("звёздочка") shows its star horizontally centred on the otherwise empty
|
||||||
@@ -167,6 +146,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1.7rem;
|
font-size: 7.6vmin; /* cqw fallback for Chrome < 105 */
|
||||||
|
font-size: 7.6cqw;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export { FriendCode } from './scrabblefb/friend-code.js';
|
|||||||
export { FriendList } from './scrabblefb/friend-list.js';
|
export { FriendList } from './scrabblefb/friend-list.js';
|
||||||
export { FriendRespondRequest } from './scrabblefb/friend-respond-request.js';
|
export { FriendRespondRequest } from './scrabblefb/friend-respond-request.js';
|
||||||
export { GameActionRequest } from './scrabblefb/game-action-request.js';
|
export { GameActionRequest } from './scrabblefb/game-action-request.js';
|
||||||
|
export { GameLimits } from './scrabblefb/game-limits.js';
|
||||||
export { GameList } from './scrabblefb/game-list.js';
|
export { GameList } from './scrabblefb/game-list.js';
|
||||||
export { GameOverEvent } from './scrabblefb/game-over-event.js';
|
export { GameOverEvent } from './scrabblefb/game-over-event.js';
|
||||||
export { GameView } from './scrabblefb/game-view.js';
|
export { GameView } from './scrabblefb/game-view.js';
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// automatically generated by the FlatBuffers compiler, do not modify
|
||||||
|
|
||||||
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
|
export class GameLimits {
|
||||||
|
bb: flatbuffers.ByteBuffer|null = null;
|
||||||
|
bb_pos = 0;
|
||||||
|
__init(i:number, bb:flatbuffers.ByteBuffer):GameLimits {
|
||||||
|
this.bb_pos = i;
|
||||||
|
this.bb = bb;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRootAsGameLimits(bb:flatbuffers.ByteBuffer, obj?:GameLimits):GameLimits {
|
||||||
|
return (obj || new GameLimits()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSizePrefixedRootAsGameLimits(bb:flatbuffers.ByteBuffer, obj?:GameLimits):GameLimits {
|
||||||
|
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||||
|
return (obj || new GameLimits()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
vsAi():number {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||||
|
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
random():number {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||||
|
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
friends():number {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||||
|
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static startGameLimits(builder:flatbuffers.Builder) {
|
||||||
|
builder.startObject(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addVsAi(builder:flatbuffers.Builder, vsAi:number) {
|
||||||
|
builder.addFieldInt32(0, vsAi, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addRandom(builder:flatbuffers.Builder, random:number) {
|
||||||
|
builder.addFieldInt32(1, random, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addFriends(builder:flatbuffers.Builder, friends:number) {
|
||||||
|
builder.addFieldInt32(2, friends, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static endGameLimits(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
|
const offset = builder.endObject();
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
static createGameLimits(builder:flatbuffers.Builder, vsAi:number, random:number, friends:number):flatbuffers.Offset {
|
||||||
|
GameLimits.startGameLimits(builder);
|
||||||
|
GameLimits.addVsAi(builder, vsAi);
|
||||||
|
GameLimits.addRandom(builder, random);
|
||||||
|
GameLimits.addFriends(builder, friends);
|
||||||
|
return GameLimits.endGameLimits(builder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,8 +113,13 @@ unreadMessages():boolean {
|
|||||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kind():number {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 34);
|
||||||
|
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
static startGameView(builder:flatbuffers.Builder) {
|
static startGameView(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(15);
|
builder.startObject(16);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
|
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
|
||||||
@@ -189,12 +194,16 @@ static addUnreadMessages(builder:flatbuffers.Builder, unreadMessages:boolean) {
|
|||||||
builder.addFieldInt8(14, +unreadMessages, +false);
|
builder.addFieldInt8(14, +unreadMessages, +false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addKind(builder:flatbuffers.Builder, kind:number) {
|
||||||
|
builder.addFieldInt32(15, kind, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean, unreadMessages:boolean):flatbuffers.Offset {
|
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean, unreadMessages:boolean, kind:number):flatbuffers.Offset {
|
||||||
GameView.startGameView(builder);
|
GameView.startGameView(builder);
|
||||||
GameView.addId(builder, idOffset);
|
GameView.addId(builder, idOffset);
|
||||||
GameView.addVariant(builder, variantOffset);
|
GameView.addVariant(builder, variantOffset);
|
||||||
@@ -211,6 +220,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
|
|||||||
GameView.addVsAi(builder, vsAi);
|
GameView.addVsAi(builder, vsAi);
|
||||||
GameView.addUnreadChat(builder, unreadChat);
|
GameView.addUnreadChat(builder, unreadChat);
|
||||||
GameView.addUnreadMessages(builder, unreadMessages);
|
GameView.addUnreadMessages(builder, unreadMessages);
|
||||||
|
GameView.addKind(builder, kind);
|
||||||
return GameView.endGameView(builder);
|
return GameView.endGameView(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as flatbuffers from 'flatbuffers';
|
|||||||
import { AdsInfo } from '../scrabblefb/ads-info.js';
|
import { AdsInfo } from '../scrabblefb/ads-info.js';
|
||||||
import { BannerInfo } from '../scrabblefb/banner-info.js';
|
import { BannerInfo } from '../scrabblefb/banner-info.js';
|
||||||
import { DictVersion } from '../scrabblefb/dict-version.js';
|
import { DictVersion } from '../scrabblefb/dict-version.js';
|
||||||
|
import { GameLimits } from '../scrabblefb/game-limits.js';
|
||||||
|
|
||||||
|
|
||||||
export class Profile {
|
export class Profile {
|
||||||
@@ -141,8 +142,13 @@ ads(obj?:AdsInfo):AdsInfo|null {
|
|||||||
return offset ? (obj || new AdsInfo()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
|
return offset ? (obj || new AdsInfo()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gameLimits(obj?:GameLimits):GameLimits|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 40);
|
||||||
|
return offset ? (obj || new GameLimits()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
|
||||||
|
}
|
||||||
|
|
||||||
static startProfile(builder:flatbuffers.Builder) {
|
static startProfile(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(18);
|
builder.startObject(19);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
|
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
|
||||||
@@ -241,6 +247,10 @@ static addAds(builder:flatbuffers.Builder, adsOffset:flatbuffers.Offset) {
|
|||||||
builder.addFieldOffset(17, adsOffset, 0);
|
builder.addFieldOffset(17, adsOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addGameLimits(builder:flatbuffers.Builder, gameLimitsOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(18, gameLimitsOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ export const app = $state<{
|
|||||||
locale: Locale;
|
locale: Locale;
|
||||||
reduceMotion: boolean;
|
reduceMotion: boolean;
|
||||||
boardLabels: BoardLabelMode;
|
boardLabels: BoardLabelMode;
|
||||||
|
/** Auto-zoom the board toward a tile when it is placed (touch/mobile only; the wide desktop and
|
||||||
|
* landscape layouts already fit the whole board and never auto-zoom). On by default. */
|
||||||
|
zoomBoard: boolean;
|
||||||
/** Completion flags for the two first-run coachmark series (components/Coachmark.svelte). */
|
/** Completion flags for the two first-run coachmark series (components/Coachmark.svelte). */
|
||||||
onboarding: OnboardingState;
|
onboarding: OnboardingState;
|
||||||
/** Whether a first-run coachmark overlay is currently on screen. While set, the scrolling promo
|
/** Whether a first-run coachmark overlay is currently on screen. While set, the scrolling promo
|
||||||
@@ -172,6 +175,7 @@ export const app = $state<{
|
|||||||
locale: 'en',
|
locale: 'en',
|
||||||
reduceMotion: false,
|
reduceMotion: false,
|
||||||
boardLabels: 'beginner',
|
boardLabels: 'beginner',
|
||||||
|
zoomBoard: true,
|
||||||
onboarding: { lobbyDone: false, gameDone: false },
|
onboarding: { lobbyDone: false, gameDone: false },
|
||||||
coachActive: false,
|
coachActive: false,
|
||||||
notifications: 0,
|
notifications: 0,
|
||||||
@@ -813,6 +817,7 @@ export async function bootstrap(): Promise<void> {
|
|||||||
app.theme = prefs.theme ?? 'auto';
|
app.theme = prefs.theme ?? 'auto';
|
||||||
app.reduceMotion = prefs.reduceMotion ?? false;
|
app.reduceMotion = prefs.reduceMotion ?? false;
|
||||||
app.boardLabels = prefs.boardLabels ?? 'beginner';
|
app.boardLabels = prefs.boardLabels ?? 'beginner';
|
||||||
|
app.zoomBoard = prefs.zoomBoard ?? true;
|
||||||
app.onboarding = await loadOnboarding();
|
app.onboarding = await loadOnboarding();
|
||||||
applyTheme(app.theme);
|
applyTheme(app.theme);
|
||||||
applyReduceMotion(app.reduceMotion);
|
applyReduceMotion(app.reduceMotion);
|
||||||
@@ -1232,6 +1237,7 @@ function persistPrefs(): void {
|
|||||||
locale: app.locale,
|
locale: app.locale,
|
||||||
reduceMotion: app.reduceMotion,
|
reduceMotion: app.reduceMotion,
|
||||||
boardLabels: app.boardLabels,
|
boardLabels: app.boardLabels,
|
||||||
|
zoomBoard: app.zoomBoard,
|
||||||
});
|
});
|
||||||
// Mirror the device-independent display prefs to Telegram CloudStorage so they follow the user
|
// Mirror the device-independent display prefs to Telegram CloudStorage so they follow the user
|
||||||
// across devices (no-op outside Telegram / on a client predating it). Locale is excluded — it
|
// across devices (no-op outside Telegram / on a client predating it). Locale is excluded — it
|
||||||
@@ -1321,6 +1327,11 @@ export function setBoardLabels(mode: BoardLabelMode): void {
|
|||||||
persistPrefs();
|
persistPrefs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setZoomBoard(on: boolean): void {
|
||||||
|
app.zoomBoard = on;
|
||||||
|
persistPrefs();
|
||||||
|
}
|
||||||
|
|
||||||
// Background/foreground lifecycle: silence the reconnect banner during a suspend and
|
// Background/foreground lifecycle: silence the reconnect banner during a suspend and
|
||||||
// reconnect quietly on return (and refresh the lobby badge for any push missed while
|
// reconnect quietly on return (and refresh the lobby badge for any push missed while
|
||||||
// hidden, §10). Several signals cover the platforms: the page Visibility API, the
|
// hidden, §10). Several signals cover the platforms: the page Visibility API, the
|
||||||
|
|||||||
@@ -421,6 +421,7 @@ describe('codec', () => {
|
|||||||
fb.GameView.addLastActivityUnix(b, BigInt(1717000000));
|
fb.GameView.addLastActivityUnix(b, BigInt(1717000000));
|
||||||
fb.GameView.addVsAi(b, true);
|
fb.GameView.addVsAi(b, true);
|
||||||
fb.GameView.addUnreadChat(b, true);
|
fb.GameView.addUnreadChat(b, true);
|
||||||
|
fb.GameView.addKind(b, 2);
|
||||||
const game = fb.GameView.endGameView(b);
|
const game = fb.GameView.endGameView(b);
|
||||||
const games = fb.GameList.createGamesVector(b, [game]);
|
const games = fb.GameList.createGamesVector(b, [game]);
|
||||||
fb.GameList.startGameList(b);
|
fb.GameList.startGameList(b);
|
||||||
@@ -436,6 +437,7 @@ describe('codec', () => {
|
|||||||
expect(gl.games[0].lastActivityUnix).toBe(1717000000);
|
expect(gl.games[0].lastActivityUnix).toBe(1717000000);
|
||||||
expect(gl.games[0].vsAi).toBe(true);
|
expect(gl.games[0].vsAi).toBe(true);
|
||||||
expect(gl.games[0].unreadChat).toBe(true);
|
expect(gl.games[0].unreadChat).toBe(true);
|
||||||
|
expect(gl.games[0].kind).toBe(2);
|
||||||
expect(gl.atGameLimit).toBe(true);
|
expect(gl.atGameLimit).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -830,6 +832,26 @@ describe('codec', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('decodes the profile active-game limits (guest tier)', () => {
|
||||||
|
const b = new Builder(64);
|
||||||
|
const uid = b.createString('u-1');
|
||||||
|
const limits = fb.GameLimits.createGameLimits(b, 1, 1, 0);
|
||||||
|
fb.Profile.startProfile(b);
|
||||||
|
fb.Profile.addUserId(b, uid);
|
||||||
|
fb.Profile.addGameLimits(b, limits);
|
||||||
|
b.finish(fb.Profile.endProfile(b));
|
||||||
|
expect(decodeProfile(b.asUint8Array()).gameLimits).toEqual({ vsAi: 1, random: 1, friends: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the profile game limits undefined when absent', () => {
|
||||||
|
const b = new Builder(64);
|
||||||
|
const uid = b.createString('u-1');
|
||||||
|
fb.Profile.startProfile(b);
|
||||||
|
fb.Profile.addUserId(b, uid);
|
||||||
|
b.finish(fb.Profile.endProfile(b));
|
||||||
|
expect(decodeProfile(b.asUint8Array()).gameLimits).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('carries the suppressed flag through the ad config', () => {
|
it('carries the suppressed flag through the ad config', () => {
|
||||||
const b = new Builder(64);
|
const b = new Builder(64);
|
||||||
const uid = b.createString('u-1');
|
const uid = b.createString('u-1');
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
RobotFriendRequestEntry,
|
RobotFriendRequestEntry,
|
||||||
Banner,
|
Banner,
|
||||||
AdsConfig,
|
AdsConfig,
|
||||||
|
GameLimits,
|
||||||
BannerCampaign,
|
BannerCampaign,
|
||||||
BestMove,
|
BestMove,
|
||||||
BestMoveTile,
|
BestMoveTile,
|
||||||
@@ -314,6 +315,7 @@ function decodeGameView(g: fb.GameView): GameView {
|
|||||||
vsAi: g.vsAi(),
|
vsAi: g.vsAi(),
|
||||||
unreadChat: g.unreadChat(),
|
unreadChat: g.unreadChat(),
|
||||||
unreadMessages: g.unreadMessages(),
|
unreadMessages: g.unreadMessages(),
|
||||||
|
kind: g.kind(),
|
||||||
seats,
|
seats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -467,9 +469,18 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
|||||||
telegramLinked: p.telegramLinked(),
|
telegramLinked: p.telegramLinked(),
|
||||||
vkLinked: p.vkLinked(),
|
vkLinked: p.vkLinked(),
|
||||||
dictVersions: decodeDictVersions(p),
|
dictVersions: decodeDictVersions(p),
|
||||||
|
gameLimits: decodeGameLimits(p),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decodeGameLimits projects the caller's tier active-game caps (per kind, -1 = unlimited), or
|
||||||
|
// undefined when absent (an old backend); the New-Game screen then applies no lock.
|
||||||
|
function decodeGameLimits(p: fb.Profile): GameLimits | undefined {
|
||||||
|
const g = p.gameLimits();
|
||||||
|
if (!g) return undefined;
|
||||||
|
return { vsAi: g.vsAi(), random: g.random(), friends: g.friends() };
|
||||||
|
}
|
||||||
|
|
||||||
// decodeDictVersions reads the profile's per-variant current dictionary versions into a
|
// decodeDictVersions reads the profile's per-variant current dictionary versions into a
|
||||||
// variant-keyed map, so the offline preloader can look up the version for each enabled
|
// variant-keyed map, so the offline preloader can look up the version for each enabled
|
||||||
// variant. An entry with an unknown variant or empty version is skipped.
|
// variant. An entry with an unknown variant or empty version is skipped.
|
||||||
@@ -1137,6 +1148,7 @@ function emptyGame(): GameView {
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 0,
|
||||||
seats: [],
|
seats: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formedGeometry, badgePlacement } from './formed';
|
||||||
|
import type { Board } from './board';
|
||||||
|
|
||||||
|
const SIZE = 15;
|
||||||
|
|
||||||
|
// board builds a 15×15 empty board with the given committed tiles placed.
|
||||||
|
function board(tiles: Array<[number, number, string]> = []): Board {
|
||||||
|
const b: Board = [];
|
||||||
|
for (let r = 0; r < SIZE; r++) {
|
||||||
|
const row: (Board[number][number])[] = [];
|
||||||
|
for (let c = 0; c < SIZE; c++) row.push(null);
|
||||||
|
b.push(row);
|
||||||
|
}
|
||||||
|
for (const [r, c, letter] of tiles) b[r][c] = { letter, blank: false };
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cells = (g: ReturnType<typeof formedGeometry>) => [...(g?.cells ?? new Set())].sort();
|
||||||
|
|
||||||
|
describe('formedGeometry', () => {
|
||||||
|
it('returns null with nothing staged', () => {
|
||||||
|
expect(formedGeometry(board(), [], true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds a fresh horizontal word with no cross words', () => {
|
||||||
|
const g = formedGeometry(board(), [
|
||||||
|
{ row: 7, col: 7 },
|
||||||
|
{ row: 7, col: 8 },
|
||||||
|
], true);
|
||||||
|
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
|
||||||
|
expect(cells(g)).toEqual(['7,7', '7,8']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds a fresh vertical word', () => {
|
||||||
|
const g = formedGeometry(board(), [
|
||||||
|
{ row: 7, col: 7 },
|
||||||
|
{ row: 8, col: 7 },
|
||||||
|
], true);
|
||||||
|
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'V', len: 2 });
|
||||||
|
expect(cells(g)).toEqual(['7,7', '8,7']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extends a committed tile into the main word (committed cell in the set)', () => {
|
||||||
|
// A committed A at (7,7); a lone tile to its right forms the two-letter main word.
|
||||||
|
const g = formedGeometry(board([[7, 7, 'A']]), [{ row: 7, col: 8 }], true);
|
||||||
|
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
|
||||||
|
expect(cells(g)).toEqual(['7,7', '7,8']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collects a cross word through a staged tile, committed cells included', () => {
|
||||||
|
// Committed X above and Y below (7,8) leave a one-cell gap; a horizontal play through it also
|
||||||
|
// completes the vertical X-_-Y cross word.
|
||||||
|
const g = formedGeometry(board([[6, 8, 'X'], [8, 8, 'Y']]), [
|
||||||
|
{ row: 7, col: 7 },
|
||||||
|
{ row: 7, col: 8 },
|
||||||
|
], true);
|
||||||
|
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
|
||||||
|
expect(cells(g)).toEqual(['6,8', '7,7', '7,8', '8,8']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits cross words under the single-word rule (multipleWords = false)', () => {
|
||||||
|
// The same board as the cross-word case, but a single-word game: the engine ignores the vertical
|
||||||
|
// X-_-Y run (and it need not be a real word), so the geometry must highlight only the main
|
||||||
|
// (horizontal) word — the reported "БО lights up green in a one-word game" bug.
|
||||||
|
const g = formedGeometry(board([[6, 8, 'X'], [8, 8, 'Y']]), [
|
||||||
|
{ row: 7, col: 7 },
|
||||||
|
{ row: 7, col: 8 },
|
||||||
|
], false);
|
||||||
|
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
|
||||||
|
expect(cells(g)).toEqual(['7,7', '7,8']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the longer axis as the main word for a lone connecting tile', () => {
|
||||||
|
// (7,8) joins a length-3 horizontal (cols 6-8) and a length-4 vertical (rows 5-8): the vertical
|
||||||
|
// is longer, so it is the main word and the horizontal becomes a cross word.
|
||||||
|
const g = formedGeometry(
|
||||||
|
board([
|
||||||
|
[7, 6, 'A'],
|
||||||
|
[7, 7, 'B'],
|
||||||
|
[5, 8, 'C'],
|
||||||
|
[6, 8, 'D'],
|
||||||
|
[8, 8, 'E'],
|
||||||
|
]),
|
||||||
|
[{ row: 7, col: 8 }],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(g?.main).toEqual({ row: 5, col: 8, dir: 'V', len: 4 });
|
||||||
|
expect(cells(g)).toEqual(['5,8', '6,8', '7,6', '7,7', '7,8', '8,8']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('badgePlacement', () => {
|
||||||
|
it('anchors a mid-board horizontal word at the last letter, top-right', () => {
|
||||||
|
expect(badgePlacement({ row: 7, col: 5, dir: 'H', len: 3 })).toEqual({ row: 7, col: 7, corner: 'tr' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anchors a right-edge horizontal word at the first letter, bottom-left', () => {
|
||||||
|
expect(badgePlacement({ row: 7, col: 12, dir: 'H', len: 3 })).toEqual({ row: 7, col: 12, corner: 'bl' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anchors a top-edge horizontal word at the first letter, bottom-left', () => {
|
||||||
|
expect(badgePlacement({ row: 0, col: 5, dir: 'H', len: 3 })).toEqual({ row: 0, col: 5, corner: 'bl' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anchors a left-edge horizontal word at the last letter, top-right', () => {
|
||||||
|
expect(badgePlacement({ row: 7, col: 0, dir: 'H', len: 3 })).toEqual({ row: 7, col: 2, corner: 'tr' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anchors a bottom-edge horizontal word at the last letter, top-right', () => {
|
||||||
|
expect(badgePlacement({ row: 14, col: 5, dir: 'H', len: 3 })).toEqual({ row: 14, col: 7, corner: 'tr' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anchors a mid-board vertical word at the first letter, top-right', () => {
|
||||||
|
expect(badgePlacement({ row: 5, col: 7, dir: 'V', len: 3 })).toEqual({ row: 5, col: 7, corner: 'tr' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anchors a top-edge vertical word at the last letter, bottom-left', () => {
|
||||||
|
expect(badgePlacement({ row: 0, col: 7, dir: 'V', len: 3 })).toEqual({ row: 2, col: 7, corner: 'bl' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anchors a right-edge vertical word at the last letter, bottom-left', () => {
|
||||||
|
expect(badgePlacement({ row: 5, col: 14, dir: 'V', len: 3 })).toEqual({ row: 7, col: 14, corner: 'bl' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anchors a full-width horizontal word at the last letter, top-right (clamped by the board)', () => {
|
||||||
|
expect(badgePlacement({ row: 7, col: 0, dir: 'H', len: 15 })).toEqual({ row: 7, col: 14, corner: 'tr' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Client-side geometry of the play being composed: which cells the formed word(s) cover, and
|
||||||
|
// where to anchor the move-score badge. This is deliberately independent of the move evaluator
|
||||||
|
// (lib/dict/eval.ts) — the network `evaluate` returns legality/score/word strings but no cell
|
||||||
|
// coordinates, and the board highlight plus the score badge must work even while the local
|
||||||
|
// dictionary is still warming up. Legality and the score come from the preview; the geometry is
|
||||||
|
// derived here purely from the board and the staged tiles. Both functions are pure (testable in
|
||||||
|
// lib/formed.test.ts); the board stays the source of truth for legality.
|
||||||
|
|
||||||
|
import type { Board } from './board';
|
||||||
|
import { BOARD_SIZE } from './premiums';
|
||||||
|
|
||||||
|
/** Play axis: horizontal or vertical. */
|
||||||
|
export type Dir = 'H' | 'V';
|
||||||
|
|
||||||
|
/** MainWord locates the play's main word: its start cell, axis and length in cells. */
|
||||||
|
export interface MainWord {
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
dir: Dir;
|
||||||
|
len: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FormedGeometry is the composed play's shape: the main word plus the set of every cell (as a
|
||||||
|
* "row,col" key) that the main word and any cross words cover — committed tiles included. */
|
||||||
|
export interface FormedGeometry {
|
||||||
|
main: MainWord;
|
||||||
|
cells: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal cell reference; PendingTile satisfies it structurally. */
|
||||||
|
interface Cell {
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = (r: number, c: number): string => `${r},${c}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* filledPredicate returns a test for whether a cell holds a tile once the staged play is on the
|
||||||
|
* board: either a committed tile sits there or a pending tile is being placed there.
|
||||||
|
*/
|
||||||
|
function filledPredicate(board: Board, pending: readonly Cell[]): (r: number, c: number) => boolean {
|
||||||
|
const pend = new Set(pending.map((p) => key(p.row, p.col)));
|
||||||
|
return (r, c) =>
|
||||||
|
r >= 0 &&
|
||||||
|
r < BOARD_SIZE &&
|
||||||
|
c >= 0 &&
|
||||||
|
c < BOARD_SIZE &&
|
||||||
|
(board[r]?.[c] != null || pend.has(key(r, c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* span walks the maximal contiguous filled run through cell (row, col) along dir and returns the
|
||||||
|
* run's start index and length on the moving axis (columns for H, rows for V).
|
||||||
|
*/
|
||||||
|
function span(
|
||||||
|
filled: (r: number, c: number) => boolean,
|
||||||
|
dir: Dir,
|
||||||
|
row: number,
|
||||||
|
col: number,
|
||||||
|
): { start: number; len: number } {
|
||||||
|
if (dir === 'H') {
|
||||||
|
let s = col;
|
||||||
|
while (filled(row, s - 1)) s--;
|
||||||
|
let e = col;
|
||||||
|
while (filled(row, e + 1)) e++;
|
||||||
|
return { start: s, len: e - s + 1 };
|
||||||
|
}
|
||||||
|
let s = row;
|
||||||
|
while (filled(s - 1, col)) s--;
|
||||||
|
let e = row;
|
||||||
|
while (filled(e + 1, col)) e++;
|
||||||
|
return { start: s, len: e - s + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* formedGeometry derives the composed play's word geometry from the board and the staged tiles.
|
||||||
|
* It returns the main word (the maximal run along the play axis through the staged tiles) and the
|
||||||
|
* set of every cell the main word and each cross word (a perpendicular run of two or more cells
|
||||||
|
* through a staged tile) cover. Under the **single-word rule** (multipleWords = false) cross words
|
||||||
|
* are omitted, matching the engine, which ignores them entirely — a perpendicular run there is
|
||||||
|
* neither validated nor scored (and need not even be a real word), so highlighting it would be
|
||||||
|
* misleading. It returns null when nothing is staged, or when the staged tiles are not on a single
|
||||||
|
* line — a shape only an illegal play produces, which the caller never asks about (it is invoked
|
||||||
|
* only for a legal preview). The play axis is fixed by the staged tiles when two or more are
|
||||||
|
* colinear; a lone tile takes whichever axis forms the longer word.
|
||||||
|
*/
|
||||||
|
export function formedGeometry(
|
||||||
|
board: Board,
|
||||||
|
pending: readonly Cell[],
|
||||||
|
multipleWords: boolean,
|
||||||
|
): FormedGeometry | null {
|
||||||
|
if (pending.length === 0) return null;
|
||||||
|
const filled = filledPredicate(board, pending);
|
||||||
|
const first = pending[0];
|
||||||
|
|
||||||
|
let dir: Dir;
|
||||||
|
if (pending.length === 1) {
|
||||||
|
const h = span(filled, 'H', first.row, first.col);
|
||||||
|
const v = span(filled, 'V', first.row, first.col);
|
||||||
|
dir = h.len >= v.len ? 'H' : 'V';
|
||||||
|
} else if (pending.every((p) => p.row === first.row)) {
|
||||||
|
dir = 'H';
|
||||||
|
} else if (pending.every((p) => p.col === first.col)) {
|
||||||
|
dir = 'V';
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cells = new Set<string>();
|
||||||
|
|
||||||
|
// Main word: the maximal run along the play axis through the staged tiles (contiguous via any
|
||||||
|
// committed tiles between them), collected from any one staged tile.
|
||||||
|
const ms = span(filled, dir, first.row, first.col);
|
||||||
|
let main: MainWord;
|
||||||
|
if (dir === 'H') {
|
||||||
|
main = { row: first.row, col: ms.start, dir, len: ms.len };
|
||||||
|
for (let c = ms.start; c < ms.start + ms.len; c++) cells.add(key(first.row, c));
|
||||||
|
} else {
|
||||||
|
main = { row: ms.start, col: first.col, dir, len: ms.len };
|
||||||
|
for (let r = ms.start; r < ms.start + ms.len; r++) cells.add(key(r, first.col));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross words: the perpendicular run through each staged tile, kept only when it is a real word
|
||||||
|
// (two or more cells). Skipped under the single-word rule, where the engine ignores cross words,
|
||||||
|
// so a perpendicular run must not be highlighted as if it were a formed word.
|
||||||
|
if (multipleWords) {
|
||||||
|
const cross: Dir = dir === 'H' ? 'V' : 'H';
|
||||||
|
for (const p of pending) {
|
||||||
|
const cs = span(filled, cross, p.row, p.col);
|
||||||
|
if (cs.len < 2) continue;
|
||||||
|
if (cross === 'H') {
|
||||||
|
for (let c = cs.start; c < cs.start + cs.len; c++) cells.add(key(p.row, c));
|
||||||
|
} else {
|
||||||
|
for (let r = cs.start; r < cs.start + cs.len; r++) cells.add(key(r, p.col));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { main, cells };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the move-score badge is anchored: a main-word cell and the tile corner it sits on. */
|
||||||
|
export interface BadgeAnchor {
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
corner: 'tr' | 'bl';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* badgePlacement chooses the main-word tile and corner for the move-score badge, so the badge
|
||||||
|
* points into the board and away from the nearest edge (the board clamps the final pill fully
|
||||||
|
* on-board). The rules: a word touching the right or top edge anchors at a tile's bottom-left
|
||||||
|
* corner (horizontal → its first letter, vertical → its last letter); a word touching the left or
|
||||||
|
* bottom edge — and any mid-board word — anchors at the top-right corner (horizontal → its last
|
||||||
|
* letter, vertical → its first letter). A full-width horizontal word (it touches both side edges)
|
||||||
|
* anchors at the last letter's top-right, which the board then clamps leftward.
|
||||||
|
*/
|
||||||
|
export function badgePlacement(main: MainWord): BadgeAnchor {
|
||||||
|
const last = BOARD_SIZE - 1;
|
||||||
|
const h = main.dir === 'H';
|
||||||
|
const minRow = main.row;
|
||||||
|
const maxRow = h ? main.row : main.row + main.len - 1;
|
||||||
|
const minCol = main.col;
|
||||||
|
const maxCol = h ? main.col + main.len - 1 : main.col;
|
||||||
|
|
||||||
|
if (h && main.len === BOARD_SIZE) {
|
||||||
|
return { row: main.row, col: maxCol, corner: 'tr' };
|
||||||
|
}
|
||||||
|
if (maxCol === last || minRow === 0) {
|
||||||
|
return h ? { row: main.row, col: minCol, corner: 'bl' } : { row: maxRow, col: main.col, corner: 'bl' };
|
||||||
|
}
|
||||||
|
return h ? { row: main.row, col: maxCol, corner: 'tr' } : { row: minRow, col: main.col, corner: 'tr' };
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ function gameView(id: string): GameView {
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 0,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove: 0,
|
toMove: 0,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ function gameView(moveCount: number, over = false): GameView {
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 0,
|
||||||
status: over ? 'finished' : 'active',
|
status: over ? 'finished' : 'active',
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove: 1,
|
toMove: 1,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ function game(seats: Seat[], over = true): GameView {
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 0,
|
||||||
status: over ? 'finished' : 'active',
|
status: over ? 'finished' : 'active',
|
||||||
players: seats.length,
|
players: seats.length,
|
||||||
toMove: 0,
|
toMove: 0,
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { activeByKind, capFor, isKindLocked } from './gamelimits';
|
||||||
|
import { GameKind, type GameView, type GameLimits } from './model';
|
||||||
|
|
||||||
|
// game builds a minimal GameView for the count: only kind, status and (optionally) hotseat matter.
|
||||||
|
function game(kind: number, status: string, extra: Partial<GameView> = {}): GameView {
|
||||||
|
return {
|
||||||
|
id: 'g',
|
||||||
|
variant: 'scrabble_en',
|
||||||
|
dictVersion: '',
|
||||||
|
status,
|
||||||
|
players: 2,
|
||||||
|
toMove: 0,
|
||||||
|
turnTimeoutSecs: 0,
|
||||||
|
multipleWordsPerTurn: true,
|
||||||
|
moveCount: 0,
|
||||||
|
endReason: '',
|
||||||
|
lastActivityUnix: 0,
|
||||||
|
vsAi: false,
|
||||||
|
unreadChat: false,
|
||||||
|
unreadMessages: false,
|
||||||
|
kind,
|
||||||
|
seats: [],
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('activeByKind', () => {
|
||||||
|
it('counts open+active games per kind, skipping finished, hotseat and untagged (kind 0)', () => {
|
||||||
|
const by = activeByKind([
|
||||||
|
game(GameKind.vsAi, 'active'),
|
||||||
|
game(GameKind.random, 'open'),
|
||||||
|
game(GameKind.random, 'active'),
|
||||||
|
game(GameKind.random, 'finished'), // finished → not counted
|
||||||
|
game(GameKind.vsAi, 'active', { hotseat: true }), // local pass-and-play → not counted
|
||||||
|
game(0, 'active'), // pre-existing / untagged → not counted
|
||||||
|
]);
|
||||||
|
expect(by[GameKind.vsAi]).toBe(1);
|
||||||
|
expect(by[GameKind.random]).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('capFor', () => {
|
||||||
|
const limits: GameLimits = { vsAi: 1, random: 10, friends: 0 };
|
||||||
|
it('maps each kind to its tier cap; unknown kind and absent limits are unlimited (-1)', () => {
|
||||||
|
expect(capFor(limits, GameKind.vsAi)).toBe(1);
|
||||||
|
expect(capFor(limits, GameKind.random)).toBe(10);
|
||||||
|
expect(capFor(limits, GameKind.friends)).toBe(0);
|
||||||
|
expect(capFor(limits, 0)).toBe(-1);
|
||||||
|
expect(capFor(undefined, GameKind.vsAi)).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isKindLocked', () => {
|
||||||
|
const guest: GameLimits = { vsAi: 1, random: 1, friends: 0 };
|
||||||
|
const durable: GameLimits = { vsAi: 10, random: 10, friends: 10 };
|
||||||
|
|
||||||
|
it('locks a guest at the per-kind cap, not another kind', () => {
|
||||||
|
const games = [game(GameKind.vsAi, 'active')];
|
||||||
|
expect(isKindLocked(games, guest, GameKind.vsAi)).toBe(true);
|
||||||
|
expect(isKindLocked(games, guest, GameKind.random)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a cap of 0 always locks (a guest cannot start friend games)', () => {
|
||||||
|
expect(isKindLocked([], guest, GameKind.friends)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unlimited cap (-1) or absent limits never lock', () => {
|
||||||
|
const many = [game(GameKind.random, 'active'), game(GameKind.random, 'active')];
|
||||||
|
expect(isKindLocked(many, { vsAi: -1, random: -1, friends: -1 }, GameKind.random)).toBe(false);
|
||||||
|
expect(isKindLocked(many, undefined, GameKind.random)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a durable account stays well under its higher cap', () => {
|
||||||
|
expect(isKindLocked([game(GameKind.random, 'active')], durable, GameKind.random)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks only the reached kind: at the vs_ai cap, random with no games stays open', () => {
|
||||||
|
const limits = { vsAi: 3, random: 2, friends: 1 };
|
||||||
|
const games = [game(GameKind.vsAi, 'active'), game(GameKind.vsAi, 'active'), game(GameKind.vsAi, 'active')];
|
||||||
|
expect(isKindLocked(games, limits, GameKind.vsAi)).toBe(true);
|
||||||
|
expect(isKindLocked(games, limits, GameKind.random)).toBe(false);
|
||||||
|
expect(isKindLocked(games, limits, GameKind.friends)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Client-side active-game limit logic: count the caller's active games per kind and test a kind
|
||||||
|
// against the caller's per-tier cap so the New-Game screen can lock a capped start. The server is
|
||||||
|
// the source of truth (it refuses a capped create); this only drives the pre-emptive UI lock. Kept
|
||||||
|
// pure (no DOM / stores) so it unit-tests in the node environment.
|
||||||
|
|
||||||
|
import { GameKind, type GameLimits, type GameView } from './model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* activeByKind counts the caller's active (status open or in-progress) games per kind. Finished
|
||||||
|
* games, local pass-and-play (hotseat) games and untagged games (kind 0, pre-existing) do not
|
||||||
|
* count — matching the server's per-kind cap, which is keyed on games.game_kind for open/active
|
||||||
|
* games only.
|
||||||
|
*/
|
||||||
|
export function activeByKind(games: GameView[]): Record<number, number> {
|
||||||
|
const out: Record<number, number> = {};
|
||||||
|
for (const g of games) {
|
||||||
|
if (g.hotseat) continue;
|
||||||
|
if (g.kind === 0) continue;
|
||||||
|
if (g.status !== 'active' && g.status !== 'open') continue;
|
||||||
|
out[g.kind] = (out[g.kind] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** capFor returns the caller's tier cap for a kind (-1 = unlimited), or -1 when the limits are
|
||||||
|
* absent (an old backend) — the lock then never applies. */
|
||||||
|
export function capFor(limits: GameLimits | undefined, kind: number): number {
|
||||||
|
if (!limits) return -1;
|
||||||
|
switch (kind) {
|
||||||
|
case GameKind.vsAi:
|
||||||
|
return limits.vsAi;
|
||||||
|
case GameKind.random:
|
||||||
|
return limits.random;
|
||||||
|
case GameKind.friends:
|
||||||
|
return limits.friends;
|
||||||
|
default:
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* isKindLocked reports whether the caller has reached its cap for kind, so the New-Game start for
|
||||||
|
* that kind is locked. An unlimited cap (-1) or absent limits never lock; a cap of 0 always locks
|
||||||
|
* (the kind is disabled for the tier).
|
||||||
|
*/
|
||||||
|
export function isKindLocked(games: GameView[], limits: GameLimits | undefined, kind: number): boolean {
|
||||||
|
const cap = capFor(limits, kind);
|
||||||
|
if (cap < 0) return false;
|
||||||
|
return (activeByKind(games)[kind] ?? 0) >= cap;
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ if (isMock && typeof window !== 'undefined') {
|
|||||||
joinOpponent(): void;
|
joinOpponent(): void;
|
||||||
joinOpponentSilently(): void;
|
joinOpponentSilently(): void;
|
||||||
adminReply(): void;
|
adminReply(): void;
|
||||||
setGameLimit(v: boolean): void;
|
setGameLimits(l: { vsAi: number; random: number; friends: number }): void;
|
||||||
clearEmail(): void;
|
clearEmail(): void;
|
||||||
confirmEmailOutOfBand(email: string): void;
|
confirmEmailOutOfBand(email: string): void;
|
||||||
setLocalSeed(seed: string): void;
|
setLocalSeed(seed: string): void;
|
||||||
@@ -49,7 +49,7 @@ if (isMock && typeof window !== 'undefined') {
|
|||||||
joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(),
|
joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(),
|
||||||
joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(),
|
joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(),
|
||||||
adminReply: () => (gateway as MockGateway).mockAdminReply(),
|
adminReply: () => (gateway as MockGateway).mockAdminReply(),
|
||||||
setGameLimit: (v: boolean) => (gateway as MockGateway).setGameLimit(v),
|
setGameLimits: (l: { vsAi: number; random: number; friends: number }) => (gateway as MockGateway).setGameLimits(l),
|
||||||
clearEmail: () => (gateway as MockGateway).mockClearEmail(),
|
clearEmail: () => (gateway as MockGateway).mockClearEmail(),
|
||||||
confirmEmailOutOfBand: (email: string) => (gateway as MockGateway).mockConfirmEmailOutOfBand(email),
|
confirmEmailOutOfBand: (email: string) => (gateway as MockGateway).mockConfirmEmailOutOfBand(email),
|
||||||
// Pin the next local game's bag seed, so the offline e2e deals a known rack (a bigint as a
|
// Pin the next local game's bag seed, so the offline e2e deals a known rack (a bigint as a
|
||||||
|
|||||||
+17
-10
@@ -87,6 +87,7 @@ export const en = {
|
|||||||
'onboarding.gameRack': 'Pick a tile and place it on the board', // rack
|
'onboarding.gameRack': 'Pick a tile and place it on the board', // rack
|
||||||
|
|
||||||
'game.bag': '{n} in the bag',
|
'game.bag': '{n} in the bag',
|
||||||
|
'game.bagCount': 'In the bag: {n}',
|
||||||
'game.bagEmpty': 'Bag is empty',
|
'game.bagEmpty': 'Bag is empty',
|
||||||
'game.hints': 'Hints {n}',
|
'game.hints': 'Hints {n}',
|
||||||
'game.yourTurn': 'Your turn',
|
'game.yourTurn': 'Your turn',
|
||||||
@@ -104,8 +105,6 @@ export const en = {
|
|||||||
'game.checkWord': 'Check word',
|
'game.checkWord': 'Check word',
|
||||||
'game.dictionary': 'Dictionary',
|
'game.dictionary': 'Dictionary',
|
||||||
'game.dropGame': 'Drop game',
|
'game.dropGame': 'Drop game',
|
||||||
'game.previewWords': '{words}: {n}',
|
|
||||||
'game.previewIllegal': 'Not a legal move',
|
|
||||||
'game.oneWordRule': 'One word per turn',
|
'game.oneWordRule': 'One word per turn',
|
||||||
'game.chooseBlank': 'Choose a letter for the blank',
|
'game.chooseBlank': 'Choose a letter for the blank',
|
||||||
'game.exchangeTitle': 'Exchange or pass',
|
'game.exchangeTitle': 'Exchange or pass',
|
||||||
@@ -215,6 +214,7 @@ export const en = {
|
|||||||
'settings.labelsClassic': 'Classic',
|
'settings.labelsClassic': 'Classic',
|
||||||
'settings.labelsNone': 'None',
|
'settings.labelsNone': 'None',
|
||||||
'settings.reduceMotion': 'Reduce motion',
|
'settings.reduceMotion': 'Reduce motion',
|
||||||
|
'settings.zoomBoard': 'Zoom the board',
|
||||||
'settings.offlineMode': 'Play mode',
|
'settings.offlineMode': 'Play mode',
|
||||||
'settings.online': 'Online',
|
'settings.online': 'Online',
|
||||||
'settings.offline': 'Offline',
|
'settings.offline': 'Offline',
|
||||||
@@ -228,20 +228,19 @@ export const en = {
|
|||||||
// --- wallet ---
|
// --- wallet ---
|
||||||
'wallet.title': 'Wallet',
|
'wallet.title': 'Wallet',
|
||||||
'wallet.tab': 'Wallet',
|
'wallet.tab': 'Wallet',
|
||||||
'wallet.balance': 'Balance',
|
|
||||||
'wallet.noChips': 'No chips yet',
|
|
||||||
'wallet.source.vk': 'VK',
|
'wallet.source.vk': 'VK',
|
||||||
'wallet.source.telegram': 'Telegram',
|
'wallet.source.telegram': 'Telegram',
|
||||||
'wallet.source.direct': 'Web',
|
'wallet.source.direct': 'Web',
|
||||||
'wallet.viewOnly': 'view only',
|
'wallet.active': 'Active',
|
||||||
'wallet.benefits': 'Benefits',
|
'wallet.activeHints': 'Hints: {n}',
|
||||||
'wallet.noAdsUntil': 'No ads until {date}',
|
'wallet.noAdsUntil': 'No ads until {date}',
|
||||||
'wallet.noAdsForever': 'No ads forever',
|
'wallet.noAdsForever': 'No ads forever',
|
||||||
'wallet.adsOn': 'Ads are on',
|
|
||||||
'wallet.hints': 'Hints {n}',
|
|
||||||
'wallet.store': 'Store',
|
'wallet.store': 'Store',
|
||||||
|
'wallet.tabBuy': 'Buy chips',
|
||||||
|
'wallet.tabSpend': 'Spend chips',
|
||||||
'wallet.empty': 'The store is empty for now',
|
'wallet.empty': 'The store is empty for now',
|
||||||
'wallet.buy': 'Buy',
|
'wallet.buy': 'Buy',
|
||||||
|
'wallet.spend': 'Exchange',
|
||||||
'wallet.rewardWatch': 'Watch an ad for {n} 🪙',
|
'wallet.rewardWatch': 'Watch an ad for {n} 🪙',
|
||||||
'wallet.rewardCta': 'Watch',
|
'wallet.rewardCta': 'Watch',
|
||||||
'wallet.rewardCredited': '+{n} 🪙 for watching',
|
'wallet.rewardCredited': '+{n} 🪙 for watching',
|
||||||
@@ -256,10 +255,11 @@ export const en = {
|
|||||||
'wallet.cur.RUB': '₽',
|
'wallet.cur.RUB': '₽',
|
||||||
'wallet.cur.VOTE': 'votes',
|
'wallet.cur.VOTE': 'votes',
|
||||||
'wallet.cur.XTR': '⭐',
|
'wallet.cur.XTR': '⭐',
|
||||||
'wallet.warnTitle': 'Web-only purchase',
|
'wallet.spendTitle': 'Confirm exchange',
|
||||||
|
'wallet.spendBody': 'Exchange {n} 🪙 for “{title}”?',
|
||||||
|
'wallet.spendConfirm': 'Exchange',
|
||||||
'wallet.warnBody':
|
'wallet.warnBody':
|
||||||
'This spends your VK/Telegram chips, and the benefit will work on the web and in the app only — because of store rules.',
|
'This spends your VK/Telegram chips, and the benefit will work on the web and in the app only — because of store rules.',
|
||||||
'wallet.warnConfirm': 'Continue',
|
|
||||||
'wallet.warnCancel': 'Cancel',
|
'wallet.warnCancel': 'Cancel',
|
||||||
'about.title': 'About',
|
'about.title': 'About',
|
||||||
'about.tab': 'Info',
|
'about.tab': 'Info',
|
||||||
@@ -313,6 +313,8 @@ export const en = {
|
|||||||
'error.invalid_email': 'Enter a valid email address.',
|
'error.invalid_email': 'Enter a valid email address.',
|
||||||
'error.invalid_config': 'Invalid game settings.',
|
'error.invalid_config': 'Invalid game settings.',
|
||||||
'error.not_found': 'Not found.',
|
'error.not_found': 'Not found.',
|
||||||
|
'error.insufficient_chips': 'Not enough chips.',
|
||||||
|
'error.product_not_found': 'This item is no longer available.',
|
||||||
'error.session_invalid': 'Your session expired. Please sign in again.',
|
'error.session_invalid': 'Your session expired. Please sign in again.',
|
||||||
'error.unauthenticated': 'Please sign in.',
|
'error.unauthenticated': 'Please sign in.',
|
||||||
'error.rate_limited': 'Too many requests, slow down.',
|
'error.rate_limited': 'Too many requests, slow down.',
|
||||||
@@ -379,6 +381,11 @@ export const en = {
|
|||||||
'new.multipleWordsPerTurn': 'Multiple words per turn',
|
'new.multipleWordsPerTurn': 'Multiple words per turn',
|
||||||
'new.start': 'Start game',
|
'new.start': 'Start game',
|
||||||
'new.invited': 'Invitation sent.',
|
'new.invited': 'Invitation sent.',
|
||||||
|
'new.limitGuestTitle': 'More games?',
|
||||||
|
'new.limitGuestBody': 'Sign in or create an account to use all the game features.',
|
||||||
|
'new.limitDurableTitle': 'Game limit',
|
||||||
|
'new.limitDurableBody': 'You have reached the limit. Finish your active games to start a new one.',
|
||||||
|
'new.limitLogin': 'Sign in',
|
||||||
'new.noFriends': 'Add friends first to invite them.',
|
'new.noFriends': 'Add friends first to invite them.',
|
||||||
'new.opponentAI': 'AI',
|
'new.opponentAI': 'AI',
|
||||||
'new.opponentRandom': 'Random player',
|
'new.opponentRandom': 'Random player',
|
||||||
|
|||||||
+17
-10
@@ -87,6 +87,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'onboarding.gameRack': 'Выбирайте фишку и ставьте на доску', // rack
|
'onboarding.gameRack': 'Выбирайте фишку и ставьте на доску', // rack
|
||||||
|
|
||||||
'game.bag': '{n} в мешке',
|
'game.bag': '{n} в мешке',
|
||||||
|
'game.bagCount': 'В мешке: {n}',
|
||||||
'game.bagEmpty': 'Мешок пуст',
|
'game.bagEmpty': 'Мешок пуст',
|
||||||
'game.hints': 'Подсказки {n}',
|
'game.hints': 'Подсказки {n}',
|
||||||
'game.yourTurn': 'Ваш ход',
|
'game.yourTurn': 'Ваш ход',
|
||||||
@@ -104,8 +105,6 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'game.checkWord': 'Проверить слово',
|
'game.checkWord': 'Проверить слово',
|
||||||
'game.dictionary': 'Словарь',
|
'game.dictionary': 'Словарь',
|
||||||
'game.dropGame': 'Покинуть игру',
|
'game.dropGame': 'Покинуть игру',
|
||||||
'game.previewWords': '{words}: {n}',
|
|
||||||
'game.previewIllegal': 'Недопустимый ход',
|
|
||||||
'game.oneWordRule': 'Одно слово за ход',
|
'game.oneWordRule': 'Одно слово за ход',
|
||||||
'game.chooseBlank': 'Выберите букву для бланка',
|
'game.chooseBlank': 'Выберите букву для бланка',
|
||||||
'game.exchangeTitle': 'Обмен или пас',
|
'game.exchangeTitle': 'Обмен или пас',
|
||||||
@@ -215,6 +214,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'settings.labelsClassic': 'Классика',
|
'settings.labelsClassic': 'Классика',
|
||||||
'settings.labelsNone': 'Без текста',
|
'settings.labelsNone': 'Без текста',
|
||||||
'settings.reduceMotion': 'Меньше анимаций',
|
'settings.reduceMotion': 'Меньше анимаций',
|
||||||
|
'settings.zoomBoard': 'Приближать доску',
|
||||||
'settings.offlineMode': 'Режим игры',
|
'settings.offlineMode': 'Режим игры',
|
||||||
'settings.online': 'Онлайн',
|
'settings.online': 'Онлайн',
|
||||||
'settings.offline': 'Оффлайн',
|
'settings.offline': 'Оффлайн',
|
||||||
@@ -228,20 +228,19 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
// --- wallet ---
|
// --- wallet ---
|
||||||
'wallet.title': 'Кошелёк',
|
'wallet.title': 'Кошелёк',
|
||||||
'wallet.tab': 'Кошелёк',
|
'wallet.tab': 'Кошелёк',
|
||||||
'wallet.balance': 'Баланс',
|
|
||||||
'wallet.noChips': 'Пока нет фишек',
|
|
||||||
'wallet.source.vk': 'VK',
|
'wallet.source.vk': 'VK',
|
||||||
'wallet.source.telegram': 'Telegram',
|
'wallet.source.telegram': 'Telegram',
|
||||||
'wallet.source.direct': 'Веб',
|
'wallet.source.direct': 'Веб',
|
||||||
'wallet.viewOnly': 'просмотр',
|
'wallet.active': 'Активные',
|
||||||
'wallet.benefits': 'Блага',
|
'wallet.activeHints': 'Подсказки: {n}',
|
||||||
'wallet.noAdsUntil': 'Без рекламы до {date}',
|
'wallet.noAdsUntil': 'Без рекламы до {date}',
|
||||||
'wallet.noAdsForever': 'Без рекламы навсегда',
|
'wallet.noAdsForever': 'Без рекламы навсегда',
|
||||||
'wallet.adsOn': 'Реклама включена',
|
|
||||||
'wallet.hints': 'Подсказки {n}',
|
|
||||||
'wallet.store': 'Магазин',
|
'wallet.store': 'Магазин',
|
||||||
|
'wallet.tabBuy': 'Купить фишки',
|
||||||
|
'wallet.tabSpend': 'Потратить фишки',
|
||||||
'wallet.empty': 'Магазин пока пуст',
|
'wallet.empty': 'Магазин пока пуст',
|
||||||
'wallet.buy': 'Купить',
|
'wallet.buy': 'Купить',
|
||||||
|
'wallet.spend': 'Обмен',
|
||||||
'wallet.rewardWatch': 'Ролик за {n} 🪙',
|
'wallet.rewardWatch': 'Ролик за {n} 🪙',
|
||||||
'wallet.rewardCta': 'Смотреть',
|
'wallet.rewardCta': 'Смотреть',
|
||||||
'wallet.rewardCredited': '+{n} 🪙 за просмотр',
|
'wallet.rewardCredited': '+{n} 🪙 за просмотр',
|
||||||
@@ -256,10 +255,11 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'wallet.cur.RUB': '₽',
|
'wallet.cur.RUB': '₽',
|
||||||
'wallet.cur.VOTE': 'голосов',
|
'wallet.cur.VOTE': 'голосов',
|
||||||
'wallet.cur.XTR': '⭐',
|
'wallet.cur.XTR': '⭐',
|
||||||
'wallet.warnTitle': 'Покупка только для веба',
|
'wallet.spendTitle': 'Подтверждение обмена',
|
||||||
|
'wallet.spendBody': 'Обменять {n} 🪙 на «{title}»?',
|
||||||
|
'wallet.spendConfirm': 'Обменять',
|
||||||
'wallet.warnBody':
|
'wallet.warnBody':
|
||||||
'Оплата спишет фишки VK/Telegram, и благо будет работать только в вебе и приложении — из-за правил магазинов.',
|
'Оплата спишет фишки VK/Telegram, и благо будет работать только в вебе и приложении — из-за правил магазинов.',
|
||||||
'wallet.warnConfirm': 'Продолжить',
|
|
||||||
'wallet.warnCancel': 'Отмена',
|
'wallet.warnCancel': 'Отмена',
|
||||||
'about.title': 'О программе',
|
'about.title': 'О программе',
|
||||||
'about.tab': 'Инфо',
|
'about.tab': 'Инфо',
|
||||||
@@ -313,6 +313,8 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'error.invalid_email': 'Введите корректный адрес почты.',
|
'error.invalid_email': 'Введите корректный адрес почты.',
|
||||||
'error.invalid_config': 'Неверные настройки игры.',
|
'error.invalid_config': 'Неверные настройки игры.',
|
||||||
'error.not_found': 'Не найдено.',
|
'error.not_found': 'Не найдено.',
|
||||||
|
'error.insufficient_chips': 'Не хватает фишек.',
|
||||||
|
'error.product_not_found': 'Этого товара больше нет.',
|
||||||
'error.session_invalid': 'Сессия истекла. Войдите снова.',
|
'error.session_invalid': 'Сессия истекла. Войдите снова.',
|
||||||
'error.unauthenticated': 'Пожалуйста, войдите.',
|
'error.unauthenticated': 'Пожалуйста, войдите.',
|
||||||
'error.rate_limited': 'Слишком много запросов, помедленнее.',
|
'error.rate_limited': 'Слишком много запросов, помедленнее.',
|
||||||
@@ -379,6 +381,11 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'new.multipleWordsPerTurn': 'Несколько слов за ход',
|
'new.multipleWordsPerTurn': 'Несколько слов за ход',
|
||||||
'new.start': 'Начать игру',
|
'new.start': 'Начать игру',
|
||||||
'new.invited': 'Приглашение отправлено.',
|
'new.invited': 'Приглашение отправлено.',
|
||||||
|
'new.limitGuestTitle': 'Больше партий?',
|
||||||
|
'new.limitGuestBody': 'Войдите или создайте учётную запись, чтобы воспользоваться всеми функциями игры.',
|
||||||
|
'new.limitDurableTitle': 'Лимит игр',
|
||||||
|
'new.limitDurableBody': 'Вы достигли лимита. Доиграйте активные партии, чтобы начать новую.',
|
||||||
|
'new.limitLogin': 'Вход',
|
||||||
'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.',
|
'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.',
|
||||||
'new.opponentAI': 'ИИ',
|
'new.opponentAI': 'ИИ',
|
||||||
'new.opponentRandom': 'Случайный игрок',
|
'new.opponentRandom': 'Случайный игрок',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ function gameView(id: string, status: GameView['status'] = 'active', toMove = 0)
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 0,
|
||||||
status,
|
status,
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove,
|
toMove,
|
||||||
@@ -43,7 +44,7 @@ beforeEach(() => clearLobby());
|
|||||||
|
|
||||||
describe('patchLobbyGame', () => {
|
describe('patchLobbyGame', () => {
|
||||||
it('replaces the matching game by id and leaves the others untouched', () => {
|
it('replaces the matching game by id and leaves the others untouched', () => {
|
||||||
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [], atGameLimit: false, offline: false });
|
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [], offline: false });
|
||||||
// The player's own move flipped game "a" to the opponent's turn.
|
// The player's own move flipped game "a" to the opponent's turn.
|
||||||
patchLobbyGame(gameView('a', 'active', 1));
|
patchLobbyGame(gameView('a', 'active', 1));
|
||||||
const snap = getLobby(false);
|
const snap = getLobby(false);
|
||||||
@@ -54,14 +55,14 @@ describe('patchLobbyGame', () => {
|
|||||||
|
|
||||||
it('preserves invitations and incoming when patching a game', () => {
|
it('preserves invitations and incoming when patching a game', () => {
|
||||||
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
||||||
setLobby({ games: [gameView('a')], invitations: [], incoming, atGameLimit: false, offline: false });
|
setLobby({ games: [gameView('a')], invitations: [], incoming, offline: false });
|
||||||
patchLobbyGame(gameView('a', 'finished'));
|
patchLobbyGame(gameView('a', 'finished'));
|
||||||
expect(getLobby(false)?.incoming).toEqual(incoming);
|
expect(getLobby(false)?.incoming).toEqual(incoming);
|
||||||
expect(getLobby(false)?.games[0].status).toBe('finished');
|
expect(getLobby(false)?.games[0].status).toBe('finished');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => {
|
it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => {
|
||||||
setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: false, offline: false });
|
setLobby({ games: [gameView('a')], invitations: [], incoming: [], offline: false });
|
||||||
patchLobbyGame(gameView('z', 'active'));
|
patchLobbyGame(gameView('z', 'active'));
|
||||||
// Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership.
|
// Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership.
|
||||||
expect(getLobby(false)?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
|
expect(getLobby(false)?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
|
||||||
@@ -74,29 +75,21 @@ describe('patchLobbyGame', () => {
|
|||||||
|
|
||||||
it('does not mutate the previous snapshot array', () => {
|
it('does not mutate the previous snapshot array', () => {
|
||||||
const games = [gameView('a', 'active', 0)];
|
const games = [gameView('a', 'active', 0)];
|
||||||
setLobby({ games, invitations: [], incoming: [], atGameLimit: false, offline: false });
|
setLobby({ games, invitations: [], incoming: [], offline: false });
|
||||||
patchLobbyGame(gameView('a', 'active', 1));
|
patchLobbyGame(gameView('a', 'active', 1));
|
||||||
expect(games[0].toMove).toBe(0); // the original array/object is left intact
|
expect(games[0].toMove).toBe(0); // the original array/object is left intact
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves the at-game-limit flag across a game patch', () => {
|
|
||||||
// A finished-game patch arriving while the lobby is unmounted must not drop the flag —
|
|
||||||
// the lobby renders the "New Game" button from the cached snapshot before the refresh.
|
|
||||||
setLobby({ games: [gameView('a')], invitations: [], incoming: [], atGameLimit: true, offline: false });
|
|
||||||
patchLobbyGame(gameView('a', 'finished'));
|
|
||||||
expect(getLobby(false)?.atGameLimit).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('patchLobbyInvitation', () => {
|
describe('patchLobbyInvitation', () => {
|
||||||
it('adds a new pending invitation to the cached lobby', () => {
|
it('adds a new pending invitation to the cached lobby', () => {
|
||||||
setLobby({ games: [], invitations: [], incoming: [], atGameLimit: false, offline: false });
|
setLobby({ games: [], invitations: [], incoming: [], offline: false });
|
||||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||||
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1']);
|
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replaces a pending invitation already in the list (e.g. an updated response)', () => {
|
it('replaces a pending invitation already in the list (e.g. an updated response)', () => {
|
||||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], atGameLimit: false, offline: false });
|
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], offline: false });
|
||||||
const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 };
|
const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 };
|
||||||
patchLobbyInvitation(updated);
|
patchLobbyInvitation(updated);
|
||||||
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
|
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
|
||||||
@@ -105,14 +98,14 @@ describe('patchLobbyInvitation', () => {
|
|||||||
|
|
||||||
it('removes an invitation that reached a terminal status', () => {
|
it('removes an invitation that reached a terminal status', () => {
|
||||||
for (const terminal of ['declined', 'cancelled', 'started', 'expired']) {
|
for (const terminal of ['declined', 'cancelled', 'started', 'expired']) {
|
||||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], atGameLimit: false, offline: false });
|
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], offline: false });
|
||||||
patchLobbyInvitation(invitation('i1', terminal));
|
patchLobbyInvitation(invitation('i1', terminal));
|
||||||
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']);
|
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is a no-op for a terminal invitation that is not in the list', () => {
|
it('is a no-op for a terminal invitation that is not in the list', () => {
|
||||||
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [], atGameLimit: false, offline: false });
|
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [], offline: false });
|
||||||
patchLobbyInvitation(invitation('i1', 'declined'));
|
patchLobbyInvitation(invitation('i1', 'declined'));
|
||||||
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']);
|
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||||
});
|
});
|
||||||
@@ -124,7 +117,7 @@ describe('patchLobbyInvitation', () => {
|
|||||||
|
|
||||||
it('preserves games and incoming when patching an invitation', () => {
|
it('preserves games and incoming when patching an invitation', () => {
|
||||||
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
||||||
setLobby({ games: [gameView('g1')], invitations: [], incoming, atGameLimit: false, offline: false });
|
setLobby({ games: [gameView('g1')], invitations: [], incoming, offline: false });
|
||||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||||
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['g1']);
|
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['g1']);
|
||||||
expect(getLobby(false)?.incoming).toEqual(incoming);
|
expect(getLobby(false)?.incoming).toEqual(incoming);
|
||||||
@@ -133,15 +126,15 @@ describe('patchLobbyInvitation', () => {
|
|||||||
|
|
||||||
describe('getLobby is mode-aware (a mode flip must not render the other mode’s games)', () => {
|
describe('getLobby is mode-aware (a mode flip must not render the other mode’s games)', () => {
|
||||||
it('returns the snapshot only for the mode it was stored under', () => {
|
it('returns the snapshot only for the mode it was stored under', () => {
|
||||||
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], atGameLimit: false, offline: false });
|
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], offline: false });
|
||||||
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['online1']);
|
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['online1']);
|
||||||
// Reading it as the OTHER (offline) mode must not surface the online snapshot.
|
// Reading it as the OTHER (offline) mode must not surface the online snapshot.
|
||||||
expect(getLobby(true)).toBeNull();
|
expect(getLobby(true)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replaces the snapshot when the mode changes, so the stale mode is dropped', () => {
|
it('replaces the snapshot when the mode changes, so the stale mode is dropped', () => {
|
||||||
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], atGameLimit: false, offline: false });
|
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], offline: false });
|
||||||
setLobby({ games: [gameView('local1')], invitations: [], incoming: [], atGameLimit: false, offline: true });
|
setLobby({ games: [gameView('local1')], invitations: [], incoming: [], offline: true });
|
||||||
expect(getLobby(false)).toBeNull();
|
expect(getLobby(false)).toBeNull();
|
||||||
expect(getLobby(true)?.games.map((g) => g.id)).toEqual(['local1']);
|
expect(getLobby(true)?.games.map((g) => g.id)).toEqual(['local1']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ interface LobbySnapshot {
|
|||||||
games: GameView[];
|
games: GameView[];
|
||||||
invitations: Invitation[];
|
invitations: Invitation[];
|
||||||
incoming: AccountRef[];
|
incoming: AccountRef[];
|
||||||
// atGameLimit rides the snapshot so the lobby renders the "New Game" button in its last
|
|
||||||
// known enabled/disabled state instantly (no flicker), then refreshes it in the background.
|
|
||||||
atGameLimit: boolean;
|
|
||||||
// Which mode the snapshot belongs to (offline = device-local games, online = server games). The
|
// Which mode the snapshot belongs to (offline = device-local games, online = server games). The
|
||||||
// lobby renders it instantly only when it matches the current mode, so a mode flip never flashes —
|
// lobby renders it instantly only when it matches the current mode, so a mode flip never flashes —
|
||||||
// or lets the player open — the other mode's games before the background refresh replaces them.
|
// or lets the player open — the other mode's games before the background refresh replaces them.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 0,
|
||||||
status,
|
status,
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove,
|
toMove,
|
||||||
|
|||||||
@@ -481,6 +481,7 @@ export class LocalSource implements GameLoopSource {
|
|||||||
hotseat: !!record.hotseat,
|
hotseat: !!record.hotseat,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 0,
|
||||||
seats,
|
seats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-10
@@ -33,6 +33,7 @@ import type {
|
|||||||
MoveResult,
|
MoveResult,
|
||||||
OutgoingList,
|
OutgoingList,
|
||||||
Profile,
|
Profile,
|
||||||
|
GameLimits,
|
||||||
ProfileUpdate,
|
ProfileUpdate,
|
||||||
PushEvent,
|
PushEvent,
|
||||||
Session,
|
Session,
|
||||||
@@ -105,8 +106,6 @@ export class MockGateway implements GatewayClient {
|
|||||||
private feedbackReplyUnread = false;
|
private feedbackReplyUnread = false;
|
||||||
// The most recently opened auto-match game still awaiting an opponent, for the e2e join hook.
|
// The most recently opened auto-match game still awaiting an opponent, for the e2e join hook.
|
||||||
private openGameId: string | null = null;
|
private openGameId: string | null = null;
|
||||||
// gameLimit forces the lobby's at_game_limit flag for the e2e (window.__mock.setGameLimit).
|
|
||||||
private gameLimit = false;
|
|
||||||
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
|
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
|
||||||
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
|
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
|
||||||
private outgoing: AccountRef[] = [];
|
private outgoing: AccountRef[] = [];
|
||||||
@@ -267,7 +266,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
async gamesList(): Promise<GameList> {
|
async gamesList(): Promise<GameList> {
|
||||||
return {
|
return {
|
||||||
games: [...this.games.values()].map((g) => structuredClone(g.view)),
|
games: [...this.games.values()].map((g) => structuredClone(g.view)),
|
||||||
atGameLimit: this.gameLimit,
|
atGameLimit: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +292,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
vsAi: true,
|
vsAi: true,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 1,
|
||||||
seats: [
|
seats: [
|
||||||
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
|
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
|
||||||
{ seat: 1, accountId: 'robot', displayName: 'Robot', score: 0, hintsUsed: 0, isWinner: false },
|
{ seat: 1, accountId: 'robot', displayName: 'Robot', score: 0, hintsUsed: 0, isWinner: false },
|
||||||
@@ -326,6 +326,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 2,
|
||||||
seats: [
|
seats: [
|
||||||
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
|
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
|
||||||
{ seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false },
|
{ seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false },
|
||||||
@@ -377,13 +378,13 @@ export class MockGateway implements GatewayClient {
|
|||||||
if (this.openGameId) this.seatOpponent(this.openGameId);
|
if (this.openGameId) this.seatOpponent(this.openGameId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// setGameLimit forces the lobby's at_game_limit flag (the e2e hook
|
// setGameLimits overrides the profile's per-kind active-game caps (the e2e hook
|
||||||
// window.__mock.setGameLimit), then nudges the lobby to re-fetch games.list so the
|
// window.__mock.setGameLimits), then emits a profile notify so the app re-fetches the profile and
|
||||||
// "New Game" button and the notice update in place. The lobby refetches on any
|
// the New-Game screen's per-kind lock recomputes in place — the same path a guest→durable upgrade
|
||||||
// non-heartbeat event; an unrecognised notify sub triggers only that refetch.
|
// takes to lift the lock.
|
||||||
setGameLimit(v: boolean): void {
|
setGameLimits(l: GameLimits): void {
|
||||||
this.gameLimit = v;
|
this.profile.gameLimits = { ...l };
|
||||||
this.emit({ kind: 'notify', sub: 'game_limit' });
|
this.emit({ kind: 'notify', sub: 'profile' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async lobbyPoll(): Promise<MatchResult> {
|
async lobbyPoll(): Promise<MatchResult> {
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export const PROFILE: Profile = {
|
|||||||
dictVersions: { erudit_ru: 'v1.3.0', scrabble_ru: 'v1.3.0', scrabble_en: 'v1.3.0' },
|
dictVersions: { erudit_ru: 'v1.3.0', scrabble_ru: 'v1.3.0', scrabble_en: 'v1.3.0' },
|
||||||
// Post-move interstitial config (short cooldowns so the mock stub is exercisable); not suppressed.
|
// Post-move interstitial config (short cooldowns so the mock stub is exercisable); not suppressed.
|
||||||
ads: { cooldownGlobalS: 300, cooldownVsAiS: 1800, cooldownHintS: 60, suppressed: false },
|
ads: { cooldownGlobalS: 300, cooldownVsAiS: 1800, cooldownHintS: 60, suppressed: false },
|
||||||
|
// The durable tier's per-kind active-game caps (the seeded defaults); the e2e lowers them through
|
||||||
|
// window.__mock.setGameLimits to exercise the New-Game lock.
|
||||||
|
gameLimits: { vsAi: 10, random: 10, friends: 10 },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
|
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
|
||||||
@@ -189,6 +192,7 @@ function activeGame(): MockGame {
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 2,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove: 0,
|
toMove: 0,
|
||||||
@@ -227,6 +231,7 @@ function finishedG2(): MockGame {
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 2,
|
||||||
status: 'finished',
|
status: 'finished',
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove: 0,
|
toMove: 0,
|
||||||
@@ -266,6 +271,7 @@ function finishedG3(): MockGame {
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 2,
|
||||||
status: 'finished',
|
status: 'finished',
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove: 0,
|
toMove: 0,
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ export interface GameView {
|
|||||||
* badge can be coloured apart from the nudge-only case. Same REST-seeded lifecycle as
|
* badge can be coloured apart from the nudge-only case. Same REST-seeded lifecycle as
|
||||||
* unreadChat; the live-event GameView leaves it false. */
|
* unreadChat; the live-event GameView leaves it false. */
|
||||||
unreadMessages: boolean;
|
unreadMessages: boolean;
|
||||||
|
/** The game's origin for the active-game limits: 0 unknown (a pre-existing game, never gated),
|
||||||
|
* 1 vs_ai, 2 random, 3 friends. The lobby counts active games per kind against the caller's
|
||||||
|
* per-kind limits (Profile.gameLimits) to lock a capped New-Game start. Local/offline games
|
||||||
|
* leave it 0 (they never count toward the online limits). */
|
||||||
|
kind: number;
|
||||||
seats: Seat[];
|
seats: Seat[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +239,21 @@ export interface Profile {
|
|||||||
* offline-capable PWA reads it to preload the matching dawg for each enabled variant off
|
* offline-capable PWA reads it to preload the matching dawg for each enabled variant off
|
||||||
* this cold-start response. Empty only in a degenerate deployment with no resident dictionary. */
|
* this cold-start response. Empty only in a degenerate deployment with no resident dictionary. */
|
||||||
dictVersions: Partial<Record<Variant, string>>;
|
dictVersions: Partial<Record<Variant, string>>;
|
||||||
|
/** The caller's tier active-game caps per kind (-1 = unlimited); the New-Game screen counts the
|
||||||
|
* player's active games per kind from the lobby and locks a capped start. Absent from an old
|
||||||
|
* backend (then no lock applies). */
|
||||||
|
gameLimits?: GameLimits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GameKind labels the game_kind wire values for the active-game limits. */
|
||||||
|
export const GameKind = { vsAi: 1, random: 2, friends: 3 } as const;
|
||||||
|
|
||||||
|
/** The caller's tier active-game caps per kind (-1 = unlimited), resolved to the guest or durable
|
||||||
|
* tier server-side. The New-Game screen locks a start whose kind is at its cap. */
|
||||||
|
export interface GameLimits {
|
||||||
|
vsAi: number;
|
||||||
|
random: number;
|
||||||
|
friends: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The post-move interstitial-ad config for the client-mirrored gate: the cooldowns (seconds) and
|
/** The post-move interstitial-ad config for the client-mirrored gate: the cooldowns (seconds) and
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { BOT_BUTTON_ID, botInfoPopup } from './nativedialogs';
|
import { BOT_BUTTON_ID, LOGIN_BUTTON_ID, botInfoPopup, gameLimitGuestPopup, gameLimitDurablePopup } from './nativedialogs';
|
||||||
|
|
||||||
describe('botInfoPopup', () => {
|
describe('botInfoPopup', () => {
|
||||||
it('inlines the bot handle and adds an open-bot button', () => {
|
it('inlines the bot handle and adds an open-bot button', () => {
|
||||||
@@ -23,3 +23,22 @@ describe('botInfoPopup', () => {
|
|||||||
expect(p.message).toBe('Welcome!\n\nUse @b now.');
|
expect(p.message).toBe('Welcome!\n\nUse @b now.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('gameLimitGuestPopup', () => {
|
||||||
|
it('offers a cancel and a login button (the sign-in funnel)', () => {
|
||||||
|
const p = gameLimitGuestPopup('More games?', 'Sign in to play more.', 'Cancel', 'Sign in');
|
||||||
|
expect(p.title).toBe('More games?');
|
||||||
|
expect(p.message).toBe('Sign in to play more.');
|
||||||
|
expect(p.buttons).toEqual([
|
||||||
|
{ id: 'cancel', text: 'Cancel' },
|
||||||
|
{ id: LOGIN_BUTTON_ID, text: 'Sign in' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gameLimitDurablePopup', () => {
|
||||||
|
it('is an OK-only notice', () => {
|
||||||
|
const p = gameLimitDurablePopup('Game limit', 'Finish a current game first.', 'OK');
|
||||||
|
expect(p.buttons).toEqual([{ id: 'ok', text: 'OK' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,21 @@ import type { TelegramPopupParams } from './telegram';
|
|||||||
/** BOT_BUTTON_ID is the showPopup button id that means "open the bot chat". */
|
/** BOT_BUTTON_ID is the showPopup button id that means "open the bot chat". */
|
||||||
export const BOT_BUTTON_ID = 'bot';
|
export const BOT_BUTTON_ID = 'bot';
|
||||||
|
|
||||||
|
/** LOGIN_BUTTON_ID is the game-limit popup button that opens the sign-in funnel. */
|
||||||
|
export const LOGIN_BUTTON_ID = 'login';
|
||||||
|
|
||||||
|
/** gameLimitGuestPopup builds the native popup that nudges a guest to sign in when a New-Game start
|
||||||
|
* is capped for guests: a cancel button and a login button (the sign-in funnel). */
|
||||||
|
export function gameLimitGuestPopup(title: string, message: string, cancelText: string, loginText: string): TelegramPopupParams {
|
||||||
|
return { title, message, buttons: [{ id: 'cancel', text: cancelText }, { id: LOGIN_BUTTON_ID, text: loginText }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** gameLimitDurablePopup builds the native notice shown to a durable account at its per-kind cap:
|
||||||
|
* an OK button only (finish a current game first). */
|
||||||
|
export function gameLimitDurablePopup(title: string, message: string, okText: string): TelegramPopupParams {
|
||||||
|
return { title, message, buttons: [{ id: 'ok', text: okText }] };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* botInfoPopup builds the native popup for a deep-link info modal: the message with the `{bot}`
|
* botInfoPopup builds the native popup for a deep-link info modal: the message with the `{bot}`
|
||||||
* token replaced by the `@username` as plain text (a native popup has no inline link, unlike the
|
* token replaced by the `@username` as plain text (a native popup has no inline link, unlike the
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 0,
|
||||||
status,
|
status,
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove: 0,
|
toMove: 0,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
|
|||||||
vsAi: false,
|
vsAi: false,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
|
kind: 0,
|
||||||
status,
|
status,
|
||||||
players: seats.length,
|
players: seats.length,
|
||||||
toMove,
|
toMove,
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export interface Prefs {
|
|||||||
locale: Locale;
|
locale: Locale;
|
||||||
reduceMotion: boolean;
|
reduceMotion: boolean;
|
||||||
boardLabels: BoardLabelMode;
|
boardLabels: BoardLabelMode;
|
||||||
|
zoomBoard: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadPrefs(): Promise<Partial<Prefs>> {
|
export async function loadPrefs(): Promise<Partial<Prefs>> {
|
||||||
|
|||||||
@@ -23,12 +23,6 @@
|
|||||||
let games = $state<GameView[]>([]);
|
let games = $state<GameView[]>([]);
|
||||||
let invitations = $state<Invitation[]>([]);
|
let invitations = $state<Invitation[]>([]);
|
||||||
let incoming = $state<AccountRef[]>([]);
|
let incoming = $state<AccountRef[]>([]);
|
||||||
// True when the player has reached the simultaneous quick-game cap: the "New Game" tab is
|
|
||||||
// disabled and a notice shows under the lists. It rides the games.list response (which the
|
|
||||||
// lobby refetches on entry and on every game event) and the cached snapshot, so it renders
|
|
||||||
// in its last known state instantly and refreshes in the background. Optimistic default
|
|
||||||
// (enabled) for the very first, uncached load; the backend gate is the authority.
|
|
||||||
let atGameLimit = $state(false);
|
|
||||||
|
|
||||||
const guest = $derived(app.profile?.isGuest ?? true);
|
const guest = $derived(app.profile?.isGuest ?? true);
|
||||||
// The lobby ⚙️ badge is the shared count: incoming friend requests plus an awaiting
|
// The lobby ⚙️ badge is the shared count: incoming friend requests plus an awaiting
|
||||||
@@ -51,9 +45,8 @@
|
|||||||
games = local;
|
games = local;
|
||||||
invitations = [];
|
invitations = [];
|
||||||
incoming = [];
|
incoming = [];
|
||||||
atGameLimit = false;
|
|
||||||
app.notifications = 0;
|
app.notifications = 0;
|
||||||
setLobby({ games, invitations, incoming, atGameLimit, offline: offlineMode.active });
|
setLobby({ games, invitations, incoming, offline: offlineMode.active });
|
||||||
app.lobbyReady = true;
|
app.lobbyReady = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -64,7 +57,6 @@
|
|||||||
// Seed the per-game unread badges from the authoritative list. The live stream only raises
|
// Seed the per-game unread badges from the authoritative list. The live stream only raises
|
||||||
// unread (it never seeds it from a GameView), so a persisted unread survives a reload here.
|
// unread (it never seeds it from a GameView), so a persisted unread survives a reload here.
|
||||||
for (const g of games) seedChatUnread(g.id, g.unreadChat, g.unreadMessages);
|
for (const g of games) seedChatUnread(g.id, g.unreadChat, g.unreadMessages);
|
||||||
atGameLimit = list.atGameLimit;
|
|
||||||
if (!guest) {
|
if (!guest) {
|
||||||
const [inv, inc] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
|
const [inv, inc] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
|
||||||
if (seq !== loadSeq) return;
|
if (seq !== loadSeq) return;
|
||||||
@@ -75,7 +67,7 @@
|
|||||||
app.notifications = incoming.length;
|
app.notifications = incoming.length;
|
||||||
void refreshFeedbackBadge();
|
void refreshFeedbackBadge();
|
||||||
}
|
}
|
||||||
setLobby({ games, invitations, incoming, atGameLimit, offline: offlineMode.active });
|
setLobby({ games, invitations, incoming, offline: offlineMode.active });
|
||||||
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list
|
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list
|
||||||
// just loaded, so the connection is up; the call is non-blocking and re-running it on each
|
// just loaded, so the connection is up; the call is non-blocking and re-running it on each
|
||||||
// lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped).
|
// lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped).
|
||||||
@@ -103,7 +95,6 @@
|
|||||||
games = cached.games;
|
games = cached.games;
|
||||||
invitations = cached.invitations;
|
invitations = cached.invitations;
|
||||||
incoming = cached.incoming;
|
incoming = cached.incoming;
|
||||||
atGameLimit = cached.atGameLimit;
|
|
||||||
}
|
}
|
||||||
void load();
|
void load();
|
||||||
});
|
});
|
||||||
@@ -231,7 +222,7 @@
|
|||||||
revealedId = null;
|
revealedId = null;
|
||||||
const prev = games;
|
const prev = games;
|
||||||
games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out
|
games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out
|
||||||
setLobby({ games, invitations, incoming, atGameLimit, offline: offlineMode.active });
|
setLobby({ games, invitations, incoming, offline: offlineMode.active });
|
||||||
try {
|
try {
|
||||||
// A local (offline) game is deleted from the device store; an online game is hidden on the
|
// A local (offline) game is deleted from the device store; an online game is hidden on the
|
||||||
// backend. Routing by id keeps the delete off the network for a local game (the transport
|
// backend. Routing by id keeps the delete off the network for a local game (the transport
|
||||||
@@ -240,7 +231,7 @@
|
|||||||
else await gateway.hideGame(id);
|
else await gateway.hideGame(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
games = prev;
|
games = prev;
|
||||||
setLobby({ games, invitations, incoming, atGameLimit, offline: offlineMode.active });
|
setLobby({ games, invitations, incoming, offline: offlineMode.active });
|
||||||
handleError(e);
|
handleError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,10 +370,6 @@
|
|||||||
{#if !games.length && !invitations.length}
|
{#if !games.length && !invitations.length}
|
||||||
<p class="empty">{t('lobby.noActive')}</p>
|
<p class="empty">{t('lobby.noActive')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if atGameLimit}
|
|
||||||
<p class="limit">{t('lobby.limitReached')}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if declineTarget}
|
{#if declineTarget}
|
||||||
@@ -411,7 +398,7 @@
|
|||||||
|
|
||||||
{#snippet tabbar()}
|
{#snippet tabbar()}
|
||||||
<TabBar>
|
<TabBar>
|
||||||
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
|
<button class="tab" onclick={() => navigate('/new')}>
|
||||||
<span class="sq" data-coach="lobby-new">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
<span class="sq" data-coach="lobby-new">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" disabled={offlineMode.active} onclick={() => navigate('/stats')}>
|
<button class="tab" disabled={offlineMode.active} onclick={() => navigate('/stats')}>
|
||||||
@@ -444,13 +431,6 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
/* A plain, low-emphasis line under the lists when the simultaneous-game cap is reached. */
|
|
||||||
.limit {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.invite {
|
.invite {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
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 Modal from '../components/Modal.svelte';
|
||||||
|
import GameLimitModal from '../components/GameLimitModal.svelte';
|
||||||
import PinPad, { type PinResult } from '../components/PinPad.svelte';
|
import PinPad, { type PinResult } from '../components/PinPad.svelte';
|
||||||
|
import { getLobby } from '../lib/lobbycache';
|
||||||
|
import { isKindLocked } from '../lib/gamelimits';
|
||||||
|
import { GameKind } from '../lib/model';
|
||||||
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';
|
||||||
@@ -14,7 +18,7 @@
|
|||||||
import { validDisplayName } from '../lib/profileValidation';
|
import { validDisplayName } from '../lib/profileValidation';
|
||||||
import { verifyPin, type PinLock } from '../lib/pin';
|
import { verifyPin, type PinLock } from '../lib/pin';
|
||||||
import { commitName, rosterReady, buildSeats, shuffleSeeded, type RosterRow } from '../lib/roster';
|
import { commitName, rosterReady, buildSeats, shuffleSeeded, type RosterRow } from '../lib/roster';
|
||||||
import type { AccountRef, Variant } from '../lib/model';
|
import type { AccountRef, GameView, Variant } from '../lib/model';
|
||||||
import {
|
import {
|
||||||
availableVariants,
|
availableVariants,
|
||||||
VARIANT_FLAG,
|
VARIANT_FLAG,
|
||||||
@@ -51,6 +55,17 @@
|
|||||||
// or a random human via auto-match. AI is the default.
|
// or a random human via auto-match. AI is the default.
|
||||||
let opponent = $state<'ai' | 'random'>('ai');
|
let opponent = $state<'ai' | 'random'>('ai');
|
||||||
|
|
||||||
|
// Active-game limit lock: the auto-start kind (vs_ai or random by the opponent choice) tested
|
||||||
|
// against the caller's per-kind cap over the online lobby's active games. A capped start opens the
|
||||||
|
// funnel modal instead of enqueuing (the server also refuses it); offline never locks (local
|
||||||
|
// games are uncapped). The lock lifts when the profile is re-fetched after a guest→durable upgrade.
|
||||||
|
const autoKind = $derived(opponent === 'ai' ? GameKind.vsAi : GameKind.random);
|
||||||
|
// The player's current games for the per-kind lock: seeded from the lobby snapshot for an instant
|
||||||
|
// render, then refreshed on mount (below) so a game created since the last lobby visit is counted.
|
||||||
|
let lobbyGames = $state<GameView[]>(getLobby(false)?.games ?? []);
|
||||||
|
const autoLocked = $derived(!offlineMode.active && isKindLocked(lobbyGames, app.profile?.gameLimits, autoKind));
|
||||||
|
let limitOpen = $state(false);
|
||||||
|
|
||||||
// --- auto-match ---
|
// --- auto-match ---
|
||||||
// Enqueue drops the player straight into a real game — a freshly opened one awaiting an
|
// Enqueue drops the player straight into a real game — a freshly opened one awaiting an
|
||||||
// opponent, or another player's open game they just joined — so we navigate into it at once
|
// opponent, or another player's open game they just joined — so we navigate into it at once
|
||||||
@@ -117,6 +132,16 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
// Refresh the lobby games so the per-kind lock counts the current set — the cached snapshot can
|
||||||
|
// lag a game created since the last lobby visit. Online only; the lock never applies offline, and
|
||||||
|
// the server enforces every cap regardless.
|
||||||
|
if (!offlineMode.active && connection.online) {
|
||||||
|
try {
|
||||||
|
lobbyGames = (await gateway.gamesList()).games;
|
||||||
|
} catch {
|
||||||
|
// Keep the cached seed on a transient failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
// Offline mode offers only the local vs_ai flow (the friends section is hidden), and the network
|
// Offline mode offers only the local vs_ai flow (the friends section is hidden), and the network
|
||||||
// is off, so skip the friend fetch — it would be refused by the transport and toast an error.
|
// is off, so skip the friend fetch — it would be refused by the transport and toast an error.
|
||||||
if (guest || offlineMode.active) return;
|
if (guest || offlineMode.active) return;
|
||||||
@@ -329,9 +354,16 @@
|
|||||||
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
|
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
|
||||||
<button
|
<button
|
||||||
class="invite"
|
class="invite"
|
||||||
disabled={!selectedAuto || (!connection.online && !offlineMode.active) || starting}
|
class:locked={autoLocked}
|
||||||
onclick={() => selectedAuto && find(selectedAuto)}
|
disabled={(autoLocked ? false : !selectedAuto) || (!connection.online && !offlineMode.active) || starting}
|
||||||
>{t('new.start')}</button>
|
onclick={() => (autoLocked ? (limitOpen = true) : selectedAuto && find(selectedAuto))}
|
||||||
|
>{#if autoLocked}🔒 {/if}{t('new.start')}</button>
|
||||||
|
<GameLimitModal
|
||||||
|
open={limitOpen}
|
||||||
|
{guest}
|
||||||
|
onclose={() => (limitOpen = false)}
|
||||||
|
onlogin={() => { limitOpen = false; navigate('/profile'); }}
|
||||||
|
/>
|
||||||
{:else if offlineMode.active}
|
{:else if offlineMode.active}
|
||||||
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master
|
<!-- 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. -->
|
PIN first (it gates the roster); each seat may add its own PIN. -->
|
||||||
@@ -660,6 +692,12 @@
|
|||||||
.invite:disabled {
|
.invite:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
/* A capped start (the active-game limit): an outline instead of the filled CTA, with a 🔒, so it
|
||||||
|
reads as gated rather than ready. Tapping it opens the funnel modal, never a game. */
|
||||||
|
.invite.locked {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
.searchhint {
|
.searchhint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
setLocalePref,
|
setLocalePref,
|
||||||
setReduceMotion,
|
setReduceMotion,
|
||||||
setTheme,
|
setTheme,
|
||||||
|
setZoomBoard,
|
||||||
} from '../lib/app.svelte';
|
} from '../lib/app.svelte';
|
||||||
import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
import type { ThemePref } from '../lib/theme';
|
import type { ThemePref } from '../lib/theme';
|
||||||
@@ -15,6 +16,10 @@
|
|||||||
import { isStandalone } from '../lib/pwa';
|
import { isStandalone } from '../lib/pwa';
|
||||||
import InstallApp from '../components/InstallApp.svelte';
|
import InstallApp from '../components/InstallApp.svelte';
|
||||||
|
|
||||||
|
// The board-zoom toggle only makes sense on a touch device (the desktop / landscape layouts fit
|
||||||
|
// the whole board and never auto-zoom), so it is shown only for a coarse pointer.
|
||||||
|
const coarsePointer = typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
|
||||||
|
|
||||||
// The offline toggle is for the installed web PWA with a confirmed email only: the service worker
|
// The offline toggle is for the installed web PWA with a confirmed email only: the service worker
|
||||||
// that lets the app launch with no network runs only in a standalone web install (not a mini-app),
|
// that lets the app launch with no network runs only in a standalone web install (not a mini-app),
|
||||||
// and a durable account (email) anchors the device-local games. Elsewhere the control is hidden.
|
// and a durable account (email) anchors the device-local games. Elsewhere the control is hidden.
|
||||||
@@ -102,6 +107,16 @@
|
|||||||
onchange={(e) => setReduceMotion(e.currentTarget.checked)}
|
onchange={(e) => setReduceMotion(e.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{#if coarsePointer}
|
||||||
|
<label class="row">
|
||||||
|
<span>{t('settings.zoomBoard')}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={app.zoomBoard}
|
||||||
|
onchange={(e) => setZoomBoard(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if offlineEligible}
|
{#if offlineEligible}
|
||||||
|
|||||||
+133
-68
@@ -5,7 +5,7 @@
|
|||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { normalizeSubtype } from '../lib/platform';
|
import { normalizeSubtype } from '../lib/platform';
|
||||||
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
import { executionContext, formatAmount, needsWebSpendWarning, type SpendContext } from '../lib/wallet';
|
import { executionContext, formatAmount, needsWebSpendWarning, spendableChips, type SpendContext } from '../lib/wallet';
|
||||||
import { isGooglePlayBuild } from '../lib/distribution';
|
import { isGooglePlayBuild } from '../lib/distribution';
|
||||||
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
|
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
|
||||||
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
|
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
|
||||||
@@ -14,11 +14,10 @@
|
|||||||
import { GatewayError } from '../lib/client';
|
import { GatewayError } from '../lib/client';
|
||||||
import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
|
import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
|
||||||
|
|
||||||
// The Wallet section: the context-visible chip balances, the active benefits, and the storefront.
|
// The Wallet section: a compact chip balance, the active benefits, and the storefront. The store
|
||||||
// Values are bought with chips (they work once the player has any); chip packs are bought with
|
// splits into "buy chips" (chip packs, funded with money — a provider redirect) and "spend chips"
|
||||||
// money (the purchase itself arrives with payment intake — here they are shown priced only). On
|
// (values bought with chips — an instant exchange). On the Google Play build the money purchases
|
||||||
// the Google Play build the money purchases are hidden behind a RuStore stub (store policy), while
|
// are hidden behind a RuStore stub (store policy), while spending already-earned chips still works.
|
||||||
// spending already-earned chips still works.
|
|
||||||
|
|
||||||
let wallet = $state<Wallet | null>(null);
|
let wallet = $state<Wallet | null>(null);
|
||||||
let catalog = $state<Catalog | null>(null);
|
let catalog = $state<Catalog | null>(null);
|
||||||
@@ -27,10 +26,17 @@
|
|||||||
// (busy) and scopes the pressed/dimmed visual to the tapped button — not every buy button.
|
// (busy) and scopes the pressed/dimmed visual to the tapped button — not every buy button.
|
||||||
let busyId = $state<string | null>(null);
|
let busyId = $state<string | null>(null);
|
||||||
const busy = $derived(busyId !== null);
|
const busy = $derived(busyId !== null);
|
||||||
// The value awaiting a web-spend warning confirmation (a spend that would draw vk/tg chips).
|
|
||||||
let warnProduct = $state<CatalogProduct | null>(null);
|
|
||||||
|
|
||||||
const context: SpendContext = executionContext();
|
const context: SpendContext = executionContext();
|
||||||
|
// The storefront half in view: buying chips for money, or spending chips on values.
|
||||||
|
let tab = $state<'buy' | 'spend'>('buy');
|
||||||
|
// The value awaiting an exchange confirmation, or null. Every chip-spend confirms (it is instant —
|
||||||
|
// no provider redirect to stand in as a confirmation), and the dialog folds in the cross-platform
|
||||||
|
// warning when the spend would draw vk/tg chips.
|
||||||
|
let spendProduct = $state<CatalogProduct | null>(null);
|
||||||
|
const spendWarn = $derived(
|
||||||
|
!!spendProduct && needsWebSpendWarning(context, wallet?.segments ?? [], spendProduct.chips),
|
||||||
|
);
|
||||||
|
|
||||||
const gpBuild = isGooglePlayBuild();
|
const gpBuild = isGooglePlayBuild();
|
||||||
// Money purchases are not permitted inside the VK iOS app (Apple ToS); the pack CTA is shown
|
// Money purchases are not permitted inside the VK iOS app (Apple ToS); the pack CTA is shown
|
||||||
// muted and taps explain why, rather than hiding the storefront. VK's device family is not on the
|
// muted and taps explain why, rather than hiding the storefront. VK's device family is not on the
|
||||||
@@ -42,6 +48,29 @@
|
|||||||
|
|
||||||
const values = $derived(catalog?.products.filter((p) => p.kind === 'value') ?? []);
|
const values = $derived(catalog?.products.filter((p) => p.kind === 'value') ?? []);
|
||||||
const packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []);
|
const packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []);
|
||||||
|
// The chips the player can actually spend in this context — a value costing more is out of reach,
|
||||||
|
// so its exchange button is disabled (the server stays authoritative and also rejects it).
|
||||||
|
const spendable = $derived(wallet ? spendableChips(wallet) : 0);
|
||||||
|
|
||||||
|
// The balance reads as the context's own segment first (🪙 N), with any linked other-platform
|
||||||
|
// segments appended behind their logo — on the web all linked segments show and spend together;
|
||||||
|
// inside a VK/Telegram store only that store's own segment is present, so it stands alone.
|
||||||
|
const mainSeg = $derived(wallet?.segments.find((s) => s.source === context));
|
||||||
|
const linkedSegs = $derived((wallet?.segments ?? []).filter((s) => s.source !== context));
|
||||||
|
|
||||||
|
// The active benefits, one short phrase each, only for what the player actually holds — nothing
|
||||||
|
// to show (ad-free lapsed and no hints) hides the whole section.
|
||||||
|
const activeItems = $derived.by(() => {
|
||||||
|
const items: string[] = [];
|
||||||
|
if (!wallet) return items;
|
||||||
|
if (wallet.hints > 0) items.push(t('wallet.activeHints', { n: wallet.hints }));
|
||||||
|
if (wallet.adsForever) items.push(t('wallet.noAdsForever'));
|
||||||
|
else if (wallet.adsPaidUntilMs > Date.now()) {
|
||||||
|
const date = new Date(wallet.adsPaidUntilMs).toLocaleDateString(i18n.locale === 'ru' ? 'ru-RU' : 'en-US');
|
||||||
|
items.push(t('wallet.noAdsUntil', { date }));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
// Rewarded video (VK ads): the "watch for chips" CTA shows only when the server offers a payout in
|
// Rewarded video (VK ads): the "watch for chips" CTA shows only when the server offers a payout in
|
||||||
// this context (wallet.rewardChips > 0 — VK, configured) and an ad is ready to show.
|
// this context (wallet.rewardChips > 0 — VK, configured) and an ad is ready to show.
|
||||||
@@ -121,26 +150,17 @@
|
|||||||
return t(`wallet.cur.${currency}` as MessageKey);
|
return t(`wallet.cur.${currency}` as MessageKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
function adsLine(): string {
|
function platformLogo(source: string): string {
|
||||||
if (!wallet) return '';
|
return source === 'vk' ? 'vk-logo.svg' : 'telegram-logo.svg';
|
||||||
if (wallet.adsForever) return t('wallet.noAdsForever');
|
|
||||||
if (wallet.adsPaidUntilMs > Date.now()) {
|
|
||||||
const date = new Date(wallet.adsPaidUntilMs).toLocaleDateString(i18n.locale === 'ru' ? 'ru-RU' : 'en-US');
|
|
||||||
return t('wallet.noAdsUntil', { date });
|
|
||||||
}
|
|
||||||
return t('wallet.adsOn');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBuy(p: CatalogProduct) {
|
// onSpend opens the exchange confirmation for a chip-priced value; doBuy runs the actual spend.
|
||||||
if (needsWebSpendWarning(context, wallet?.segments ?? [], p.chips)) {
|
function onSpend(p: CatalogProduct) {
|
||||||
warnProduct = p;
|
spendProduct = p;
|
||||||
return;
|
|
||||||
}
|
|
||||||
void doBuy(p);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doBuy(p: CatalogProduct) {
|
async function doBuy(p: CatalogProduct) {
|
||||||
warnProduct = null;
|
spendProduct = null;
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
busyId = p.productId;
|
busyId = p.productId;
|
||||||
try {
|
try {
|
||||||
@@ -183,30 +203,28 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="wallet" data-testid="wallet">
|
<div class="wallet" data-testid="wallet">
|
||||||
<section>
|
<div class="balance" data-testid="balance">
|
||||||
<h3>{t('wallet.balance')}</h3>
|
<span class="coin" aria-hidden="true">🪙</span><span class="num" class:dim={mainSeg && !mainSeg.spendable}>{mainSeg?.chips ?? 0}</span>
|
||||||
{#if wallet && wallet.segments.length > 0}
|
{#each linkedSegs as s (s.source)}
|
||||||
{#each wallet.segments as s (s.source)}
|
<span class="plus" aria-hidden="true">+</span><img class="plogo" src={platformLogo(s.source)} width="16" height="16" alt={sourceLabel(s.source)} /><span class="num" class:dim={!s.spendable}>{s.chips}</span>
|
||||||
<div class="row">
|
|
||||||
<span class="name">{sourceLabel(s.source)}</span>
|
|
||||||
<span class="value"
|
|
||||||
>🪙 {s.chips}{#if !s.spendable} <span class="muted">({t('wallet.viewOnly')})</span>{/if}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
{:else if !loading}
|
</div>
|
||||||
<p class="empty">{t('wallet.noChips')}</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
{#if activeItems.length > 0}
|
||||||
<h3>{t('wallet.benefits')}</h3>
|
<section data-testid="active">
|
||||||
<div class="row"><span class="name">{adsLine()}</span></div>
|
<h3>{t('wallet.active')}</h3>
|
||||||
<div class="row"><span class="name">{t('wallet.hints', { n: wallet?.hints ?? 0 })}</span></div>
|
<div class="row"><span class="name">{activeItems.join('. ')}</span></div>
|
||||||
</section>
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3>{t('wallet.store')}</h3>
|
<h3>{t('wallet.store')}</h3>
|
||||||
|
<div class="seg">
|
||||||
|
<button class="opt" class:active={tab === 'buy'} data-testid="tab-buy" onclick={() => (tab = 'buy')}>{t('wallet.tabBuy')}</button>
|
||||||
|
<button class="opt" class:active={tab === 'spend'} data-testid="tab-spend" onclick={() => (tab = 'spend')}>{t('wallet.tabSpend')}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tab === 'buy'}
|
||||||
{#if purchaseBlocked}
|
{#if purchaseBlocked}
|
||||||
<!-- prettier-ignore -->
|
<!-- prettier-ignore -->
|
||||||
<p class="ios-note" data-testid="ios-note">{t('wallet.iosBlockedPre')}<a href={siteRoot} target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{t('wallet.iosBlockedLink')}</a>{t('wallet.iosBlockedPost')}</p>
|
<p class="ios-note" data-testid="ios-note">{t('wallet.iosBlockedPre')}<a href={siteRoot} target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{t('wallet.iosBlockedLink')}</a>{t('wallet.iosBlockedPost')}</p>
|
||||||
@@ -219,16 +237,6 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#each values as p (p.productId)}
|
|
||||||
<div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
|
|
||||||
<span class="name">{p.title}</span>
|
|
||||||
<span class="price">🪙 {p.chips}</span>
|
|
||||||
<button class="buy" class:working={busyId === p.productId} data-testid="buy" disabled={busy} onclick={() => onBuy(p)}
|
|
||||||
>{t('wallet.buy')}</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if gpBuild}
|
{#if gpBuild}
|
||||||
<p class="stub" data-testid="gp-stub">{t('wallet.gpStub')}</p>
|
<p class="stub" data-testid="gp-stub">{t('wallet.gpStub')}</p>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -253,22 +261,35 @@
|
|||||||
<p class="offer" data-testid="offer">
|
<p class="offer" data-testid="offer">
|
||||||
<a href="/offer/" target="_blank" rel="noopener noreferrer">{t('wallet.offer')}</a>
|
<a href="/offer/" target="_blank" rel="noopener noreferrer">{t('wallet.offer')}</a>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{:else if !loading}
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !loading && values.length === 0 && (gpBuild || packs.length === 0)}
|
|
||||||
<p class="empty">{t('wallet.empty')}</p>
|
<p class="empty">{t('wallet.empty')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{#each values as p (p.productId)}
|
||||||
|
<div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
|
||||||
|
<span class="name">{p.title}</span>
|
||||||
|
<span class="price">🪙 {p.chips}</span>
|
||||||
|
<button class="buy" class:working={busyId === p.productId} class:cantafford={spendable < p.chips} data-testid="buy" disabled={busy || spendable < p.chips} onclick={() => onSpend(p)}
|
||||||
|
>{t('wallet.spend')}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if !loading && values.length === 0}
|
||||||
|
<p class="empty">{t('wallet.empty')}</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if warnProduct}
|
{#if spendProduct}
|
||||||
<Modal title={t('wallet.warnTitle')} onclose={() => (warnProduct = null)}>
|
<Modal title={t('wallet.spendTitle')} onclose={() => (spendProduct = null)}>
|
||||||
<p class="warn-body" data-testid="warn">{t('wallet.warnBody')}</p>
|
<p class="spend-body" data-testid="spend-body">{t('wallet.spendBody', { n: spendProduct.chips, title: spendProduct.title })}</p>
|
||||||
|
{#if spendWarn}<p class="warn-body" data-testid="warn">{t('wallet.warnBody')}</p>{/if}
|
||||||
<div class="warn-actions">
|
<div class="warn-actions">
|
||||||
<button class="cancel" onclick={() => (warnProduct = null)}>{t('wallet.warnCancel')}</button>
|
<button class="cancel" onclick={() => (spendProduct = null)}>{t('wallet.warnCancel')}</button>
|
||||||
<button class="buy" data-testid="warn-confirm" onclick={() => warnProduct && doBuy(warnProduct)}
|
<button class="buy" data-testid="spend-confirm" onclick={() => spendProduct && doBuy(spendProduct)}
|
||||||
>{t('wallet.warnConfirm')}</button
|
>{t('wallet.spendConfirm')}</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -281,6 +302,29 @@
|
|||||||
gap: 18px;
|
gap: 18px;
|
||||||
padding: var(--pad);
|
padding: var(--pad);
|
||||||
}
|
}
|
||||||
|
/* The compact balance: the context's own chips (🪙 N) then any linked other-platform segments
|
||||||
|
behind their logo, digits monospaced so they align; a non-spendable (view-only) segment dims. */
|
||||||
|
.balance {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
.balance .num {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.balance .num.dim {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.balance .plus {
|
||||||
|
margin: 0 3px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.balance .plogo {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
section {
|
section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -291,6 +335,26 @@
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
/* The buy/spend segmented control, matching the New Game type selector. */
|
||||||
|
.seg {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.opt {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 64px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.opt.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -304,16 +368,11 @@
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.value,
|
|
||||||
.price {
|
.price {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.muted {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
.buy {
|
.buy {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
@@ -330,7 +389,8 @@
|
|||||||
/* The dimmed look is scoped: the tapped button while its purchase is in flight (working), or a
|
/* The dimmed look is scoped: the tapped button while its purchase is in flight (working), or a
|
||||||
blocked pack on VK iOS — never every buy button at once when one purchase is busy. */
|
blocked pack on VK iOS — never every buy button at once when one purchase is busy. */
|
||||||
.buy.blocked,
|
.buy.blocked,
|
||||||
.buy.working {
|
.buy.working,
|
||||||
|
.buy.cantafford {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
.ios-note {
|
.ios-note {
|
||||||
@@ -362,8 +422,13 @@
|
|||||||
.offer a {
|
.offer a {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
.spend-body {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
.warn-body {
|
.warn-body {
|
||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.warn-actions {
|
.warn-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user