Files
feuerwerk/tools/lib/projects.mjs
T
adminandClaude Opus 5.5 6097307b6d
feuerwerk / gate (push) Successful in 4m30s
feuerwerk / suite (push) Successful in 18m38s
Move repo, CI and the video worker over to the name Feuerwerk (#422)
Stage 3 of the rename. The repo now lives at meleki/feuerwerk, the
runners carry feuerwerk labels and the WSL distro on melektaus-atx is
called feuerwerk-ci. The code has to follow, or CI finds no runner and
the suite no longer picks the GPU path.

- ci.yml: workflow name feuerwerk, jobs on feuerwerk-ci (gate, stein2)
  and feuerwerk-wsl (suite, melektaus-atx).
- GAME_MAKER_* variables are now FEUERWERK_*. This includes
  FEUERWERK_BROWSER=wsl, which the runner config on melektaus-atx sets.
- The video worker runs as container feuerwerk-video with the image
  feuerwerk-video and works on /srv/feuerwerk.
- e2e.sh starts the unit feuerwerk-e2e, the CI image keeps its paths
  under the new name.
- README and repo links point to git.nockal.com/meleki/feuerwerk. The
  old URLs still redirect with 301.

Both runners keep their old labels until the first CI run with this
ci.yml is through. Then they go, together with the image tag
game-maker-ci:latest and GAME_MAKER_BROWSER in the atx config.

Tests:
- Gate on melektaus-atx through the renamed distro: CHECK EXIT 0,
  307 files, 4756 tests.
- e2e 60 on melektaus-atx: ALL PASSED, 20 checks. Renderer is the
  RTX 4070 through D3D12, so FEUERWERK_BROWSER reaches the GPU path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:42:52 +02:00

195 lines
7.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
* `FEUERWERK_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.FEUERWERK_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}`);
}