Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master
This commit was merged in pull request #111.
This commit is contained in:
@@ -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/*' \
|
||||||
|
|||||||
+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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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