Compare commits

..

12 Commits

Author SHA1 Message Date
developer 6badc20078 Merge pull request 'Release v1.15.0 — in-game UX + wallet redesign + WAL-alert fix' (#243) from development into master 2026-07-10 16:15:20 +00:00
developer a241e43d79 Merge pull request 'Wallet redesign: split buy/spend, compact balance, exchange confirm + insufficient-chips fix' (#242) from feature/wallet-redesign into development
CI / changes (push) Successful in 2s
CI / deploy (push) Successful in 1m56s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m12s
CI / deploy (pull_request) Has been skipped
2026-07-10 16:00:54 +00:00
Ilia Denisov 8ce986922a feat(ui): wallet storefront redesign — split buy/spend, compact balance, exchange confirm
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
Reworks the Wallet screen to be more compact and to separate the two flows, and fixes a
reported bug.

Bug: buying a value with too few chips showed the generic "Something went wrong" toast — the
backend's `insufficient_chips` (409) code was propagated correctly but had no i18n mapping, so
`errorKey` fell back to `error.generic`. Add `error.insufficient_chips` / `error.product_not_found`.
Also disable a value's Exchange button up front when the spendable balance cannot cover it (the
server stays authoritative).

Redesign:
- a compact **balance** row leads the screen — the context's own chips (🪙 N) then any linked
  other-platform chips behind that platform's logo (monospaced digits); inside a VK/TG store only
  that store's own segment shows;
- the benefits section is renamed **Active**, moved above the store, shows one inline line
  (hints + ad-free) and hides when nothing is active;
- the **store** splits by a two-way toggle into **Buy chips** (money packs + the watch-an-ad row at
  the top) and **Spend chips** (values, exchanged for chips);
- a value's action reads **Exchange** and opens a single confirmation dialog (chip spends are
  instant, with no provider window to confirm them); the existing cross-platform store-compliance
  warning folds into that same dialog when the spend would draw VK/TG chips.

No payments-model or wire change; the money→chips→values model is unchanged (verified: money buys
only chip packs, values are chips-only — "no-ads for money" is structurally impossible).

Tests: wallet e2e updated for the new layout (chromium + webkit green). Docs: FUNCTIONAL (+_ru)
wallet story rewritten. App bundle budget 126 → 127 KB (always-loaded settings hub).
2026-07-10 17:46:22 +02:00
developer 3469278260 Merge pull request 'fix(monitoring): don't false-fire the WAL-stalled alert on an idle database' (#241) from fix/pg-archive-stalled-idle-falsepositive into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 25s
CI / ui (push) Successful in 1m12s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m38s
2026-07-10 14:31:57 +00:00
Ilia Denisov c66bf1eceb fix(monitoring): pg_archive_stalled must not false-fire on an idle database
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
The "WAL archiving stalled" alert fired on prod during a quiet period. An idle Postgres
archives nothing — it never force-switches an empty WAL segment on archive_timeout — so
pg_stat_archiver_last_archive_age grows past 30 min even though archiving is perfectly
healthy (failed_count 0, no .ready segments, pg_wal flat, backups current). A false
positive: no data and no disk at risk (nothing was written, so the frozen recovery point
equals the live state).

Gate the age condition on pg_wal actually growing:

  (pg_stat_archiver_last_archive_age > 1800) and on() (delta(pg_wal_size_bytes[35m]) > 16MB)

so it fires only when WAL is being produced but not archived (the real "pg_wal fills the
disk" danger; genuine archive_command failures are already caught by pg_archive_failing).
`and on()` bridges the two metrics' differing label sets (last_archive_age carries a server
label, the pg_wal gauge does not); delta() (not increase) suits the gauge. pg_stat_wal is
not exported by this postgres_exporter, so pg_wal size growth is the available signal.
Validated on prod with promtool: the expression is empty while idle.

Also fix the runbook command in both archive alerts' annotations: `pgbackrest check` needs
`--pg1-user=scrabble`, or it connects as role "root" (which does not exist) and aborts with
"no database found".
2026-07-10 16:25:01 +02:00
developer 7b4d2421e2 Merge pull request 'In-game UX: board highlight + score badge, full-width rack, bag badge, zoom setting' (#240) from feature/ingame-ux-board-highlight into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m49s
2026-07-10 14:06:20 +00:00
Ilia Denisov bf46b9492d fix(ui): one-word games must not highlight phantom cross words; review polish
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m25s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
Board-highlight bug (reported on the contour): formedGeometry walked cross words
unconditionally, so in a single-word (one-word-per-turn) game a staged tile sitting
next to a committed tile lit up a green "cross word" the engine ignores — and which
need not even be a real word (the reported "БО lights up green in a one-word ПОПА
play"). Gate the cross-word walk on the game's multipleWordsPerTurn flag. The score
(8) was already correct — a premium square under the main word.

Also, from review:
- the turn strip reads the staged play's "WORD+WORD = N" while composing a legal move,
  reverting to the turn / result text otherwise;
- the Exchange/Pass dialog shows the bag count ("In the bag: N" / "Bag is empty")
  right-aligned in the title row, via a new optional Modal `titleAside`;
- cosmetics: half the turn strip's bottom padding (the plaques below carry their own
  top pad); a top gap above the landscape rack (it sat flush under the docked history);
  more horizontal padding on tab count badges so a 2-3 digit bag count clears the pill
  ends;
- admin console: the game Summary now shows the single-word / multiple-words rule.

Tests: formed single-word case added; full unit (584) + e2e (chromium + webkit, 113
each) green; backend build + adminconsole templates parse. Docs (FUNCTIONAL +_ru,
UI_DESIGN) updated.
2026-07-10 15:58:05 +02:00
Ilia Denisov 1e1117c28e chore(ui): raise the app bundle budget to 126 KB for the in-game board UX
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m59s
2026-07-10 15:03:30 +02:00
Ilia Denisov 77a690fcf6 feat(ui): move in-game status to the board — highlight, score badge, full-width rack
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 13s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
Remove the under-board status strip and relocate its signals:
- bag count -> a badge on the exchange/pass control + the foot of the move table
- whose-turn / win-lose -> a thin strip above the score plaques
- the tentative-move caption -> the board itself: staged tiles tint green (legal) or
  pink (illegal), the board tiles a formed word runs through go a shade darker, and an
  orange score badge sits on the main word (digit sized like a tile value, clamped on-board)

The word geometry (covered cells + badge anchor) is a new pure client-side helper
(ui/src/lib/formed.ts), independent of the move evaluator, so it works on the local or
network preview path alike; the badge's number still comes from the preview score.

Rack: a seven-column grid filling the tray width in both layouts — square, full-width
tiles — with the confirm control in the fixed 7th slot.

Settings: a touch-only "Zoom the board" toggle (default on, device-local) gates the
tile-placement auto-zoom; taking a hint while zoomed in now zooms out so the highlighted
hint word is never left off-screen.

Docs (FUNCTIONAL +_ru, UI_DESIGN, ARCHITECTURE) and e2e/unit tests updated.
2026-07-10 14:58:32 +02:00
developer 0ca01133b5 Merge pull request 'Release v1.14.1 — ansible certs-dir fix + Robokassa go-live' (#239) from development into master 2026-07-10 10:58:38 +00:00
developer 2683103fc1 Merge pull request 'fix(deploy): provision the certs dir traversable by the nonroot gateway' (#238) from fix/ansible-certs-dir-traversable into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 11s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m57s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-10 10:39:06 +00:00
Ilia Denisov 2d2dd2bc47 fix(deploy): provision the certs dir traversable by the nonroot gateway
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The Ansible base-directory loop created /opt/scrabble/certs at mode 0750
(deploy:deploy), like config/dumps/images. The gateway (and backend) run as
the distroless nonroot UID 65532 — not the deploy user — so the container
cannot traverse a 0750 certs dir and fails at startup with
"mtls: load server keypair: ... permission denied", crash-looping.

This is latent: a long-running container holds the keypair in memory and
never re-reads the file, so the misconfig only bites when a container
restarts (a host reboot / redeploy). A hoster maintenance reboot exposed it
on prod — the gateway came back crash-looping while the deploy could not SSH
in mid-reboot.

Split certs out of the 0750 loop and create it 0755 (traversable). The keys
stay 0644 by design (the gateway compose relies on it); the host is
single-tenant + SSH-access-controlled, so a traversable certs dir adds no
meaningful exposure. The live prod host was already chmod-fixed by hand; this
keeps the next provisioning run from re-tightening it.
2026-07-10 12:34:21 +02:00
32 changed files with 1018 additions and 377 deletions
@@ -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>
+6 -2
View File
@@ -348,8 +348,12 @@ type GameDetailView struct {
UpdatedAt string UpdatedAt string
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
Seats []SeatRow // 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
// 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.
HasRobot bool HasRobot bool
@@ -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.
+4 -2
View File
@@ -255,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
+15 -1
View File
@@ -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`.' }
+5 -1
View File
@@ -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
+31 -17
View File
@@ -204,13 +204,17 @@ 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
@@ -372,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
@@ -528,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
+35 -19
View File
@@ -212,13 +212,17 @@ e-mail) либо ввод фразы. Активные игры форфейтя
### Игровой процесс ### Игровой процесс
Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления: Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления:
без выбранных фишек — пас, с выбранными — обмен. Фишки кладутся без выбора без выбранных фишек — пас, с выбранными — обмен. Число оставшихся в мешке фишек
показано счётчиком на этом элементе и внизу таблицы ходов. Фишки кладутся без выбора
направления — игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая направления — игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая
уже лежащее уже лежащее
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое сдаче и считается; безлимитный предпросмотр показывает **прямо на доске**, допустим ли
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно предполагаемый ход: выложенные фишки становятся светло-зелёными, когда образуют слово
отправить только после подтверждения, что он допустим. Предпросмотр считается **на устройстве** (и чуть темнее — фишки доски, через которые проходит слово; в партии «одно слово за ход»
подсвечивается только основное слово), или спокойно-розовыми, когда слова нет, — а
**оранжевый бейдж** на основном слове несёт очки хода. Кнопка
подтверждения появляется только когда ход допустим. Предпросмотр считается **на устройстве**
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
@@ -381,7 +385,10 @@ UTC; при создании аккаунта она подставляется
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
Telegram. Telegram. На сенсорном устройстве в настройках также есть переключатель **«Приближать
доску»** (по умолчанию включён): если выключить, при кидании фишки доска больше не
приближается к ней автоматически. Это настройка на устройство, и она скрыта на десктопе,
где доска и так помещается целиком и авто-зума нет.
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты **Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
@@ -541,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 о внутриигровой валюте); трата уже заработанных фишек по-прежнему работает. Кошелёк
**не хранит историю покупок** — только текущие балансы, активные блага и магазин.
### Рекламный баннер ### Рекламный баннер
+33 -20
View File
@@ -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`)
+2 -2
View File
@@ -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
}); });
+41
View File
@@ -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();
});
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}); });
+5 -1
View File
@@ -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.
+1
View File
@@ -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 };
} }
+16
View File
@@ -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). */
+23 -1
View File
@@ -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>
+3 -1
View File
@@ -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;
} }
+68
View File
@@ -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>
+120 -102
View File
@@ -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>
{#if view.game.endReason === 'aborted'}
<p class="horganizer">{t('game.abortedNote')}</p>
{/if}
</div> </div>
{#if view.game.endReason === 'aborted'} <div class="hbagfoot">{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</div>
<p class="horganizer">{t('game.abortedNote')}</p>
{/if}
{/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
View File
@@ -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>
+11
View File
@@ -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
+129
View File
@@ -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' });
});
});
+174
View File
@@ -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' };
}
+12 -10
View File
@@ -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.',
+12 -10
View File
@@ -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': 'Слишком много запросов, помедленнее.',
+1
View File
@@ -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>> {
+15
View File
@@ -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}
+165 -100
View File
@@ -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,92 +203,93 @@
</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"> {/each}
<span class="name">{sourceLabel(s.source)}</span> </div>
<span class="value"
>🪙 {s.chips}{#if !s.spendable}&nbsp;<span class="muted">({t('wallet.viewOnly')})</span>{/if}</span
>
</div>
{/each}
{:else if !loading}
<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>
{#if purchaseBlocked} <div class="seg">
<!-- prettier-ignore --> <button class="opt" class:active={tab === 'buy'} data-testid="tab-buy" onclick={() => (tab = 'buy')}>{t('wallet.tabBuy')}</button>
<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> <button class="opt" class:active={tab === 'spend'} data-testid="tab-spend" onclick={() => (tab = 'spend')}>{t('wallet.tabSpend')}</button>
{/if} </div>
{#if canReward}
<div class="row reward" data-testid="reward-row">
<span class="name">{t('wallet.rewardWatch', { n: wallet?.rewardChips ?? 0 })}</span>
<button class="buy" class:working={busyId === 'reward'} data-testid="watch-ad" disabled={busy} onclick={onWatchAd}
>{t('wallet.rewardCta')}</button
>
</div>
{/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 tab === 'buy'}
<p class="stub" data-testid="gp-stub">{t('wallet.gpStub')}</p> {#if purchaseBlocked}
<!-- 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>
{/if}
{#if canReward}
<div class="row reward" data-testid="reward-row">
<span class="name">{t('wallet.rewardWatch', { n: wallet?.rewardChips ?? 0 })}</span>
<button class="buy" class:working={busyId === 'reward'} data-testid="watch-ad" disabled={busy} onclick={onWatchAd}
>{t('wallet.rewardCta')}</button
>
</div>
{/if}
{#if gpBuild}
<p class="stub" data-testid="gp-stub">{t('wallet.gpStub')}</p>
{:else}
{#each packs as p (p.productId)}
<div class="row product" data-testid="product" data-kind="pack" data-pid={p.productId}>
<span class="name">{p.title}</span>
<span class="price">{formatAmount(p.moneyAmount, p.moneyCurrency)}&nbsp;{currencyLabel(p.moneyCurrency)}</span>
<button
class="buy"
class:blocked={purchaseBlocked}
class:working={busyId === p.productId}
data-testid="buy-pack"
disabled={busy}
onclick={() => {
if (purchaseBlocked) showToast(t('wallet.platformNoBuy'));
else void onOrder(p);
}}>{t('wallet.buy')}</button
>
</div>
{/each}
{#if packs.length > 0}
<p class="offer" data-testid="offer">
<a href="/offer/" target="_blank" rel="noopener noreferrer">{t('wallet.offer')}</a>
</p>
{:else if !loading}
<p class="empty">{t('wallet.empty')}</p>
{/if}
{/if}
{:else} {:else}
{#each packs as p (p.productId)} {#each values as p (p.productId)}
<div class="row product" data-testid="product" data-kind="pack" data-pid={p.productId}> <div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
<span class="name">{p.title}</span> <span class="name">{p.title}</span>
<span class="price">{formatAmount(p.moneyAmount, p.moneyCurrency)}&nbsp;{currencyLabel(p.moneyCurrency)}</span> <span class="price">🪙 {p.chips}</span>
<button <button class="buy" class:working={busyId === p.productId} class:cantafford={spendable < p.chips} data-testid="buy" disabled={busy || spendable < p.chips} onclick={() => onSpend(p)}
class="buy" >{t('wallet.spend')}</button
class:blocked={purchaseBlocked}
class:working={busyId === p.productId}
data-testid="buy-pack"
disabled={busy}
onclick={() => {
if (purchaseBlocked) showToast(t('wallet.platformNoBuy'));
else void onOrder(p);
}}>{t('wallet.buy')}</button
> >
</div> </div>
{/each} {/each}
{#if packs.length > 0} {#if !loading && values.length === 0}
<p class="offer" data-testid="offer"> <p class="empty">{t('wallet.empty')}</p>
<a href="/offer/" target="_blank" rel="noopener noreferrer">{t('wallet.offer')}</a>
</p>
{/if} {/if}
{/if} {/if}
{#if !loading && values.length === 0 && (gpBuild || packs.length === 0)}
<p class="empty">{t('wallet.empty')}</p>
{/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;