Compare commits

...

9 Commits

Author SHA1 Message Date
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer be1627936f Merge pull request 'chore(deploy): pin dictionary seed to v1.3.0' (#110) from feature/pin-dict-v130 into development
CI / changes (push) Successful in 2s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 54s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m19s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 13:06:26 +00:00
Ilia Denisov 1ba52dd0b4 refactor(deploy): make DICT_VERSION a required build arg (single-sourced)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m28s
Drop the literal version default from the build files (backend Dockerfile both
stages, loadtest Dockerfile, the compose build-arg) so the release tag is not
duplicated as a stale-prone default a newcomer can't tell from the real source.

DICT_VERSION is now required: compose uses ${DICT_VERSION:?…} and the Dockerfiles
have no ARG default, so a missing value fails loudly instead of baking a stale tag.
The tag lives only in its genuine sources — ci.yaml env (CI tests), the Gitea
TEST_/PROD_DICT_VERSION variables (deploy seed) and deploy/.env.example (local).
Adds a "Bumping the dictionary version" section to deploy/README and fixes the bare
docker-build examples (CLAUDE.md, README.md, loadtest/README) to pass --build-arg.
2026-06-22 15:03:07 +02:00
Ilia Denisov bb0e3e17e5 chore(deploy): pin dictionary seed to v1.3.0
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
Bump DICT_VERSION v1.2.1 -> v1.3.0 across the seed surface: .env.example, the
compose build-arg default, both backend Dockerfile stages, the loadtest
Dockerfile, the CI dawg-download version, and the deploy/backend docs.

v1.3.0 drops the abbreviation class from the Russian word list (scrabble-dictionary
#6). This only seeds a FRESH volume; a live contour/prod volume is unaffected (the
.seed_version marker wins — seed-drift guard) and moves to v1.3.0 through the admin
console (ARCHITECTURE §5). Per-contour deploy still overrides via TEST_/PROD_DICT_VERSION.
2026-06-22 14:45:52 +02:00
developer 1ef2bde395 Merge pull request 'feat(ui): Erudit blank tiles carry the star (✻) mark' (#109) from feature/erudit-blank-star into development
CI / changes (push) Successful in 1s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 54s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
2026-06-22 11:06:22 +00:00
Ilia Denisov 62f66735a3 fix(ui): nudge the rack blank star up a pixel
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 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
By eye the centred star still read a hair low; subtract 1px from its top
offset (top: calc(0.5% - 1px)).
2026-06-22 12:55:06 +02:00
Ilia Denisov 81680a1d5e fix(ui): raise the rack blank star to centre on the letters
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
Top-aligning it still read a touch low; move the empty-blank star up
(top 8% -> 0.5%) so its ink centres against the rack letters' block,
matching the board tile's centred mark. Size unchanged.
2026-06-22 12:22:48 +02:00
Ilia Denisov a393561d79 fix(ui): align the Erudit blank star with neighbouring tiles
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 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
Rack: the empty-blank star is now top-anchored level with the letters and a
touch larger (was centred, sitting low). Board and Stats best-move tiles: the
placed-blank star's ink is centred on the value digits' line (was slightly
high). CSS-only nudges; pixel offsets measured against the rendered glyphs.
2026-06-22 12:11:35 +02:00
Ilia Denisov e3e4cedc77 feat(ui): Erudit blank tiles carry the star (✻) mark
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 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
The Erudit variant's blank is the "звёздочка", so render it with a star.
An empty rack blank (and its drag ghost) shows ✻ centred; a placed blank
keeps its designated letter and carries ✻ where the (absent) point value
sits — on the board and in the Stats best-move tiles. The Scrabble variants
are unchanged. Gated by usesStarBlank() in lib/variants.ts.
2026-06-22 11:52:00 +02:00
18 changed files with 127 additions and 29 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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/*' \
+1 -1
View File
@@ -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
View File
@@ -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,
+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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" \
+18 -6
View File
@@ -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>
+12 -1
View File
@@ -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;
+2 -2
View File
@@ -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
View File
@@ -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>
+15
View File
@@ -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);
});
});
+14
View File
@@ -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.
+1 -1
View File
@@ -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>