Merge branch 'feat/marketing-assets'
All checks were successful
CI / test (push) Successful in 24s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lukas Müllner 2026-11-13 17:20:40 +01:00
commit dd09c58fec
6 changed files with 432 additions and 1 deletions

11
PLAN.md
View file

@ -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).

48
package-lock.json generated
View file

@ -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",

View file

@ -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": {

View file

@ -0,0 +1,66 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ninelands capture</title>
<link rel="stylesheet" href="capture.css">
<style>
body {
display: grid;
place-items: center;
height: 100vh;
overflow: hidden;
}
#board {
height: 94vh;
aspect-ratio: 1 / 1;
}
/* Kachel: Ausschnitt des Bretts mit Schriftzug */
body.tile #board {
height: auto;
width: 100vw;
position: absolute;
top: 50%;
transform: translateY(-50%);
}
#logo {
display: none;
}
body.tile #logo {
display: block;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-family: var(--font-pixel);
font-weight: 700;
font-size: 72px;
color: var(--px-gold);
background: var(--px-ink);
padding: 6px 28px 10px;
border: 6px solid var(--px-gold-dark);
text-shadow: 4px 4px 0 #000;
letter-spacing: 0.04em;
white-space: nowrap;
}
</style>
</head>
<body>
<svg width="0" height="0" style="position: absolute" aria-hidden="true">
<symbol id="i-warn" viewBox="0 0 24 24">
<g
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3 2 20h20zM12 10v4M12 17v.5" />
</g>
</symbol>
</svg>
<div id="board"></div>
<div id="logo">Ninelands</div>
<script src="capture.js" type="module"></script>
</body>
</html>

114
scripts/capture/capture.ts Normal file
View file

@ -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<Game['place']>[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<void> {
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<void>
/** 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()

190
scripts/capture/record.mjs Normal file
View file

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