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.
This commit is contained in:
2026-10-15 19:13:11 -07:00
parent 89194dd94a
commit 5357622c2d
10 changed files with 1215 additions and 0 deletions
+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
+38
View File
@@ -0,0 +1,38 @@
# 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`.
The script creates the first account, plays a few games for "Jump back in",
moves the Time Machine to the week the demo fills, and writes `home.png`,
`game.png` and `playing.png`.
+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: 459 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 730 KiB

After

Width:  |  Height:  |  Size: 252 KiB

+718
View File
@@ -0,0 +1,718 @@
#!/usr/bin/env node
// Builds a small, made-up Flashpoint install for trying the app and taking the
// README's screenshots: invented games with generated art, curated playlists,
// and one playable HTML5 game. Nothing in it comes from real games.
//
// node scripts/demo/build-demo-data.mjs <dir>
//
// Needs the backend's dependencies (npm run install:all). Replaces <dir>'s
// database, images and playlists if they exist.
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { deflateSync } from 'node:zlib';
const here = dirname(new URL(import.meta.url).pathname);
const require = createRequire(join(here, '../../backend/package.json'));
const Database = require('better-sqlite3');
const out = process.argv[2];
if (!out) {
console.error('usage: node scripts/demo/build-demo-data.mjs <dir>');
process.exit(2);
}
// --- Images ------------------------------------------------------------------
const crcTable = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(buf) {
let c = 0xffffffff;
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const head = Buffer.alloc(8);
head.writeUInt32BE(data.length, 0);
head.write(type, 4, 'ascii');
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
return Buffer.concat([head, data, crc]);
}
class Canvas {
constructor(width, height) {
this.width = width;
this.height = height;
this.px = Buffer.alloc(width * height * 3);
}
set(x, y, [r, g, b], alpha = 1) {
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
const i = (y * this.width + x) * 3;
this.px[i] = this.px[i] * (1 - alpha) + r * alpha;
this.px[i + 1] = this.px[i + 1] * (1 - alpha) + g * alpha;
this.px[i + 2] = this.px[i + 2] * (1 - alpha) + b * alpha;
}
gradient(top, bottom) {
for (let y = 0; y < this.height; y++) {
const t = y / (this.height - 1);
const color = top.map((c, i) => c + (bottom[i] - c) * t);
for (let x = 0; x < this.width; x++) this.set(x, y, color);
}
}
rect(x0, y0, w, h, color, alpha = 1) {
[x0, y0, w, h] = [x0, y0, w, h].map(Math.round);
for (let y = Math.max(0, y0); y < Math.min(this.height, y0 + h); y++)
for (let x = Math.max(0, x0); x < Math.min(this.width, x0 + w); x++)
this.set(x, y, color, alpha);
}
/** Fills the pixels inside(x, y) accepts, within a bounding box */
shape(x0, y0, x1, y1, color, inside, alpha = 1) {
for (let y = Math.floor(y0); y <= y1; y++)
for (let x = Math.floor(x0); x <= x1; x++)
if (inside(x + 0.5, y + 0.5)) this.set(x, y, color, alpha);
}
circle(cx, cy, r, color, alpha = 1) {
this.shape(
cx - r,
cy - r,
cx + r,
cy + r,
color,
(x, y) => (x - cx) ** 2 + (y - cy) ** 2 <= r * r,
alpha
);
}
polygon(points, color, alpha = 1) {
const xs = points.map((p) => p[0]);
const ys = points.map((p) => p[1]);
this.shape(
Math.min(...xs),
Math.min(...ys),
Math.max(...xs),
Math.max(...ys),
color,
(x, y) => {
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const [xi, yi] = points[i];
const [xj, yj] = points[j];
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
},
alpha
);
}
png() {
const raw = Buffer.alloc((this.width * 3 + 1) * this.height);
for (let y = 0; y < this.height; y++) {
this.px.copy(raw, y * (this.width * 3 + 1) + 1, y * this.width * 3, (y + 1) * this.width * 3);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(this.width, 0);
ihdr.writeUInt32BE(this.height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // truecolor
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', deflateSync(raw, { level: 9 })),
chunk('IEND', Buffer.alloc(0)),
]);
}
}
// A 5x7 pixel font, for titles on the generated art
const FONT = {
A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'],
D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'],
E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
G: ['01110', '10001', '10000', '10111', '10001', '10001', '01111'],
H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
I: ['01110', '00100', '00100', '00100', '00100', '00100', '01110'],
J: ['00111', '00010', '00010', '00010', '00010', '10010', '01100'],
K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
N: ['10001', '10001', '11001', '10101', '10011', '10001', '10001'],
O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'],
R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'],
V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'],
W: ['10001', '10001', '10001', '10101', '10101', '10101', '01010'],
X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'],
Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'],
0: ['01110', '10001', '10011', '10101', '11001', '10001', '01110'],
1: ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
2: ['01110', '10001', '00001', '00010', '00100', '01000', '11111'],
3: ['11111', '00010', '00100', '00010', '00001', '10001', '01110'],
4: ['00010', '00110', '01010', '10010', '11111', '00010', '00010'],
5: ['11111', '10000', '11110', '00001', '00001', '10001', '01110'],
6: ['00110', '01000', '10000', '11110', '10001', '10001', '01110'],
7: ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
8: ['01110', '10001', '10001', '01110', '10001', '10001', '01110'],
9: ['01110', '10001', '10001', '01111', '00001', '00010', '01100'],
'-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'],
'!': ['00100', '00100', '00100', '00100', '00100', '00000', '00100'],
"'": ['00100', '00100', '01000', '00000', '00000', '00000', '00000'],
':': ['00000', '00100', '00100', '00000', '00100', '00100', '00000'],
'.': ['00000', '00000', '00000', '00000', '00000', '01100', '01100'],
' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
};
function textWidth(text, scale) {
return text.length * 6 * scale - scale;
}
function drawText(canvas, text, x, y, scale, color, shadow = [20, 20, 30]) {
[...text.toUpperCase()].forEach((ch, i) => {
const glyph = FONT[ch] ?? FONT[' '];
glyph.forEach((row, gy) => {
[...row].forEach((bit, gx) => {
if (bit !== '1') return;
const px = x + (i * 6 + gx) * scale;
const py = y + gy * scale;
if (shadow) canvas.rect(px + scale, py + scale, scale, scale, shadow, 0.6);
canvas.rect(px, py, scale, scale, color);
});
});
});
}
/** Splits text into lines of at most max characters, at spaces */
function wrap(text, max) {
const lines = [];
for (const word of text.toUpperCase().split(' ')) {
const last = lines[lines.length - 1];
if (last !== undefined && (last + ' ' + word).length <= max)
lines[lines.length - 1] += ' ' + word;
else lines.push(word);
}
return lines;
}
function hsl(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = (n) => {
const k = (n + h / 30) % 12;
return Math.round(255 * (l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))));
};
return [f(0), f(8), f(4)];
}
/** A seeded generator, so each game's art is the same on every build */
function random(seed) {
let state = createHash('sha256').update(seed).digest().readUInt32LE(0);
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function star(cx, cy, outer, inner, points = 5) {
return Array.from({ length: points * 2 }, (_, i) => {
const r = i % 2 ? inner : outer;
const a = (Math.PI * i) / points - Math.PI / 2;
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
});
}
function ellipse(c, cx, cy, rx, ry, color) {
c.shape(
cx - rx,
cy - ry,
cx + rx,
cy + ry,
color,
(x, y) => ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 <= 1
);
}
/** Pixel Penguin Panic's penguin, standing at (x, y), s pixels to the unit */
function penguin(c, x, y, s) {
ellipse(c, x, y - 26 * s, 22 * s, 28 * s, [17, 24, 39]);
ellipse(c, x, y - 22 * s, 14 * s, 20 * s, [248, 250, 252]);
c.rect(x - 9 * s, y - 46 * s, 7 * s, 7 * s, [255, 255, 255]);
c.rect(x + 3 * s, y - 46 * s, 7 * s, 7 * s, [255, 255, 255]);
c.rect(x - 6 * s, y - 44 * s, 3 * s, 4 * s, [0, 0, 0]);
c.rect(x + 6 * s, y - 44 * s, 3 * s, 4 * s, [0, 0, 0]);
c.rect(x - 5 * s, y - 36 * s, 10 * s, 5 * s, [245, 158, 11]);
c.rect(x - 16 * s, y - 2 * s, 12 * s, 4 * s, [245, 158, 11]);
c.rect(x + 4 * s, y - 2 * s, 12 * s, 4 * s, [245, 158, 11]);
}
function fish(c, x, y, s) {
ellipse(c, x, y, 14 * s, 8 * s, [56, 189, 248]);
c.polygon(
[
[x + 12 * s, y],
[x + 22 * s, y - 8 * s],
[x + 22 * s, y + 8 * s],
],
[56, 189, 248]
);
c.rect(x - 8 * s, y - 3 * s, 3 * s, 3 * s, [0, 0, 0]);
}
function penguinScene(c, ground, s) {
c.gradient([30, 58, 138], [125, 211, 252]);
c.rect(0, ground, c.width, c.height - ground, [224, 242, 254]);
for (let i = 0; i < 40; i++)
c.rect((i * 97) % c.width, (i * 53) % Math.round(ground * 0.6), 2, 2, [255, 255, 255], 0.8);
}
function logo(game) {
if (game.title === 'Pixel Penguin Panic') {
const c = new Canvas(256, 256);
penguinScene(c, 200, 2);
fish(c, 60, 60, 2);
fish(c, 196, 96, 2);
penguin(c, 128, 196, 2.6);
c.rect(0, 206, 256, 50, [10, 10, 20], 0.45);
drawText(
c,
'PENGUIN',
Math.round((256 - textWidth('PENGUIN', 4)) / 2),
216,
4,
[255, 255, 255]
);
return c.png();
}
const rand = random(game.title);
const hue = Math.floor(rand() * 360);
const c = new Canvas(256, 256);
c.gradient(hsl(hue, 0.7, 0.62), hsl((hue + 40) % 360, 0.65, 0.32));
const accent = hsl((hue + 180) % 360, 0.85, 0.6);
const kind = Math.floor(rand() * 4);
if (kind === 0) c.circle(128, 96, 64, accent);
if (kind === 1) c.polygon(star(128, 98, 74, 32), accent);
if (kind === 2)
c.polygon(
[
[128, 26],
[206, 104],
[128, 182],
[50, 104],
],
accent
);
if (kind === 3)
for (let i = 0; i < 5; i++) c.rect(40 + i * 36, 40 + (i % 2) * 24, 28, 110, accent, 0.85);
c.circle(128 - 40, 60, 18, [255, 255, 255], 0.25);
const lines = wrap(game.title, 10);
const scale = lines.some((line) => line.length > 8) ? 3 : 4;
const top = 250 - lines.length * 8 * scale - 10;
c.rect(0, top - 8, 256, 256 - top + 8, [10, 10, 20], 0.45);
lines.forEach((line, i) => {
drawText(
c,
line,
Math.round((256 - textWidth(line, scale)) / 2),
top + i * 8 * scale,
scale,
[255, 255, 255]
);
});
return c.png();
}
function screenshot(game) {
if (game.title === 'Pixel Penguin Panic') {
const c = new Canvas(640, 480);
penguinScene(c, 420, 1);
for (const [x, y] of [
[180, 140],
[430, 230],
[300, 330],
])
fish(c, x, y, 1);
c.circle(520, 300, 13, [100, 116, 139]);
penguin(c, 300, 420, 1);
drawText(c, 'SCORE 00120', 16, 16, 3, [255, 255, 255], null);
drawText(
c,
'PIXEL PENGUIN PANIC',
Math.round((640 - textWidth('PIXEL PENGUIN PANIC', 4)) / 2),
70,
4,
[255, 255, 255],
null
);
return c.png();
}
const rand = random(game.title + ' screenshot');
const hue = Math.floor(rand() * 360);
const c = new Canvas(640, 480);
c.gradient(hsl(hue, 0.6, 0.75), hsl((hue + 30) % 360, 0.55, 0.45));
c.circle(520, 90, 46, hsl((hue + 60) % 360, 0.9, 0.8));
for (let i = 0; i < 3; i++) {
const y = 330 + i * 30;
const color = hsl((hue + 120 + i * 15) % 360, 0.45, 0.45 - i * 0.08);
c.shape(0, y - 60, 639, 479, color, (x, py) => py > y + Math.sin(x / (60 + i * 25) + i) * 22);
}
for (let i = 0; i < 4; i++) {
const x = 40 + Math.floor(rand() * 520);
const y = 140 + Math.floor(rand() * 150);
c.rect(x, y, 90, 14, hsl((hue + 200) % 360, 0.4, 0.3));
for (let j = 0; j < 3; j++) c.circle(x + 18 + j * 27, y - 16, 7, [255, 214, 64]);
}
c.rect(300, 300, 34, 34, hsl((hue + 180) % 360, 0.85, 0.55));
c.rect(308, 308, 6, 6, [255, 255, 255]);
c.rect(320, 308, 6, 6, [255, 255, 255]);
c.rect(0, 0, 640, 40, [10, 10, 20], 0.5);
drawText(c, game.title, 14, 12, 2, [255, 255, 255]);
drawText(c, 'SCORE 004200', 640 - 14 - textWidth('SCORE 004200', 2), 12, 2, [255, 230, 120]);
return c.png();
}
function platformLogo(name) {
const c = new Canvas(64, 64);
const hue = name === 'HTML5' ? 18 : 355;
c.gradient(hsl(hue, 0.8, 0.55), hsl(hue, 0.8, 0.4));
const label = name === 'HTML5' ? 'H5' : name[0];
drawText(c, label, Math.round((64 - textWidth(label, 4)) / 2), 18, 4, [255, 255, 255]);
return c.png();
}
// --- Games -------------------------------------------------------------------
const STUDIOS = [
'Lunchbreak Labs',
'Pixel Pals',
'Dial-Up Dreams',
'Recess Studios',
'Paperclip Games',
'Snack Attack Software',
'Homework Later',
'Screensaver Society',
];
// Every game here is made up: these describe games that don't exist
const BLURBS = {
Action: (t) =>
`Dodge, dash and button-mash your way through ${t}. Three difficulty settings, a high score table, and a secret boss for anyone who finds all twelve keys.`,
Adventure: (t) =>
`A point-and-click adventure. ${t} has you poking at everything, combining odd items, and talking to anyone who will listen, until the mystery finally makes sense.`,
Puzzle: (t) =>
`${t} starts easy and gets devious. Forty levels, a level editor for making your own, and a hint button that only works three times, so use it wisely.`,
Shooter: (t) =>
`Fill the screen with lasers in ${t}. Collect power-ups, keep your combo going, and save a few bombs for the last wave, because you will need them.`,
Platformer: (t) =>
`Run, jump and double-jump through ${t}. Six worlds of moving platforms and bottomless pits, and every star you grab brings the bonus world a little closer.`,
Sports: (t) =>
`Pick your team and play a full season of ${t}, or jump straight into a quick match. Unlock new courts, kits and trick shots as you climb the league.`,
Strategy: (t) =>
`Plan ahead in ${t}: build, upgrade and outsmart each wave before it reaches your base. Twenty maps, three difficulty settings, and an endless mode for the brave.`,
Driving: (t) =>
`Floor it in ${t}. Twelve tracks, unlockable rides, shortcuts for the curious, and a ghost of your best lap to beat.`,
'Escape the Room': (t) =>
`You're locked in. Search every corner, crack the codes and find your way out of ${t}. Click everything: the answer is usually somewhere you already looked.`,
'Dress Up': (t) =>
`Mix and match hundreds of outfits in ${t}: hats, shoes, capes and more. Save your favorite look, or press shuffle and see what happens.`,
Arcade: (t) =>
`${t} is easy to learn and hard to put down. Every round gets a little faster, and there's always one more high score to beat. How long can you last?`,
Toy: (t) =>
`No goals, no score: ${t} is just for messing around. Click everything, turn every knob, and see what happens when you do it all at once.`,
Animation: (t) =>
`${t}: a short cartoon, about three minutes long, with a catchy song you won't get out of your head.`,
};
// [title, platform, genre, extra tags, release date (YYYY-MM-DD) or null]
const ARCADE = [
['Pixel Penguin Panic', 'HTML5', 'Arcade', ['Animals'], '2008-06-18'],
['Turbo Teacup 2', 'Flash', 'Driving', ['Comedy'], '2008-06-16'],
['Laser Llama', 'Flash', 'Shooter', ['Animals', 'Space'], '2008-06-17'],
['Sock Drawer Escape', 'Flash', 'Escape the Room', [], '2008-06-19'],
['Cloud Hopper', 'Flash', 'Platformer', [], '2008-06-20'],
['Toaster Tactics', 'Flash', 'Strategy', ['Comedy'], '2008-06-21'],
['Hamster Hoops', 'Flash', 'Sports', ['Animals'], '2008-06-22'],
['Bubble Wrap Bandit', 'Flash', 'Action', ['Comedy'], null],
['Moon Mower', 'Flash', 'Arcade', ['Space'], null],
['Captain Crunchbyte', 'Flash', 'Platformer', ['Robots'], null],
['Rocket Radish', 'Flash', 'Shooter', ['Space'], null],
['Dial-Up Dash', 'Flash', 'Platformer', [], null],
['Cardboard Castle Defense', 'Flash', 'Strategy', [], null],
['Snowball Sniper', 'Flash', 'Shooter', ['Winter'], null],
['Jelly Jump', 'HTML5', 'Platformer', [], null],
['Lunchbox Legends', 'Flash', 'Adventure', [], null],
['Office Chair Grand Prix', 'Flash', 'Driving', ['Comedy'], null],
['Mini Golf Mayhem', 'Flash', 'Sports', [], null],
['Pocket Pirates', 'Flash', 'Adventure', ['Pirates'], null],
['Gumball Galaxy', 'Flash', 'Shooter', ['Space'], null],
['The Lost Floppy', 'Flash', 'Adventure', ['Mystery'], null],
['Block Party Puzzle', 'Flash', 'Puzzle', [], null],
['Tile Tumble', 'HTML5', 'Puzzle', [], null],
['Plumbing Puzzler', 'Flash', 'Puzzle', [], null],
['Kite Fighter', 'Flash', 'Action', [], null],
['Garden Gnome Rescue', 'Flash', 'Adventure', [], null],
['Scrapyard Racers', 'Flash', 'Driving', ['Robots'], null],
['Penguin Paintball', 'Flash', 'Shooter', ['Animals', 'Winter'], null],
['Escape the Treehouse', 'Flash', 'Escape the Room', [], null],
['Escape the Laundromat', 'Flash', 'Escape the Room', ['Mystery'], null],
['Dress Up Dino', 'Flash', 'Dress Up', ['Animals'], null],
['Robot Runway', 'Flash', 'Dress Up', ['Robots'], null],
['Lemonade Empire', 'Flash', 'Strategy', [], null],
['Bowling for Buttons', 'Flash', 'Sports', [], null],
['Skate Park Kid', 'Flash', 'Sports', [], null],
['Space Mail', 'Flash', 'Adventure', ['Space'], null],
['Haunted Homework', 'Flash', 'Adventure', ['Mystery'], null],
['Magnet Mike', 'Flash', 'Puzzle', ['Robots'], null],
['Crayon Kart', 'Flash', 'Driving', [], null],
['Ninja Noodle', 'Flash', 'Action', ['Comedy'], null],
['Fishbowl Frenzy', 'Flash', 'Arcade', ['Animals'], null],
['Cactus Catcher', 'Flash', 'Arcade', [], null],
['Brick Buster 3000', 'Flash', 'Arcade', [], null],
['Volcano Village', 'Flash', 'Strategy', [], null],
['Drum Doodle', 'Flash', 'Toy', ['Music'], null],
['Bubble Machine', 'Flash', 'Toy', [], null],
['Paper Plane Lab', 'Flash', 'Toy', [], null],
['Snow Globe Shaker', 'Flash', 'Toy', ['Winter'], null],
];
const THEATRE = [
['The Adventures of Blob', 'Flash', 'Animation', ['Comedy'], null],
['Lunch Break: Episode 1', 'Flash', 'Animation', ['Comedy'], null],
['Stick Figure Sunday', 'Flash', 'Animation', [], null],
['The Great Sandwich', 'Flash', 'Animation', [], null],
];
function uuid(text) {
const hex = createHash('sha256').update(`dotcom-arcade-demo:${text}`).digest('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
}
function releaseDate(title, fixed) {
if (fixed) return fixed;
const rand = random(title + ' release');
const start = Date.UTC(2005, 0, 1);
const end = Date.UTC(2012, 11, 31);
return new Date(start + rand() * (end - start)).toISOString().slice(0, 10);
}
const games = [
...ARCADE.map((g) => [...g, 'arcade']),
...THEATRE.map((g) => [...g, 'theatre']),
].map(([title, platform, genre, extra, date, library], i) => {
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const ext = platform === 'HTML5' ? 'index.html' : `${slug}.swf`;
return {
id: uuid(title),
title,
platform,
library,
tags: [genre, ...extra],
developer: STUDIOS[i % STUDIOS.length],
releaseDate: releaseDate(title, date),
description: BLURBS[genre](title),
launchCommand: `http://games.example/${slug}/${ext}`,
};
});
const PLAYLISTS = [
{
title: 'Lunchtime Classics',
description: 'Quick games for a short break.',
games: [
'Pixel Penguin Panic',
'Moon Mower',
'Fishbowl Frenzy',
'Cactus Catcher',
'Brick Buster 3000',
'Jelly Jump',
'Kite Fighter',
'Ninja Noodle',
'Hamster Hoops',
'Mini Golf Mayhem',
],
},
{
title: 'Toys to Enjoy',
description: 'Nothing to win, plenty to click.',
games: [
'Drum Doodle',
'Bubble Machine',
'Paper Plane Lab',
'Snow Globe Shaker',
'Dress Up Dino',
'Robot Runway',
'Lemonade Empire',
],
},
{
title: 'Computer Lab Favorites',
description: 'Played on every school computer, until someone blocked the site.',
games: [
'Turbo Teacup 2',
'Office Chair Grand Prix',
'Crayon Kart',
'Laser Llama',
'Rocket Radish',
'Gumball Galaxy',
'Captain Crunchbyte',
'Cloud Hopper',
'Dial-Up Dash',
],
},
{
title: 'Puzzles and Escapes',
description: 'For when you want to think a little.',
games: [
'Block Party Puzzle',
'Tile Tumble',
'Plumbing Puzzler',
'Magnet Mike',
'Sock Drawer Escape',
'Escape the Treehouse',
'Escape the Laundromat',
'The Lost Floppy',
'Haunted Homework',
],
},
];
// --- Write it ----------------------------------------------------------------
for (const sub of [
'Data/flashpoint.sqlite',
'Data/Images',
'Data/Logos',
'Data/Playlists',
'Legacy/htdocs/games.example',
]) {
rmSync(join(out, sub), { recursive: true, force: true });
}
for (const sub of [
'Data/Images/Logos',
'Data/Images/Screenshots',
'Data/Logos',
'Data/Playlists',
'Data/Games',
'Legacy/htdocs',
]) {
mkdirSync(join(out, sub), { recursive: true });
}
writeFileSync(join(out, 'version.txt'), 'Flashpoint 14 Ultimate - Dotcom Arcade demo data\n');
const db = new Database(join(out, 'Data/flashpoint.sqlite'));
db.exec(readFileSync(join(here, 'flashpoint-schema.sql'), 'utf8'));
const now = '2026-01-01T00:00:00.000Z';
db.transaction(() => {
db.prepare("INSERT INTO tag_category (id, name, color) VALUES (1, 'default', '#FFFFFF')").run();
const tagIds = new Map();
for (const name of [...new Set(games.flatMap((g) => g.tags))].sort()) {
const id = tagIds.size + 1;
db.prepare('INSERT INTO tag_alias (id, tagId, name) VALUES (?, ?, ?)').run(id, id, name);
db.prepare('INSERT INTO tag (id, primaryAliasId, categoryId) VALUES (?, ?, 1)').run(id, id);
tagIds.set(name, id);
}
const platformIds = new Map();
for (const name of ['Flash', 'HTML5']) {
const id = platformIds.size + 1;
db.prepare('INSERT INTO platform_alias (id, platformId, name) VALUES (?, ?, ?)').run(
id,
id,
name
);
db.prepare('INSERT INTO platform (id, primaryAliasId) VALUES (?, ?)').run(id, id);
platformIds.set(name, id);
}
const insert = db.prepare(`
INSERT INTO game (
id, title, alternateTitles, series, developer, publisher, dateAdded, dateModified,
broken, extreme, playMode, status, notes, source, applicationPath, launchCommand,
releaseDate, version, originalDescription, language, library, orderTitle,
tagsStr, platformsStr, platformId, platformName, archiveState
) VALUES (?, ?, '', '', ?, ?, ?, ?, 0, 0, 'Single Player', 'Playable', '', ?, ?, ?, ?, '', ?, 'en', ?, ?, ?, ?, ?, ?, 2)
`);
for (const g of games) {
insert.run(
g.id,
g.title,
g.developer,
g.developer,
now,
now,
g.launchCommand,
g.platform === 'HTML5' ? ':browser-mode:' : 'FPSoftware\\Flash\\flashplayer_32_sa.exe',
g.launchCommand,
g.releaseDate,
g.description,
g.library,
g.title.toLowerCase(),
g.tags.join('; '),
g.platform,
platformIds.get(g.platform),
g.platform
);
for (const tag of g.tags)
db.prepare('INSERT INTO game_tags_tag (gameId, tagId) VALUES (?, ?)').run(
g.id,
tagIds.get(tag)
);
db.prepare('INSERT INTO game_platforms_platform (gameId, platformId) VALUES (?, ?)').run(
g.id,
platformIds.get(g.platform)
);
}
})();
db.close();
const shard = (id) => `${id.slice(0, 2)}/${id.slice(2, 4)}`;
for (const g of games) {
for (const [dir, image] of [
['Logos', logo(g)],
['Screenshots', screenshot(g)],
]) {
mkdirSync(join(out, 'Data/Images', dir, shard(g.id)), { recursive: true });
writeFileSync(join(out, 'Data/Images', dir, shard(g.id), `${g.id}.png`), image);
}
}
for (const name of ['Flash', 'HTML5'])
writeFileSync(join(out, 'Data/Logos', `${name}.png`), platformLogo(name));
const byTitle = new Map(games.map((g) => [g.title, g]));
for (const p of PLAYLISTS) {
const id = uuid(`playlist:${p.title}`);
const playlist = {
id,
title: p.title,
description: p.description,
author: 'Dotcom Arcade demo',
library: 'arcade',
extreme: false,
games: p.games.map((title, order) => {
const game = byTitle.get(title);
if (!game) throw new Error(`playlist ${p.title}: no game ${title}`);
return { gameId: game.id, notes: '', order, playlistId: id };
}),
};
writeFileSync(join(out, 'Data/Playlists', `${id}.json`), JSON.stringify(playlist, null, 2));
}
const penguinDir = join(out, 'Legacy/htdocs/games.example/pixel-penguin-panic');
mkdirSync(penguinDir, { recursive: true });
writeFileSync(join(penguinDir, 'index.html'), readFileSync(join(here, 'pixel-penguin-panic.html')));
console.log(
`Demo Flashpoint data: ${games.length} games, ${PLAYLISTS.length} playlists, in ${out}`
);
+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>
+102
View File
@@ -0,0 +1,102 @@
#!/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);
}
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}`);