Files
feuerwerk/tools/lib/projects.mjs
T
admin 5671a38044
game-maker / suite (push) Successful in 12m1s
A game that stays silent is only half a game, so a sound is an asset like any other (#45)
Sounds follow the road fonts already paved: the file goes to the server
content-addressed under its sha256, the project keeps a named asset that
points at it, and nothing is ever re-encoded. The format is read from the
file's own signature, not from what the browser claims - the same .m4a
arrives as audio/mp4, audio/x-m4a or as nothing at all.

Playing is two nodes. "Play sound" takes the asset and may raise or lower
its volume; every call is its own voice, so two jumps in a row are two
jumps heard. Its loop input is a choice of three - from the asset, on,
off - because a checkbox has to spend one of its two positions on "not
set" and would lose a case: an asset that loops could then never be
played once.

Below the graph core stays browser-free: NodeContext hands out an asset
id, BehaviorSystem resolves it against the project and folds the asset's
own volume and loop into the call, and only viewport/SoundLibrary knows
about Web Audio. A run without a sink plays nothing instead of throwing.

The CI browser is muted, so the ear is no witness: the debug hook counts
started and stopped voices, and step 34 reads that counter.
2026-09-15 07:54:28 +02:00

160 lines
5.7 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';
export const PROJECTS = {
playground: '649188c15414',
fixture: '3caa9b6a0c92',
jumpRun: '3d271f3bb58b',
tetris: '2072b56583af',
actor: '3ccb5c67220b',
};
export const DEFAULT_BASE_URL = 'https://nockal.com/game-maker/';
/**
* 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 },
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 },
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 },
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 };
}
/** 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 current = await fetch(url, { 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' },
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}`);
}