From a0eacf301106f6bc8cfcf4a41984f78384f21ba8 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 2 Jul 2026 16:20:35 +0200 Subject: [PATCH] feat(game): finished-game export as a PNG image behind a format chooser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The history header's export button now opens a chooser — Telegram's native popup inside Telegram, the app's own modal elsewhere — offering the GCG file and a new client-rendered PNG of the final position (lib/gameimage, Canvas 2D, lazy dynamic import, zero dependencies): light theme, classic A..O/1..15 axes, label-free premium fills, and a fixed-typography per-seat scoresheet with GCG-style move coordinates, multi-word sub-lines, endgame rack-settlement row, winner trophy and a hostname + device-locale finish date footer; a long game stretches the board, never the typography. Delivery mirrors the GCG rules (Web Share with no blob fallback, else download) except on Android Telegram/VK WebViews and the desktop VK iframe, where a binary PNG has no clipboard-text fallback: those get a preview modal with a long-press/right-click save hint and a copy-image button where ClipboardItem exists. --- docs/FUNCTIONAL.md | 37 ++- docs/FUNCTIONAL_ru.md | 36 ++- docs/UI_DESIGN.md | 33 ++- ui/e2e/export.spec.ts | 90 +++++++ ui/e2e/quickmatch.spec.ts | 2 +- ui/e2e/social.spec.ts | 6 +- ui/src/game/Game.svelte | 122 +++++++++- ui/src/lib/gameimage.test.ts | 146 +++++++++++ ui/src/lib/gameimage.ts | 452 +++++++++++++++++++++++++++++++++++ ui/src/lib/history.test.ts | 24 +- ui/src/lib/history.ts | 17 +- ui/src/lib/i18n/en.ts | 8 +- ui/src/lib/i18n/ru.ts | 8 +- ui/src/lib/share.test.ts | 65 ++++- ui/src/lib/share.ts | 87 ++++++- 15 files changed, 1097 insertions(+), 36 deletions(-) create mode 100644 ui/e2e/export.spec.ts create mode 100644 ui/src/lib/gameimage.test.ts create mode 100644 ui/src/lib/gameimage.ts diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 2b0af0d..0e510bb 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 5bb3c73..448de8c 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -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. Статистика (только у постоянных аккаунтов): победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все образованные им слова и бонус за все фишки). Также показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index b25dc6c..e0a7ed6 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -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 (`@`), 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 diff --git a/ui/e2e/export.spec.ts b/ui/e2e/export.spec.ts new file mode 100644 index 0000000..f5d7154 --- /dev/null +++ b/ui/e2e/export.spec.ts @@ -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 { + 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 { + 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 . + 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(); +}); diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts index 36c27f1..ddf85d2 100644 --- a/ui/e2e/quickmatch.spec.ts +++ b/ui/e2e/quickmatch.spec.ts @@ -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); }); diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 742044f..fe12a2a 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -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(); // 💬 diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 21082a3..f2eba4a 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -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 // ): 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). --> {:else} - + {/if} {:else} @@ -1452,6 +1509,33 @@ {/if} +{#if exportOpen} + (exportOpen = false)}> +
+ + +
+
+{/if} + +{#if exportPreviewUrl} + + {t('game.exportGcg')} +

{t('game.imageSaveHint')}

+ {#if canCopyImage()} + + {/if} +
+{/if} +