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
+32 -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,37 @@ 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 always the app's **own
modal** — deliberately not Telegram's native popup, whose callback runs without
user activation and silently breaks the share/clipboard delivery it leads to.
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** is Web-Shared or downloaded the
same way, but a binary image has no clipboard-text fallback at all — so the image
option is currently **withheld inside the in-app webviews** (Telegram / VK) and
offered on the plain web and mobile browsers only; it returns there with the
server-rendered signed-URL delivery (Telegram `downloadFile` /
`VKWebAppDownloadFile`). 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
+31 -7
View File
@@ -16,7 +16,7 @@ top-1 подсказку, безлимитную проверку слова с
профиль только для чтения. Он также включает управление друзьями (в т.ч.
одноразовые коды-приглашения) и блоками, дружеские приглашения в игру,
редактирование профиля и привязку email, экран статистики и просмотр истории
партии с экспортом GCG. В настройках также выбирается стиль подписей бонус-клеток
партии с экспортом в GCG / PNG-картинку. В настройках также выбирается стиль подписей бонус-клеток
(новичок / классика / без текста). Подсказка **выставляет предложенные фишки на
доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет.
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
@@ -327,12 +327,36 @@ Telegram.
канал для тех, кто пишет боту напрямую, а не через приложение.
### История и статистика
Завершённые партии архивируются в независимом от словаря виде и экспортируются
в GCG; экспорт доступен **только после завершения партии** и никогда — для
тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а партия
с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает;
в Android-приложении (Telegram / VK), где нет ни Web Share, ни рабочей загрузки файла, копирует GCG
в буфер обмена (с подтверждающим тостом); иначе скачивает файл. Статистика (только у постоянных аккаунтов):
Завершённые партии архивируются в независимом от словаря виде и экспортируются в
**двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка**
финальной позиции; экспорт доступен **только после завершения партии** и никогда —
для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а
партия с ИИ одноразовая). Выбор формата — всегда **собственный модал** приложения,
сознательно не нативный попап Telegram: его коллбек приходит без user activation и
молча ломает доставку через share/буфер, к которой ведёт выбор.
**Картинка** рендерится на клиенте (Canvas 2D, всегда светлая тема): финальная
доска с классическими осями координат A..O / 1..15 слева — бонус-клетки чистой
цветовой заливкой, без текстовых подписей — и компактная таблица ходов по местам
справа: в шапке имя и финальный счёт каждого места (🏆 у победителя), далее по
строке на ход: классическая координата главного слова (горизонтальный ход —
цифра первой, `8G`; вертикальный — буква первой, `H8` — конвенция GCG), слово и
его очки; дополнительные слова мультисловного хода — второй мелкой строкой,
не-игровые ходы — локализованными пометками (пас/обмен/сдался/таймаут), а
замыкающая строка ± показывает концовочную поправку за рэк, когда она была
(финальные счета авторитетны — суммы ходов её не содержат). В подвале — хост сайта
и дата завершения в локали устройства. Типографика таблицы фиксирована; длинная
партия растягивает доску (не ниже минимума), так что пустот на картинке нет.
Доставка по форматам: файлом **GCG** клиент делится через Web Share там, где
платформа это поддерживает; в Android-приложении (Telegram / VK), где нет ни Web
Share, ни рабочей загрузки файла, копирует текст GCG в буфер обмена (с
подтверждающим тостом); иначе скачивает файл. **PNG** делится и скачивается так же,
но у бинарной картинки нет текстового фолбэка через буфер вовсе — поэтому пункт
«картинка» пока **скрыт во встроенных webview** (Telegram / VK) и предлагается
только в обычном вебе и мобильных браузерах; туда он вернётся с серверным рендером
и доставкой по подписанному URL (Telegram `downloadFile` /
`VKWebAppDownloadFile`). Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки). Также
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
+27 -8
View File
@@ -403,15 +403,34 @@ 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 the **format chooser**: always the app's own modal, never Telegram's native popup
(a `showPopup` callback carries no user activation, which silently breaks the
`navigator.share` / clipboard delivery it leads to — on-device finding). The accent button
leads with the image where offered, 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 option shows **only outside the in-app
WebViews** for now (a binary PNG has no working route there — no Web Share, a dead
`<a download>`, a text-only clipboard, and the long-press menu mangles data: URLs); it
returns there with the server-rendered signed-URL delivery. 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
+87
View File
@@ -0,0 +1,87 @@
import { expect, test, type Page } from './fixtures';
// The finished-game export: the history header's 📤 button opens the app's own format
// chooser modal — never Telegram's native popup, whose callback runs without user
// activation and so kills navigator.share / clipboard writes (verified on-device).
// The PNG option is offered only outside the in-app WebViews (no working binary
// delivery there until the server-rendered signed-URL path lands); the GCG file is
// always offered. g3 ("vs Rick") is the seeded finished game. Web Share is
// force-disabled per test so both engines deterministically exercise the download
// 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 offers both formats on the plain web.
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 app modal and withholds the image option', async ({
page,
}) => {
await disableWebShare(page);
// A Telegram WebApp stub with showPopup present: were the chooser still native, it would
// be called — the spy locks the regression (its activation-less callback breaks share and
// clipboard delivery on real devices).
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 { __popupCalled?: boolean }).__popupCalled = true;
setTimeout(() => cb(''), 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 app's own modal opened with the GCG option only — no PNG in an in-app WebView…
await expect(page.getByRole('button', { name: 'GCG file' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Image (PNG)' })).toHaveCount(0);
// …and the native popup was never used for the chooser.
expect(
await page.evaluate(() => (window as unknown as { __popupCalled?: boolean }).__popupCalled),
).toBeFalsy();
});
+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(); // 💬
+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'] };
/**