Compare commits
15 Commits
48b06f4594
..
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| c5d22fceca | |||
| be1627936f | |||
| 1ba52dd0b4 | |||
| bb0e3e17e5 | |||
| 1ef2bde395 | |||
| 62f66735a3 | |||
| 81680a1d5e | |||
| a393561d79 | |||
| e3e4cedc77 | |||
| deaa7a29c5 | |||
| 91de26d80b | |||
| 6b6362a629 | |||
| 8a06fbc3c7 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -31,7 +31,7 @@ on:
|
||||
# unit/integration jobs inherit it. The deploy job overrides it per contour with
|
||||
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
|
||||
env:
|
||||
DICT_VERSION: v1.2.1
|
||||
DICT_VERSION: v1.3.0
|
||||
|
||||
jobs:
|
||||
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
||||
|
||||
@@ -144,7 +144,7 @@ go run ./backend/cmd/backend # /healthz, /readyz on :8080
|
||||
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI
|
||||
pnpm start # UI mock mode: lobby -> game, no backend
|
||||
|
||||
docker build -f backend/Dockerfile -t scrabble-backend . # images; gateway embeds the SPA
|
||||
docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required (no default); gateway embeds the SPA
|
||||
docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway .
|
||||
docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing
|
||||
docker compose -f deploy/docker-compose.yml config # validate the full contour
|
||||
|
||||
@@ -89,7 +89,7 @@ observability stack (OTel Collector → Prometheus + Tempo → Grafana) + a fron
|
||||
services build from multi-stage distroless `*/Dockerfile`.
|
||||
|
||||
```sh
|
||||
docker build -f backend/Dockerfile -t scrabble-backend . # pulls the DAWG release artifact
|
||||
docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required; pulls that DAWG release artifact
|
||||
docker build -f gateway/Dockerfile -t scrabble-gateway . # node stage builds + embeds the UI
|
||||
docker compose -f deploy/docker-compose.yml config # validate (needs the TEST_/PROD_ env)
|
||||
```
|
||||
|
||||
+6
-4
@@ -7,12 +7,14 @@
|
||||
# (GOPRIVATE), so the build stage needs git and network.
|
||||
#
|
||||
# Build from the repository root so go.work, go.work.sum, pkg/ and backend/ are all
|
||||
# in the Docker context:
|
||||
# docker build -f backend/Dockerfile -t scrabble-backend .
|
||||
# in the Docker context. DICT_VERSION has no default — the caller supplies the
|
||||
# scrabble-dictionary release tag (compose/CI pass it; see deploy/README.md
|
||||
# "Bumping the dictionary version"):
|
||||
# docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend .
|
||||
|
||||
# --- dictionary artifact -----------------------------------------------------
|
||||
FROM alpine:3.20 AS dawg
|
||||
ARG DICT_VERSION=v1.2.1
|
||||
ARG DICT_VERSION
|
||||
RUN apk add --no-cache curl tar
|
||||
RUN mkdir -p /dawg \
|
||||
&& curl -fsSL -o /tmp/dawg.tar.gz \
|
||||
@@ -42,7 +44,7 @@ FROM gcr.io/distroless/static-debian12:nonroot
|
||||
# Re-declare the build arg in this stage so it labels the seed dictionary. One
|
||||
# DICT_VERSION drives both the artifact the dawg stage downloads and the version
|
||||
# label the binary pins, so the resident version equals the release tag.
|
||||
ARG DICT_VERSION=v1.2.1
|
||||
ARG DICT_VERSION
|
||||
COPY --from=build /out/backend /usr/local/bin/backend
|
||||
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
|
||||
# mounted at /opt/dawg inherits this ownership on first use, so the admin console
|
||||
|
||||
+1
-1
@@ -228,7 +228,7 @@ internal/banview/ # gateway active-ban mirror: the console's Active IP bans p
|
||||
```sh
|
||||
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
|
||||
# DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg):
|
||||
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.2.1/scrabble-dawg-v1.2.1.tar.gz | tar xz -C /tmp/dawg
|
||||
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.3.0/scrabble-dawg-v1.3.0.tar.gz | tar xz -C /tmp/dawg
|
||||
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
|
||||
BACKEND_DICT_DIR=/tmp/dawg \
|
||||
GOPRIVATE='gitea.iliadenisov.ru/*' \
|
||||
|
||||
@@ -207,10 +207,11 @@ type provisionSeed struct {
|
||||
|
||||
// telegramSeed derives the create-time seed from Telegram launch fields: a
|
||||
// supported preferred language from languageCode (an ISO-639 code, possibly
|
||||
// region-tagged like "ru-RU"), and a display name sanitized from firstName or,
|
||||
// failing that, username (sanitizeDisplayName strips disallowed characters to the
|
||||
// editable format). When neither yields any letters, it falls back to a generated
|
||||
// placeholder in the seeded language (placeholderDisplayName).
|
||||
// region-tagged like "ru-RU"), and a display name. The name precedence is the real
|
||||
// name (firstName, sanitized to the editable format) → the @username taken verbatim
|
||||
// (already a valid handle, only trimmed and length-capped, never character-stripped)
|
||||
// → a generated placeholder in the seeded language (placeholderDisplayName), reached
|
||||
// only when firstName has no usable letters and no username is set.
|
||||
func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
||||
var seed provisionSeed
|
||||
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
||||
@@ -218,7 +219,13 @@ func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
||||
}
|
||||
name := sanitizeDisplayName(firstName)
|
||||
if name == "" {
|
||||
name = sanitizeDisplayName(username)
|
||||
// The real name yielded nothing usable: fall back to the @username verbatim
|
||||
// (Telegram guarantees a valid handle), only trimmed and capped to the column
|
||||
// width — never character-stripped like the real name.
|
||||
name = strings.TrimSpace(username)
|
||||
if r := []rune(name); len(r) > maxDisplayName {
|
||||
name = strings.TrimRight(string(r[:maxDisplayName]), " ")
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
name = placeholderDisplayName(seed.preferredLanguage)
|
||||
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
|
||||
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
|
||||
// create-time account seed: supported-language detection (bare and region-tagged),
|
||||
// the first-name / username display-name precedence, and the sanitization that
|
||||
// strips disallowed characters (emoji, digits, punctuation) to the editable format.
|
||||
// the real-name → @username (verbatim) → placeholder display-name precedence, and
|
||||
// the sanitization of the real name (emoji, digits, punctuation stripped to the
|
||||
// editable format). The username, when used, is kept verbatim.
|
||||
func TestTelegramSeed(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
languageCode, username, firstName string
|
||||
@@ -28,6 +29,7 @@ func TestTelegramSeed(t *testing.T) {
|
||||
"punct to space": {"en", "user", "John❤Doe", "en", "John Doe"},
|
||||
"digits dropped": {"ru", "user", "Маша123", "ru", "Маша"},
|
||||
"garbage to username": {"en", "good", "123!@#", "en", "good"},
|
||||
"username verbatim": {"en", "co_ol99", "🎮🎮", "en", "co_ol99"},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
@@ -49,10 +51,10 @@ func TestTelegramSeedPlaceholder(t *testing.T) {
|
||||
languageCode, username, firstName string
|
||||
wantRe string
|
||||
}{
|
||||
"en empty": {"en", "", "", `^Player-\d{5}$`},
|
||||
"ru empty": {"ru", "", "", `^Игрок-\d{5}$`},
|
||||
"default en": {"fr", "", "", `^Player-\d{5}$`},
|
||||
"both garbage": {"ru", "123", "!!!", `^Игрок-\d{5}$`},
|
||||
"en empty": {"en", "", "", `^Player-\d{5}$`},
|
||||
"ru empty": {"ru", "", "", `^Игрок-\d{5}$`},
|
||||
"default en": {"fr", "", "", `^Player-\d{5}$`},
|
||||
"name garbage, no username": {"ru", "", "!!!", `^Игрок-\d{5}$`},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ POSTGRES_PASSWORD=change-me # required
|
||||
# the active version lives in the DB. On a live volume a changed value is ignored (the
|
||||
# recorded .seed_version marker wins — the seed-drift guard); change a running
|
||||
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5).
|
||||
DICT_VERSION=v1.2.1
|
||||
DICT_VERSION=v1.3.0
|
||||
|
||||
# --- Logging ----------------------------------------------------------------
|
||||
LOG_LEVEL=info
|
||||
|
||||
+23
-1
@@ -80,7 +80,7 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
||||
| --- | --- | --- | --- |
|
||||
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
|
||||
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
|
||||
| `DICT_VERSION` | variable | `v1.2.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. |
|
||||
| `DICT_VERSION` | variable | `v1.3.0` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. |
|
||||
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
|
||||
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
|
||||
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
|
||||
@@ -117,6 +117,28 @@ collector's / gateway's internal IP is fine (connected route), but its `AWG_CONF
|
||||
which resolves `otelcol`, `gateway` and `api.telegram.org`. `GATEWAY_ADMIN_*` is
|
||||
intentionally **unset** — caddy owns `/_gm` in the contour.
|
||||
|
||||
## Bumping the dictionary version
|
||||
|
||||
The dictionary ships as a versioned **release artifact** (`scrabble-dawg-vX.Y.Z.tar.gz`) from
|
||||
[`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary). The tag is
|
||||
a build-time input with **no default** in the images, so it is set in exactly two places to
|
||||
move the whole stack — change both to a new release:
|
||||
|
||||
1. **CI tests** — `.gitea/workflows/ci.yaml` `env.DICT_VERSION` (the unit/integration jobs
|
||||
download that dawg).
|
||||
2. **Deploy seed** — the Gitea repo variables `TEST_DICT_VERSION` / `PROD_DICT_VERSION` (the tag
|
||||
the deploy bakes into a **fresh** volume's image; the deploy job feeds it to `compose` as
|
||||
`DICT_VERSION`).
|
||||
|
||||
For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`); a bare
|
||||
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
|
||||
default — a missing value fails loudly instead of baking a stale tag.
|
||||
|
||||
Bumping the seed is a **no-op on a live volume** (the `.seed_version` marker wins — the
|
||||
seed-drift guard). A running contour/prod moves to a new release **through the admin console**
|
||||
`/_gm/dictionary` (upload the tarball, preview the per-variant diff, confirm); in-flight games
|
||||
keep their pinned version, new games use the new one (ARCHITECTURE.md §5).
|
||||
|
||||
## Production rollout
|
||||
|
||||
Prod runs on **two hosts** (main = full stack + ACME on the domain; tg = the bot only,
|
||||
|
||||
@@ -68,9 +68,11 @@ services:
|
||||
context: ..
|
||||
dockerfile: backend/Dockerfile
|
||||
args:
|
||||
# Seed dictionary for a FRESH volume; the per-contour value comes from the
|
||||
# deploy env (Gitea TEST_/PROD_DICT_VERSION). See the volume note below.
|
||||
DICT_VERSION: ${DICT_VERSION:-v1.2.1}
|
||||
# Seed dictionary for a FRESH volume; required (no default) so the release tag is
|
||||
# set in exactly one place per context — the deploy env (Gitea TEST_/PROD_DICT_VERSION)
|
||||
# or .env for local builds. See the volume note below + deploy/README.md "Bumping the
|
||||
# dictionary version".
|
||||
DICT_VERSION: ${DICT_VERSION:?set DICT_VERSION — the scrabble-dictionary release tag, e.g. in deploy/.env}
|
||||
# Build version stamped into the binary (git tag; see pkg/version).
|
||||
VERSION: ${APP_VERSION:-dev}
|
||||
restart: unless-stopped
|
||||
|
||||
+4
-1
@@ -109,7 +109,10 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
|
||||
## Tiles & board
|
||||
|
||||
- **Tiles**: the letter sits in the **top-left** corner (offset a touch more than the
|
||||
value), the point value bottom-right; blanks show no value.
|
||||
value), the point value bottom-right; blanks show no value. In **Erudit** the blank is the
|
||||
"звёздочка" (star) chip: an unplaced blank shows the star (`✻`, U+273B) centred on the rack
|
||||
tile, and a placed blank carries it in the value corner; the Scrabble variants leave the
|
||||
blank unmarked (`usesStarBlank` in `lib/variants.ts`).
|
||||
- **Board zoom** (`Board.svelte`): a two-state zoom (full 15×15 ↔ ~9 cells) by **growing
|
||||
the board's width** inside a fixed-size viewport (a real layout change → native scroll
|
||||
that works consistently across browsers; no `transform`, which broke scrolling
|
||||
|
||||
+2
-1
@@ -12,7 +12,8 @@
|
||||
|
||||
# --- dictionary artifact -----------------------------------------------------
|
||||
FROM alpine:3.20 AS dawg
|
||||
ARG DICT_VERSION=v1.2.1
|
||||
# Required, no default: the build caller supplies the scrabble-dictionary release tag.
|
||||
ARG DICT_VERSION
|
||||
RUN apk add --no-cache curl tar
|
||||
RUN mkdir -p /dawg \
|
||||
&& curl -fsSL -o /tmp/dawg.tar.gz \
|
||||
|
||||
+2
-2
@@ -35,8 +35,8 @@ The harness reaches Postgres and the gateway directly, so run it as a one-shot
|
||||
container on the contour's docker network (this bypasses the host→gateway hairpin):
|
||||
|
||||
```sh
|
||||
# from the repo root
|
||||
docker build -f loadtest/Dockerfile -t scrabble-loadtest .
|
||||
# from the repo root (DICT_VERSION has no default — pass the scrabble-dictionary release tag)
|
||||
docker build --build-arg DICT_VERSION=v1.3.0 -f loadtest/Dockerfile -t scrabble-loadtest .
|
||||
|
||||
docker run --rm --cpus=3 --name scrabble-loadtest --network scrabble-internal \
|
||||
-e POSTGRES_PASSWORD="$TEST_POSTGRES_PASSWORD" \
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script lang="ts">
|
||||
// A best-move word drawn as a row of game tiles, mirroring the board's placed-tile
|
||||
// look (letter top-left, point value bottom-right) at a small fixed size. A blank tile
|
||||
// shows its letter but no value, exactly as on the board. Letters are upper-cased for
|
||||
// display. The tile values ride on each tile, so this renders without the variant's
|
||||
// alphabet table (which the statistics screen has not cached).
|
||||
import type { BestMoveTile } from '../lib/model';
|
||||
// shows its letter but no value, exactly as on the board; in Erudit it also carries the
|
||||
// blank's star (✻) in the value corner. Letters are upper-cased for display. The tile
|
||||
// values ride on each tile, so this needs only the variant id (for the star) — not the
|
||||
// variant's alphabet table, which the statistics screen has not cached.
|
||||
import type { BestMoveTile, Variant } from '../lib/model';
|
||||
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
|
||||
|
||||
let { word }: { word: BestMoveTile[] } = $props();
|
||||
let { word, variant }: { word: BestMoveTile[]; variant: Variant } = $props();
|
||||
|
||||
const label = $derived(word.map((t) => t.letter).join('').toUpperCase());
|
||||
</script>
|
||||
@@ -15,7 +17,11 @@
|
||||
{#each word as tile, i (i)}
|
||||
<span class="tile" class:blank={tile.blank} aria-hidden="true">
|
||||
<span class="letter">{tile.letter.toUpperCase()}</span>
|
||||
{#if !tile.blank}<span class="val">{tile.value}</span>{/if}
|
||||
{#if !tile.blank}
|
||||
<span class="val">{tile.value}</span>
|
||||
{:else if usesStarBlank(variant)}
|
||||
<span class="val blankmark">{BLANK_STAR}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/each}
|
||||
</span>
|
||||
@@ -50,4 +56,10 @@
|
||||
font-size: 7px;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
|
||||
its ink kept on the value digits' line (mirrors the board tile). */
|
||||
.blankmark {
|
||||
font-size: 8px;
|
||||
bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type { Premium } from '../lib/premiums';
|
||||
import { valueForLetter } from '../lib/alphabet';
|
||||
import type { Variant } from '../lib/model';
|
||||
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
|
||||
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
|
||||
import type { Locale } from '../lib/i18n/catalog';
|
||||
|
||||
@@ -254,7 +255,11 @@
|
||||
>
|
||||
{#if letter}
|
||||
<span class="letter">{letter}</span>
|
||||
{#if !blank}<span class="val">{valueForLetter(variant, letter)}</span>{/if}
|
||||
{#if !blank}
|
||||
<span class="val">{valueForLetter(variant, letter)}</span>
|
||||
{:else if usesStarBlank(variant)}
|
||||
<span class="val blankmark">{BLANK_STAR}</span>
|
||||
{/if}
|
||||
{:else if r === centre.row && c === centre.col}
|
||||
<span class="star">★</span>
|
||||
{:else if bl?.kind === 'single'}
|
||||
@@ -410,6 +415,12 @@
|
||||
font-size: 2.4cqw;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
|
||||
its ink centred on the same line as a neighbouring tile's value digit. */
|
||||
.blankmark {
|
||||
font-size: 2.8cqw;
|
||||
bottom: 0;
|
||||
}
|
||||
.star {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { badgeKind } from '../lib/unread';
|
||||
import { historyGrid } from '../lib/history';
|
||||
import { centre, premiumGrid } from '../lib/premiums';
|
||||
import { variantNameKey } from '../lib/variants';
|
||||
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
|
||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||
import { hintsLeft } from '../lib/hints';
|
||||
import { shareOrDownloadGcg } from '../lib/share';
|
||||
@@ -1293,7 +1293,7 @@
|
||||
|
||||
{#if drag}
|
||||
<div class="ghost" class:touch={drag.touch} style="left:{drag.x}px; top:{drag.y}px">
|
||||
<span>{drag.blank ? '' : drag.letter}</span>
|
||||
<span>{drag.blank ? (usesStarBlank(variant) ? BLANK_STAR : '') : drag.letter}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
+18
-2
@@ -3,6 +3,7 @@
|
||||
import { BLANK } from '../lib/placement';
|
||||
import { valueForLetter } from '../lib/alphabet';
|
||||
import type { Variant } from '../lib/model';
|
||||
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
|
||||
|
||||
let {
|
||||
slots,
|
||||
@@ -66,8 +67,12 @@
|
||||
animate:hop={shuffling}
|
||||
onpointerdown={(e) => ondown(e, slot.index)}
|
||||
>
|
||||
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
|
||||
{#if slot.letter !== BLANK}<span class="val">{valueForLetter(variant, slot.letter)}</span>{/if}
|
||||
{#if slot.letter === BLANK}
|
||||
{#if usesStarBlank(variant)}<span class="star">{BLANK_STAR}</span>{/if}
|
||||
{:else}
|
||||
<span class="letter">{slot.letter}</span>
|
||||
<span class="val">{valueForLetter(variant, slot.letter)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -133,4 +138,15 @@
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Erudit's blank ("звёздочка") shows its star horizontally centred on the otherwise empty
|
||||
tile face; the top offset centres its ink against the neighbouring letters' block, nudged
|
||||
up a pixel to sit right by eye (it is slightly larger than them so it reads). */
|
||||
.star {
|
||||
position: absolute;
|
||||
top: calc(0.5% - 1px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -257,6 +257,7 @@ export const en = {
|
||||
'invitations.with': 'With {names}',
|
||||
'invitations.accept': 'Accept',
|
||||
'invitations.decline': 'Decline',
|
||||
'invitations.declineConfirm': 'Decline invitation?',
|
||||
'invitations.cancel': 'Cancel',
|
||||
'invitations.waiting': 'Waiting for replies',
|
||||
|
||||
@@ -264,10 +265,10 @@ export const en = {
|
||||
'new.withFriends': 'Play with friends',
|
||||
'new.pickFriends': 'Choose who to invite',
|
||||
'new.searchFriends': 'Search friends',
|
||||
'new.gameType': 'Game type',
|
||||
'new.gameType': 'Variant',
|
||||
'new.invite': 'Send invitation',
|
||||
'new.moveTime': 'Move time',
|
||||
'new.hintsPerPlayer': 'Hints per player',
|
||||
'new.hintsPerPlayer': 'Hints',
|
||||
'new.multipleWordsPerTurn': 'Multiple words per turn',
|
||||
'new.start': 'Start game',
|
||||
'new.invited': 'Invitation sent.',
|
||||
|
||||
@@ -258,6 +258,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'invitations.with': 'С {names}',
|
||||
'invitations.accept': 'Принять',
|
||||
'invitations.decline': 'Отклонить',
|
||||
'invitations.declineConfirm': 'Отклонить приглашение?',
|
||||
'invitations.cancel': 'Отменить',
|
||||
'invitations.waiting': 'Ожидаем ответы',
|
||||
|
||||
@@ -265,10 +266,10 @@ export const ru: Record<MessageKey, string> = {
|
||||
'new.withFriends': 'Игра с друзьями',
|
||||
'new.pickFriends': 'Кого пригласить',
|
||||
'new.searchFriends': 'Поиск друзей',
|
||||
'new.gameType': 'Тип игры',
|
||||
'new.gameType': 'Вариант',
|
||||
'new.invite': 'Отправить приглашение',
|
||||
'new.moveTime': 'Время на ход',
|
||||
'new.hintsPerPlayer': 'Подсказок на игрока',
|
||||
'new.hintsPerPlayer': 'Подсказки',
|
||||
'new.multipleWordsPerTurn': 'Несколько слов за ход',
|
||||
'new.start': 'Начать игру',
|
||||
'new.invited': 'Приглашение отправлено.',
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
availableVariants,
|
||||
supportsMultipleWordsToggle,
|
||||
multipleWordsForRequest,
|
||||
usesStarBlank,
|
||||
BLANK_STAR,
|
||||
} from './variants';
|
||||
|
||||
describe('ALL_VARIANTS', () => {
|
||||
@@ -53,3 +55,16 @@ describe('multipleWordsForRequest', () => {
|
||||
expect(multipleWordsForRequest('scrabble_en', true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('usesStarBlank', () => {
|
||||
it('marks the blank with a star for Erudit only', () => {
|
||||
expect(usesStarBlank('erudit_ru')).toBe(true);
|
||||
expect(usesStarBlank('scrabble_ru')).toBe(false);
|
||||
expect(usesStarBlank('scrabble_en')).toBe(false);
|
||||
});
|
||||
|
||||
it('BLANK_STAR is the heavy teardrop-spoked asterisk (U+273B)', () => {
|
||||
expect(BLANK_STAR).toBe('✻');
|
||||
expect(BLANK_STAR.codePointAt(0)).toBe(0x273b);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,20 @@ export const VARIANT_FLAG: Record<Variant, string> = {
|
||||
// ru -> Russian + Эрудит.
|
||||
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { scrabble_en: 'en', scrabble_ru: 'ru', erudit_ru: 'ru' };
|
||||
|
||||
// BLANK_STAR is the glyph drawn on an Эрудит blank tile: the variant's blank is the
|
||||
// "звёздочка" (star) chip, so it carries a star rather than a bare face. U+273B HEAVY
|
||||
// TEARDROP-SPOKED ASTERISK.
|
||||
export const BLANK_STAR = '✻';
|
||||
|
||||
// usesStarBlank reports whether a variant marks its blank tiles with BLANK_STAR. Only
|
||||
// Эрудит does: an empty rack blank shows the star centred, and a placed blank carries it
|
||||
// in the value corner (the corner is free — a blank has no point value). The Scrabble
|
||||
// variants leave the blank unmarked (an empty rack face; a placed blank shown by its
|
||||
// designated letter alone).
|
||||
export function usesStarBlank(v: Variant): boolean {
|
||||
return v === 'erudit_ru';
|
||||
}
|
||||
|
||||
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set
|
||||
// they enabled in Settings). An empty or absent set is ungated (returns every variant)
|
||||
// — a safety fallback; a real profile always carries at least one preference.
|
||||
|
||||
+83
-20
@@ -3,17 +3,19 @@
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import TabBar from '../components/TabBar.svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { resultBadge } from '../lib/result';
|
||||
import { badgeKind } from '../lib/unread';
|
||||
import { getLobby, setLobby } from '../lib/lobbycache';
|
||||
import { preloadGames } from '../lib/preload';
|
||||
import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
|
||||
import type { AccountRef, GameView, Invitation } from '../lib/model';
|
||||
import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants';
|
||||
|
||||
let games = $state<GameView[]>([]);
|
||||
let invitations = $state<Invitation[]>([]);
|
||||
@@ -209,11 +211,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
const variantKey: Record<string, MessageKey> = {
|
||||
scrabble_en: 'new.english',
|
||||
scrabble_ru: 'new.russian',
|
||||
erudit_ru: 'new.erudit',
|
||||
};
|
||||
// The invitation pending a decline confirmation: the ❌ opens a modal (mirroring the
|
||||
// in-game resign confirmation) rather than declining on the first tap.
|
||||
let declineTarget = $state<Invitation | null>(null);
|
||||
function confirmDecline() {
|
||||
const inv = declineTarget;
|
||||
declineTarget = null;
|
||||
if (inv) declineInvite(inv);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Screen title={app.profile?.displayName ?? t('app.title')}>
|
||||
@@ -230,16 +235,23 @@
|
||||
<span class="sub">{t('invitations.waiting')}</span>
|
||||
{:else}
|
||||
<span class="who">{t('invitations.from', { name: inv.inviter.displayName })}</span>
|
||||
<span class="sub">{t(variantKey[inv.variant] ?? 'new.english')}</span>
|
||||
<span class="vrow">
|
||||
{#if VARIANT_FLAG[inv.variant]}
|
||||
<span class="vflag">{VARIANT_FLAG[inv.variant]}</span>
|
||||
{:else}
|
||||
<img class="vflag-img" src="flag-ussr.svg" alt="" />
|
||||
{/if}
|
||||
<span class="sub">{t(VARIANT_RULES[inv.variant])}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if !inv.multipleWordsPerTurn}<span class="sub">{t('game.oneWordRule')}</span>{/if}
|
||||
</span>
|
||||
<span class="acts">
|
||||
{#if inv.inviter.accountId === myId}
|
||||
<button class="ghost" onclick={() => cancelInvite(inv)}>{t('invitations.cancel')}</button>
|
||||
<button class="iconbtn" onclick={() => cancelInvite(inv)} aria-label={t('invitations.cancel')}>❌</button>
|
||||
{:else}
|
||||
<button class="btn" onclick={() => acceptInvite(inv)}>{t('invitations.accept')}</button>
|
||||
<button class="ghost" onclick={() => declineInvite(inv)}>{t('invitations.decline')}</button>
|
||||
<button class="iconbtn" onclick={() => acceptInvite(inv)} aria-label={t('invitations.accept')}>✅</button>
|
||||
<button class="iconbtn" onclick={() => (declineTarget = inv)} aria-label={t('invitations.decline')}>❌</button>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
@@ -308,6 +320,15 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if declineTarget}
|
||||
<Modal title={t('invitations.declineConfirm')} onclose={() => (declineTarget = null)}>
|
||||
<div class="confirm-row">
|
||||
<button class="cancel" onclick={() => (declineTarget = null)}>{t('common.cancel')}</button>
|
||||
<button class="danger" onclick={confirmDecline} disabled={!connection.online}>{t('invitations.decline')}</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
{#snippet tabbar()}
|
||||
<TabBar>
|
||||
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
|
||||
@@ -365,6 +386,40 @@
|
||||
border-radius: var(--radius);
|
||||
user-select: none;
|
||||
}
|
||||
/* The middle column grows; the envelope and the action column stay at their natural width. */
|
||||
.invite .info {
|
||||
flex: 1;
|
||||
}
|
||||
/* The variant row in an invitation: flag + the rules summary (mirrors NewGame). */
|
||||
.vrow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
.vflag {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.vflag-img {
|
||||
width: 1.3rem;
|
||||
height: auto;
|
||||
border-radius: 2px;
|
||||
align-self: center;
|
||||
}
|
||||
/* Borderless icon action (✅ / ❌), like the in-game .hicon buttons. */
|
||||
.iconbtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.iconbtn:active {
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
/* Game rows are a compact, flat list: no per-card frame, a hairline divider between
|
||||
consecutive rows. */
|
||||
.list {
|
||||
@@ -516,23 +571,31 @@
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
/* The ✅ / ❌ actions stack vertically with a small gap — a min-width right column. */
|
||||
.acts {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
flex: 0 0 auto;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
/* Decline-confirmation modal (mirrors the in-game resign confirm). */
|
||||
.confirm-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.confirm-row button {
|
||||
flex: 1;
|
||||
padding: 11px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.ghost {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
.danger {
|
||||
background: var(--danger) !important;
|
||||
color: #fff !important;
|
||||
border-color: var(--danger) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -67,9 +67,12 @@
|
||||
let friends = $state<AccountRef[]>([]);
|
||||
let selected = $state<string[]>([]);
|
||||
let friendFilter = $state('');
|
||||
// No default game type yet — the player must pick one (a smarter default from play
|
||||
// history / language would be a future refinement). '' renders the disabled placeholder option.
|
||||
// A lone offered variant is pre-selected and its picker disabled (nothing else to choose);
|
||||
// with several, the player picks. '' renders the disabled placeholder option.
|
||||
let inviteVariant = $state<Variant | ''>('');
|
||||
$effect(() => {
|
||||
if (variants.length === 1 && !inviteVariant) inviteVariant = variants[0].id;
|
||||
});
|
||||
let timeoutSecs = $state(86400);
|
||||
let hints = $state(1);
|
||||
|
||||
@@ -152,6 +155,7 @@
|
||||
<input type="checkbox" bind:checked={multipleWords} />
|
||||
</label>
|
||||
{/if}
|
||||
<div class="grow"></div>
|
||||
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
|
||||
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
|
||||
<button
|
||||
@@ -179,7 +183,7 @@
|
||||
<div class="settings-row">
|
||||
<label class="field">
|
||||
<span>{t('new.gameType')}</span>
|
||||
<select bind:value={inviteVariant} class:placeholder={!inviteVariant}>
|
||||
<select bind:value={inviteVariant} class:placeholder={!inviteVariant} disabled={variants.length === 1}>
|
||||
<option value="" disabled>—</option>
|
||||
{#each variants as v (v.id)}<option value={v.id}>{t(v.label)}</option>{/each}
|
||||
</select>
|
||||
@@ -387,6 +391,11 @@
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
/* Pushes the auto-match start cluster (move-limit hint + Start button) to the bottom,
|
||||
mirroring the friend-game invite button pinned by the .fg scroll area. */
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
.invite {
|
||||
flex: 0 0 auto;
|
||||
padding: 14px;
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
{#each bestMoves as bm (bm.variant)}
|
||||
<span class="variant">{t(variantNameKey(bm.variant))}</span>
|
||||
<span class="score">{bm.score}</span>
|
||||
<span class="wordcell"><WordTiles word={bm.word} /></span>
|
||||
<span class="wordcell"><WordTiles word={bm.word} variant={bm.variant} /></span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user