Files
scrabble-game/ui/src/lib/gameimage.ts
T
Ilia Denisov 3471d40576
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m27s
feat(export): server-rendered artifacts behind one signed download URL
The finished-game export now works identically on every platform: the
client mints a signed relative URL (game.export_url) carrying its date
locale, IANA time zone and localized non-play labels, resolves it
against its own origin and hands it to the platform's native download —
Telegram downloadFile, VKWebAppDownloadFile, or a plain browser anchor
(also the desktop VK iframe). Both artifacts ride the route: the .gcg
text (no more clipboard mode, except the legacy pre-8.0 Telegram
fallback) and the PNG of the final position.

The PNG is rasterized by a new internal 'renderer' sidecar (node:22-slim
+ skia-canvas + baked Liberation/Noto Color Emoji fonts) executing the
SAME ui/src/lib/gameimage.ts the web project unit-tests, bundled at
image build time — one renderer, no drift; the browser no longer draws
or delivers bytes itself. The backend rebuilds the render payload from
the journal + engine.AlphabetTable, verifies the HMAC (10-minute TTL,
BACKEND_EXPORT_SIGN_KEY, constant-time, uniform 404s) on its public
group, and streams the artifact as a named attachment; the gateway
forwards /dl/* behind the per-IP public rate limiter (caddy @gateway
matcher extended — the landing catch-all trap).

Deploy: renderer service (compose + prod overlay + roll before backend
+ prod push list), EXPORT_SIGN_KEY env (TEST_/PROD_ secrets), CI runs
the sidecar smoke in the ui job. Docs: ARCHITECTURE export-delivery
section, FUNCTIONAL(+_ru), UI_DESIGN, TESTING, deploy/README.
2026-07-02 18:44:07 +02:00

465 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Finished-game PNG export: a light-theme snapshot of the final board (classic A..O / 1..15
// axes) with a compact per-seat scoresheet beside it. Everything is drawn by hand on a
// Canvas 2D — no dependencies. The module is the SHARED source of truth for the export
// image: the render sidecar bundles it (renderer/src/entry.ts) and rasterizes on
// skia-canvas, while this ui project keeps the pure parts unit-tested (the browser app
// itself no longer imports it — delivery is the server's signed URL).
//
// buildScoresheet and layoutImage are pure (vitest-covered); drawGameImage is the thin
// canvas pass exercised by the sidecar's node test.
import type { GameView, MoveRecord, Variant } from './model';
import { replay } from './board';
import { historyGrid, type HistoryCell } from './history';
import { premiumGrid, centre, BOARD_SIZE } from './premiums';
import { valueForLetter } from './alphabet';
// The light-theme palette, mirrored from ui/src/app.css :root — the export is always light
// regardless of the app theme, and a canvas cannot resolve CSS custom properties, so the
// values are pinned here. Keep in sync with app.css.
const C = {
paper: '#ffffff',
text: '#14181f',
muted: '#6b7280',
border: '#d8dce2',
cell: '#e7ece8',
cellDark: '#d9deda', // color-mix(in srgb, --cell-bg 94%, #000)
tile: '#f4e2b8',
tileEdge: '#d8c190',
tileText: '#2a2113',
premText: '#2a2113',
prem: { TW: '#e06a5b', DW: '#efa6a0', TL: '#4f8fd6', DL: '#a8cdec' } as Record<string, string>,
};
const FONT = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif';
/** One scoresheet cell: a play (classic coordinate + words + points), a localized non-play
* action, or an empty filler keeping the seat columns aligned. */
export type SheetCell =
| { kind: 'play'; coord: string; words: string[]; score: number }
| { kind: 'action'; label: string }
| { kind: 'empty' };
/** Scoresheet is the pure content of the image's right column: one column per seat. */
export interface Scoresheet {
/** Per seat: display name, final (adjusted) score, winner flag. */
header: { name: string; score: number; winner: boolean }[];
/** Row-major move grid, one column per seat (row k = each seat's k-th move). */
rows: SheetCell[][];
/** Per-seat endgame rack settlement: final Seat.score minus the seat's last running
* total. The journal carries no adjustment move (engine applyEndAdjustment folds it
* into the final score only), so the sheet shows it as a closing ± row. */
adjustments: number[];
hasAdjustments: boolean;
/** Bottom line: "<hostname> · <finish date>". */
footer: string;
}
/** formatFinishedDate renders the game's finish time for the footer in the DEVICE locale
* and time zone (regional conventions, not the UI language) — e.g. "3 июля 2026 г., 16:23".
* Both ride in from the client on a server render; an invalid zone falls back to the
* process default rather than failing the whole image. */
export function formatFinishedDate(unixSec: number, dateLocale?: string, timeZone?: string): string {
const opts: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
};
try {
return new Intl.DateTimeFormat(dateLocale, { ...opts, timeZone }).format(new Date(unixSec * 1000));
} catch {
return new Intl.DateTimeFormat(dateLocale, opts).format(new Date(unixSec * 1000));
}
}
/**
* buildScoresheet lays the finished game's journal out as the export scoresheet.
* actionLabel localizes a non-play move (the caller passes the same mapping the in-game
* history uses); hostname/dateLocale feed the footer (tests pin them, production uses
* location.hostname and the device locale).
*/
export function buildScoresheet(
game: GameView,
moves: MoveRecord[],
actionLabel: (action: string) => string,
opts?: { hostname?: string; dateLocale?: string; timeZone?: string },
): Scoresheet {
const seats = game.seats;
const grid = historyGrid(moves, seats.length, null);
const rows = grid.map((row) =>
row.map((c: HistoryCell): SheetCell => {
if (c.kind === 'play') {
return { kind: 'play', coord: c.coord ?? '', words: c.words ?? [], score: c.score ?? 0 };
}
if (c.kind === 'action') return { kind: 'action', label: actionLabel(c.action ?? '') };
return { kind: 'empty' };
}),
);
// The last running total each seat reached in the journal; the final Seat.score differs
// from it exactly by the endgame rack settlement (resignations freeze scores — zero diff).
const lastTotal = seats.map((s) => {
for (let i = moves.length - 1; i >= 0; i--) {
if (moves[i].player === s.seat) return moves[i].total;
}
return 0;
});
const adjustments = seats.map((s, i) => s.score - lastTotal[i]);
const host = opts?.hostname ?? (typeof location !== 'undefined' ? location.hostname : '');
return {
header: seats.map((s) => ({ name: s.displayName, score: s.score, winner: s.isWinner })),
rows,
adjustments,
hasAdjustments: adjustments.some((a) => a !== 0),
footer: `${host} · ${formatFinishedDate(game.lastActivityUnix, opts?.dateLocale, opts?.timeZone)}`,
};
}
// Fixed scoresheet typography (@1x logical px). The column format never changes with game
// length — the board stretches instead (layoutImage).
const MARGIN = 22;
const AXIS = 20; // top letters / left numbers gutter
const GAP = 22; // board ↔ scoresheet
const COL_W = 158; // one seat column
const COL_GAP = 14; // between seat columns
const HEAD_NAME_H = 20;
const HEAD_SCORE_H = 24;
const HEAD_RULE_H = 12;
const ROW_H = 17;
const SUB_ROW_H = 13; // extra-words line under a multi-word play
const ADJ_ROW_H = 20;
const FOOTER_H = 30;
/** The board never shrinks below this side (@1x) even for a two-move game. */
export const MIN_SIDE = 620;
/** ImageLayout is the pure geometry of the export image (@1x; the canvas scales it). */
export interface ImageLayout {
w: number;
h: number;
board: { x: number; y: number; side: number; cell: number };
hist: { x: number; y: number; w: number; colW: number; colGap: number };
/** Per move-row height (a row with any multi-word play gets the extra sub-line). */
rowHeights: number[];
headNameH: number;
headScoreH: number;
headRuleH: number;
rowH: number;
subRowH: number;
adjRowH: number;
footerH: number;
axis: number;
margin: number;
}
/**
* layoutImage computes the export geometry from the scoresheet: the fixed-typography
* history column height decides the board side (never below MIN_SIDE), so the image
* carries no dead space under the board and a long game simply grows both.
*/
export function layoutImage(sheet: Scoresheet): ImageLayout {
const seats = sheet.header.length;
const rowHeights = sheet.rows.map(
(row) => ROW_H + (row.some((c) => c.kind === 'play' && c.words.length > 1) ? SUB_ROW_H : 0),
);
const histContentH =
HEAD_NAME_H +
HEAD_SCORE_H +
HEAD_RULE_H +
rowHeights.reduce((a, b) => a + b, 0) +
(sheet.hasAdjustments ? ADJ_ROW_H : 0);
const side = Math.max(MIN_SIDE, Math.ceil(histContentH));
const histW = seats * COL_W + (seats - 1) * COL_GAP;
return {
w: MARGIN + AXIS + side + GAP + histW + MARGIN,
h: MARGIN + AXIS + side + FOOTER_H + MARGIN,
board: { x: MARGIN + AXIS, y: MARGIN + AXIS, side, cell: side / BOARD_SIZE },
hist: { x: MARGIN + AXIS + side + GAP, y: MARGIN + AXIS, w: histW, colW: COL_W, colGap: COL_GAP },
rowHeights,
headNameH: HEAD_NAME_H,
headScoreH: HEAD_SCORE_H,
headRuleH: HEAD_RULE_H,
rowH: ROW_H,
subRowH: SUB_ROW_H,
adjRowH: ADJ_ROW_H,
footerH: FOOTER_H,
axis: AXIS,
margin: MARGIN,
};
}
/** RenderOptions carries the presentation the caller resolves from app state. */
export interface RenderOptions {
/** Localizes a non-play move for the scoresheet (pass/exchange/resign/timeout). */
actionLabel: (action: string) => string;
/** Footer host override (defaults to location.hostname) — for tests/harnesses. */
hostname?: string;
/** Footer date locale override (defaults to the device locale) — for tests. */
dateLocale?: string;
/** Footer IANA time zone (the device zone on a server render; defaults to local). */
timeZone?: string;
/** Canvas pixel scale (default 2 — crisp on hi-dpi screens and in chats). */
scale?: number;
}
/** CanvasLike is the minimal canvas surface drawGameImage needs — satisfied by both the
* browser HTMLCanvasElement and the render sidecar's skia-canvas Canvas, so the drawing
* code is shared verbatim between the two runtimes. */
export interface CanvasLike {
width: number;
height: number;
getContext(contextId: '2d'): CanvasRenderingContext2D | null;
}
/**
* drawGameImage sizes the canvas and draws the finished game onto it: final board with
* classic axes on the left, the scoresheet on the right, the site + finish date at the
* bottom. Light theme always; no last-move highlight (an archival artifact, not a live
* frame). The caller owns the canvas and its PNG encoding (toBlob in the browser,
* toBuffer in the sidecar).
*/
export function drawGameImage(canvas: CanvasLike, game: GameView, moves: MoveRecord[], opts: RenderOptions): void {
const sheet = buildScoresheet(game, moves, opts.actionLabel, {
hostname: opts.hostname,
dateLocale: opts.dateLocale,
timeZone: opts.timeZone,
});
const layout = layoutImage(sheet);
const scale = opts.scale ?? 2;
canvas.width = Math.round(layout.w * scale);
canvas.height = Math.round(layout.h * scale);
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('gameimage: no 2d context');
ctx.scale(scale, scale);
ctx.fillStyle = C.paper;
ctx.fillRect(0, 0, layout.w, layout.h);
drawAxes(ctx, layout);
drawBoard(ctx, layout, game.variant, moves);
drawSheet(ctx, layout, sheet);
ctx.fillStyle = C.muted;
ctx.font = `11px ${FONT}`;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillText(sheet.footer, layout.margin, layout.h - layout.margin - 6);
}
function drawAxes(ctx: CanvasRenderingContext2D, l: ImageLayout): void {
const { x, y, cell } = l.board;
ctx.fillStyle = C.muted;
ctx.font = `${Math.max(10, cell * 0.28)}px ${FONT}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let c = 0; c < BOARD_SIZE; c++) {
ctx.fillText(String.fromCharCode(65 + c), x + c * cell + cell / 2, y - l.axis / 2);
}
for (let r = 0; r < BOARD_SIZE; r++) {
ctx.fillText(String(r + 1), x - l.axis / 2, y + r * cell + cell / 2);
}
}
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function drawBoard(
ctx: CanvasRenderingContext2D,
l: ImageLayout,
variant: Variant,
moves: MoveRecord[],
): void {
const { x, y, cell, side } = l.board;
const board = replay(moves);
const prem = premiumGrid(variant);
const ctr = centre(variant);
// Squares first (premium colours + the alternating plain-cell tint, as in game). Bonus
// squares carry NO text labels on the export — the fill alone reads universally, with the
// coordinate axes carrying the location.
for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c < BOARD_SIZE; c++) {
const p = prem[r][c];
ctx.fillStyle = p ? C.prem[p] : (r + c) % 2 === 1 && !board[r][c] ? C.cellDark : C.cell;
ctx.fillRect(x + c * cell, y + r * cell, cell + 0.5, cell + 0.5);
}
}
// The centre star on an uncovered centre square (rare in a finished game).
if (!board[ctr.row][ctr.col]) {
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = C.premText;
ctx.globalAlpha = 0.7;
ctx.font = `${cell * 0.54}px ${FONT}`;
ctx.fillText('★', x + ctr.col * cell + cell / 2, y + ctr.row * cell + cell / 2);
ctx.globalAlpha = 1;
}
// Tiles: rounded face, inset bottom bevel and a soft right-side shadow, letter top-left,
// value bottom-right — the in-game look (Board.svelte), light palette.
const rad = cell * 0.1;
for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c < BOARD_SIZE; c++) {
const t = board[r][c];
if (!t) continue;
const tx = x + c * cell;
const ty = y + r * cell;
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.4)';
ctx.shadowOffsetX = cell * 0.05;
ctx.shadowBlur = cell * 0.07;
ctx.fillStyle = C.tile;
roundRect(ctx, tx, ty, cell, cell, rad);
ctx.fill();
ctx.restore();
// Inset bottom bevel: a tile-edge strip clipped to the rounded face.
ctx.save();
roundRect(ctx, tx, ty, cell, cell, rad);
ctx.clip();
ctx.fillStyle = C.tileEdge;
ctx.fillRect(tx, ty + cell - cell * 0.05, cell, cell * 0.05);
ctx.restore();
ctx.fillStyle = C.tileText;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.font = `700 ${cell * 0.63}px ${FONT}`;
ctx.fillText(t.letter, tx + cell * 0.08, ty + cell * 0.07);
const isErudit = variant === 'erudit_ru';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
if (t.blank && isErudit) {
// The Erudit blank marker (✻) in the value corner, as in game (usesStarBlank).
ctx.font = `${cell * 0.32}px ${FONT}`;
ctx.fillText('✻', tx + cell * 0.9, ty + cell * 0.9);
} else if (!t.blank) {
ctx.font = `600 ${cell * 0.28}px ${FONT}`;
ctx.fillText(String(valueForLetter(variant, t.letter)), tx + cell * 0.9, ty + cell * 0.9);
}
}
}
// A hairline frame so the pale board edge reads against the paper.
ctx.strokeStyle = C.border;
ctx.lineWidth = 1;
ctx.strokeRect(x - 0.5, y - 0.5, side + 1, side + 1);
}
/** fitText shrinks the font size until text fits maxW (floor 8.5px), returning the size. */
function fitText(ctx: CanvasRenderingContext2D, text: string, weight: string, size: number, maxW: number): number {
let s = size;
ctx.font = `${weight} ${s}px ${FONT}`;
while (s > 8.5 && ctx.measureText(text).width > maxW) {
s -= 0.5;
ctx.font = `${weight} ${s}px ${FONT}`;
}
return s;
}
function ellipsize(ctx: CanvasRenderingContext2D, text: string, maxW: number): string {
if (ctx.measureText(text).width <= maxW) return text;
let t = text;
while (t.length > 1 && ctx.measureText(t + '…').width > maxW) t = t.slice(0, -1);
return t + '…';
}
function drawSheet(ctx: CanvasRenderingContext2D, l: ImageLayout, sheet: Scoresheet): void {
const { x, y, colW, colGap } = l.hist;
const seats = sheet.header.length;
const colX = (i: number) => x + i * (colW + colGap);
// Header: per-seat name (ellipsized) over the final score; 🏆 marks the winner.
for (let i = 0; i < seats; i++) {
const h = sheet.header[i];
const cx = colX(i) + colW / 2;
ctx.fillStyle = C.text;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `600 13px ${FONT}`;
ctx.fillText(ellipsize(ctx, h.name, colW - 8), cx, y + l.headNameH / 2);
ctx.font = `700 16px ${FONT}`;
const score = h.winner ? `${h.score} 🏆` : String(h.score);
ctx.fillText(score, cx, y + l.headNameH + l.headScoreH / 2);
}
// Rule under the header, spanning the sheet.
const ruleY = y + l.headNameH + l.headScoreH + l.headRuleH / 2;
ctx.strokeStyle = C.border;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, ruleY);
ctx.lineTo(x + l.hist.w, ruleY);
ctx.stroke();
// Move rows: "<coord> <WORD>" left, points right; extra words on a small second line.
let rowY = y + l.headNameH + l.headScoreH + l.headRuleH;
for (let r = 0; r < sheet.rows.length; r++) {
const row = sheet.rows[r];
const rh = l.rowHeights[r];
for (let i = 0; i < seats; i++) {
const cell = row[i];
const bx = colX(i);
const baseline = rowY + l.rowH / 2;
if (cell.kind === 'play') {
const scoreText = String(cell.score);
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.font = `600 11.5px ${FONT}`;
ctx.fillStyle = C.text;
ctx.fillText(scoreText, bx + colW, baseline);
const scoreW = ctx.measureText(scoreText).width + 6;
ctx.textAlign = 'left';
ctx.fillStyle = C.muted;
ctx.font = `600 10.5px ${FONT}`;
ctx.fillText(cell.coord, bx, baseline);
const coordW = ctx.measureText('D15').width + 5; // fixed slot so words align
const wordMax = colW - coordW - scoreW;
const word = cell.words[0] ?? '';
ctx.fillStyle = C.text;
const size = fitText(ctx, word, '500', 11.5, wordMax);
ctx.font = `500 ${size}px ${FONT}`;
ctx.fillText(ellipsize(ctx, word, wordMax), bx + coordW, baseline);
if (cell.words.length > 1) {
ctx.fillStyle = C.muted;
ctx.font = `9.5px ${FONT}`;
ctx.fillText(
ellipsize(ctx, `+ ${cell.words.slice(1).join(', ')}`, colW - coordW),
bx + coordW,
rowY + l.rowH + l.subRowH / 2 - 1,
);
}
} else if (cell.kind === 'action') {
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = C.muted;
ctx.font = `italic 10.5px ${FONT}`;
ctx.fillText(ellipsize(ctx, cell.label, colW), bx, baseline);
}
}
rowY += rh;
}
// Endgame rack settlement (when any seat has one): a closing ± row per column.
if (sheet.hasAdjustments) {
for (let i = 0; i < seats; i++) {
const a = sheet.adjustments[i];
if (a === 0) continue;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillStyle = C.muted;
ctx.font = `600 10.5px ${FONT}`;
ctx.fillText(a > 0 ? `+${a}` : String(a), colX(i) + colW, rowY + l.adjRowH / 2);
}
}
}