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
|
# 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.
|
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
|
||||||
env:
|
env:
|
||||||
DICT_VERSION: v1.2.1
|
DICT_VERSION: v1.3.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
# 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
|
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI
|
||||||
pnpm start # UI mock mode: lobby -> game, no backend
|
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 gateway -t scrabble-gateway .
|
||||||
docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing
|
docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing
|
||||||
docker compose -f deploy/docker-compose.yml config # validate the full contour
|
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`.
|
services build from multi-stage distroless `*/Dockerfile`.
|
||||||
|
|
||||||
```sh
|
```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 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)
|
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.
|
# (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
|
# Build from the repository root so go.work, go.work.sum, pkg/ and backend/ are all
|
||||||
# in the Docker context:
|
# in the Docker context. DICT_VERSION has no default — the caller supplies the
|
||||||
# docker build -f backend/Dockerfile -t scrabble-backend .
|
# 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 -----------------------------------------------------
|
# --- dictionary artifact -----------------------------------------------------
|
||||||
FROM alpine:3.20 AS dawg
|
FROM alpine:3.20 AS dawg
|
||||||
ARG DICT_VERSION=v1.2.1
|
ARG DICT_VERSION
|
||||||
RUN apk add --no-cache curl tar
|
RUN apk add --no-cache curl tar
|
||||||
RUN mkdir -p /dawg \
|
RUN mkdir -p /dawg \
|
||||||
&& curl -fsSL -o /tmp/dawg.tar.gz \
|
&& 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
|
# 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
|
# 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.
|
# 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
|
COPY --from=build /out/backend /usr/local/bin/backend
|
||||||
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
|
# 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
|
# 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
|
```sh
|
||||||
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
|
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):
|
# 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_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
|
||||||
BACKEND_DICT_DIR=/tmp/dawg \
|
BACKEND_DICT_DIR=/tmp/dawg \
|
||||||
GOPRIVATE='gitea.iliadenisov.ru/*' \
|
GOPRIVATE='gitea.iliadenisov.ru/*' \
|
||||||
|
|||||||
@@ -207,10 +207,11 @@ type provisionSeed struct {
|
|||||||
|
|
||||||
// telegramSeed derives the create-time seed from Telegram launch fields: a
|
// telegramSeed derives the create-time seed from Telegram launch fields: a
|
||||||
// supported preferred language from languageCode (an ISO-639 code, possibly
|
// supported preferred language from languageCode (an ISO-639 code, possibly
|
||||||
// region-tagged like "ru-RU"), and a display name sanitized from firstName or,
|
// region-tagged like "ru-RU"), and a display name. The name precedence is the real
|
||||||
// failing that, username (sanitizeDisplayName strips disallowed characters to the
|
// name (firstName, sanitized to the editable format) → the @username taken verbatim
|
||||||
// editable format). When neither yields any letters, it falls back to a generated
|
// (already a valid handle, only trimmed and length-capped, never character-stripped)
|
||||||
// placeholder in the seeded language (placeholderDisplayName).
|
// → 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 {
|
func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
||||||
var seed provisionSeed
|
var seed provisionSeed
|
||||||
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
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)
|
name := sanitizeDisplayName(firstName)
|
||||||
if name == "" {
|
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 == "" {
|
if name == "" {
|
||||||
name = placeholderDisplayName(seed.preferredLanguage)
|
name = placeholderDisplayName(seed.preferredLanguage)
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import (
|
|||||||
|
|
||||||
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
|
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
|
||||||
// create-time account seed: supported-language detection (bare and region-tagged),
|
// create-time account seed: supported-language detection (bare and region-tagged),
|
||||||
// the first-name / username display-name precedence, and the sanitization that
|
// the real-name → @username (verbatim) → placeholder display-name precedence, and
|
||||||
// strips disallowed characters (emoji, digits, punctuation) to the editable format.
|
// 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) {
|
func TestTelegramSeed(t *testing.T) {
|
||||||
cases := map[string]struct {
|
cases := map[string]struct {
|
||||||
languageCode, username, firstName string
|
languageCode, username, firstName string
|
||||||
@@ -28,6 +29,7 @@ func TestTelegramSeed(t *testing.T) {
|
|||||||
"punct to space": {"en", "user", "John❤Doe", "en", "John Doe"},
|
"punct to space": {"en", "user", "John❤Doe", "en", "John Doe"},
|
||||||
"digits dropped": {"ru", "user", "Маша123", "ru", "Маша"},
|
"digits dropped": {"ru", "user", "Маша123", "ru", "Маша"},
|
||||||
"garbage to username": {"en", "good", "123!@#", "en", "good"},
|
"garbage to username": {"en", "good", "123!@#", "en", "good"},
|
||||||
|
"username verbatim": {"en", "co_ol99", "🎮🎮", "en", "co_ol99"},
|
||||||
}
|
}
|
||||||
for name, tc := range cases {
|
for name, tc := range cases {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
@@ -49,10 +51,10 @@ func TestTelegramSeedPlaceholder(t *testing.T) {
|
|||||||
languageCode, username, firstName string
|
languageCode, username, firstName string
|
||||||
wantRe string
|
wantRe string
|
||||||
}{
|
}{
|
||||||
"en empty": {"en", "", "", `^Player-\d{5}$`},
|
"en empty": {"en", "", "", `^Player-\d{5}$`},
|
||||||
"ru empty": {"ru", "", "", `^Игрок-\d{5}$`},
|
"ru empty": {"ru", "", "", `^Игрок-\d{5}$`},
|
||||||
"default en": {"fr", "", "", `^Player-\d{5}$`},
|
"default en": {"fr", "", "", `^Player-\d{5}$`},
|
||||||
"both garbage": {"ru", "123", "!!!", `^Игрок-\d{5}$`},
|
"name garbage, no username": {"ru", "", "!!!", `^Игрок-\d{5}$`},
|
||||||
}
|
}
|
||||||
for name, tc := range cases {
|
for name, tc := range cases {
|
||||||
t.Run(name, func(t *testing.T) {
|
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
|
# 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
|
# recorded .seed_version marker wins — the seed-drift guard); change a running
|
||||||
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5).
|
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5).
|
||||||
DICT_VERSION=v1.2.1
|
DICT_VERSION=v1.3.0
|
||||||
|
|
||||||
# --- Logging ----------------------------------------------------------------
|
# --- Logging ----------------------------------------------------------------
|
||||||
LOG_LEVEL=info
|
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_DB` | variable | `scrabble` | Database name. |
|
||||||
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
|
| `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`). |
|
| `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. |
|
| `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. |
|
| `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
|
which resolves `otelcol`, `gateway` and `api.telegram.org`. `GATEWAY_ADMIN_*` is
|
||||||
intentionally **unset** — caddy owns `/_gm` in the contour.
|
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
|
## Production rollout
|
||||||
|
|
||||||
Prod runs on **two hosts** (main = full stack + ACME on the domain; tg = the bot only,
|
Prod runs on **two hosts** (main = full stack + ACME on the domain; tg = the bot only,
|
||||||
|
|||||||
@@ -68,9 +68,11 @@ services:
|
|||||||
context: ..
|
context: ..
|
||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
args:
|
args:
|
||||||
# Seed dictionary for a FRESH volume; the per-contour value comes from the
|
# Seed dictionary for a FRESH volume; required (no default) so the release tag is
|
||||||
# deploy env (Gitea TEST_/PROD_DICT_VERSION). See the volume note below.
|
# set in exactly one place per context — the deploy env (Gitea TEST_/PROD_DICT_VERSION)
|
||||||
DICT_VERSION: ${DICT_VERSION:-v1.2.1}
|
# 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).
|
# Build version stamped into the binary (git tag; see pkg/version).
|
||||||
VERSION: ${APP_VERSION:-dev}
|
VERSION: ${APP_VERSION:-dev}
|
||||||
restart: unless-stopped
|
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 & board
|
||||||
|
|
||||||
- **Tiles**: the letter sits in the **top-left** corner (offset a touch more than the
|
- **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
|
- **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
|
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
|
that works consistently across browsers; no `transform`, which broke scrolling
|
||||||
|
|||||||
+2
-1
@@ -12,7 +12,8 @@
|
|||||||
|
|
||||||
# --- dictionary artifact -----------------------------------------------------
|
# --- dictionary artifact -----------------------------------------------------
|
||||||
FROM alpine:3.20 AS dawg
|
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 apk add --no-cache curl tar
|
||||||
RUN mkdir -p /dawg \
|
RUN mkdir -p /dawg \
|
||||||
&& curl -fsSL -o /tmp/dawg.tar.gz \
|
&& 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):
|
container on the contour's docker network (this bypasses the host→gateway hairpin):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# from the repo root
|
# from the repo root (DICT_VERSION has no default — pass the scrabble-dictionary release tag)
|
||||||
docker build -f loadtest/Dockerfile -t scrabble-loadtest .
|
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 \
|
docker run --rm --cpus=3 --name scrabble-loadtest --network scrabble-internal \
|
||||||
-e POSTGRES_PASSWORD="$TEST_POSTGRES_PASSWORD" \
|
-e POSTGRES_PASSWORD="$TEST_POSTGRES_PASSWORD" \
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// A best-move word drawn as a row of game tiles, mirroring the board's placed-tile
|
// 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
|
// 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
|
// shows its letter but no value, exactly as on the board; in Erudit it also carries the
|
||||||
// display. The tile values ride on each tile, so this renders without the variant's
|
// blank's star (✻) in the value corner. Letters are upper-cased for display. The tile
|
||||||
// alphabet table (which the statistics screen has not cached).
|
// values ride on each tile, so this needs only the variant id (for the star) — not the
|
||||||
import type { BestMoveTile } from '../lib/model';
|
// 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());
|
const label = $derived(word.map((t) => t.letter).join('').toUpperCase());
|
||||||
</script>
|
</script>
|
||||||
@@ -15,7 +17,11 @@
|
|||||||
{#each word as tile, i (i)}
|
{#each word as tile, i (i)}
|
||||||
<span class="tile" class:blank={tile.blank} aria-hidden="true">
|
<span class="tile" class:blank={tile.blank} aria-hidden="true">
|
||||||
<span class="letter">{tile.letter.toUpperCase()}</span>
|
<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>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
@@ -50,4 +56,10 @@
|
|||||||
font-size: 7px;
|
font-size: 7px;
|
||||||
font-weight: 600;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import type { Premium } from '../lib/premiums';
|
import type { Premium } from '../lib/premiums';
|
||||||
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 { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
|
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
|
||||||
import type { Locale } from '../lib/i18n/catalog';
|
import type { Locale } from '../lib/i18n/catalog';
|
||||||
|
|
||||||
@@ -254,7 +255,11 @@
|
|||||||
>
|
>
|
||||||
{#if letter}
|
{#if letter}
|
||||||
<span class="letter">{letter}</span>
|
<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}
|
{:else if r === centre.row && c === centre.col}
|
||||||
<span class="star">★</span>
|
<span class="star">★</span>
|
||||||
{:else if bl?.kind === 'single'}
|
{:else if bl?.kind === 'single'}
|
||||||
@@ -410,6 +415,12 @@
|
|||||||
font-size: 2.4cqw;
|
font-size: 2.4cqw;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
|
||||||
|
its ink centred on the same line as a neighbouring tile's value digit. */
|
||||||
|
.blankmark {
|
||||||
|
font-size: 2.8cqw;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
.star {
|
.star {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
import { badgeKind } from '../lib/unread';
|
import { badgeKind } from '../lib/unread';
|
||||||
import { historyGrid } from '../lib/history';
|
import { historyGrid } from '../lib/history';
|
||||||
import { centre, premiumGrid } from '../lib/premiums';
|
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 { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||||
import { hintsLeft } from '../lib/hints';
|
import { hintsLeft } from '../lib/hints';
|
||||||
import { shareOrDownloadGcg } from '../lib/share';
|
import { shareOrDownloadGcg } from '../lib/share';
|
||||||
@@ -1293,7 +1293,7 @@
|
|||||||
|
|
||||||
{#if drag}
|
{#if drag}
|
||||||
<div class="ghost" class:touch={drag.touch} style="left:{drag.x}px; top:{drag.y}px">
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
+18
-2
@@ -3,6 +3,7 @@
|
|||||||
import { BLANK } from '../lib/placement';
|
import { BLANK } from '../lib/placement';
|
||||||
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';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
slots,
|
slots,
|
||||||
@@ -66,8 +67,12 @@
|
|||||||
animate:hop={shuffling}
|
animate:hop={shuffling}
|
||||||
onpointerdown={(e) => ondown(e, slot.index)}
|
onpointerdown={(e) => ondown(e, slot.index)}
|
||||||
>
|
>
|
||||||
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
|
{#if slot.letter === BLANK}
|
||||||
{#if slot.letter !== BLANK}<span class="val">{valueForLetter(variant, slot.letter)}</span>{/if}
|
{#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>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -133,4 +138,15 @@
|
|||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 600;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ export const en = {
|
|||||||
'invitations.with': 'With {names}',
|
'invitations.with': 'With {names}',
|
||||||
'invitations.accept': 'Accept',
|
'invitations.accept': 'Accept',
|
||||||
'invitations.decline': 'Decline',
|
'invitations.decline': 'Decline',
|
||||||
|
'invitations.declineConfirm': 'Decline invitation?',
|
||||||
'invitations.cancel': 'Cancel',
|
'invitations.cancel': 'Cancel',
|
||||||
'invitations.waiting': 'Waiting for replies',
|
'invitations.waiting': 'Waiting for replies',
|
||||||
|
|
||||||
@@ -264,10 +265,10 @@ export const en = {
|
|||||||
'new.withFriends': 'Play with friends',
|
'new.withFriends': 'Play with friends',
|
||||||
'new.pickFriends': 'Choose who to invite',
|
'new.pickFriends': 'Choose who to invite',
|
||||||
'new.searchFriends': 'Search friends',
|
'new.searchFriends': 'Search friends',
|
||||||
'new.gameType': 'Game type',
|
'new.gameType': 'Variant',
|
||||||
'new.invite': 'Send invitation',
|
'new.invite': 'Send invitation',
|
||||||
'new.moveTime': 'Move time',
|
'new.moveTime': 'Move time',
|
||||||
'new.hintsPerPlayer': 'Hints per player',
|
'new.hintsPerPlayer': 'Hints',
|
||||||
'new.multipleWordsPerTurn': 'Multiple words per turn',
|
'new.multipleWordsPerTurn': 'Multiple words per turn',
|
||||||
'new.start': 'Start game',
|
'new.start': 'Start game',
|
||||||
'new.invited': 'Invitation sent.',
|
'new.invited': 'Invitation sent.',
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'invitations.with': 'С {names}',
|
'invitations.with': 'С {names}',
|
||||||
'invitations.accept': 'Принять',
|
'invitations.accept': 'Принять',
|
||||||
'invitations.decline': 'Отклонить',
|
'invitations.decline': 'Отклонить',
|
||||||
|
'invitations.declineConfirm': 'Отклонить приглашение?',
|
||||||
'invitations.cancel': 'Отменить',
|
'invitations.cancel': 'Отменить',
|
||||||
'invitations.waiting': 'Ожидаем ответы',
|
'invitations.waiting': 'Ожидаем ответы',
|
||||||
|
|
||||||
@@ -265,10 +266,10 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'new.withFriends': 'Игра с друзьями',
|
'new.withFriends': 'Игра с друзьями',
|
||||||
'new.pickFriends': 'Кого пригласить',
|
'new.pickFriends': 'Кого пригласить',
|
||||||
'new.searchFriends': 'Поиск друзей',
|
'new.searchFriends': 'Поиск друзей',
|
||||||
'new.gameType': 'Тип игры',
|
'new.gameType': 'Вариант',
|
||||||
'new.invite': 'Отправить приглашение',
|
'new.invite': 'Отправить приглашение',
|
||||||
'new.moveTime': 'Время на ход',
|
'new.moveTime': 'Время на ход',
|
||||||
'new.hintsPerPlayer': 'Подсказок на игрока',
|
'new.hintsPerPlayer': 'Подсказки',
|
||||||
'new.multipleWordsPerTurn': 'Несколько слов за ход',
|
'new.multipleWordsPerTurn': 'Несколько слов за ход',
|
||||||
'new.start': 'Начать игру',
|
'new.start': 'Начать игру',
|
||||||
'new.invited': 'Приглашение отправлено.',
|
'new.invited': 'Приглашение отправлено.',
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
availableVariants,
|
availableVariants,
|
||||||
supportsMultipleWordsToggle,
|
supportsMultipleWordsToggle,
|
||||||
multipleWordsForRequest,
|
multipleWordsForRequest,
|
||||||
|
usesStarBlank,
|
||||||
|
BLANK_STAR,
|
||||||
} from './variants';
|
} from './variants';
|
||||||
|
|
||||||
describe('ALL_VARIANTS', () => {
|
describe('ALL_VARIANTS', () => {
|
||||||
@@ -53,3 +55,16 @@ describe('multipleWordsForRequest', () => {
|
|||||||
expect(multipleWordsForRequest('scrabble_en', true)).toBe(true);
|
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 + Эрудит.
|
// ru -> Russian + Эрудит.
|
||||||
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { scrabble_en: 'en', scrabble_ru: 'ru', erudit_ru: 'ru' };
|
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
|
// 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)
|
// 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.
|
// — 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 { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import Screen from '../components/Screen.svelte';
|
import Screen from '../components/Screen.svelte';
|
||||||
import TabBar from '../components/TabBar.svelte';
|
import TabBar from '../components/TabBar.svelte';
|
||||||
|
import Modal from '../components/Modal.svelte';
|
||||||
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
|
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { navigate } from '../lib/router.svelte';
|
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 { resultBadge } from '../lib/result';
|
||||||
import { badgeKind } from '../lib/unread';
|
import { badgeKind } from '../lib/unread';
|
||||||
import { getLobby, setLobby } from '../lib/lobbycache';
|
import { getLobby, setLobby } from '../lib/lobbycache';
|
||||||
import { preloadGames } from '../lib/preload';
|
import { preloadGames } from '../lib/preload';
|
||||||
import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
|
import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
|
||||||
import type { AccountRef, GameView, Invitation } from '../lib/model';
|
import type { AccountRef, GameView, Invitation } from '../lib/model';
|
||||||
|
import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants';
|
||||||
|
|
||||||
let games = $state<GameView[]>([]);
|
let games = $state<GameView[]>([]);
|
||||||
let invitations = $state<Invitation[]>([]);
|
let invitations = $state<Invitation[]>([]);
|
||||||
@@ -209,11 +211,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const variantKey: Record<string, MessageKey> = {
|
// The invitation pending a decline confirmation: the ❌ opens a modal (mirroring the
|
||||||
scrabble_en: 'new.english',
|
// in-game resign confirmation) rather than declining on the first tap.
|
||||||
scrabble_ru: 'new.russian',
|
let declineTarget = $state<Invitation | null>(null);
|
||||||
erudit_ru: 'new.erudit',
|
function confirmDecline() {
|
||||||
};
|
const inv = declineTarget;
|
||||||
|
declineTarget = null;
|
||||||
|
if (inv) declineInvite(inv);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Screen title={app.profile?.displayName ?? t('app.title')}>
|
<Screen title={app.profile?.displayName ?? t('app.title')}>
|
||||||
@@ -230,16 +235,23 @@
|
|||||||
<span class="sub">{t('invitations.waiting')}</span>
|
<span class="sub">{t('invitations.waiting')}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="who">{t('invitations.from', { name: inv.inviter.displayName })}</span>
|
<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}
|
||||||
{#if !inv.multipleWordsPerTurn}<span class="sub">{t('game.oneWordRule')}</span>{/if}
|
{#if !inv.multipleWordsPerTurn}<span class="sub">{t('game.oneWordRule')}</span>{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="acts">
|
<span class="acts">
|
||||||
{#if inv.inviter.accountId === myId}
|
{#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}
|
{:else}
|
||||||
<button class="btn" onclick={() => acceptInvite(inv)}>{t('invitations.accept')}</button>
|
<button class="iconbtn" onclick={() => acceptInvite(inv)} aria-label={t('invitations.accept')}>✅</button>
|
||||||
<button class="ghost" onclick={() => declineInvite(inv)}>{t('invitations.decline')}</button>
|
<button class="iconbtn" onclick={() => (declineTarget = inv)} aria-label={t('invitations.decline')}>❌</button>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -308,6 +320,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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()}
|
{#snippet tabbar()}
|
||||||
<TabBar>
|
<TabBar>
|
||||||
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
|
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
|
||||||
@@ -365,6 +386,40 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
user-select: none;
|
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
|
/* Game rows are a compact, flat list: no per-card frame, a hairline divider between
|
||||||
consecutive rows. */
|
consecutive rows. */
|
||||||
.list {
|
.list {
|
||||||
@@ -516,23 +571,31 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* The ✅ / ❌ actions stack vertically with a small gap — a min-width right column. */
|
||||||
.acts {
|
.acts {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.btn {
|
/* Decline-confirmation modal (mirrors the in-game resign confirm). */
|
||||||
padding: 8px 12px;
|
.confirm-row {
|
||||||
border: 1px solid var(--accent);
|
display: flex;
|
||||||
background: var(--accent);
|
gap: 8px;
|
||||||
color: var(--accent-text);
|
}
|
||||||
|
.confirm-row button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 11px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
|
||||||
.ghost {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -67,9 +67,12 @@
|
|||||||
let friends = $state<AccountRef[]>([]);
|
let friends = $state<AccountRef[]>([]);
|
||||||
let selected = $state<string[]>([]);
|
let selected = $state<string[]>([]);
|
||||||
let friendFilter = $state('');
|
let friendFilter = $state('');
|
||||||
// No default game type yet — the player must pick one (a smarter default from play
|
// A lone offered variant is pre-selected and its picker disabled (nothing else to choose);
|
||||||
// history / language would be a future refinement). '' renders the disabled placeholder option.
|
// with several, the player picks. '' renders the disabled placeholder option.
|
||||||
let inviteVariant = $state<Variant | ''>('');
|
let inviteVariant = $state<Variant | ''>('');
|
||||||
|
$effect(() => {
|
||||||
|
if (variants.length === 1 && !inviteVariant) inviteVariant = variants[0].id;
|
||||||
|
});
|
||||||
let timeoutSecs = $state(86400);
|
let timeoutSecs = $state(86400);
|
||||||
let hints = $state(1);
|
let hints = $state(1);
|
||||||
|
|
||||||
@@ -152,6 +155,7 @@
|
|||||||
<input type="checkbox" bind:checked={multipleWords} />
|
<input type="checkbox" bind:checked={multipleWords} />
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="grow"></div>
|
||||||
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
|
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
|
||||||
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
|
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
|
||||||
<button
|
<button
|
||||||
@@ -179,7 +183,7 @@
|
|||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>{t('new.gameType')}</span>
|
<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>
|
<option value="" disabled>—</option>
|
||||||
{#each variants as v (v.id)}<option value={v.id}>{t(v.label)}</option>{/each}
|
{#each variants as v (v.id)}<option value={v.id}>{t(v.label)}</option>{/each}
|
||||||
</select>
|
</select>
|
||||||
@@ -387,6 +391,11 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
/* Pushes the auto-match start cluster (move-limit hint + Start button) to the bottom,
|
||||||
|
mirroring the friend-game invite button pinned by the .fg scroll area. */
|
||||||
|
.grow {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
.invite {
|
.invite {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
{#each bestMoves as bm (bm.variant)}
|
{#each bestMoves as bm (bm.variant)}
|
||||||
<span class="variant">{t(variantNameKey(bm.variant))}</span>
|
<span class="variant">{t(variantNameKey(bm.variant))}</span>
|
||||||
<span class="score">{bm.score}</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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user