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 + + + + + +
+ + + + 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() +}