diff --git a/PLAN.md b/PLAN.md
index 4bb3f90..7341975 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -202,3 +202,14 @@ Abgabe: **9. Dezember 2026, 03:00 MEZ**. Eingereicht wird in „Best Daily Game
nicht: Hinweis „None of your remaining moves earns gold – end the game to keep your gold.“ und „End game“ leuchtet golden.
- Fix: Im goldenen Ziel-Feld waren Stern und „/ Ziel“ ebenfalls gold (Gold auf Gold, unsichtbar) – beim Umsortieren
der CSS-Regeln für Biome (`noDescendingSpecificity`) waren die Deklarationen vertauscht. Jetzt alles in Tinte.
+
+### 2026-11-13 – Katalog-Assets (Entwurf)
+
+- `npm run capture` (`scripts/capture/`): baut eine Aufnahmeseite nur mit dem Brett, ein Bot (`smart`) spielt eine
+ Partie mit Vorschau, Playwright nimmt auf, ffmpeg macht daraus `dist/marketing/featuring.mp4` (1500×1000, 30 fps,
+ ≈ 11 s, ≈ 1,7 MB), `fallback.gif` (1000×667, ≤ 2 MB), `fallback.png` (1500×1000) und `tile.png` (560×256 mit
+ Schriftzug). Maße laut Developer Portal; Größen werden bei Bedarf automatisch heruntergeregelt.
+- **Karte vom 2026-10-10** (vor dem ersten Spieltag): läuft nie als Tageskarte – eine künftige Tageskarte im Video
+ wäre ein Spoiler. Der Bot holt dort in 26 Zügen alle 3 Sterne.
+- Playwright als devDependency in der Version des Hauptspiels (1.55.1), damit die vorhandenen Browser im Cache passen.
+- Vor der Einreichung neu aufnehmen (z. B. mit Winter-Thema aus Phase 2).
diff --git a/package-lock.json b/package-lock.json
index 11b814b..489362a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
"@types/node": "24.19.2",
"devvit": "0.15.3",
"esbuild": "0.28.2",
+ "playwright": "1.55.1",
"typescript": "7.0.2"
},
"engines": {
@@ -5427,6 +5428,53 @@
"node": ">=6"
}
},
+ "node_modules/playwright": {
+ "version": "1.55.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz",
+ "integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.55.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.55.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz",
+ "integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
diff --git a/package.json b/package.json
index 599e060..282fc43 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,8 @@
"test:types": "tsc --build",
"test:unit": "esbuild --bundle --log-level=warning --platform=node --format=esm --packages=external --outdir=dist/test --outbase=src src/**/*.test.ts && node --test dist/test/**/*.test.js",
"publish": "npm run clean && npm run build && devvit publish",
- "watch": "sh -c 'trap \"kill 0\" exit; npm run build:client -- --watch=forever& npm run build:server -- --watch=forever& wait' --"
+ "watch": "sh -c 'trap \"kill 0\" exit; npm run build:client -- --watch=forever& npm run build:server -- --watch=forever& wait' --",
+ "capture": "node scripts/capture/record.mjs"
},
"devDependencies": {
"@biomejs/biome": "2.6.6",
@@ -25,6 +26,7 @@
"@types/node": "24.19.2",
"devvit": "0.15.3",
"esbuild": "0.28.2",
+ "playwright": "1.55.1",
"typescript": "7.0.2"
},
"engines": {
diff --git a/scripts/capture/capture.html b/scripts/capture/capture.html
new file mode 100644
index 0000000..c622da9
--- /dev/null
+++ b/scripts/capture/capture.html
@@ -0,0 +1,66 @@
+
+
+
+
+ Ninelands capture
+
+
+
+
+
+
+ Ninelands
+
+
+
diff --git a/scripts/capture/capture.ts b/scripts/capture/capture.ts
new file mode 100644
index 0000000..ab09c7b
--- /dev/null
+++ b/scripts/capture/capture.ts
@@ -0,0 +1,114 @@
+// Aufnahmeseite für die Katalog-Assets: nur das Brett, ein Bot spielt eine echte Partie.
+// Gesteuert von scripts/capture/record.mjs über window.capture.
+import '../../src/client/style.css'
+import {ZONE_NAMES} from '../../src/client/texts.ts'
+import {BoardView} from '../../src/engine/app/board/BoardView.ts'
+import {BOTS} from '../../src/engine/core/bots.ts'
+import {Game} from '../../src/engine/core/Game.ts'
+import {mulberry32} from '../../src/engine/core/rng.ts'
+import {dailyBoard, dailyMap} from '../../src/shared/daily.ts'
+
+const params = new URLSearchParams(location.search)
+// Karte von vor dem ersten Spieltag – läuft nie als Tageskarte, verrät also nichts
+const day = params.get('day') ?? '2026-10-10'
+const seed = Number(params.get('seed') ?? 1)
+
+const view = new BoardView({interactive: true})
+const game = new Game(dailyBoard(dailyMap(new Date(`${day}T00:00:00Z`))), {
+ undoLimit: 0,
+})
+view.setBoard(game.board)
+view.setLandNames(ZONE_NAMES)
+const host = document.getElementById('board') as HTMLDivElement
+host.append(view.svg)
+
+// Ganze Partie vorab berechnen, damit die Aufnahme nur noch abspielt
+const moves: {unit: Parameters[0]; cell: number}[] = []
+{
+ const sim = game.clone()
+ const rng = mulberry32(seed)
+ while (!sim.isGameOver()) {
+ const m = BOTS.smart(sim, rng)
+ sim.place(m.unit, m.cell)
+ moves.push({unit: m.unit, cell: m.cell})
+ }
+}
+
+function draw(): void {
+ view.update(game)
+ view.setRulers(game.rulers())
+}
+
+const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
+
+/** Einen Zug zeigen: kurz die Vorschau (beanspruchtes Land + Gold), dann setzen */
+async function step(
+ i: number,
+ previewMs: number,
+ afterMs: number,
+): Promise {
+ const m = moves[i]
+ if (!m) return
+ const p = game.preview(m.unit, m.cell)
+ if (p.ok) {
+ view.setPlacementPreview({
+ cell: m.cell,
+ unit: m.unit,
+ zoi: p.zoi,
+ label: `${p.net >= 0 ? '+' : '−'}${Math.abs(p.net)}`,
+ owner: 1,
+ tone: p.net > 0 ? 'gain' : p.net < 0 ? 'loss' : 'neutral',
+ })
+ await sleep(previewMs)
+ }
+ view.setPlacementPreview(null)
+ const r = game.place(m.unit, m.cell)
+ if (r.ok) {
+ const net = r.yield.points - r.cost
+ if (net !== 0)
+ view.floatLabel(
+ m.cell,
+ `${net > 0 ? '+' : '−'}${Math.abs(net)}`,
+ net > 0 ? 'gain' : 'loss',
+ )
+ }
+ draw()
+ await sleep(afterMs)
+}
+
+declare global {
+ interface Window {
+ capture: {
+ moves: number
+ /** Partie abspielen: firstMoves sofort (für ein aussagekräftiges erstes Bild), dann im Takt */
+ play(
+ firstMoves: number,
+ previewMs: number,
+ afterMs: number,
+ ): Promise
+ /** Sofort bis Zug n springen (Standbilder) */
+ jump(n: number): void
+ }
+ }
+}
+
+window.capture = {
+ moves: moves.length,
+ async play(firstMoves, previewMs, afterMs) {
+ for (let i = 0; i < firstMoves; i++) {
+ const m = moves[i]
+ if (m) game.place(m.unit, m.cell)
+ }
+ draw()
+ for (let i = firstMoves; i < moves.length; i++)
+ await step(i, previewMs, afterMs)
+ },
+ jump(n) {
+ for (let i = game.moves.length; i < Math.min(n, moves.length); i++) {
+ const m = moves[i]
+ if (m) game.place(m.unit, m.cell)
+ }
+ draw()
+ },
+}
+draw()
diff --git a/scripts/capture/record.mjs b/scripts/capture/record.mjs
new file mode 100644
index 0000000..8c671ef
--- /dev/null
+++ b/scripts/capture/record.mjs
@@ -0,0 +1,190 @@
+#!/usr/bin/env node
+// Katalog-Assets für das Developer Portal aufnehmen (Maße laut Portal, Stand 2026-11-13):
+// featuring.mp4 3:2, 1500×1000, 6–15 s, ≤ 30 fps, ≤ 3 MB
+// fallback.gif 3:2, 1000×667, 3–10 s Schleife, Ziel ≤ 2 MB
+// fallback.png 1500×1000, ≤ 800 KB (Standbild statt GIF)
+// tile.png 560×256, ≤ 800 KB
+// Nur das Brett, keine Menüs. Braucht ffmpeg. Aufruf: npm run capture (Ergebnis in dist/marketing/)
+import {execFileSync} from 'node:child_process'
+import {
+ copyFileSync,
+ mkdirSync,
+ readdirSync,
+ readFileSync,
+ statSync,
+} from 'node:fs'
+import {createServer} from 'node:http'
+import {dirname, extname, join, resolve} from 'node:path'
+import {fileURLToPath} from 'node:url'
+import {build} from 'esbuild'
+import {chromium} from 'playwright'
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
+const site = join(root, 'dist/capture')
+const out = join(root, 'dist/marketing')
+mkdirSync(site, {recursive: true})
+mkdirSync(out, {recursive: true})
+
+// 1. Aufnahmeseite bauen (gleiche Defines wie build:client)
+await build({
+ entryPoints: [join(root, 'scripts/capture/capture.ts')],
+ bundle: true,
+ format: 'esm',
+ outdir: site,
+ logLevel: 'warning',
+ define: {
+ 'import.meta.env.MODE': '"reddit"',
+ 'import.meta.env.BASE_URL': '"/"',
+ __API_ORIGIN__: '""',
+ },
+ loader: {'.woff2': 'file', '.woff': 'file'},
+ assetNames: 'fonts/[name]-[hash]',
+})
+copyFileSync(
+ join(root, 'scripts/capture/capture.html'),
+ join(site, 'index.html'),
+)
+
+// 2. Statischer Server: zuerst die Aufnahmeseite, dann public/ (Sprites)
+const types = {
+ '.html': 'text/html',
+ '.js': 'text/javascript',
+ '.css': 'text/css',
+ '.png': 'image/png',
+ '.woff2': 'font/woff2',
+ '.woff': 'font/woff',
+}
+const server = createServer((req, res) => {
+ const path = decodeURIComponent((req.url ?? '/').split('?')[0])
+ for (const dir of [site, join(root, 'public')]) {
+ const file = join(dir, path === '/' ? 'index.html' : path)
+ if (file.startsWith(dir) && existsFile(file)) {
+ res.writeHead(200, {
+ 'Content-Type': types[extname(file)] ?? 'application/octet-stream',
+ })
+ res.end(readFileSync(file))
+ return
+ }
+ }
+ res.writeHead(404)
+ res.end()
+})
+function existsFile(f) {
+ try {
+ return statSync(f).isFile()
+ } catch {
+ return false
+ }
+}
+await new Promise(r => server.listen(0, '127.0.0.1', r))
+const base = `http://127.0.0.1:${server.address().port}/`
+const ffmpeg = (...args) =>
+ execFileSync('ffmpeg', ['-v', 'error', '-y', ...args])
+const size = f => statSync(join(out, f)).size
+const kb = f => `${Math.round(size(f) / 1024)} KB`
+
+const browser = await chromium.launch()
+try {
+ // 3. Video: Partie mit Vorschau im Takt; die ersten 2 Züge (Burg + Bauer) stehen schon im ersten Bild
+ const videoDir = join(site, 'video')
+ const ctx = await browser.newContext({
+ viewport: {width: 1500, height: 1000},
+ recordVideo: {dir: videoDir, size: {width: 1500, height: 1000}},
+ })
+ const t0 = Date.now()
+ const page = await ctx.newPage()
+ await page.goto(base)
+ await page.evaluate(() => document.fonts.ready)
+ await page.waitForTimeout(400)
+ const start = (Date.now() - t0) / 1000
+ await page.evaluate(() => window.capture.play(2, 180, 200))
+ await page.waitForTimeout(2200) // Endbild mit allen Bannern stehen lassen
+ const length = (Date.now() - t0) / 1000 - start
+ await ctx.close()
+ const webm = join(videoDir, readdirSync(videoDir)[0])
+
+ // MP4 (H.264, 30 fps); CRF erhöhen, bis es unter 3 MB passt
+ for (const crf of [20, 24, 28, 32]) {
+ ffmpeg(
+ '-ss',
+ String(start),
+ '-i',
+ webm,
+ '-t',
+ String(Math.min(length, 15)),
+ '-r',
+ '30',
+ '-c:v',
+ 'libx264',
+ '-preset',
+ 'slow',
+ '-crf',
+ String(crf),
+ '-pix_fmt',
+ 'yuv420p',
+ '-movflags',
+ '+faststart',
+ '-an',
+ join(out, 'featuring.mp4'),
+ )
+ if (size('featuring.mp4') <= 3 * 1024 * 1024) break
+ }
+
+ // GIF (aus der Mitte, 1000×667, eigene Palette); Stufen probieren, bis ≤ 2 MB
+ for (const [fps, colors, secs] of [
+ [12, 96, 8],
+ [10, 64, 8],
+ [10, 64, 6],
+ [8, 48, 6],
+ ]) {
+ ffmpeg(
+ '-ss',
+ String(start + 1),
+ '-t',
+ String(secs),
+ '-i',
+ webm,
+ '-vf',
+ `fps=${fps},scale=1000:667:flags=lanczos,split[a][b];[a]palettegen=max_colors=${colors}:stats_mode=diff[p];[b][p]paletteuse=dither=none`,
+ '-loop',
+ '0',
+ join(out, 'fallback.gif'),
+ )
+ if (size('fallback.gif') <= 2 * 1024 * 1024) break
+ }
+
+ // 4. Standbilder: fertiges Brett (Fallback) und Kachel mit Schriftzug
+ const still = await browser.newPage({viewport: {width: 1500, height: 1000}})
+ await still.goto(base)
+ await still.evaluate(() => document.fonts.ready)
+ await still.evaluate(() => window.capture.jump(999))
+ await still.waitForTimeout(300)
+ await still.screenshot({path: join(out, 'fallback.png')})
+ await still.close()
+
+ const tile = await browser.newPage({viewport: {width: 1120, height: 512}})
+ await tile.goto(base)
+ await tile.evaluate(() => {
+ document.body.classList.add('tile')
+ return document.fonts.ready
+ })
+ await tile.evaluate(() => window.capture.jump(999))
+ await tile.waitForTimeout(300)
+ await tile.screenshot({path: join(site, 'tile@2x.png')})
+ await tile.close()
+ ffmpeg(
+ '-i',
+ join(site, 'tile@2x.png'),
+ '-vf',
+ 'scale=560:256:flags=lanczos',
+ join(out, 'tile.png'),
+ )
+
+ console.log(`Fertig in ${out}:`)
+ for (const f of ['featuring.mp4', 'fallback.gif', 'fallback.png', 'tile.png'])
+ console.log(` ${f} ${kb(f)}`)
+ console.log(` Videolänge ≈ ${Math.min(length, 15).toFixed(1)} s`)
+} finally {
+ await browser.close()
+ server.close()
+}