feat(game): finished-game export as a PNG image behind a format chooser #159

Merged
developer merged 2 commits from feature/game-export-image into development 2026-07-02 15:29:41 +00:00
15 changed files with 1097 additions and 36 deletions
Showing only changes of commit a0eacf3011 - Show all commits
+30 -7
View File
@@ -16,7 +16,7 @@ the top-1 hint, the unlimited word-check with complaint, per-game chat and nudge
real-time in-app updates, switching interface language (en/ru) and theme, and a
read-only profile. It also handles managing friends (including one-time friend
codes) and blocks, friend-game invitations, editing the profile and binding an
email, the statistics screen, and the in-game history viewer with GCG export.
email, the statistics screen, and the in-game history viewer with GCG / PNG-image export.
Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and
costs nothing when the rack has no legal move. The word-check accepts only the
variant's alphabet, remembers answers within the session and rate-limits repeats.
@@ -318,12 +318,35 @@ silently ignores their messages) or clear a thread's messages. This is independe
Feedback above — it serves people who write to the bot directly rather than through the app.
### History & statistics
Finished games are archived in a dictionary-independent form and exportable to
GCG; the export is offered **only once a game is finished**, and never for an
honest-AI practice game (a live game's export would leak the move journal; an AI
game is throwaway). The client shares the `.gcg` file where the platform supports
it; on an Android in-app client (Telegram / VK), which has neither Web Share nor a working file
download, it copies the GCG to the clipboard (with a confirming toast); otherwise it downloads the file. Statistics (durable accounts only):
Finished games are archived in a dictionary-independent form and exportable in
**two formats behind one 📤 button** — the GCG file and a rendered **PNG image** of
the final position; the export is offered **only once a game is finished**, and
never for an honest-AI practice game (a live game's export would leak the move
journal; an AI game is throwaway). The format chooser is native where the platform
has one (Telegram's popup), the app's own modal elsewhere.
The **image** is rendered on the client (Canvas 2D, always the light theme): the
final board with classic coordinate axes A..O / 1..15 on the left — premium squares
as plain colour fills, no text labels — and a compact per-seat scoresheet on the
right: each seat's name and final score in the header (🏆 by the winner), then one
row per move carrying the main word's classic coordinate (an across play is
row-first, `8G`; a down play column-first, `H8` — the GCG convention), the word and
its points; extra words of a multi-word play ride a smaller second line, non-play
moves show as localized notes (pass/exchange/resign/timeout), and a closing ± row
shows the endgame rack settlement when there was one (the final scores are
authoritative — running totals alone do not include it). The footer stamps the site
host and the finish date in the device locale. The scoresheet typography is fixed;
a long game stretches the board (never below its minimum) so the image carries no
dead space.
Delivery per format: the **GCG** file is Web-Shared where the platform supports it;
on an Android in-app client (Telegram / VK), which has neither Web Share nor a
working file download, it copies the GCG text to the clipboard (with a confirming
toast); otherwise it downloads the file. The **PNG** shares or downloads the same
way where possible, but a binary image has no clipboard-text fallback — those
in-app webviews (and the desktop VK iframe) get a **preview modal** instead: the
image with a long-press / right-click save hint and a copy-image button where the
clipboard-image API exists. Statistics (durable accounts only):
wins, losses, draws, max points in a game, and max points for a single move (the
best play, which already includes every word it formed plus the all-tiles bonus). It
also shows the player's **move count** (their plays — passes and exchanges do not
+29 -7
View File
@@ -16,7 +16,7 @@ top-1 подсказку, безлимитную проверку слова с
профиль только для чтения. Он также включает управление друзьями (в т.ч.
одноразовые коды-приглашения) и блоками, дружеские приглашения в игру,
редактирование профиля и привязку email, экран статистики и просмотр истории
партии с экспортом GCG. В настройках также выбирается стиль подписей бонус-клеток
партии с экспортом в GCG / PNG-картинку. В настройках также выбирается стиль подписей бонус-клеток
(новичок / классика / без текста). Подсказка **выставляет предложенные фишки на
доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет.
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
@@ -327,12 +327,34 @@ Telegram.
канал для тех, кто пишет боту напрямую, а не через приложение.
### История и статистика
Завершённые партии архивируются в независимом от словаря виде и экспортируются
в GCG; экспорт доступен **только после завершения партии** и никогда — для
тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а партия
с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает;
в Android-приложении (Telegram / VK), где нет ни Web Share, ни рабочей загрузки файла, копирует GCG
в буфер обмена (с подтверждающим тостом); иначе скачивает файл. Статистика (только у постоянных аккаунтов):
Завершённые партии архивируются в независимом от словаря виде и экспортируются в
**двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка**
финальной позиции; экспорт доступен **только после завершения партии** и никогда —
для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а
партия с ИИ одноразовая). Выбор формата — нативный диалог там, где платформа его
даёт (попап Telegram), иначе собственный модал приложения.
**Картинка** рендерится на клиенте (Canvas 2D, всегда светлая тема): финальная
доска с классическими осями координат A..O / 1..15 слева — бонус-клетки чистой
цветовой заливкой, без текстовых подписей — и компактная таблица ходов по местам
справа: в шапке имя и финальный счёт каждого места (🏆 у победителя), далее по
строке на ход: классическая координата главного слова (горизонтальный ход —
цифра первой, `8G`; вертикальный — буква первой, `H8` — конвенция GCG), слово и
его очки; дополнительные слова мультисловного хода — второй мелкой строкой,
не-игровые ходы — локализованными пометками (пас/обмен/сдался/таймаут), а
замыкающая строка ± показывает концовочную поправку за рэк, когда она была
(финальные счета авторитетны — суммы ходов её не содержат). В подвале — хост сайта
и дата завершения в локали устройства. Типографика таблицы фиксирована; длинная
партия растягивает доску (не ниже минимума), так что пустот на картинке нет.
Доставка по форматам: файлом **GCG** клиент делится через Web Share там, где
платформа это поддерживает; в Android-приложении (Telegram / VK), где нет ни Web
Share, ни рабочей загрузки файла, копирует текст GCG в буфер обмена (с
подтверждающим тостом); иначе скачивает файл. **PNG** делится и скачивается так
же, где возможно, но у бинарной картинки нет текстового фолбэка через буфер — в
этих встроенных webview (и в десктопном iframe VK) вместо него **модал-превью**:
картинка с подсказкой «долгое нажатие / правый клик — сохранить» и кнопкой
копирования картинки там, где есть clipboard-image API. Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки). Также
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
+25 -8
View File
@@ -403,15 +403,32 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
lobby with a calm modal pointing at the bot (`@<username>`), not a red error on the
Friends tab. Flex text inputs carry `min-width:0` so they shrink instead of overflowing in
Safari.
- **History / GCG**: the in-game slide-down history lays each move out in a per-seat grid
(the word(s) and the move score, no running total); *Export GCG* (the 📤 in the history
header) delivers the `.gcg` file — Web Share where available, a clipboard copy in an Android in-app
WebView (Telegram / VK, which has neither Web Share nor a working download), else a Blob download —
and appears only once the game is finished — and
never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board:
it closes the history drawer (portrait) and zooms the board out.
- **History / export**: the in-game slide-down history lays each move out in a per-seat grid
(the word(s) and the move score, no running total); the 📤 in the history header appears
only once the game is finished — never in an honest-AI game (throwaway practice) — and
opens a **format chooser**: Telegram's native popup inside Telegram (image / GCG / cancel),
the app's own modal elsewhere (the accent button leads with the image, the GCG file is the
quiet alternative; GCG needs the connection, the image renders locally). The GCG file goes
by Web Share where available, a clipboard copy in an Android in-app WebView (Telegram / VK,
which has neither Web Share nor a working download), else a Blob download. The image goes by
Web Share / Blob download too, but on those in-app WebViews (and the desktop VK iframe) it
opens a **preview modal** instead — the PNG as a data: URL with a long-press / right-click
save hint and a copy-image button where `ClipboardItem` exists. Confirming a resign reveals
the full board: it closes the history drawer (portrait) and zooms the board out.
- **Export image** (`lib/gameimage.ts`, Canvas 2D, dynamically imported): always the light
palette (pinned constants mirroring `app.css`), the final board with classic axes A..O /
1..15, premium squares as plain colour fills (no text labels), tiles in the in-game style
(bevel, letter top-left, value bottom-right, ✻ for an Erudit blank), no last-move
highlight. The right column is the scoresheet: per-seat name over the final score (🏆 by
the winner), one fixed-typography row per move — muted classic coordinate (across =
row-first `8G`, down = column-first `H8`), the main word, points right-aligned; extra
words of a multi-word play on a smaller second line; italic localized notes for
pass/exchange/resign/timeout; a closing muted ± row for the endgame rack settlement when
present. Footer: `hostname · finish date` in the **device** locale. The scoresheet's
height drives the board side (never below its minimum): a long game stretches the board,
never the typography, so the image has no dead space.
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
offers *Export GCG* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and
offers *Export game* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and
the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than
hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input
row is turn-driven: on your turn the message field shows until your one allowed message is
+90
View File
@@ -0,0 +1,90 @@
import { expect, test, type Page } from './fixtures';
// The finished-game export: the history header's 📤 button opens a format chooser —
// the GCG file or the rendered PNG image (lib/gameimage) — as a native Telegram popup
// inside Telegram and as the app's own modal elsewhere. g3 ("vs Rick") is the seeded
// finished game. Web Share is force-disabled per test so every engine deterministically
// exercises the download (plain browser) or preview (in-app webview) branch.
async function disableWebShare(page: Page): Promise<void> {
await page.addInitScript(() => {
Object.defineProperty(navigator, 'canShare', { value: () => false, configurable: true });
Object.defineProperty(navigator, 'share', { value: undefined, configurable: true });
});
}
async function openFinishedHistory(page: Page): Promise<void> {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /Rick/ }).click();
await expect(page.locator('[data-cell]').first()).toBeVisible();
await page.locator('.scoreboard').click();
await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible();
}
test('the export chooser downloads the PNG image on a plain browser', async ({ page }) => {
await disableWebShare(page);
await openFinishedHistory(page);
await page.getByRole('button', { name: 'Export game' }).click();
// The app's own chooser modal (not inside Telegram) offers both formats.
await expect(page.getByRole('button', { name: 'GCG file' })).toBeVisible();
const download = page.waitForEvent('download');
await page.getByRole('button', { name: 'Image (PNG)' }).click();
expect((await download).suggestedFilename()).toMatch(/^game-.+\.png$/);
});
test('the export chooser downloads the GCG file on a plain browser', async ({ page }) => {
await disableWebShare(page);
await openFinishedHistory(page);
await page.getByRole('button', { name: 'Export game' }).click();
const download = page.waitForEvent('download');
await page.getByRole('button', { name: 'GCG file' }).click();
expect((await download).suggestedFilename()).toMatch(/^game-.+\.gcg$/);
});
test('inside Telegram the chooser is the native popup and the image lands in the preview modal', async ({
page,
}) => {
await disableWebShare(page);
// A Telegram WebApp stub whose showPopup records its params and picks the image option —
// the native-chooser path. With no Web Share in the webview, the image must land in the
// long-press preview modal (the Android Telegram delivery), never a dead <a download>.
await page.addInitScript(() => {
Object.assign(window, {
Telegram: {
WebApp: {
initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef',
initDataUnsafe: {},
ready() {},
expand() {},
showPopup(params: unknown, cb: (id: string) => void) {
(window as unknown as { __popup?: unknown }).__popup = params;
setTimeout(() => cb('image'), 0);
},
},
},
});
});
await page.goto('/');
await expect(page.getByText('Your turn')).toBeVisible(); // auto-authenticated lobby
await page.getByRole('button', { name: /Rick/ }).click();
await expect(page.locator('[data-cell]').first()).toBeVisible();
await page.locator('.scoreboard').click();
await page.getByRole('button', { name: 'Export game' }).click();
// The native popup carried both formats plus cancel (≤3 buttons, Bot API 6.2)…
const popup = await page.evaluate(
() => (window as unknown as { __popup?: { buttons?: { id?: string; type?: string }[] } }).__popup,
);
expect(popup?.buttons?.map((b) => b.id ?? b.type)).toEqual(['image', 'gcg', 'cancel']);
// …and no in-app chooser modal was mounted.
await expect(page.getByRole('button', { name: 'GCG file' })).toHaveCount(0);
// The preview modal shows the rendered PNG as a data: URL (long-press-saveable) + the hint.
const img = page.locator('.export-preview');
await expect(img).toBeVisible();
expect(await img.getAttribute('src')).toMatch(/^data:image\/png/);
await expect(page.locator('.export-hint')).toBeVisible();
});
+1 -1
View File
@@ -80,7 +80,7 @@ test('AI game: after it ends, no GCG export and no comms entry', async ({ page }
// Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and
// no comms entry at all.
await page.locator('.scoreboard').click();
await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Export game' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Chat' })).toHaveCount(0);
});
+3 -3
View File
@@ -154,14 +154,14 @@ test('GCG export appears only for a finished game', async ({ page }) => {
// The finished game vs Kaya exposes export 📤 in the history header.
await page.getByRole('button', { name: /Kaya/ }).click();
await page.locator('.scoreboard').click(); // open the history
await expect(page.getByRole('button', { name: 'Export GCG' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible();
});
test('GCG export is hidden for an active game', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Ann/ }).click();
await page.locator('.scoreboard').click(); // open the history (shows 🏁 leave, not 📤 export)
await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Export game' })).toHaveCount(0);
});
test('finished game draws an inert footer and trims live-only controls', async ({ page }) => {
@@ -172,7 +172,7 @@ test('finished game draws an inert footer and trims live-only controls', async (
await expect(page.locator('.tab').first()).toBeDisabled();
// The history header offers Export GCG, not Drop game, once the game is over.
await page.locator('.scoreboard').click();
await expect(page.getByRole('button', { name: 'Export GCG' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Drop game' })).toHaveCount(0);
// The comms hub offers Chat only — the Dictionary tab is hidden for a finished game.
await page.getByRole('button', { name: 'Chat' }).click(); // 💬
+119 -3
View File
@@ -22,12 +22,12 @@
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 { blobToDataURL, canCopyImage, copyImageToClipboard, 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';
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
import { insideTelegram, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram';
import { insideTelegram, telegramDialogsAvailable, telegramShowConfirm, telegramShowPopup } from '../lib/telegram';
import { haptic } from '../lib/haptics';
import {
BLANK,
@@ -927,6 +927,30 @@
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: a native Telegram popup where available, the app's own modal
// elsewhere (the onResignClick pattern).
let exportOpen = $state(false);
let exportPreviewUrl = $state('');
let exportBlob: Blob | null = null;
async function onExportClick() {
if (insideTelegram() && telegramDialogsAvailable()) {
const choice = await telegramShowPopup({
message: t('game.exportChoice'),
buttons: [
{ id: 'image', type: 'default', text: t('game.exportImageOpt') },
{ id: 'gcg', type: 'default', text: t('game.exportGcgOpt') },
{ type: 'cancel' },
],
});
if (choice === 'image') void exportImage();
else if (choice === 'gcg') void exportGcg();
return;
}
exportOpen = true;
}
async function exportGcg() {
try {
const gcg = await gateway.exportGcg(id);
@@ -938,6 +962,39 @@
}
}
// 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 / download where they
// work, else the preview modal (Android Telegram/VK WebView and the desktop VK iframe, where
// neither exists for a binary file) with a long-press/right-click save hint.
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' });
const outcome = await shareOrDownloadImage(file, insideTelegram() || insideVK());
if (outcome === 'preview') {
exportBlob = blob;
exportPreviewUrl = await blobToDataURL(blob);
}
} catch (e) {
handleError(e);
}
}
function closeExportPreview() {
exportPreviewUrl = '';
exportBlob = null;
}
async function copyExportImage() {
if (exportBlob && (await copyImageToClipboard(exportBlob))) {
showToast(t('game.imageCopied'), 'info');
} else {
showToast(t('error.generic'), 'error');
}
}
// 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.
@@ -1270,7 +1327,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>
@@ -1452,6 +1509,33 @@
</Modal>
{/if}
{#if exportOpen}
<Modal title={t('game.exportGcg')} onclose={() => (exportOpen = false)}>
<div class="export-opts">
<button class="confirm" onclick={() => { exportOpen = false; void exportImage(); }}>
{t('game.exportImageOpt')}
</button>
<button
class="export-alt"
onclick={() => { exportOpen = false; void exportGcg(); }}
disabled={!connection.online}
>
{t('game.exportGcgOpt')}
</button>
</div>
</Modal>
{/if}
{#if exportPreviewUrl}
<Modal title={t('game.exportImageOpt')} onclose={closeExportPreview}>
<img class="export-preview" src={exportPreviewUrl} alt={t('game.exportGcg')} />
<p class="export-hint">{t('game.imageSaveHint')}</p>
{#if canCopyImage()}
<button class="confirm" onclick={copyExportImage}>{t('game.imageCopy')}</button>
{/if}
</Modal>
{/if}
<style>
.scoreboard {
position: relative;
@@ -1836,6 +1920,38 @@
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;
}
/* Preview modal (webviews with no Web Share / download): the image itself is the artifact —
the user saves it with a long-press (or right-click in the desktop VK iframe). */
.export-preview {
display: block;
width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.export-hint {
margin: 8px 0;
color: var(--text-muted);
font-size: 0.85rem;
}
/* --- 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 },
);
}
+7 -1
View File
@@ -310,7 +310,13 @@ export const en = {
'stats.maxWord': 'Best move',
'stats.guestHint': 'Sign in to track your statistics.',
'game.exportGcg': 'Export GCG',
'game.exportGcg': 'Export game',
'game.exportChoice': 'Export the finished game as:',
'game.exportImageOpt': 'Image (PNG)',
'game.exportGcgOpt': 'GCG file',
'game.imageSaveHint': 'Long-press (or right-click) the image to save it.',
'game.imageCopy': 'Copy image',
'game.imageCopied': 'Image copied to the clipboard.',
'game.gcgActiveOnly': 'Available once the game is finished.',
'game.gcgCopied': 'GCG copied to the clipboard.',
'game.addFriendShort': 'Add friend?',
+7 -1
View File
@@ -310,7 +310,13 @@ export const ru: Record<MessageKey, string> = {
'stats.maxWord': 'Лучший ход',
'stats.guestHint': 'Войдите, чтобы вести статистику.',
'game.exportGcg': 'Экспорт GCG',
'game.exportGcg': 'Экспорт партии',
'game.exportChoice': 'Экспортировать завершённую партию как:',
'game.exportImageOpt': 'Картинка (PNG)',
'game.exportGcgOpt': 'Файл GCG',
'game.imageSaveHint': 'Чтобы сохранить картинку — долгое нажатие (или правый клик).',
'game.imageCopy': 'Скопировать картинку',
'game.imageCopied': 'Картинка скопирована в буфер обмена.',
'game.gcgActiveOnly': 'Доступно после завершения игры.',
'game.gcgCopied': 'GCG скопирован в буфер обмена.',
'game.addFriendShort': 'В друзья?',
+64 -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,69 @@ describe('shareOrDownloadGcg', () => {
});
});
describe('pickImageDelivery', () => {
const canShareNav = { canShare: () => true, share: async () => {} };
const noShareNav = { canShare: () => false, share: async () => {} };
it('shares when the platform can share files, even in an in-app webview (iOS)', () => {
expect(pickImageDelivery(canShareNav, file, false)).toBe('share');
expect(pickImageDelivery(canShareNav, file, true)).toBe('share');
});
it('previews in an in-app webview that cannot share files (a binary PNG has no clipboard-text fallback)', () => {
expect(pickImageDelivery(noShareNav, file, true)).toBe('preview');
expect(pickImageDelivery(undefined, file, true)).toBe('preview');
});
it('downloads on a plain browser without Web Share (desktop)', () => {
expect(pickImageDelivery(noShareNav, file, false)).toBe('download');
expect(pickImageDelivery(undefined, file, false)).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(), false)).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(), false)).toBe('downloaded');
expect(createElement).toHaveBeenCalledWith('a');
expect(anchor.click).toHaveBeenCalledOnce();
expect(anchor.download).toBe('game-1.png');
});
it('hands an in-app webview over to the preview modal (no share, no dead download)', async () => {
const { createElement } = stubEnv(false, vi.fn());
expect(await shareOrDownloadImage(png(), true)).toBe('preview');
expect(createElement).not.toHaveBeenCalled();
});
});
describe('pickTextShare', () => {
it('shares when Web Share is available', () => {
expect(pickTextShare({ share: async () => {}, canShare: () => true })).toBe('share');
+85 -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,88 @@ function downloadFile(content: string, filename: string): void {
URL.revokeObjectURL(url);
}
/**
* pickImageDelivery decides how to deliver the PNG game image. Web Share (with the file) wins
* wherever it exists — mobile browsers and the iOS Telegram Mini App; a plain desktop browser
* downloads the Blob. The remaining webviews (Android Telegram/VK, the desktop VK iframe) can
* neither share a file nor honour <a download>, and unlike the tiny GCG text a binary PNG has
* no clipboard-text fallback — there the caller shows the image in a preview modal ('preview')
* for a long-press/right-click save, with a clipboard-image copy where supported. Pure, so it
* is unit-tested with a mock navigator.
*/
export function pickImageDelivery(
nav: ShareNav | undefined,
file: File,
inAppWebView: boolean,
): 'share' | 'download' | 'preview' {
if (
nav &&
typeof nav.canShare === 'function' &&
typeof nav.share === 'function' &&
nav.canShare({ files: [file] })
) {
return 'share';
}
return inAppWebView ? 'preview' : 'download';
}
/**
* shareOrDownloadImage delivers the rendered game image by the best available route and reports
* which it took — 'shared', 'downloaded' or 'preview' (the caller opens its preview modal). 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,
inAppWebView: boolean,
): Promise<'shared' | 'downloaded' | 'preview'> {
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
const decision = pickImageDelivery(nav, file, inAppWebView);
if (decision === 'share' && nav) {
try {
await nav.share({ files: [file], title: file.name });
} catch {
/* cancelled or failed — intentionally a no-op (see shareOrDownloadGcg) */
}
return 'shared';
}
if (decision === 'download') downloadFile(file, file.name, file.type);
return decision === 'download' ? 'downloaded' : 'preview';
}
/** canCopyImage reports whether the clipboard-image API (ClipboardItem) is available, so the
* preview modal offers a copy button only where it can work. */
export function canCopyImage(): boolean {
return (
typeof navigator !== 'undefined' &&
typeof ClipboardItem !== 'undefined' &&
typeof navigator.clipboard?.write === 'function'
);
}
/** copyImageToClipboard writes the PNG blob to the system clipboard (where canCopyImage). */
export async function copyImageToClipboard(blob: Blob): Promise<boolean> {
if (!canCopyImage()) return false;
try {
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
return true;
} catch {
return false;
}
}
/** blobToDataURL inlines a blob as a data: URL — the preview modal's <img> source (a data URL
* survives long-press "save image" in webviews where a blob: URL may not). */
export function blobToDataURL(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result));
r.onerror = () => reject(r.error ?? new Error('blobToDataURL failed'));
r.readAsDataURL(blob);
});
}
type TextShareNav = Pick<Navigator, 'share'> & { canShare?: Navigator['canShare'] };
/**