feat(game): finished-game export as a PNG image behind a format chooser
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 59s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s

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.
This commit is contained in:
Ilia Denisov
2026-07-02 16:20:35 +02:00
parent 2ab01ed8f7
commit a0eacf3011
15 changed files with 1097 additions and 36 deletions
+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');
});
});