Files
feuerwerk/tools/lib/projects.mjs
T
adminandClaude Opus 5.5 043c224cc3 Rename Game Maker to Feuerwerk and serve it at /feuerwerk/ (#422)
The editor gets a name of its own that fits beside Brandmauer,
Glutspeicher and Feuerwehr. This is stage 1, 2 and 4 of #422: the
name, the URL and the stored state in the browser. Repository, CI
runners, containers and the GAME_MAKER_* variables follow in stage 3.

- All paths come from SITE_PATH in src/core/site.ts: the API endpoint,
  the data folders, the play page and the base in vite.config.ts. A
  later move is one line instead of fifteen.
- The window title, the sign-in screen, the playground and the README
  say Feuerwerk. The package, __feuerwerkPlay, the take worklet and the
  writing app in Ogg and Matroska follow.
- The session cookie is now feuerwerk_session, so everyone signs in
  once more.
- localStorage keys are now feuerwerk.*. moveOldStoredKeys copies the
  old game-maker.* keys over on the first start, so settings, panel
  sizes and save games survive.
- tools/ci/serve.sh serves the build under /feuerwerk/. The variable
  GAME_MAKER_BASE_URL keeps its name until stage 3.

Outside the repository, nockal.com/feuerwerk/ is a second symlink to
the same tree. nginx sends the bare /game-maker/ folder there with a
301. Everything else under /game-maker/ is still served directly, so
shared play links and the API keep working.

Finding: when nginx redirects a path without a trailing slash or with
a query, the Brandmauer answers with its own 307 to the same URL plus
a slash, placed after the query. A play link would lose its token that
way, which is why only the bare folder is redirected.

Tests:
- Gate on melektaus-atx: CHECK EXIT 0, 307 files, 4756 tests
  (4 new in site.test.ts).
- Counter-check: when the migration misses the old prefix, 3 of the
  4 new tests fail.
- e2e 60 under /feuerwerk/: ALL PASSED, 20 checks.
- From outside: /feuerwerk/ 200, /game-maker/ 301, an old play
  link 200.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:27:43 +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
* `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}`);
}