/** * Which project each tool writes to, and the guard that keeps every tool away from the * playground. * * playground the user's own project. No tool writes it – it is maintained in the editor. * fixture rewritten by every e2e run (tools/seed-e2e.mjs + tools/e2e.mjs). * jumpRun the example game (tools/seed-jumprun.mjs, played by tools/smoke-jumprun.mjs). * tetris the second example game (tools/seed-tetris.mjs, played by tools/smoke-tetris.mjs). * actor the four-direction walk demo (tools/seed-actor.mjs). * * All ids exist on the server (created via POST ?r=projects); PUT does not create projects. */ import { readFile } from 'node:fs/promises'; import { basename, extname } from 'node:path'; import { authHeaders } from './auth.mjs'; export const PROJECTS = { playground: '649188c15414', fixture: '3caa9b6a0c92', jumpRun: '3d271f3bb58b', tetris: '2072b56583af', actor: '3ccb5c67220b', }; export const DEFAULT_BASE_URL = 'https://nockal.com/feuerwerk/'; /** * Reads `[projectId] [baseUrl]` from argv, falling back to `defaultId` and to * `GAME_MAKER_BASE_URL` – which is how CI points every tool at its own server without * repeating the project ids in shell scripts. Targeting the playground aborts: no tool may * overwrite or mutate it. */ export function cliArgs(script, defaultId) { const projectId = process.argv[2] ?? defaultId; const baseUrl = process.argv[3] ?? process.env.GAME_MAKER_BASE_URL ?? DEFAULT_BASE_URL; if (projectId === PROJECTS.playground) { console.error(`refusing to run ${script} against the playground project (${PROJECTS.playground}).`); process.exit(2); } return { projectId, baseUrl }; } const MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', }; /** * Uploads an image and returns `{file, width, height}` for a texture asset. Storage is * content-addressed, so re-seeding the same file is a no-op rather than a second copy – which is * what makes a seed that ships its own image reproducible (`data/textures/` is git-ignored). */ export async function uploadTexture(baseUrl, filePath) { const bytes = await readFile(filePath); const type = MIME[extname(filePath).slice(1).toLowerCase()]; if (!type) { console.error(`unsupported image type: ${filePath}`); process.exit(1); } const res = await fetch(`${baseUrl}api/index.php?r=textures`, { method: 'POST', headers: { 'Content-Type': type, ...(await authHeaders(baseUrl)) }, body: bytes, signal: AbortSignal.timeout(20_000), }); const result = await res.json(); if (!res.ok) { console.error('texture upload failed', res.status, result); process.exit(1); } return { file: result.file, width: result.width, height: result.height }; } const FONT_MIME = { ttf: 'font/ttf', otf: 'font/otf', woff: 'font/woff', woff2: 'font/woff2', }; /** * Uploads a typeface and returns `{file}` for a font asset. Content-addressed like an image, so * re-seeding the same file is a no-op (`data/fonts/` is git-ignored). */ export async function uploadFont(baseUrl, filePath) { const bytes = await readFile(filePath); const type = FONT_MIME[extname(filePath).slice(1).toLowerCase()]; if (!type) { console.error(`unsupported font type: ${filePath}`); process.exit(1); } // The name rides along like a browser upload's: the picker captions the hash with it. const name = encodeURIComponent(basename(filePath)); const res = await fetch(`${baseUrl}api/index.php?r=fonts&name=${name}`, { method: 'POST', headers: { 'Content-Type': type, ...(await authHeaders(baseUrl)) }, body: bytes, signal: AbortSignal.timeout(20_000), }); const result = await res.json(); if (!res.ok) { console.error('font upload failed', res.status, result); process.exit(1); } return { file: result.file, name: result.name }; } /** * Uploads a sound and returns `{file, name}` for a sound asset. Takes the bytes rather than a path: * the seed builds its WAVs itself (see `lib/audio.mjs`), because no audio file is guaranteed to * exist on both the host and in the CI image. Content-addressed like a font (`data/sounds/` is * git-ignored), so re-seeding the same bytes is a no-op. */ export async function uploadSound(baseUrl, bytes, fileName, type = 'audio/wav') { const res = await fetch(`${baseUrl}api/index.php?r=sounds&name=${encodeURIComponent(fileName)}`, { method: 'POST', headers: { 'Content-Type': type, ...(await authHeaders(baseUrl)) }, body: bytes, signal: AbortSignal.timeout(20_000), }); const result = await res.json(); if (!res.ok) { console.error('sound upload failed', res.status, result); process.exit(1); } return { file: result.file, name: result.name, bytes: result.bytes }; } /** * Uploads a video (#402) and waits until the worker has converted it: the record of the ready MP4, * with its size, length and frame rate. Exits when the conversion fails or takes too long. */ export async function uploadVideo(baseUrl, bytes, fileName, timeoutMs = 60_000) { const headers = { 'Content-Type': 'application/octet-stream', ...(await authHeaders(baseUrl)) }; const res = await fetch(`${baseUrl}api/index.php?r=videos&name=${encodeURIComponent(fileName)}`, { method: 'POST', headers, body: bytes, signal: AbortSignal.timeout(20_000), }); const uploaded = await res.json(); if (!res.ok) { console.error('video upload failed', res.status, uploaded); process.exit(1); } const until = Date.now() + timeoutMs; for (let record = uploaded; ;) { if (record?.status === 'ready') return record; if (record?.status === 'failed' || Date.now() > until) { console.error('video conversion did not finish', record); process.exit(1); } await new Promise((resolve) => setTimeout(resolve, 250)); const list = await fetch(`${baseUrl}api/index.php?r=videos`, { headers: await authHeaders(baseUrl), signal: AbortSignal.timeout(20_000), }); record = (await list.json()).find((item) => item.file === uploaded.file); } } /** Overwrites a project, keeping the name it already has on the server. */ export async function writeProject(projectId, baseUrl, project) { const url = `${baseUrl}api/index.php?r=projects/${projectId}`; // A request without a timeout keeps its socket open, so a stalled server hangs the seed // instead of failing it – and the run that follows never starts. const timeout = () => AbortSignal.timeout(20_000); const headers = await authHeaders(baseUrl); const current = await fetch(url, { headers, signal: timeout() }); if (!current.ok) { console.error(`GET failed: ${current.status} – create the project first (POST ?r=projects)`); process.exit(1); } const doc = await current.json(); const body = { revision: doc.revision, project: { ...project, id: projectId, name: doc.name ?? project.name }, }; const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), signal: timeout(), }); const result = await res.json(); if (!res.ok) { console.error('PUT failed', res.status, result); process.exit(1); } console.log(`seeded ${projectId} (${body.project.name}) → revision ${result.revision}`); }