Merge pull request 'feat(game): finished-game export as a PNG image behind a format chooser' (#159) from feature/game-export-image into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m0s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m21s

This commit was merged in pull request #159.
This commit is contained in:
2026-07-02 15:29:40 +00:00
15 changed files with 989 additions and 35 deletions
+72 -2
View File
@@ -22,7 +22,7 @@
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { hintsLeft } from '../lib/hints';
import { shareOrDownloadGcg } from '../lib/share';
import { shareOrDownloadGcg, shareOrDownloadImage } from '../lib/share';
import { insideVK, vkCopyText } from '../lib/vk';
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
import { patchLobbyGame } from '../lib/lobbycache';
@@ -931,6 +931,22 @@
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
}
// The finished-game export offers two formats — the GCG file and the rendered PNG image —
// behind one 📤 button, always through the app's own modal. Never Telegram's native popup
// here: its callback runs with NO user activation, so navigator.share / clipboard writes
// silently fail from it (verified on-device, TG iOS + Android). A real DOM button click in
// the modal keeps the gesture alive for the delivery APIs.
let exportOpen = $state(false);
// The PNG option is withheld in an in-app WebView (Telegram / VK) for now: a binary image
// has no working delivery there (no Web Share, a dead <a download>, text-only clipboard,
// and the long-press menu mangles data: URLs). It returns with the server-rendered
// signed-URL delivery (Telegram downloadFile / VKWebAppDownloadFile).
const exportImageAvailable = $derived(!(insideTelegram() || insideVK()));
function onExportClick() {
exportOpen = true;
}
async function exportGcg() {
try {
const gcg = await gateway.exportGcg(id);
@@ -942,6 +958,22 @@
}
}
// exportImage renders the finished game as a PNG (lib/gameimage, dynamically imported so the
// renderer stays out of the startup bundle) and delivers it — Web Share where the platform
// supports it, else a download (only reachable outside the in-app WebViews, see
// exportImageAvailable).
async function exportImage() {
if (!view) return;
try {
const { renderGameImage } = await import('../lib/gameimage');
const blob = await renderGameImage(view.game, moves, { actionLabel: moveActionLabel });
const file = new File([blob], `game-${id.slice(0, 8)}.png`, { type: 'image/png' });
await shareOrDownloadImage(file);
} catch (e) {
handleError(e);
}
}
// The GCG export copies to the clipboard in an Android in-app WebView (no Web Share, a dead
// <a download>): VKWebAppCopyText inside VK — which also works in the desktop VK iframe, where
// navigator.clipboard is blocked — and navigator.clipboard elsewhere.
@@ -1274,7 +1306,7 @@
icon pinned right (the header is space-between). -->
<span aria-hidden="true"></span>
{:else}
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
<button class="hicon" onclick={onExportClick} aria-label={t('game.exportGcg')}>📤</button>
{/if}
{:else}
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
@@ -1456,6 +1488,25 @@
</Modal>
{/if}
{#if exportOpen}
<Modal title={t('game.exportGcg')} onclose={() => (exportOpen = false)}>
<div class="export-opts">
{#if exportImageAvailable}
<button class="confirm" onclick={() => { exportOpen = false; void exportImage(); }}>
{t('game.exportImageOpt')}
</button>
{/if}
<button
class={exportImageAvailable ? 'export-alt' : 'confirm'}
onclick={() => { exportOpen = false; void exportGcg(); }}
disabled={!connection.online}
>
{t('game.exportGcgOpt')}
</button>
</div>
</Modal>
{/if}
<style>
.scoreboard {
position: relative;
@@ -1840,6 +1891,25 @@
color: #fff !important;
border-color: var(--danger) !important;
}
/* Export chooser: the image option leads (accent .confirm), the GCG file is the quiet
alternative below it. */
.export-opts {
display: flex;
flex-direction: column;
gap: 8px;
}
.export-alt {
width: 100%;
padding: 11px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-weight: 600;
}
.export-alt:disabled {
opacity: 0.5;
}
/* --- Landscape (wide) layout ------------------------------------------------------------
When the viewport is wider than tall the game lays out as two columns: a left panel (rack,
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest';
import { buildScoresheet, layoutImage, formatFinishedDate, MIN_SIDE } from './gameimage';
import type { GameView, MoveRecord, Seat } from './model';
function seat(n: number, name: string, score: number, winner = false): Seat {
return { seat: n, accountId: `a${n}`, displayName: name, score, hintsUsed: 0, isWinner: winner };
}
function game(seats: Seat[], over = true): GameView {
return {
id: 'g1',
variant: 'scrabble_ru',
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
unreadMessages: false,
status: over ? 'finished' : 'active',
players: seats.length,
toMove: 0,
turnTimeoutSecs: 300,
multipleWordsPerTurn: false,
moveCount: 0,
endReason: over ? 'standard' : '',
lastActivityUnix: 1_782_997_629, // 2026-07-02 (UTC)
seats,
};
}
function play(player: number, words: string[], score: number, total: number, dir: 'H' | 'V' = 'H'): MoveRecord {
return { player, action: 'play', dir, mainRow: 7, mainCol: 7, tiles: [], words, count: words.length, score, total };
}
// A non-play move carries the seat's current running total on the wire (engine game.go
// sets Total: g.scores[player] on pass/exchange/resign) — the fixture mirrors that.
function action(player: number, act: 'pass' | 'exchange' | 'resign', total = 0): MoveRecord {
return { player, action: act, dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total };
}
const label = (a: string) => `<${a}>`;
describe('buildScoresheet', () => {
it('builds per-seat header, coordinated play rows and localized actions', () => {
const g = game([seat(0, 'Аня', 118, true), seat(1, 'Боб', 92)]);
const moves = [
play(0, ['ГРОТ'], 24, 24),
action(1, 'exchange'),
play(0, ['МЫС'], 18, 42, 'V'),
play(1, ['СОН'], 12, 12),
];
const s = buildScoresheet(g, moves, label, { hostname: 'erudit-game.ru', dateLocale: 'ru' });
expect(s.header).toEqual([
{ name: 'Аня', score: 118, winner: true },
{ name: 'Боб', score: 92, winner: false },
]);
expect(s.rows).toHaveLength(2);
expect(s.rows[0][0]).toMatchObject({ kind: 'play', coord: '8H', words: ['ГРОТ'], score: 24 });
expect(s.rows[0][1]).toMatchObject({ kind: 'action', label: '<exchange>' });
expect(s.rows[1][0]).toMatchObject({ kind: 'play', coord: 'H8' }); // vertical → letter first
expect(s.rows[1][1]).toMatchObject({ kind: 'play', coord: '8H', words: ['СОН'] });
expect(s.footer.startsWith('erudit-game.ru · ')).toBe(true);
expect(s.footer).toContain('2026');
});
it('derives the endgame rack settlement from final scores vs last running totals', () => {
// Seat 0 went out (+6 from the opponent rack), seat 1 lost its rack value (6).
const g = game([seat(0, 'A', 48, true), seat(1, 'B', 24)]);
const moves = [play(0, ['X'], 42, 42), play(1, ['Y'], 30, 30)];
const s = buildScoresheet(g, moves, label, { hostname: 'h' });
expect(s.adjustments).toEqual([6, -6]);
expect(s.hasAdjustments).toBe(true);
});
it('reports no settlement when the final scores match the journal (resignation)', () => {
const g = game([seat(0, 'A', 42, true), seat(1, 'B', 30)]);
const moves = [play(0, ['X'], 42, 42), play(1, ['Y'], 30, 30), action(1, 'resign', 30)];
const s = buildScoresheet(g, moves, label, { hostname: 'h' });
expect(s.adjustments).toEqual([0, 0]);
expect(s.hasAdjustments).toBe(false);
});
it('keeps one column per seat for a four-player game', () => {
const g = game([seat(0, 'A', 10), seat(1, 'B', 20, true), seat(2, 'C', 5), seat(3, 'D', 0)]);
const moves = [play(0, ['X'], 10, 10), play(1, ['Y'], 20, 20), play(2, ['Z'], 5, 5)];
const s = buildScoresheet(g, moves, label, { hostname: 'h' });
expect(s.header).toHaveLength(4);
expect(s.rows[0]).toHaveLength(4);
expect(s.rows[0][3]).toEqual({ kind: 'empty' });
});
});
describe('layoutImage', () => {
const short = (seats = 2) =>
buildScoresheet(
game(Array.from({ length: seats }, (_, i) => seat(i, `P${i}`, 0))),
[play(0, ['ГРОТ'], 24, 24)],
label,
{ hostname: 'h' },
);
it('clamps the board to MIN_SIDE for a short game', () => {
const l = layoutImage(short());
expect(l.board.side).toBe(MIN_SIDE);
expect(l.board.cell).toBeCloseTo(MIN_SIDE / 15);
});
it('stretches the board (and the image) to a long scoresheet, keeping the typography fixed', () => {
const moves: MoveRecord[] = [];
for (let i = 0; i < 80; i++) moves.push(play(i % 2, ['СЛОВО'], 10, 10 * i));
const s = buildScoresheet(game([seat(0, 'A', 300, true), seat(1, 'B', 290)]), moves, label, { hostname: 'h' });
const l = layoutImage(s);
expect(l.board.side).toBeGreaterThan(MIN_SIDE);
// The column content exactly fills the board height (that is the stretch contract).
const content =
l.headNameH + l.headScoreH + l.headRuleH + l.rowHeights.reduce((a, b) => a + b, 0) + l.adjRowH;
expect(l.board.side).toBe(Math.ceil(content));
expect(l.h).toBe(l.margin + l.axis + l.board.side + l.footerH + l.margin);
});
it('gives a row the extra sub-line only when a play formed several words', () => {
const s = buildScoresheet(
game([seat(0, 'A', 0), seat(1, 'B', 0)]),
[play(0, ['ОСА', 'ОМ'], 12, 12), play(1, ['ДОМ'], 8, 8)],
label,
{ hostname: 'h' },
);
const l = layoutImage(s);
expect(l.rowHeights[0]).toBe(l.rowH + l.subRowH);
expect(l.rowHeights).toHaveLength(1);
});
it('widens the sheet per seat', () => {
const two = layoutImage(short(2));
const four = layoutImage(short(4));
expect(four.hist.w).toBeGreaterThan(two.hist.w);
expect(four.w - four.hist.w).toBe(two.w - two.hist.w); // board block unchanged
});
});
describe('formatFinishedDate', () => {
it('formats with the given locale (regional convention, long month)', () => {
const s = formatFinishedDate(1_782_997_629, 'ru');
expect(s).toContain('2026');
expect(s).toMatch(/июн|июл/); // TZ-dependent day, but always mid-summer
});
});
+452
View File
@@ -0,0 +1,452 @@
// 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 — and the module is reached only through a dynamic import, so
// it stays out of the startup bundle. The scoresheet typography is fixed; the board instead
// stretches to the scoresheet's height (never below MIN_SIDE), so a long game grows the
// board rather than leaving a hole under it.
//
// buildScoresheet and layoutImage are pure (vitest-covered); renderGameImage is the thin
// canvas pass exercised by the Playwright e2e.
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
* (regional date conventions, not the UI language) — e.g. "3 июля 2026 г., 16:23".
* dateLocale is for tests; production passes undefined = the system default. */
export function formatFinishedDate(unixSec: number, dateLocale?: string): string {
return new Intl.DateTimeFormat(dateLocale, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).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 },
): 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)}`,
};
}
// 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;
/** Canvas pixel scale (default 2 — crisp on hi-dpi screens and in chats). */
scale?: number;
}
/**
* renderGameImage draws the finished game as a PNG blob: 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).
*/
export async function renderGameImage(
game: GameView,
moves: MoveRecord[],
opts: RenderOptions,
): Promise<Blob> {
const sheet = buildScoresheet(game, moves, opts.actionLabel, {
hostname: opts.hostname,
dateLocale: opts.dateLocale,
});
const layout = layoutImage(sheet);
const scale = opts.scale ?? 2;
const canvas = document.createElement('canvas');
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);
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('gameimage: toBlob failed'))), 'image/png');
});
}
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);
}
}
}
+23 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { historyGrid, type HistoryCell } from './history';
import { historyGrid, moveCoord, type HistoryCell } from './history';
import type { MoveRecord } from './model';
function play(player: number, words: string[], score: number): MoveRecord {
@@ -67,4 +67,26 @@ describe('historyGrid', () => {
expect(kinds(grid)).toEqual([['play', 'empty']]);
expect(grid[0][0]).toMatchObject({ words: ['ДОМ'] });
});
it('stamps a play cell with its classic coordinate', () => {
const grid = historyGrid([play(0, ['ДОМ'], 12)], 1, null);
expect(grid[0][0]).toMatchObject({ kind: 'play', coord: '8H' });
});
});
describe('moveCoord', () => {
// The classic notation the GCG export uses (backend gcgPos): an across play is
// row-then-column ("8G"), a down play column-then-row ("H8").
it('puts the row number first for an across play', () => {
expect(moveCoord({ dir: 'H', mainRow: 7, mainCol: 6 })).toBe('8G');
});
it('puts the column letter first for a down play', () => {
expect(moveCoord({ dir: 'V', mainRow: 7, mainCol: 7 })).toBe('H8');
});
it('covers the board corners', () => {
expect(moveCoord({ dir: 'H', mainRow: 0, mainCol: 0 })).toBe('1A');
expect(moveCoord({ dir: 'V', mainRow: 14, mainCol: 14 })).toBe('O15');
});
});
+16 -1
View File
@@ -16,11 +16,26 @@ export interface HistoryCell {
words?: string[];
/** Points scored by the move — set for kind 'play'. */
score?: number;
/** The main word's board coordinate in classic notation (see moveCoord) — set for kind
* 'play'. The in-game panel does not show it; the game-image export does. */
coord?: string;
/** The non-play action ('pass' | 'exchange' | 'resign' | 'timeout' | …) — set for kind
* 'action'; the component localizes it. */
action?: string;
}
/**
* moveCoord renders a play's board coordinate in the classic notation the GCG export uses
* (the backend's gcgPos): row-then-column (e.g. "8G") for an across play, column-then-row
* ("H8") for a down play. Rows are 1-based numbers top to bottom, columns letters from "A"
* left to right.
*/
export function moveCoord(mv: Pick<MoveRecord, 'dir' | 'mainRow' | 'mainCol'>): string {
const col = String.fromCharCode(65 + mv.mainCol);
const row = String(mv.mainRow + 1);
return mv.dir === 'V' ? col + row : row + col;
}
/**
* historyGrid arranges the move journal into a row-major matrix with one column per seat.
* Each seat's moves fill its column top to bottom, so a seat's k-th move lands in row k of
@@ -40,7 +55,7 @@ export function historyGrid(
if (m.player < 0 || m.player >= seatCount) continue; // defensive: drop an out-of-range seat
cols[m.player].push(
m.action === 'play'
? { kind: 'play', player: m.player, words: m.words, score: m.score }
? { kind: 'play', player: m.player, words: m.words, score: m.score, coord: moveCoord(m) }
: { kind: 'action', player: m.player, action: m.action },
);
}
+3 -1
View File
@@ -310,7 +310,9 @@ export const en = {
'stats.maxWord': 'Best move',
'stats.guestHint': 'Sign in to track your statistics.',
'game.exportGcg': 'Export GCG',
'game.exportGcg': 'Export game',
'game.exportImageOpt': 'Image (PNG)',
'game.exportGcgOpt': 'GCG file',
'game.gcgActiveOnly': 'Available once the game is finished.',
'game.gcgCopied': 'GCG copied to the clipboard.',
'game.addFriendShort': 'Add friend?',
+3 -1
View File
@@ -310,7 +310,9 @@ export const ru: Record<MessageKey, string> = {
'stats.maxWord': 'Лучший ход',
'stats.guestHint': 'Войдите, чтобы вести статистику.',
'game.exportGcg': 'Экспорт GCG',
'game.exportGcg': 'Экспорт партии',
'game.exportImageOpt': 'Картинка (PNG)',
'game.exportGcgOpt': 'Файл GCG',
'game.gcgActiveOnly': 'Доступно после завершения игры.',
'game.gcgCopied': 'GCG скопирован в буфер обмена.',
'game.addFriendShort': 'В друзья?',
+50 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { pickGcgDelivery, pickTextShare, shareOrDownloadGcg, shareText } from './share';
import { pickGcgDelivery, pickImageDelivery, pickTextShare, shareOrDownloadGcg, shareOrDownloadImage, shareText } from './share';
import type { GcgExport } from './model';
const file = {} as File;
@@ -83,6 +83,55 @@ describe('shareOrDownloadGcg', () => {
});
});
describe('pickImageDelivery', () => {
const canShareNav = { canShare: () => true, share: async () => {} };
const noShareNav = { canShare: () => false, share: async () => {} };
it('shares when the platform can share files', () => {
expect(pickImageDelivery(canShareNav, file)).toBe('share');
});
it('downloads on a plain browser without Web Share (desktop)', () => {
expect(pickImageDelivery(noShareNav, file)).toBe('download');
expect(pickImageDelivery(undefined, file)).toBe('download');
});
});
describe('shareOrDownloadImage', () => {
afterEach(() => vi.unstubAllGlobals());
function stubEnv(canShare: boolean, share: () => Promise<void>) {
const anchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() };
const createElement = vi.fn(() => anchor);
vi.stubGlobal('navigator', { canShare: () => canShare, share });
vi.stubGlobal('document', { createElement, body: { appendChild: vi.fn() } });
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:x'), revokeObjectURL: vi.fn() });
return { anchor, createElement };
}
const png = () => ({ name: 'game-1.png', type: 'image/png' }) as File;
it('never falls back to the navigating Blob download when a share is cancelled', async () => {
const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError'));
const { createElement } = stubEnv(true, share);
expect(await shareOrDownloadImage(png())).toBe('shared');
expect(share).toHaveBeenCalledOnce();
expect(createElement).not.toHaveBeenCalled();
});
it('downloads via an anchor on a desktop browser that cannot share files', async () => {
const { anchor, createElement } = stubEnv(false, vi.fn());
expect(await shareOrDownloadImage(png())).toBe('downloaded');
expect(createElement).toHaveBeenCalledWith('a');
expect(anchor.click).toHaveBeenCalledOnce();
expect(anchor.download).toBe('game-1.png');
});
});
describe('pickTextShare', () => {
it('shares when Web Share is available', () => {
expect(pickTextShare({ share: async () => {}, canShare: () => true })).toBe('share');
+43 -2
View File
@@ -67,9 +67,10 @@ export async function shareOrDownloadGcg(
return 'downloaded';
}
function downloadFile(content: string, filename: string): void {
function downloadFile(content: Blob | string, filename: string, type = 'application/x-gcg'): void {
if (typeof document === 'undefined') return;
const url = URL.createObjectURL(new Blob([content], { type: 'application/x-gcg' }));
const blob = typeof content === 'string' ? new Blob([content], { type }) : content;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
@@ -79,6 +80,46 @@ function downloadFile(content: string, filename: string): void {
URL.revokeObjectURL(url);
}
/**
* pickImageDelivery decides how to deliver the PNG game image: Web Share (with the file)
* wherever it exists — mobile browsers and the iOS Telegram Mini App — else a Blob download.
* The image option is not offered inside the in-app WebViews at all (Android Telegram/VK, the
* desktop VK iframe): a binary PNG has no working route there until the server-rendered
* signed-URL delivery lands, so this decision never sees them. Pure, unit-tested with a mock
* navigator.
*/
export function pickImageDelivery(nav: ShareNav | undefined, file: File): 'share' | 'download' {
if (
nav &&
typeof nav.canShare === 'function' &&
typeof nav.share === 'function' &&
nav.canShare({ files: [file] })
) {
return 'share';
}
return 'download';
}
/**
* shareOrDownloadImage delivers the rendered game image and reports the route taken. The Web
* Share invariant matches shareOrDownloadGcg: a cancelled or failed share is a deliberate
* no-op, never a Blob-download fallback (an <a download> strands the iOS WKWebView on the
* blob: URL).
*/
export async function shareOrDownloadImage(file: File): Promise<'shared' | 'downloaded'> {
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
if (pickImageDelivery(nav, file) === 'share' && nav) {
try {
await nav.share({ files: [file], title: file.name });
} catch {
/* cancelled or failed — intentionally a no-op (see shareOrDownloadGcg) */
}
return 'shared';
}
downloadFile(file, file.name, file.type);
return 'downloaded';
}
type TextShareNav = Pick<Navigator, 'share'> & { canShare?: Navigator['canShare'] };
/**