A Foundry VTT game system: Basic Roleplaying d100 in a drab British agency that deals with crossings, where reality keeps a record of what belongs where and can be made to get it wrong. Contents: - rules.mjs as the single authority for every rule formula, with 40 rules and 132 spot-checked values. Nothing may restate a formula it defines. - 25 postings, each with a key characteristic, trained skills, talents and its own kit; a 175-item catalogue across four eras; 231 generated icons. - Reaction Lanes initiative, re-rolled every round including the first. - Coherence, and the Quiet Arts, which only ever arrive from a crossing gone wrong. - Graded defences: only a critical stops a critical. - Borrowed Authority — access as a record rather than a hacking roll. - A case generator, and a rules journal generated from rules.mjs so the book cannot drift from the code. Built compendia are committed so a remote Foundry needs only the files. node_modules is not; `npm install` restores it for rebuilding content. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
177 lines
6.5 KiB
JavaScript
177 lines
6.5 KiB
JavaScript
/**
|
|
* Dice textures for The Custodians, generated rather than drawn.
|
|
*
|
|
* Written as raw RGBA and encoded to PNG with node:zlib, so the repo gains no
|
|
* image dependency and the textures can be regenerated deterministically. Every
|
|
* pattern is tileable — Dice So Nice wraps these across a die face, and a seam
|
|
* shows up immediately on a d20.
|
|
*
|
|
* The set is the setting: the Custodians keep a filing system, and the game is
|
|
* about that filing system being wrong. So the dice carry a ruled grid, and the
|
|
* further an agent drifts from their own reality the more the grid stops agreeing
|
|
* with itself.
|
|
*
|
|
* node tools/make-dice-textures.mjs
|
|
*/
|
|
import { deflateSync } from "node:zlib";
|
|
import { writeFile, mkdir } from "node:fs/promises";
|
|
|
|
const SIZE = 256;
|
|
|
|
/* ---------- a minimal PNG encoder: RGBA, no interlace ---------- */
|
|
function crc32(buf) {
|
|
let c, table = crc32.table;
|
|
if (!table) {
|
|
table = crc32.table = new Int32Array(256);
|
|
for (let n = 0; n < 256; n++) {
|
|
c = n;
|
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
table[n] = c;
|
|
}
|
|
}
|
|
c = -1;
|
|
for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
return (c ^ -1) >>> 0;
|
|
}
|
|
|
|
function chunk(type, data) {
|
|
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
|
const td = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
|
|
return Buffer.concat([len, td, crc]);
|
|
}
|
|
|
|
function png(width, height, rgba) {
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
|
|
ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
|
// each scanline is prefixed with filter byte 0
|
|
const raw = Buffer.alloc(height * (width * 4 + 1));
|
|
for (let y = 0; y < height; y++) {
|
|
raw[y * (width * 4 + 1)] = 0;
|
|
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
|
|
}
|
|
return Buffer.concat([
|
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
chunk("IHDR", ihdr),
|
|
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
|
chunk("IEND", Buffer.alloc(0))
|
|
]);
|
|
}
|
|
|
|
/* ---------- deterministic noise, so a rebuild is byte-identical ---------- */
|
|
let seed = 20260810;
|
|
const rnd = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
|
|
|
|
const canvas = () => Buffer.alloc(SIZE * SIZE * 4);
|
|
const put = (buf, x, y, r, g, b, a = 255) => {
|
|
const i = ((y & (SIZE - 1)) * SIZE + (x & (SIZE - 1))) * 4;
|
|
buf[i] = r; buf[i + 1] = g; buf[i + 2] = b; buf[i + 3] = a;
|
|
};
|
|
const fill = (buf, r, g, b, a = 255) => {
|
|
for (let i = 0; i < buf.length; i += 4) { buf[i] = r; buf[i + 1] = g; buf[i + 2] = b; buf[i + 3] = a; }
|
|
};
|
|
|
|
/* ============================================================
|
|
1. REGISTRY — the ruled grid of a filing system, kept true.
|
|
============================================================ */
|
|
function registry() {
|
|
const b = canvas();
|
|
fill(b, 24, 38, 141); // ultramarine ground
|
|
for (let i = 0; i < SIZE; i++) {
|
|
for (const step of [32]) {
|
|
for (let k = 0; k < SIZE; k += step) {
|
|
put(b, i, k, 102, 116, 216); // --rb-line
|
|
put(b, k, i, 102, 116, 216);
|
|
}
|
|
}
|
|
for (let k = 16; k < SIZE; k += 32) { // fainter half-lines
|
|
put(b, i, k, 60, 78, 175);
|
|
put(b, k, i, 60, 78, 175);
|
|
}
|
|
}
|
|
// registry ticks at the intersections
|
|
for (let x = 0; x < SIZE; x += 64) for (let y = 0; y < SIZE; y += 64) {
|
|
for (let d = -3; d <= 3; d++) { put(b, x + d, y, 242, 234, 114); put(b, x, y + d, 242, 234, 114); }
|
|
}
|
|
return b;
|
|
}
|
|
|
|
/* ============================================================
|
|
2. DRIFT — the same grid, no longer agreeing with itself.
|
|
Used for agents who have come loose from their own reality.
|
|
============================================================ */
|
|
function drift() {
|
|
const b = canvas();
|
|
fill(b, 20, 30, 120);
|
|
const wobble = y => Math.round(6 * Math.sin(y / 14) + 3 * Math.sin(y / 5.5));
|
|
for (let i = 0; i < SIZE; i++) {
|
|
for (let k = 0; k < SIZE; k += 32) {
|
|
put(b, i + wobble(i), k, 122, 108, 200);
|
|
put(b, k, i + wobble(i), 122, 108, 200);
|
|
}
|
|
}
|
|
for (let n = 0; n < 900; n++) { // grain where it has torn
|
|
const x = (rnd() * SIZE) | 0, y = (rnd() * SIZE) | 0;
|
|
put(b, x, y, 200, 120, 150, 255);
|
|
}
|
|
for (let x = 0; x < SIZE; x += 64) for (let y = 0; y < SIZE; y += 64) {
|
|
const ox = ((rnd() * 7) | 0) - 3, oy = ((rnd() * 7) | 0) - 3;
|
|
for (let d = -3; d <= 3; d++) { put(b, x + d + ox, y + oy, 255, 122, 92); put(b, x + ox, y + d + oy, 255, 122, 92); }
|
|
}
|
|
return b;
|
|
}
|
|
|
|
/* ============================================================
|
|
3. DISPLACED — the grid is gone. What is left is not filed.
|
|
============================================================ */
|
|
function displaced() {
|
|
const b = canvas();
|
|
fill(b, 12, 14, 60);
|
|
for (let n = 0; n < 5200; n++) {
|
|
const x = (rnd() * SIZE) | 0, y = (rnd() * SIZE) | 0;
|
|
const v = 40 + ((rnd() * 90) | 0);
|
|
put(b, x, y, v, v * 0.5, v * 0.7);
|
|
}
|
|
// a few surviving fragments of rule, going nowhere
|
|
for (let n = 0; n < 26; n++) {
|
|
const x = (rnd() * SIZE) | 0, y = (rnd() * SIZE) | 0, len = 6 + ((rnd() * 22) | 0);
|
|
const horiz = rnd() > 0.5;
|
|
for (let d = 0; d < len; d++) {
|
|
if (horiz) put(b, x + d, y, 150, 120, 190); else put(b, x, y + d, 150, 120, 190);
|
|
}
|
|
}
|
|
return b;
|
|
}
|
|
|
|
/* ============================================================
|
|
4. AGENCY — brushed plate, for the agency-issue set.
|
|
============================================================ */
|
|
function agency() {
|
|
const b = canvas();
|
|
for (let y = 0; y < SIZE; y++) {
|
|
for (let x = 0; x < SIZE; x++) {
|
|
const grain = 6 * Math.sin(x / 1.7 + y / 90) + (rnd() * 7 - 3.5);
|
|
const v = 30 + grain;
|
|
put(b, x, y, 22 + v, 33 + v, 95 + v);
|
|
}
|
|
}
|
|
for (let k = 0; k < SIZE; k += 48) for (let i = 0; i < SIZE; i++) put(b, i, k, 90, 104, 190);
|
|
return b;
|
|
}
|
|
|
|
const TEXTURES = {
|
|
"custodians-registry": registry,
|
|
"custodians-drift": drift,
|
|
"custodians-displaced": displaced,
|
|
"custodians-agency": agency
|
|
};
|
|
|
|
await mkdir(new URL("../dice/", import.meta.url), { recursive: true });
|
|
for (const [name, make] of Object.entries(TEXTURES)) {
|
|
const buf = png(SIZE, SIZE, make());
|
|
await writeFile(new URL(`../dice/${name}.png`, import.meta.url), buf);
|
|
console.log(` ${name}.png ${SIZE}x${SIZE} ${(buf.length / 1024).toFixed(1)} kB`);
|
|
}
|
|
console.log("Custodians | dice textures written");
|