Compare commits

...
8 Commits
Author SHA1 Message Date
branderson a84ba7f6de Merge pull request 'Retry image builds once after a GHCR 429' (#17) from ci-retry-ghcr into main
Images / app (push) Successful in 4m56s
Images / session-manager (push) Successful in 1m49s
Images / streamer (push) Successful in 3m40s
Reviewed-on: #17
Reviewed-by: branderson <brad@branderson.io>
2026-10-15 21:29:32 -07:00
branderson d45aa8b603 ci: retry forge image builds once after a GHCR 429 too 2026-10-15 21:28:37 -07:00
branderson d54a44da1d ci: retry GitHub image builds once after a GHCR 429
The streamer's base image pull from ghcr.io intermittently gets 429 Too
Many Requests on GitHub's shared runners, failing the first attempt of
the last two runs; re-running minutes later succeeded both times.
2026-10-15 20:05:38 -07:00
branderson bf175b64a2 Merge pull request 'Retake screenshots on made-up demo data' (#16) from demo-screenshots into main
Images / app (push) Successful in 5m47s
Images / session-manager (push) Successful in 7m22s
Images / streamer (push) Failing after 1m14s
Reviewed-on: #16
Reviewed-by: branderson <brad@branderson.io>
2026-10-15 19:47:24 -07:00
branderson 245f499f04 docs: note restarting the app between screenshot runs 2026-10-15 19:37:05 -07:00
branderson c1350387b1 docs: draw demo game art as genre scenes
Tiles and screenshots were abstract shapes. Each is now a small scene for
its genre, with several layouts per genre steered by the game's tags, so
no two games look alike. The screenshot script pins the browser's date so
the Game of the Day doesn't change between runs.
2026-10-15 19:36:42 -07:00
branderson 5357622c2d docs: retake screenshots on made-up demo data
The README's screenshots showed real games whose authors never agreed to
be featured. scripts/demo/build-demo-data.mjs builds a Flashpoint install
of invented games with generated art, plus Pixel Penguin Panic, a small
HTML5 game written for it. scripts/demo/take-screenshots.mjs retakes the
screenshots from an app running on it.
2026-10-15 19:13:11 -07:00
branderson 89194dd94a Merge pull request 'Publish only a latest image tag' (#15) from latest-tag-only into main
Images / app (push) Successful in 4m49s
Images / session-manager (push) Successful in 1m43s
Images / streamer (push) Successful in 3m32s
Reviewed-on: #15
Reviewed-by: branderson <brad@branderson.io>
2026-10-15 17:35:04 -07:00
12 changed files with 2529 additions and 2 deletions
+22 -1
View File
@@ -78,7 +78,28 @@ jobs:
images: ${{ steps.registry.outputs.host }}/${{ steps.registry.outputs.owner }}/${{ matrix.image }}
tags: type=raw,value=latest
- uses: docker/build-push-action@v6
# GHCR sometimes answers the streamer's base image pull with 429 Too Many
# Requests; it clears within minutes, so retry once.
- id: build
uses: docker/build-push-action@v6
continue-on-error: true
with:
context: ${{ matrix.context }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
build-args: |
VERSION=${{ steps.version.outputs.sha }}
provenance: false
- name: Wait before retrying
if: steps.build.outcome == 'failure'
run: sleep 120
- name: Retry the build
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
push: true
+22 -1
View File
@@ -58,7 +58,28 @@ jobs:
images: ghcr.io/${{ steps.owner.outputs.name }}/${{ matrix.image }}
tags: type=raw,value=latest
- uses: docker/build-push-action@v6
# GHCR sometimes answers the streamer's base image pull with 429 Too Many
# Requests; it clears within minutes, so retry once.
- id: build
uses: docker/build-push-action@v6
continue-on-error: true
with:
context: ${{ matrix.context }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
build-args: |
VERSION=${{ steps.version.outputs.sha }}
provenance: false
- name: Wait before retrying
if: steps.build.outcome == 'failure'
run: sleep 120
- name: Retry the build
if: steps.build.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
push: true
+1
View File
@@ -17,6 +17,7 @@ the Dotcom Arcade application.
7. **[Common Pitfalls](./common-pitfalls.md)** - Known issues and solutions
8. **[Upstream Sync](./upstream-sync.md)** - Merging changes from flashpoint-web
9. **[GitHub Mirror](./github-mirror.md)** - Publishing to GitHub and GHCR
10. **[Demo Data](./demo-data.md)** - Made-up games for development and screenshots
## Quick Start
+45
View File
@@ -0,0 +1,45 @@
# Demo data
`scripts/demo/build-demo-data.mjs` builds a small Flashpoint install of made-up
games, for developing without a 2.3 TB download and for taking the README's
screenshots. Everything in it is invented:
- about 50 games with fake titles, studios and descriptions;
- generated tile art and screenshots;
- four curated playlists;
- **Pixel Penguin Panic**, a tiny HTML5 game written for the demo, which is
the only one that plays.
The database uses Flashpoint 14's schema (`scripts/demo/flashpoint-schema.sql`,
from [FPA-Rust](https://github.com/FlashpointProject/FPA-Rust), MIT).
## Using it
```sh
npm run install:all
node scripts/demo/build-demo-data.mjs /tmp/demo-flashpoint
```
Then point the app at it, with `FLASHPOINT_PATH=/tmp/demo-flashpoint` in
`backend/.env` for development, or `FLASHPOINT_HOST_PATH` in `.env` for Docker.
## Screenshots
The README's screenshots in `docs/images` come from the demo data, so they show
no real games. To retake them after a UI change:
1. Build the frontend (`npm run build`), then run the app on the demo data with
a fresh user database, serving the built frontend (`SERVE_FRONTEND=true`).
2. Install Playwright: `npm install --no-save playwright && npx playwright install chromium`.
3. Run `node scripts/demo/take-screenshots.mjs http://localhost:3100`. Restart
the app with a fresh user database before running it again: repeated runs
trip the login rate limit.
The script creates the first account, plays a few games for "Jump back in",
fixes the browser's date so the Game of the Day is always the same, moves the
Time Machine to the week the demo fills, and writes `home.png`, `game.png` and
`playing.png`.
Each game's tile and screenshot is a small scene drawn for its genre (a road
for driving games, a block grid for puzzles, and so on), with layouts varied by
the game's tags and seeded by its title, so every build draws the same art.
+1
View File
@@ -127,6 +127,7 @@ for Flashpoint Ultimate.
- [Common Pitfalls](08-development/common-pitfalls.md) - Known issues
- [Upstream Sync](08-development/upstream-sync.md) - Merging changes from flashpoint-web
- [GitHub Mirror](08-development/github-mirror.md) - Publishing to GitHub and GHCR
- [Demo Data](08-development/demo-data.md) - Made-up games for development and screenshots
### 09. Deployment
Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 800 KiB

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 730 KiB

After

Width:  |  Height:  |  Size: 248 KiB

File diff suppressed because it is too large Load Diff
+246
View File
@@ -0,0 +1,246 @@
-- Flashpoint 14's database schema, the result of the migrations in
-- https://github.com/FlashpointProject/FPA-Rust (crates/flashpoint-archive),
-- Copyright (c) 2024 Flashpoint Archive, MIT License.
CREATE TABLE "additional_app" (
"id" varchar NOT NULL,
"applicationPath" varchar NOT NULL,
"autoRunBefore" boolean NOT NULL,
"launchCommand" varchar NOT NULL,
"name" varchar NOT NULL COLLATE NOCASE,
"waitForExit" boolean NOT NULL,
"parentGameId" varchar,
PRIMARY KEY("id")
);
CREATE TABLE "custom_id_order" (
"id" VARCHAR NOT NULL
);
CREATE TABLE ext_data (
"extId" varchar NOT NULL,
"gameId" varchar NOT NULL,
"data" jsonb,
PRIMARY KEY(extId, gameId)
);
CREATE TABLE "game" (
"id" varchar NOT NULL,
"parentGameId" varchar,
"title" varchar NOT NULL,
"alternateTitles" varchar NOT NULL,
"series" varchar NOT NULL,
"developer" varchar NOT NULL,
"publisher" varchar NOT NULL,
"dateAdded" datetime NOT NULL,
"dateModified" datetime NOT NULL DEFAULT (datetime('now')),
"broken" boolean NOT NULL,
"extreme" boolean NOT NULL,
"playMode" varchar NOT NULL,
"status" varchar NOT NULL,
"notes" varchar NOT NULL,
"source" varchar NOT NULL,
"applicationPath" varchar NOT NULL,
"launchCommand" varchar NOT NULL,
"releaseDate" varchar NOT NULL,
"version" varchar NOT NULL,
"originalDescription" varchar NOT NULL,
"language" varchar NOT NULL,
"library" varchar NOT NULL,
"orderTitle" varchar NOT NULL,
"activeDataId" integer,
"activeDataOnDisk" boolean NOT NULL DEFAULT (0),
"tagsStr" varchar NOT NULL DEFAULT ('') COLLATE NOCASE,
"platformsStr" varchar,
"platformId" integer,
"lastPlayed" datetime,
"playtime" integer DEFAULT 0,
"playCounter" integer DEFAULT 0,
"archiveState" integer DEFAULT 2,
"activeGameConfigId" integer,
"activeGameConfigOwner" varchar COLLATE NOCASE, "platformName" varchar COLLATE NOCASE, "ruffleSupport" varchar NOT NULL DEFAULT '', "logoPath" varchar, "screenshotPath" varchar, "owner" varchar,
PRIMARY KEY("id"),
CONSTRAINT "FK_45a9180069d42ac8231ff11acd0" FOREIGN KEY("parentGameId") REFERENCES "game"("id") ON DELETE NO ACTION ON UPDATE NO ACTION
);
CREATE TABLE "game_config" (
"id" integer NOT NULL,
"gameId" varchar NOT NULL COLLATE NOCASE,
"name" varchar NOT NULL COLLATE NOCASE,
"owner" varchar NOT NULL COLLATE NOCASE,
"middleware" varchar,
PRIMARY KEY("id" AUTOINCREMENT)
);
CREATE TABLE "game_data" (
"id" integer NOT NULL,
"gameId" varchar,
"title" varchar NOT NULL,
"dateAdded" datetime NOT NULL,
"sha256" varchar NOT NULL,
"crc32" integer NOT NULL,
"presentOnDisk" boolean NOT NULL DEFAULT (0),
"path" varchar,
"size" integer NOT NULL,
"parameters" varchar,
"applicationPath" varchar,
"launchCommand" varchar,
PRIMARY KEY("id" AUTOINCREMENT),
CONSTRAINT "UQ_gameid_dateadded" UNIQUE("gameId", "dateAdded"),
CONSTRAINT "FK_gamedata_gameid" FOREIGN KEY("gameId") REFERENCES "game"("id") ON DELETE NO ACTION ON UPDATE NO ACTION
);
CREATE TABLE "game_platforms_platform" (
"gameId" varchar NOT NULL,
"platformId" integer NOT NULL,
PRIMARY KEY("gameId","platformId")
);
CREATE TABLE "game_redirect" (
"id" VARCHAR NOT NULL,
"sourceId" VARCHAR NOT NULL,
"dateAdded" datetime,
PRIMARY KEY("id", "sourceId")
);
CREATE TABLE "game_tags_tag" (
"gameId" varchar NOT NULL,
"tagId" integer NOT NULL,
PRIMARY KEY("gameId","tagId")
);
CREATE TABLE "platform" (
"id" integer NOT NULL,
"dateModified" datetime NOT NULL DEFAULT (datetime('now')),
"primaryAliasId" integer,
"description" varchar NOT NULL DEFAULT '',
CONSTRAINT "REL_platform_primary_alias_unique" UNIQUE("primaryAliasId"),
PRIMARY KEY("id" AUTOINCREMENT)
);
CREATE TABLE "platform_alias" (
"id" integer NOT NULL,
"platformId" integer,
"name" varchar NOT NULL COLLATE NOCASE,
CONSTRAINT "UQ_platform_alias_name_unique" UNIQUE("name"),
PRIMARY KEY("id" AUTOINCREMENT)
);
CREATE TABLE "tag" (
"id" integer NOT NULL,
"dateModified" datetime NOT NULL DEFAULT (datetime('now')),
"primaryAliasId" integer,
"categoryId" integer,
"description" varchar NOT NULL DEFAULT '',
CONSTRAINT "REL_3c002904ab97fb1b4e61e8493c" UNIQUE("primaryAliasId"),
PRIMARY KEY("id" AUTOINCREMENT)
);
CREATE TABLE "tag_alias" (
"id" integer NOT NULL,
"tagId" integer,
"name" varchar NOT NULL COLLATE NOCASE,
CONSTRAINT "UQ_34d6ff6807129b3b193aea26789" UNIQUE("name"),
PRIMARY KEY("id" AUTOINCREMENT)
);
CREATE TABLE "tag_category" (
"id" integer NOT NULL,
"name" varchar NOT NULL COLLATE NOCASE,
"color" varchar NOT NULL,
"description" varchar,
CONSTRAINT "UQ_tag_category_name" UNIQUE("name"),
PRIMARY KEY("id" AUTOINCREMENT)
);
CREATE TABLE "tag_filter_index" (
"id" VARCHAR NOT NULL,
PRIMARY KEY("id")
);
CREATE TABLE "tag_filter_index_info" (
"key" VARCHAR NOT NULL, dirty INTEGER DEFAULT 1,
PRIMARY KEY("key")
);
CREATE INDEX "IDX_34d6ff6807129b3b193aea2678" ON "tag_alias" (
"name"
);
CREATE INDEX "IDX_6366e7093c3571f85f1b5ffd4f" ON "game_tags_tag" (
"gameId"
);
CREATE INDEX "IDX_d12253f0cbce01f030a9ced11d" ON "game_tags_tag" (
"tagId"
);
CREATE INDEX "IDX_ext_data_extId" ON "ext_data" (
"extId"
);
CREATE INDEX "IDX_ext_data_gameId" ON "ext_data" (
"gameId"
);
CREATE INDEX "IDX_gameTitle" ON "game" (
"title"
);
CREATE INDEX "IDX_game_activeDataId" ON "game" (
"activeDataId"
);
CREATE INDEX "IDX_game_config_game_id" ON "game_config" (
"gameId"
);
CREATE INDEX "IDX_lookup_dateAdded" ON "game" (
"library",
"dateAdded"
);
CREATE INDEX "IDX_lookup_dateModified" ON "game" (
"library",
"dateModified"
);
CREATE INDEX "IDX_lookup_developer" ON "game" (
"library",
"developer"
);
CREATE INDEX "IDX_lookup_lastPlayed" ON "game" (
"library",
"lastPlayed"
);
CREATE INDEX "IDX_lookup_playtime" ON "game" (
"library",
"playtime"
);
CREATE INDEX "IDX_lookup_publisher" ON "game" (
"library",
"publisher"
);
CREATE INDEX "IDX_lookup_series" ON "game" (
"library",
"series"
);
CREATE INDEX "IDX_lookup_title" ON "game" (
"library",
"title"
);
CREATE INDEX "IDX_redirect_sourceId" ON "game_redirect" (
"sourceId"
);
CREATE INDEX "IDX_total" ON "game" (
"library",
"broken",
"extreme"
);
+109
View File
@@ -0,0 +1,109 @@
<!doctype html>
<!-- Pixel Penguin Panic: a tiny game made for Dotcom Arcade's demo data. Catch
the fish, dodge the rocks. Plays itself until a key is pressed. -->
<html>
<head>
<meta charset="utf-8">
<title>Pixel Penguin Panic</title>
<style>
html, body { margin: 0; height: 100%; background: #0b1730; }
canvas { display: block; width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated; }
</style>
</head>
<body>
<canvas id="game" width="640" height="480"></canvas>
<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const W = 640, H = 480, GROUND = 420;
let penguin = { x: W / 2, dir: 0 }, things = [], score = 0, best = 0, lives = 3;
let demo = true, tick = 0, flash = 0;
const keys = {};
addEventListener('keydown', (e) => { keys[e.key] = true; if (demo) { demo = false; reset(); } });
addEventListener('keyup', (e) => { keys[e.key] = false; });
canvas.addEventListener('pointerdown', () => { if (demo) { demo = false; reset(); } });
canvas.addEventListener('pointermove', (e) => {
if (demo) return;
const r = canvas.getBoundingClientRect();
penguin.x = ((e.clientX - r.left) / r.width) * W;
});
function reset() { things = []; score = 0; lives = 3; penguin.x = W / 2; }
function spawn() {
const rock = Math.random() < 0.3;
things.push({ x: 30 + Math.random() * (W - 60), y: -20, vy: 2 + Math.random() * 2 + score / 400, rock });
}
function update() {
tick++;
if (tick % 28 === 0) spawn();
if (demo) {
const fish = things.filter((t) => !t.rock).sort((a, b) => b.y - a.y)[0];
const target = fish ? fish.x : W / 2;
penguin.x += Math.max(-5, Math.min(5, target - penguin.x));
} else {
if (keys.ArrowLeft || keys.a) penguin.x -= 6;
if (keys.ArrowRight || keys.d) penguin.x += 6;
}
penguin.x = Math.max(24, Math.min(W - 24, penguin.x));
for (const t of things) {
t.y += t.vy;
if (t.y > GROUND - 40 && t.y < GROUND && Math.abs(t.x - penguin.x) < 30) {
t.done = true;
if (t.rock) { lives--; flash = 12; if (lives <= 0) { best = Math.max(best, score); reset(); } }
else score += 10;
}
if (t.y > H) t.done = true;
}
things = things.filter((t) => !t.done);
if (flash > 0) flash--;
}
function drawPenguin(x) {
const y = GROUND;
ctx.fillStyle = '#111827'; ctx.beginPath(); ctx.ellipse(x, y - 26, 22, 28, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#f8fafc'; ctx.beginPath(); ctx.ellipse(x, y - 22, 14, 20, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.fillRect(x - 9, y - 46, 7, 7); ctx.fillRect(x + 3, y - 46, 7, 7);
ctx.fillStyle = '#000'; ctx.fillRect(x - 6, y - 44, 3, 4); ctx.fillRect(x + 6, y - 44, 3, 4);
ctx.fillStyle = '#f59e0b'; ctx.fillRect(x - 5, y - 36, 10, 5); ctx.fillRect(x - 16, y - 2, 12, 4); ctx.fillRect(x + 4, y - 2, 12, 4);
}
function drawFish(t) {
ctx.fillStyle = '#38bdf8';
ctx.beginPath(); ctx.ellipse(t.x, t.y, 14, 8, 0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.moveTo(t.x + 12, t.y); ctx.lineTo(t.x + 22, t.y - 8); ctx.lineTo(t.x + 22, t.y + 8); ctx.fill();
ctx.fillStyle = '#000'; ctx.fillRect(t.x - 8, t.y - 3, 3, 3);
}
function drawRock(t) {
ctx.fillStyle = '#64748b';
ctx.beginPath(); ctx.arc(t.x, t.y, 13, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#94a3b8'; ctx.fillRect(t.x - 6, t.y - 7, 5, 4);
}
function draw() {
const sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, '#1e3a8a'); sky.addColorStop(1, '#7dd3fc');
ctx.fillStyle = sky; ctx.fillRect(0, 0, W, H);
ctx.fillStyle = 'rgba(255,255,255,0.8)';
for (let i = 0; i < 40; i++) ctx.fillRect((i * 97 + tick * 0.3) % W, (i * 53) % 260, 2, 2);
ctx.fillStyle = '#e0f2fe'; ctx.fillRect(0, GROUND, W, H - GROUND);
ctx.fillStyle = '#bae6fd'; for (let x = 0; x < W; x += 40) ctx.fillRect(x, GROUND, 20, 6);
things.forEach((t) => (t.rock ? drawRock(t) : drawFish(t)));
drawPenguin(penguin.x);
if (flash % 4 > 1) { ctx.fillStyle = 'rgba(239,68,68,0.3)'; ctx.fillRect(0, 0, W, H); }
ctx.fillStyle = '#fff'; ctx.font = 'bold 20px monospace';
ctx.fillText(`SCORE ${String(score).padStart(5, '0')}`, 16, 32);
ctx.fillText('♥'.repeat(lives), W - 80, 32);
ctx.textAlign = 'center';
ctx.font = 'bold 34px monospace'; ctx.fillText('PIXEL PENGUIN PANIC', W / 2, 90);
if (demo && Math.floor(tick / 30) % 2) { ctx.font = 'bold 18px monospace'; ctx.fillText('PRESS ANY KEY', W / 2, 124); }
ctx.textAlign = 'left';
}
(function loop() { update(); draw(); requestAnimationFrame(loop); })();
</script>
</body>
</html>
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env node
// Takes the README's screenshots (docs/images) from an app running on the demo
// data, with a fresh user database. See docs/08-development/demo-data.md.
//
// node scripts/demo/take-screenshots.mjs [base URL, default http://localhost:3100]
//
// Needs Playwright: npm install --no-save playwright && npx playwright install chromium
import { dirname, join } from 'node:path';
import { chromium } from 'playwright';
const base = process.argv[2] ?? 'http://localhost:3100';
const images = join(dirname(new URL(import.meta.url).pathname), '../../docs/images');
const user = { username: 'demo', email: 'demo@example.com', password: 'demo-password' };
const browser = await chromium.launch();
const context = await browser.newContext({
baseURL: base,
viewport: { width: 1280, height: 1060 },
});
const page = await context.newPage();
const api = context.request;
async function call(method, url, data) {
const response = await api.fetch(url, { method, data });
if (!response.ok())
throw new Error(`${method} ${url}: ${response.status()} ${await response.text()}`);
return response.json();
}
const { needsSetup } = await call('GET', '/api/auth/setup-status');
if (needsSetup) await call('POST', '/api/auth/register', user);
const { tokens } = await call('POST', '/api/auth/login', {
username: user.username,
password: user.password,
});
const auth = { Authorization: `Bearer ${tokens.accessToken}` };
async function findGame(title) {
const response = await api.post('/api/games', {
data: { search: title, limit: 1 },
headers: auth,
});
const [game] = (await response.json()).data;
if (game?.title !== title) throw new Error(`demo game not found: ${title}`);
return game;
}
// Recently played, oldest first, for the homepage's "Jump back in"
for (const title of [
'Moon Mower',
'Jelly Jump',
'Laser Llama',
'Lemonade Empire',
'Crayon Kart',
'Drum Doodle',
'Pocket Pirates',
]) {
const game = await findGame(title);
const start = await api.post('/api/play/start', {
data: { gameId: game.id, gameTitle: game.title },
headers: auth,
});
const { sessionId } = await start.json();
await api.post('/api/play/end', { data: { sessionId }, headers: auth });
}
async function settle() {
await page.waitForLoadState('networkidle');
await page.waitForFunction(() => [...document.images].every((image) => image.complete));
await page.waitForTimeout(500);
}
// The Game of the Day follows the browser's date; this one picks The Lost Floppy
await page.clock.setFixedTime(new Date('2026-06-08T12:00:00'));
await page.goto('/login');
await page.getByPlaceholder('Enter your username').fill(user.username);
await page.getByPlaceholder('Enter your password').fill(user.password);
await page.locator('button[type="submit"]').click();
await page.waitForURL((url) => url.pathname !== '/login');
await page.goto('/');
await settle();
// The Time Machine opens on a random week; travel to the one the demo fills
await page.getByTitle('Pick a date').click();
await page.getByRole('combobox', { name: /month/i }).selectOption({ label: 'Jun' });
await page.getByRole('combobox', { name: /year/i }).selectOption('2008');
await page.getByRole('button', { name: /June 16(th)?, 2008/ }).click();
await settle();
await page.screenshot({ path: join(images, 'home.png') });
const penguin = await findGame('Pixel Penguin Panic');
await page.goto(`/browse/${penguin.id}`);
await settle();
await page.screenshot({ path: join(images, 'game.png') });
await page
.getByRole('button', { name: /^play$/i })
.first()
.click();
await page.waitForTimeout(6000);
await page.screenshot({ path: join(images, 'playing.png') });
await browser.close();
console.log(`Screenshots written to ${images}`);