Opened all of it this time, rather than driving the system through its API.
Two more defects of the same family as the NPC sheet, and in the same place —
the types added last:
- Talent and Art had no TYPES.Item.* label, so their sheets printed the raw
key "TYPES.Item.talent" under the name.
- Neither had a branch in item-sheet.hbs or an isTalent/isPower flag, so the
Detail tab rendered EMPTY over a document holding category, rule, uses per
case, Coherence cost, push cost and origin. Eleven of thirteen types were
handled, which is exactly the shape an eye skips.
Both now have a header statline and a full Detail section, and a guard walks
template.json: every declared type must have a label, a flag set in ringbrp.mjs,
and an {{#if isX}} section in the sheet.
One hardening fix: loseCoherence applies the cost and THEN announces it, so
anything that throws in between leaves the actor quietly poorer with no message.
localize() throws on a non-string, which an unrecognised reason key is.
Everything else came back clean, and it is worth recording what was checked so
the next audit knows what "checked" meant: 13 item types opened, all render; the
expedition sheet (4 tabs); all 7 chat cards driven through their real entry
points — roll, coherence, reaction via Combat.rollAll, hit location, weapon
damage, major wound, and contact through a case file that actually has a group;
all 3 dialogs opened by clicking the controls a player clicks. No errors, no raw
localisation keys anywhere.
Also: Culland Hall as a playable Scene. Drawn as SVG rather than generated,
because a room count, a door position and a label are all load-bearing here and
a generator cannot be trusted with any of them — three label-collision passes in
the browser before it was clean, which is the documented cost of doing it this
way. 2800x2000 at 100px to the square so the Foundry grid lands on the grid
drawn into the image; 5ft squares; no walls, because half-done walls are worse
than none and a GM who wants vision can add them in ten minutes.
Verified in Foundry: the scene imports, activates, and draws.
Version 1.0.2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1111 lines
50 KiB
JavaScript
1111 lines
50 KiB
JavaScript
/**
|
||
* Builds the RingBRP LevelDB compendium packs from tools/content.mjs.
|
||
* Run: node tools/build-packs.mjs
|
||
*
|
||
* IDs are derived deterministically from a key string, so rebuilding a pack
|
||
* keeps stable references and does not duplicate documents in existing worlds.
|
||
*/
|
||
|
||
import { ClassicLevel } from "classic-level";
|
||
import { createHash } from "node:crypto";
|
||
import { ICON_MAP, SKILL_ICON } from "./icons.mjs";
|
||
import { rm, mkdir } from "node:fs/promises";
|
||
import { existsSync } from "node:fs";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import {
|
||
SKILL_CATALOGUE, WEAPONS, ARMOURS, GEAR, VEHICLES, PREGENS, NPCS, DEVICES, TALENTS,
|
||
SITE_TABLE, HAZARD_TABLE, hitPoints, majorWound, damageModifier, styleFor
|
||
} from "./content.mjs";
|
||
import { SHIELD } from "../rules.mjs";
|
||
|
||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||
const PACKS = path.join(ROOT, "packs");
|
||
|
||
const ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||
function idFor(key) {
|
||
const h = createHash("sha256").update(`ringbrp:${key}`).digest();
|
||
let out = "";
|
||
for (let i = 0; i < 16; i++) out += ALPHABET[h[i] % ALPHABET.length];
|
||
return out;
|
||
}
|
||
|
||
const BASE = () => ({ folder: null, sort: 0, flags: {}, ownership: { default: 0 } });
|
||
const IC = k => ICON_MAP[k] ? `systems/ringbrp/icons/${k}.svg` : null;
|
||
const ICON = {
|
||
skill: IC("skill_mental"),
|
||
weapon: IC("pistol_med"),
|
||
melee: IC("dagger"),
|
||
armour: IC("stab_vest"),
|
||
gear: IC("pack") ?? IC("medkit"),
|
||
device: IC("crossing_regulator"),
|
||
actor: IC("actor_pc"),
|
||
table: IC("table"),
|
||
journal: IC("journal"),
|
||
talent: IC("talent_generic"),
|
||
npc: IC("actor_npc"),
|
||
caseFile: IC("actor_case")
|
||
};
|
||
|
||
/* ---------------- item factories ---------------- */
|
||
|
||
function skillItem({ fam, spec, cat, base, name, extra = {} }) {
|
||
return {
|
||
_id: idFor(`skill:${fam}:${spec}`),
|
||
name, type: "skill", img: IC(SKILL_ICON[cat]) ?? ICON.skill,
|
||
system: {
|
||
description: "",
|
||
familyId: fam, specialisationId: spec, categoryId: cat,
|
||
base: { mode: "flat", value: base, formula: "" },
|
||
value: base,
|
||
trainingCap: null, transferGroup: "",
|
||
experienceTicked: false, experienceEligible: true, canBeTaught: true,
|
||
...extra
|
||
},
|
||
effects: [], ...BASE()
|
||
};
|
||
}
|
||
|
||
function weaponItem(w) {
|
||
const isMelee = w.cls === "melee";
|
||
return {
|
||
_id: idFor(`weapon:${w.key}`),
|
||
name: w.name, type: "weapon", img: IC(w.key) ?? (isMelee ? ICON.melee : ICON.weapon),
|
||
system: {
|
||
description: "", massKg: w.mass ?? 0, quantity: 1, equipped: false, carried: true,
|
||
traditionIds: [w.trad ?? "tradition_local"],
|
||
weaponClass: w.cls,
|
||
skillFamilyId: w.fam, skillSpecialisationId: w.spec,
|
||
damage: w.byBand
|
||
? { mode: "byBand", formula: "", short: w.byBand.short, medium: w.byBand.medium, long: w.byBand.long }
|
||
: { mode: "single", formula: w.dmg, short: "", medium: "", long: "" },
|
||
appliesDamageModifier: !!w.mod,
|
||
woundType: w.wound,
|
||
monofilament: !!w.mono,
|
||
fireMode: w.fire ?? "",
|
||
ranges: { short: w.ranges[0], medium: w.ranges[1], long: w.ranges[2] },
|
||
ammo: { capacity: w.cap ?? 0, loaded: w.cap ?? 0, reserve: 0, reloadRounds: w.fire === "slow" ? 2 : 1 },
|
||
energyPerShot: w.energy ?? 0,
|
||
damageTags: w.mono ? ["monofilament"] : (w.cls === "energy" ? ["energy"] : ["kinetic"]),
|
||
coherenceCost: w.coherence ?? 0
|
||
},
|
||
effects: [], ...BASE(),
|
||
flags: { ringbrp: { key: w.key, era: w.era ?? "modern" } }
|
||
};
|
||
}
|
||
|
||
function armourItem(a) {
|
||
return {
|
||
_id: idFor(`armour:${a.key}`),
|
||
name: a.name, type: "armour", img: IC(a.key) ?? ICON.armour,
|
||
system: {
|
||
coherenceCost: a.coherence ?? 0,
|
||
description: "", massKg: a.mass, quantity: 1, equipped: false, carried: true,
|
||
traditionIds: [a.trad ?? "tradition_local"],
|
||
points: a.points, coverage: a.covers ?? (a.shield ? SHIELD.coverage : "all"),
|
||
shield: !!a.shield, raised: false,
|
||
ignoredBy: [], degradation: { current: 0, max: a.tough ?? 4 }
|
||
},
|
||
effects: [], ...BASE(),
|
||
flags: { ringbrp: { key: a.key, era: a.era ?? "modern" } }
|
||
};
|
||
}
|
||
|
||
function gearItem(g) {
|
||
return {
|
||
_id: idFor(`gear:${g.key}`),
|
||
name: g.name, type: "gear", img: IC(g.key) ?? ICON.gear,
|
||
system: {
|
||
description: g.desc ? `<p>${g.desc}</p>` : "",
|
||
massKg: g.mass, quantity: 1, equipped: false, carried: true,
|
||
traditionIds: [g.trad ?? "tradition_home"],
|
||
powerDraw: 0, skillBonus: { skillFamilyId: g.bonus?.fam ?? "", value: g.bonus?.value ?? 0 },
|
||
researchFacility: g.facility ?? "",
|
||
coherenceCost: g.coherence ?? 0,
|
||
// A sight applies itself off the range band, and a lamp off the dark. Both are
|
||
// facts about what is in the pack rather than choices made at the trigger.
|
||
optic: { kind: g.optic ?? "", fits: g.fits ?? "any" },
|
||
lightSource: !!g.lit,
|
||
lightReachM: g.lit ? (g.reach ?? 10) : 0,
|
||
isAnchor: /anchor/i.test(g.name)
|
||
},
|
||
effects: [], ...BASE(),
|
||
flags: { ringbrp: { key: g.key, era: g.era ?? "modern" } }
|
||
};
|
||
}
|
||
|
||
function vehicleItem(v) {
|
||
return {
|
||
_id: idFor(`vehicle:${v.key}`),
|
||
name: v.name, type: "vehicle", img: IC(v.key) ?? ICON.gear,
|
||
system: {
|
||
description: v.desc ? `<p>${v.desc}</p>` : "",
|
||
// A vehicle is carried by nobody: massKg is what it weighs, not what it costs
|
||
// an agent to have. `carried` false keeps it out of the encumbrance sum.
|
||
massKg: v.mass ?? 0, quantity: 1, equipped: false, carried: false,
|
||
traditionIds: [v.trad ?? "tradition_home"],
|
||
skillFamilyId: v.fam ?? "pilot", skillSpecialisationId: v.spec ?? "ground_car",
|
||
crew: v.crew ?? 1, seats: v.seats ?? 0, cargoKg: v.cargo ?? 0,
|
||
speedBand: v.speed ?? "road", rangeKm: v.range ?? 0,
|
||
hull: { value: v.hull ?? 0, max: v.hull ?? 0 },
|
||
hullMax: v.hullMax ?? v.hull ?? 0,
|
||
spacecraft: !!v.spacecraft, heat: 0, signature: 0, band: "close", stations: [],
|
||
armourPoints: v.armour ?? 0, terrain: v.terrain ?? [],
|
||
powerDraw: 0, coherenceCost: v.coherence ?? 0,
|
||
era: v.era ?? "modern", notes: ""
|
||
},
|
||
effects: [], ...BASE(),
|
||
flags: { ringbrp: { key: v.key, era: v.era ?? "modern" } }
|
||
};
|
||
}
|
||
|
||
function deviceItem(d) {
|
||
return {
|
||
_id: idFor(`device:${d.key}`),
|
||
name: d.name, type: "device", img: ICON.device,
|
||
system: {
|
||
description: d.desc ?? "", massKg: d.mass, quantity: 1, equipped: false, carried: true,
|
||
traditionIds: d.traditions,
|
||
documentId: d.documentId, materialId: "standard",
|
||
integrity: d.integrity, functions: d.functions
|
||
},
|
||
effects: [], ...BASE()
|
||
};
|
||
}
|
||
|
||
/* ---------------- register expansion ---------------- */
|
||
|
||
/**
|
||
* A posting or trade expanded into the shape buildActor wants.
|
||
*
|
||
* Shared by the starter scenario and the duty roster, because two copies of this is
|
||
* two places for the induction rule to drift. Ratings are FIXED at the middle of each
|
||
* band rather than rolled: a pregen that varies between installs is not a pregen.
|
||
*
|
||
* `civilian` builds the person BEFORE the department found them: their trade and the
|
||
* tools of it, and nothing the agency ever handed them. No induction skills, no
|
||
* standing issue, no tier issue, no trade cant — that last one matters, because trade
|
||
* cant is the department's own argot and a civilian has never heard it spoken.
|
||
*/
|
||
const REG = await import("../postings.mjs");
|
||
const midBand = b => Math.round((b[0] + b[1]) / 2);
|
||
|
||
function expandFromRegister(spec, { where = "roster", civilian = false } = {}) {
|
||
const { ROLES, TRADES, INDUCTION, TRADE_BANDS, STANDING_ISSUE, TIER_ISSUE, TIERS } = REG;
|
||
const isTrade = !!spec.trade;
|
||
const R = isTrade ? TRADES[spec.trade] : ROLES[spec.role];
|
||
if (!R) throw new Error(`${where}: no ${isTrade ? "trade" : "role"} "${spec.trade ?? spec.role}"`);
|
||
const tierKey = { "Probationary": "probationary", "Field Officer": "officer",
|
||
"Senior Field Officer": "senior", "Case Officer": "veteran" }[spec.rank]
|
||
?? "officer";
|
||
const T = TIERS[tierKey];
|
||
const coreV = isTrade ? midBand(TRADE_BANDS.core) : midBand(T.core);
|
||
const supportV = isTrade ? midBand(TRADE_BANDS.support) : midBand(T.support);
|
||
const inducV = midBand(INDUCTION.bandByTier[tierKey] ?? INDUCTION.band);
|
||
|
||
const skills = [];
|
||
const seen = new Set();
|
||
const addSkill = (id, val) => {
|
||
if (seen.has(id)) return; // induction never demotes a trade
|
||
seen.add(id);
|
||
const [fam, sp = ""] = id.split(":");
|
||
skills.push({ fam, spec: sp, val });
|
||
};
|
||
for (const k of R.core) addSkill(k, coreV);
|
||
for (const k of R.support) addSkill(k, supportV);
|
||
if (isTrade && !civilian) for (const k of INDUCTION.skills) addSkill(k, inducV);
|
||
if (!civilian) addSkill("language:trade_cant", 55);
|
||
// Floors only ever RAISE a rating, so a posting already better stays better.
|
||
for (const [id, floor] of Object.entries(spec.floors ?? {})) {
|
||
const [fam, sp = ""] = id.split(":");
|
||
const found = skills.find(x => x.fam === fam && x.spec === sp);
|
||
if (found) found.val = Math.max(found.val, floor);
|
||
else { skills.push({ fam, spec: sp, val: floor }); seen.add(id); }
|
||
}
|
||
|
||
// A civilian has never been near a crossing, so nothing anomalous and nothing that
|
||
// costs Coherence to use — the criminal's trade kit contains a black mirror, which is
|
||
// exactly the sort of thing you acquire AFTER the night the department noticed you.
|
||
const isAnomalous = k => {
|
||
const it = GEAR_LOOKUP.get(k) ?? WEAPON_LOOKUP.get(k) ?? ARMOUR_LOOKUP.get(k);
|
||
return !!it && (it.era === "anomalous" || !!it.coherence);
|
||
};
|
||
// No firearm and no body armour either. The police trade kit contains a sidearm and a
|
||
// stab vest, which is right for a serving officer and wrong for the person before the
|
||
// department found them — this is Britain, and the two ex-police pregens were walking
|
||
// into a civilian prologue armed and armoured. The department issues those. Playing
|
||
// LAST ADMISSION found it: its only fight was over in one round because a punter had
|
||
// a revolver. A criminal's knife stays; a knife is not an issued weapon.
|
||
const isIssuedForce = k => {
|
||
const w = WEAPON_LOOKUP.get(k);
|
||
if (w) return w.cls !== "melee";
|
||
return ARMOUR_LOOKUP.has(k);
|
||
};
|
||
const kitKeys = civilian
|
||
? [...R.kit, ...(spec.extraWeapons ?? []), ...(spec.extraGear ?? [])]
|
||
.filter(k => !isAnomalous(k) && !isIssuedForce(k))
|
||
: [...STANDING_ISSUE, ...R.kit,
|
||
...(isTrade ? INDUCTION.kit : []),
|
||
...(spec.extraWeapons ?? []), ...(spec.extraGear ?? []),
|
||
...(TIER_ISSUE[tierKey] ?? [])];
|
||
const weapons = [], armour = [], gear = [];
|
||
for (const k of new Set(kitKeys)) {
|
||
if (WEAPON_LOOKUP.has(k)) weapons.push(k);
|
||
else if (ARMOUR_LOOKUP.has(k)) armour.push(k);
|
||
else if (GEAR_LOOKUP.has(k)) gear.push(k);
|
||
else throw new Error(`${where}: ${spec.name} issued unknown item "${k}"`);
|
||
}
|
||
return { ...spec, label: R.label, blurb: R.blurb, skills, weapons, armour, gear };
|
||
}
|
||
|
||
/* ---------------- actor factory ---------------- */
|
||
|
||
const SKILL_LOOKUP = new Map(
|
||
SKILL_CATALOGUE.map(([fam, spec, cat, base, name, extra]) => [`${fam}:${spec}`, { fam, spec, cat, base, name, extra }])
|
||
);
|
||
const WEAPON_LOOKUP = new Map(WEAPONS.map(w => [w.key, w]));
|
||
const ARMOUR_LOOKUP = new Map(ARMOURS.map(a => [a.key, a]));
|
||
const GEAR_LOOKUP = new Map(GEAR.map(g => [g.key, g]));
|
||
const DEVICE_LOOKUP = new Map(DEVICES.map(d => [d.key, d]));
|
||
|
||
function buildActor(spec, type) {
|
||
const ch = spec.ch;
|
||
const hp = hitPoints(ch.con, ch.siz);
|
||
const mw = majorWound(hp);
|
||
const style = styleFor(spec.chosenStyle ?? "reflex");
|
||
const actorId = idFor(`actor:${spec.key}`);
|
||
const stableKey = spec.key;
|
||
|
||
const items = [];
|
||
const push = (doc, suffix) => {
|
||
const copy = foundryClone(doc);
|
||
copy._id = idFor(`owned:${spec.key}:${suffix}`);
|
||
items.push(copy);
|
||
};
|
||
|
||
for (const s of spec.skills ?? []) {
|
||
const meta = SKILL_LOOKUP.get(`${s.fam}:${s.spec}`);
|
||
if (!meta) throw new Error(`Unknown skill ${s.fam}:${s.spec} on ${spec.name}`);
|
||
const doc = skillItem({ ...meta, cat: meta.cat, base: meta.base, name: meta.name, extra: meta.extra ?? {} });
|
||
doc.system.value = s.val;
|
||
push(doc, `skill:${s.fam}:${s.spec}`);
|
||
}
|
||
// Every skill, so the sheet reads like a BRP sheet rather than eleven entries
|
||
// scattered over seven categories. Trained ones were pushed above; the rest
|
||
// come in at printed base.
|
||
const trained = new Set((spec.skills ?? []).map(x => `${x.fam}:${x.spec}`));
|
||
for (const [fam, sp, cat, base, name, extra] of SKILL_CATALOGUE) {
|
||
if (trained.has(`${fam}:${sp}`)) continue;
|
||
const doc = skillItem({ fam, spec: sp, cat, base, name, extra: extra ?? {} });
|
||
doc.system.value = base;
|
||
push(doc, `skill:${fam}:${sp}`);
|
||
}
|
||
for (const key of spec.talents ?? []) {
|
||
const t = TALENTS.find(x => x.key === key);
|
||
if (!t) throw new Error(`Unknown talent ${key} on ${spec.name}`);
|
||
push(talentItem(t), `talent:${key}`);
|
||
}
|
||
// Everyone has hands. "Punch / kick" was in the catalogue and issued to nobody,
|
||
// so every agent had Brawl and no item to roll damage with — an unarmed attack
|
||
// was unrollable for every character in the game.
|
||
for (const key of [...new Set(["punch", ...(spec.weapons ?? [])])]) {
|
||
const w = WEAPON_LOOKUP.get(key);
|
||
if (!w) throw new Error(`Unknown weapon ${key} on ${spec.name}`);
|
||
const doc = weaponItem(w);
|
||
doc.system.equipped = true;
|
||
push(doc, `weapon:${key}`);
|
||
}
|
||
for (const key of spec.armour ?? []) {
|
||
const a = ARMOUR_LOOKUP.get(key);
|
||
if (!a) throw new Error(`Unknown armour ${key} on ${spec.name}`);
|
||
const doc = armourItem(a);
|
||
doc.system.equipped = true;
|
||
push(doc, `armour:${key}`);
|
||
}
|
||
for (const entry of spec.gear ?? []) {
|
||
const [key, qty] = Array.isArray(entry) ? entry : [entry, 1];
|
||
const g = GEAR_LOOKUP.get(key);
|
||
if (!g) throw new Error(`Unknown gear ${key} on ${spec.name}`);
|
||
const doc = gearItem(g);
|
||
doc.system.quantity = qty;
|
||
push(doc, `gear:${key}`);
|
||
}
|
||
for (const key of spec.devices ?? []) {
|
||
const d = DEVICE_LOOKUP.get(key);
|
||
if (!d) throw new Error(`Unknown device ${key} on ${spec.name}`);
|
||
push(deviceItem(d), `device:${key}`);
|
||
}
|
||
|
||
const system = {
|
||
characteristics: Object.fromEntries(
|
||
["str", "siz", "con", "int", "pow", "dex", "cha", "edu"].map(k => [k, { value: ch[k] ?? 10 }])
|
||
),
|
||
marksmanship: { style, chosenStyle: spec.chosenStyle ?? style },
|
||
hp: { value: hp, max: hp },
|
||
majorWound: { value: mw },
|
||
wounds: [],
|
||
familiarities: {
|
||
traditions: ["tradition_home"],
|
||
environments: spec.environments ?? []
|
||
},
|
||
combat: { defencesThisRound: 0, defenceTypeLock: "", stance: "normal", secondAttackUsed: false, aiming: { active: false, targetUuid: "", bonus: 0 } },
|
||
// Written explicitly rather than left to the template default. Every packed
|
||
// actor shipped as "hominid", a name from the Ringworld setting that is not a
|
||
// species this game defines — it only ever worked because the sheet falls back
|
||
// to baseline, so most of the cast was drawn and hit-located as something the
|
||
// rules do not contain.
|
||
speciesProfile: spec.species ?? "baseline",
|
||
notes: ""
|
||
};
|
||
|
||
if (type === "character") {
|
||
system.age = spec.age ?? { chronological: 30, physiological: 30 };
|
||
if (spec.rank) system.rank = spec.rank;
|
||
system.pursuits = [];
|
||
system.biography = spec.bio ?? "";
|
||
} else {
|
||
system.role = spec.role ?? "";
|
||
system.morale = { downThreshold: 2, note: "" };
|
||
system.naturalArmour = { value: Number(spec.naturalArmour) || 0 };
|
||
system.tactics = spec.tactics ?? "";
|
||
}
|
||
|
||
// A generated portrait if the spec names one, the generic silhouette otherwise.
|
||
// The token gets the same face: a pregen you can recognise on the map is worth
|
||
// more at a convention table than a correct one you cannot tell apart.
|
||
let face = ICON.actor;
|
||
if (spec.portrait) {
|
||
const rel = `art/portraits/${spec.portrait}.webp`;
|
||
// A missing portrait is a broken image on a pregen sheet that nobody notices
|
||
// until it is on a projector at a table. Fail the build instead.
|
||
if (!existsSync(path.join(ROOT, rel))) throw new Error(`${spec.name}: no portrait at ${rel}`);
|
||
face = `systems/ringbrp/${rel}`;
|
||
}
|
||
|
||
const actorDoc = {
|
||
_id: actorId,
|
||
name: spec.name, type, img: face,
|
||
system,
|
||
items: items.map(i => i._id), // Foundry LevelDB packs store an ID LIST here
|
||
effects: [],
|
||
prototypeToken: {
|
||
name: spec.name, displayName: 20, actorLink: type === "character",
|
||
width: 1, height: 1, texture: { src: face },
|
||
sight: { enabled: true }, disposition: type === "character" ? 1 : -1,
|
||
bar1: { attribute: "hp" }, bar2: { attribute: null },
|
||
flags: {}
|
||
},
|
||
...BASE(), flags: { ringbrp: { key: stableKey } }
|
||
};
|
||
return { actorDoc, itemDocs: items };
|
||
}
|
||
|
||
function foundryClone(o) { return JSON.parse(JSON.stringify(o)); }
|
||
|
||
|
||
function talentItem(t) {
|
||
const iconKey = t.cat === "species" ? "talent_species"
|
||
: t.cat === "agency" ? "talent_agency"
|
||
: t.cat === "field" ? "talent_field" : "talent_generic";
|
||
return {
|
||
_id: idFor(`talent:${t.key}`),
|
||
name: t.name, type: "talent", img: IC(iconKey) ?? ICON.talent,
|
||
system: {
|
||
description: `<p>${t.summary}</p><p><strong>${t.rule}</strong></p>`,
|
||
talentId: t.key, category: t.cat, summary: t.summary, rule: t.rule,
|
||
skillBonus: { skillFamilyId: t.bonus?.fam ?? "", value: t.bonus?.value ?? 0 },
|
||
grantsAction: t.action ?? "", coherenceCost: t.coherence ?? 0,
|
||
usesPerCase: t.uses ?? 0, usedThisCase: 0,
|
||
speciesId: t.species ?? ""
|
||
},
|
||
effects: [], ...BASE()
|
||
};
|
||
}
|
||
|
||
/* ---------------- table + journal factories ---------------- */
|
||
|
||
function tableDoc(spec, key) {
|
||
const id = idFor(`table:${key}`);
|
||
const results = spec.results.map((text, i) => ({
|
||
_id: idFor(`table:${key}:${i}`),
|
||
type: "text",
|
||
name: `${i + 1}`,
|
||
description: `<p>${text}</p>`,
|
||
img: ICON.table,
|
||
weight: 1,
|
||
range: [i + 1, i + 1],
|
||
drawn: false,
|
||
flags: {}
|
||
}));
|
||
return {
|
||
doc: {
|
||
_id: id, name: spec.name, img: ICON.table,
|
||
description: "", formula: spec.formula, replacement: true, displayRoll: true,
|
||
results: results.map(r => r._id), ...BASE()
|
||
},
|
||
results, id
|
||
};
|
||
}
|
||
|
||
function journalDoc(name, key, pages) {
|
||
const id = idFor(`journal:${key}`);
|
||
const pageDocs = pages.map((p, i) => ({
|
||
_id: idFor(`journal:${key}:${i}`),
|
||
name: p.name, type: "text",
|
||
title: { show: true, level: 1 },
|
||
text: { format: 1, content: p.content, markdown: "" },
|
||
image: {}, video: { controls: true, volume: 0.5 }, src: null,
|
||
system: {}, sort: (i + 1) * 100, ownership: { default: -1 }, flags: {},
|
||
category: null
|
||
}));
|
||
return { doc: { _id: id, name, pages: pageDocs.map(p => p._id), categories: [], ...BASE() }, pages: pageDocs, id };
|
||
}
|
||
|
||
/* ---------------- writer ---------------- */
|
||
|
||
/**
|
||
* A compendium folder. Foundry stores these as !folders! documents inside the pack and
|
||
* an actor joins one by setting its `folder` to the folder id. Thirty-six pregens in a
|
||
* flat list is the difference between a GM finding a character and a GM giving up.
|
||
*/
|
||
function packFolder(packName, name, sort, type = "Actor") {
|
||
const _id = idFor(`folder:${packName}:${name}`);
|
||
return { _id, name, type, sorting: "a", sort, color: null, description: "",
|
||
folder: null, flags: {}, ...BASE() };
|
||
}
|
||
|
||
async function writePack(packName, entries) {
|
||
const dir = path.join(PACKS, packName);
|
||
await rm(dir, { recursive: true, force: true });
|
||
await mkdir(dir, { recursive: true });
|
||
const db = new ClassicLevel(dir, { valueEncoding: "json" });
|
||
await db.open();
|
||
const batch = db.batch();
|
||
for (const [key, value] of entries) batch.put(key, value);
|
||
await batch.write();
|
||
await db.close();
|
||
console.log(` ${packName.padEnd(10)} ${String(entries.length).padStart(4)} keys`);
|
||
}
|
||
|
||
/* ---------------- main ---------------- */
|
||
|
||
// Refuse to build if a rule has been duplicated. The packs used to be built under
|
||
// one rule and played under another; this makes that impossible to ship.
|
||
{
|
||
const { execFileSync } = await import("node:child_process");
|
||
try {
|
||
execFileSync(process.execPath, [new URL("check-rules.mjs", import.meta.url).pathname],
|
||
{ stdio: "inherit" });
|
||
// And refuse if a posting is issued kit the catalogue cannot supply.
|
||
execFileSync(process.execPath, [new URL("check-kits.mjs", import.meta.url).pathname],
|
||
{ stdio: "inherit" });
|
||
// And refuse if the localisation file cannot be loaded, which un-localises
|
||
// every sheet in the system and gives no error the user would connect to it.
|
||
execFileSync(process.execPath, [new URL("check-lang.mjs", import.meta.url).pathname],
|
||
{ stdio: "inherit" });
|
||
// And refuse if a template will not compile. A parse error in a .hbs is a sheet
|
||
// that never opens, and no other guard reads templates — npc-sheet.hbs shipped
|
||
// broken and every NPC in the system threw on click.
|
||
execFileSync(process.execPath, [new URL("check-templates.mjs", import.meta.url).pathname],
|
||
{ stdio: "inherit" });
|
||
// And refuse if a rule a play-through already found is broken again. The static
|
||
// guards cannot tell whether a rule is READ; these tests can.
|
||
execFileSync(process.execPath, [new URL("check-behaviour.mjs", import.meta.url).pathname],
|
||
{ stdio: "inherit" });
|
||
} catch { console.error("build aborted: pre-build checks failed"); process.exit(1); }
|
||
}
|
||
|
||
console.log("Custodians | building compendium packs");
|
||
|
||
// skills
|
||
{
|
||
const entries = SKILL_CATALOGUE.map(([fam, spec, cat, base, name, extra]) => {
|
||
const doc = skillItem({ fam, spec, cat, base, name, extra: extra ?? {} });
|
||
return [`!items!${doc._id}`, doc];
|
||
});
|
||
await writePack("skills", entries);
|
||
}
|
||
|
||
// talents
|
||
await writePack("talents", TALENTS.map(t => { const d = talentItem(t); return [`!items!${d._id}`, d]; }));
|
||
|
||
// weapons
|
||
await writePack("weapons", WEAPONS.map(w => {
|
||
const d = weaponItem(w); return [`!items!${d._id}`, d];
|
||
}));
|
||
|
||
// armour
|
||
await writePack("armour", ARMOURS.map(a => {
|
||
const d = armourItem(a); return [`!items!${d._id}`, d];
|
||
}));
|
||
|
||
// gear + devices
|
||
{
|
||
const entries = [];
|
||
for (const g of GEAR) { const d = gearItem(g); entries.push([`!items!${d._id}`, d]); }
|
||
for (const dv of DEVICES) { const d = deviceItem(dv); entries.push([`!items!${d._id}`, d]); }
|
||
await writePack("gear", entries);
|
||
}
|
||
|
||
// vehicles — their own pack, because a lorry is not a piece of kit
|
||
{
|
||
const entries = VEHICLES.map(v => { const d = vehicleItem(v); return [`!items!${d._id}`, d]; });
|
||
await writePack("vehicles", entries);
|
||
}
|
||
|
||
// pregens — the six hand-written originals plus the ten-agent duty roster
|
||
{
|
||
const { ROSTER, BEFORE } = await import("./roster.mjs");
|
||
const roster = ROSTER.map(spec => {
|
||
const e = expandFromRegister(spec, { where: "roster" });
|
||
return { ...e, bio: `<p><strong>${e.label}, ${spec.rank}.</strong> ${e.blurb}</p>${spec.bio}` };
|
||
});
|
||
|
||
// The same ten before the department found them. Same key stem, same face, same
|
||
// characteristics — a person's STR does not change when they sign the Act. What
|
||
// changes is everything the job gave them, which is the whole point of the pair.
|
||
const { TRADES } = REG;
|
||
const before = ROSTER.map(spec => {
|
||
const b = BEFORE[spec.key];
|
||
if (!b) throw new Error(`roster: ${spec.name} has no "before" entry`);
|
||
const civ = expandFromRegister(
|
||
{ ...spec, key: `${spec.key}_before`, name: `${spec.name} (before)`,
|
||
role: undefined, trade: b.trade, rank: b.job,
|
||
age: { chronological: b.age, physiological: b.age },
|
||
// One talent, taken from the trade's own list rather than invented, so a
|
||
// civilian never holds a knack the department taught them.
|
||
talents: TRADES[b.trade].talents.slice(0, 1) },
|
||
{ where: "roster:before", civilian: true });
|
||
return { ...civ, bio: `<p><strong>${b.job}.</strong> ${civ.blurb}</p>${b.bio}` };
|
||
});
|
||
|
||
// The whole value of this set is that it holds NOTHING the department gave them, and
|
||
// that is a property of a flag passed three call-frames away. Check it here, where
|
||
// the objects actually are, rather than trusting the flag.
|
||
const { STANDING_ISSUE, INDUCTION } = REG;
|
||
for (const c of before) {
|
||
const carried = [...c.weapons, ...c.armour, ...c.gear];
|
||
// An item the trade itself owns stays, even when the department also hands it out:
|
||
// an interpreter had a notebook long before anyone issued her one. Only kit that
|
||
// could ONLY have come from the department is a failure.
|
||
const ownKit = TRADES[c.trade].kit;
|
||
const issued = carried.filter(k => !ownKit.includes(k)
|
||
&& (STANDING_ISSUE.includes(k) || INDUCTION.kit.includes(k)));
|
||
if (issued.length) throw new Error(`${c.name} is carrying issued kit: ${issued.join(", ")}`);
|
||
const odd = carried.filter(k => {
|
||
const it = GEAR_LOOKUP.get(k) ?? WEAPON_LOOKUP.get(k) ?? ARMOUR_LOOKUP.get(k);
|
||
return !!it && (it.era === "anomalous" || !!it.coherence);
|
||
});
|
||
if (odd.length) throw new Error(`${c.name} is carrying something anomalous: ${odd.join(", ")}`);
|
||
if (c.armour.length) throw new Error(`${c.name} is wearing armour: ${c.armour.join(", ")}`);
|
||
const guns = c.weapons.filter(k => WEAPON_LOOKUP.get(k)?.cls !== "melee");
|
||
if (guns.length) throw new Error(`${c.name} is carrying a firearm: ${guns.join(", ")}`);
|
||
// Same rule for skills: the linguist's core list contains trade cant, because it is
|
||
// a language and she is a linguist. Only a rating she could ONLY have been taught
|
||
// at the training centre is a failure.
|
||
const ownSkills = new Set([...TRADES[c.trade].core, ...TRADES[c.trade].support]);
|
||
const taught = c.skills.filter(s => {
|
||
const id = s.spec ? `${s.fam}:${s.spec}` : s.fam;
|
||
return !ownSkills.has(id) && (INDUCTION.skills.includes(id) || id === "language:trade_cant");
|
||
});
|
||
if (taught.length) throw new Error(`${c.name} knows induction skills: ${taught.map(s => s.spec ? `${s.fam}:${s.spec}` : s.fam).join(", ")}`);
|
||
}
|
||
|
||
const fDuty = packFolder("pregens", "Duty roster — agents", 0);
|
||
const fBefore = packFolder("pregens", "Before the department — civilians", 1);
|
||
const fOrig = packFolder("pregens", "Original pregens", 2);
|
||
const folderFor = spec =>
|
||
before.includes(spec) ? fBefore._id : roster.includes(spec) ? fDuty._id : fOrig._id;
|
||
|
||
const entries = [
|
||
[`!folders!${fDuty._id}`, fDuty],
|
||
[`!folders!${fBefore._id}`, fBefore],
|
||
[`!folders!${fOrig._id}`, fOrig]
|
||
];
|
||
for (const p of [...PREGENS, ...roster, ...before]) {
|
||
const { actorDoc, itemDocs } = buildActor(p, "character");
|
||
actorDoc.folder = folderFor(p);
|
||
entries.push([`!actors!${actorDoc._id}`, actorDoc]);
|
||
for (const it of itemDocs) entries.push([`!actors.items!${actorDoc._id}.${it._id}`, it]);
|
||
}
|
||
await writePack("pregens", entries);
|
||
}
|
||
|
||
// npcs
|
||
{
|
||
const entries = [];
|
||
for (const n of NPCS) {
|
||
const { actorDoc, itemDocs } = buildActor(n, "npc");
|
||
entries.push([`!actors!${actorDoc._id}`, actorDoc]);
|
||
for (const it of itemDocs) entries.push([`!actors.items!${actorDoc._id}.${it._id}`, it]);
|
||
}
|
||
await writePack("npcs", entries);
|
||
}
|
||
|
||
// tables
|
||
{
|
||
const entries = [];
|
||
for (const [spec, key] of [[SITE_TABLE, "sites"], [HAZARD_TABLE, "hazards"]]) {
|
||
const t = tableDoc(spec, key);
|
||
entries.push([`!tables!${t.id}`, t.doc]);
|
||
for (const r of t.results) entries.push([`!tables.results!${t.id}.${r._id}`, r]);
|
||
}
|
||
await writePack("tables", entries);
|
||
}
|
||
|
||
// PAPER WINDOW — the starter scenario, shipped as an Adventure so the whole thing
|
||
// imports in one click rather than as nine loose documents a GM has to assemble.
|
||
{
|
||
const S = await import("./scenario-starter.mjs");
|
||
|
||
// Expanded by the shared helper; the starter adds its own biography on top.
|
||
const expand = spec => {
|
||
const e = expandFromRegister(spec, { where: "starter" });
|
||
return { ...e,
|
||
bio: `<p><strong>${e.label}, ${spec.rank}.</strong> ${e.blurb}</p>`
|
||
+ `<p><strong>Your job in this case.</strong> ${spec.spotlight}</p>`
|
||
+ (spec.complication ? `<p><strong>Your complication.</strong> ${spec.complication}</p>` : "")
|
||
+ `<p class="rb-gm-note"><em>${spec.note}</em></p>` };
|
||
};
|
||
|
||
const actorDocs = [], itemDocsFor = new Map();
|
||
for (const spec of S.STARTER_TEAM) {
|
||
const { actorDoc, itemDocs } = buildActor(expand(spec), "character");
|
||
actorDoc.items = itemDocs; // Adventures embed full documents
|
||
actorDocs.push(actorDoc);
|
||
}
|
||
for (const spec of S.STARTER_CAST) {
|
||
const { actorDoc, itemDocs } = buildActor(spec, "npc");
|
||
actorDoc.items = itemDocs;
|
||
actorDocs.push(actorDoc);
|
||
}
|
||
|
||
// A case may carry no credential at all — BLACK PLATFORM's station is already shut
|
||
// to the public, so there is nobody to be somebody else in front of.
|
||
const A = S.STARTER_AUTHORITY;
|
||
const authId = A ? idFor(`item:${A.key}`) : null;
|
||
const authority = A ? {
|
||
_id: authId, name: A.name, type: "authority", img: IC("cover_id") ?? ICON.gear,
|
||
system: {
|
||
description: A.description,
|
||
holderName: A.holderName, holderRole: A.holderRole, issuer: A.issuer,
|
||
scope: A.scope, scopeNote: A.scopeNote,
|
||
trust: { value: A.trust, max: 4 },
|
||
expires: "", redFlags: A.redFlags, dossier: [],
|
||
establishesRemit: false, timesWidened: 0, mergeSteps: 0,
|
||
burned: false, suspect: false, aiPosture: "", notes: ""
|
||
},
|
||
effects: [], ...BASE(), flags: { ringbrp: { key: A.key } }
|
||
} : null;
|
||
|
||
// The case file, already at the scenario's opening state.
|
||
const C = S.STARTER_CASE;
|
||
const caseId = idFor(`actor:${C.key}`);
|
||
// Group ITEMS from the scenario data. resolveContact looks these up and returns null
|
||
// without one, so a standing with no group silently switches off READ/SIGNAL/OFFER.
|
||
const groupDocs = S.STARTER_GROUPS.map(g => ({
|
||
_id: idFor(`owned:${C.key}:group_${g.groupId}`),
|
||
name: g.name, type: "group", img: ICON.actor,
|
||
system: {
|
||
description: g.description,
|
||
groupId: g.groupId, speciesId: "baseline", population: g.population,
|
||
npcTemplateId: g.npcTemplateId,
|
||
taboos: g.taboos, desires: g.desires,
|
||
correctOfferForm: g.correctOfferForm,
|
||
rewardsByStanding: g.rewardsByStanding,
|
||
standing: 0, impression: 0
|
||
},
|
||
effects: [], ...BASE()
|
||
}));
|
||
|
||
const caseDoc = {
|
||
_id: caseId, name: C.name, type: "expedition", img: ICON.caseFile ?? ICON.actor,
|
||
system: {
|
||
integrityMode: "abstract",
|
||
integrity: { value: C.containment, max: 10 },
|
||
supply: { value: C.resources, max: 10 },
|
||
power: { value: C.clearance, max: 10 },
|
||
assets: [], members: S.STARTER_TEAM.map(t => t.key),
|
||
journey: {
|
||
destinationId: "rookery_heath", destinationName: C.destination,
|
||
destinationKnowledge: "briefed", currentLeg: 0, elapsedDays: 0,
|
||
siteTableId: "sites", hazardTableId: "hazards", returnClock: 0,
|
||
legs: C.legs.map((l, i) => ({
|
||
// `kind`, not `kindId` — resolveLegDialog reads leg.kind and falls back to a
|
||
// regex on the terrain text. Writing the wrong field worked only because
|
||
// "Extraction — closing the register" happens to start with the word.
|
||
index: i, terrain: l.label, mode: "foot", kind: l.kindId,
|
||
difficulty: l.difficulty, modifier: l.mod, days: 0,
|
||
explored: true, resolved: false, forcedEncounterId: "",
|
||
costSupply: 1, costPower: 0
|
||
}))
|
||
},
|
||
standings: C.standings.map(id => ({ groupId: id, value: 0, impression: 0, encounters: 0,
|
||
knownTaboos: [], knownDesires: [], pidginEstablished: false, obligations: [] })),
|
||
across: false, crossingModifier: 0, salvageEra: "modern",
|
||
notes: C.notes
|
||
},
|
||
items: groupDocs, effects: [],
|
||
prototypeToken: { name: C.name, displayName: 20, actorLink: true, width: 1, height: 1,
|
||
texture: { src: ICON.actor }, sight: { enabled: false }, disposition: 1,
|
||
bar1: { attribute: "integrity" }, bar2: { attribute: "supply" }, flags: {} },
|
||
...BASE()
|
||
};
|
||
actorDocs.push(caseDoc);
|
||
|
||
const gmJournal = journalDoc("PAPER WINDOW — Run the case", "starter_gm", S.STARTER_PAGES);
|
||
const handouts = journalDoc("PAPER WINDOW — Player handouts", "starter_handouts", S.STARTER_HANDOUTS);
|
||
// Handouts are for handing out. A GM who has to change ownership on seven pages
|
||
// before the session starts will simply read them aloud instead.
|
||
handouts.doc.ownership = { default: 2 };
|
||
handouts.doc.flags = { ringbrp: { brand: true } };
|
||
for (const p of handouts.pages) p.ownership = { default: 2 };
|
||
|
||
const advId = idFor("adventure:black_platform");
|
||
const adventure = {
|
||
_id: advId,
|
||
name: "BLACK PLATFORM — The 00:17 Service",
|
||
img: IC("actor_case") ?? ICON.caseFile ?? ICON.actor,
|
||
caption: "A six-player action starter. Three to four hours.",
|
||
description:
|
||
"<p><em>A train is running through a station whose rails were taken out in 1976. "
|
||
+ "Two engineers boarded it and have not come back. It returns every eleven minutes, "
|
||
+ "and every time it comes back there are more people on it.</em></p>"
|
||
+ "<p>Go down, take the platform, get aboard, find the engineers, fight forward along "
|
||
+ "a moving train, and stop it before it reaches a terminus that does not exist in "
|
||
+ "this world.</p>"
|
||
+ "<p>The complete starter: GM scenario, player handouts, six pregenerated agents who "
|
||
+ "can all fight, the ticket inspectors, the Conductor, the two casualties, and the "
|
||
+ "case file already set to Containment 9, Resources 8, Clearance 5 with its four "
|
||
+ "phases loaded.</p>",
|
||
actors: actorDocs,
|
||
items: authority ? [authority] : [],
|
||
journal: [
|
||
{ ...gmJournal.doc, pages: gmJournal.pages },
|
||
{ ...handouts.doc, pages: handouts.pages }
|
||
],
|
||
scenes: [], combats: [], tables: [], macros: [], cards: [], playlists: [], folders: [],
|
||
sort: 0, ...BASE()
|
||
};
|
||
|
||
await writePack("starter", [[`!adventures!${advId}`, adventure]]);
|
||
console.log(` starter BLACK PLATFORM — ${actorDocs.length} actors, `
|
||
+ `${S.STARTER_PAGES.length} GM pages, ${S.STARTER_HANDOUTS.length} handouts`);
|
||
}
|
||
|
||
/**
|
||
* Culland Hall as a playable Scene.
|
||
*
|
||
* The map is DRAWN (art/maps/culland-hall.svg) rather than generated, because a room
|
||
* count, a door position and a label are all load-bearing in this scenario and a
|
||
* generator cannot be trusted with any of them. 2800x2000 at 100px to the square, so
|
||
* the Foundry grid lands exactly on the grid drawn into the image.
|
||
*
|
||
* No walls or lighting: the scenario is a conversation with a floor plan behind it, and
|
||
* a GM who wants line-of-sight can add them in ten minutes. Shipping half-done walls
|
||
* would be worse than shipping none.
|
||
*/
|
||
function cullandScene() {
|
||
const id = idFor("scene:culland_hall");
|
||
return {
|
||
_id: id,
|
||
name: "Culland Hall — ground floor and grounds",
|
||
navigation: true, navName: "Culland Hall", active: false,
|
||
background: { src: "systems/ringbrp/art/maps/culland-hall.webp" },
|
||
foreground: null, thumb: null,
|
||
width: 2800, height: 2000, padding: 0.1,
|
||
initial: { x: 1400, y: 1000, scale: 0.4 },
|
||
backgroundColor: "#0c125b",
|
||
grid: { type: 1, size: 100, style: "solidLines", thickness: 1,
|
||
color: "#6674d8", alpha: 0.12, distance: 5, units: "ft" },
|
||
tokenVision: false, fog: { exploration: false, overlay: null },
|
||
globalLight: true, darkness: 0,
|
||
environment: { globalLight: { enabled: true }, darknessLevel: 0 },
|
||
drawings: [], tokens: [], lights: [], sounds: [], regions: [], templates: [],
|
||
tiles: [], walls: [], notes: [],
|
||
playlist: null, playlistSound: null, journal: null, journalEntryPage: null,
|
||
weather: "", flags: { ringbrp: { key: "culland_hall" } },
|
||
...BASE()
|
||
};
|
||
}
|
||
|
||
// LAST ADMISSION — the two-hour prologue, and the one to run by default. Same shape as
|
||
// OPEN DAY's adventure and for the same reason: journals and cast only, because the
|
||
// player characters are the "(before)" pregens in the Pregens pack.
|
||
{
|
||
const L = await import("./scenario-lastadmission.mjs");
|
||
|
||
const castDocs = [];
|
||
for (const spec of L.LA_CAST) {
|
||
const { actorDoc, itemDocs } = buildActor(spec, "npc");
|
||
actorDoc.items = itemDocs;
|
||
castDocs.push(actorDoc);
|
||
}
|
||
|
||
const gmJournal = journalDoc("LAST ADMISSION — Run the prologue", "la_gm", L.LA_PAGES);
|
||
const handouts = journalDoc("LAST ADMISSION — Player handouts", "la_handouts", L.LA_HANDOUTS);
|
||
handouts.doc.ownership = { default: 2 };
|
||
handouts.doc.flags = { ringbrp: { brand: true } };
|
||
for (const pg of handouts.pages) pg.ownership = { default: 2 };
|
||
|
||
const advId = idFor("adventure:last_admission");
|
||
const adventure = {
|
||
_id: advId,
|
||
name: "LAST ADMISSION — The Culland Haunting",
|
||
img: IC("journal") ?? ICON.journal,
|
||
caption: "The two-hour prologue. A haunted house, and a fight on the lawn.",
|
||
description:
|
||
"<p><em>Twelve thousand people paid to be told this house was haunted. It was "
|
||
+ "listening.</em></p>"
|
||
+ "<p>The last night of a cheap scare attraction in a real empty hall. Three of the "
|
||
+ "ghosts on the poster were invented in an afternoon by a marketing agency and "
|
||
+ "performed forty times a night for six weeks. They are in the building now, and "
|
||
+ "they have a page each, and they do the page.</p>"
|
||
+ "<p>There is a fourth name on the poster. The copywriter took it off a memorial "
|
||
+ "in the parish church because the layout needed four. She was real, nobody wrote "
|
||
+ "her a page, and she wants her name off the wall.</p>"
|
||
+ "<p>Two hours. One house, one decision, and something to hit. Played with the "
|
||
+ "second intake's <strong>“(before)”</strong> sheets from the Pregens "
|
||
+ "compendium.</p>",
|
||
actors: castDocs,
|
||
items: [],
|
||
journal: [
|
||
{ ...gmJournal.doc, pages: gmJournal.pages },
|
||
{ ...handouts.doc, pages: handouts.pages }
|
||
],
|
||
scenes: [cullandScene()], combats: [], tables: [], macros: [], cards: [], playlists: [], folders: [],
|
||
sort: 0, ...BASE()
|
||
};
|
||
|
||
await writePack("lastadmission", [[`!adventures!${advId}`, adventure]]);
|
||
console.log(` lastadm LAST ADMISSION — ${castDocs.length} cast, `
|
||
+ `${L.LA_PAGES.length} GM pages, ${L.LA_HANDOUTS.length} handouts`);
|
||
}
|
||
|
||
// OPEN DAY — the prologue. A much smaller adventure than BLACK PLATFORM: no case file,
|
||
// no contact groups, no authority, no combat. The player characters are the ten
|
||
// "(before)" pregens already in the Pregens pack — shipping a second copy here would
|
||
// give a GM two sets of the same ten people and no way to know which one their notes
|
||
// were on.
|
||
{
|
||
const O = await import("./scenario-openday.mjs");
|
||
|
||
const castDocs = [];
|
||
for (const spec of O.OPENDAY_CAST) {
|
||
const { actorDoc, itemDocs } = buildActor(spec, "npc");
|
||
actorDoc.items = itemDocs;
|
||
castDocs.push(actorDoc);
|
||
}
|
||
|
||
const gmJournal = journalDoc("OPEN DAY — Run the prologue", "openday_gm", O.OPENDAY_PAGES);
|
||
const handouts = journalDoc("OPEN DAY — Player handouts", "openday_handouts", O.OPENDAY_HANDOUTS);
|
||
handouts.doc.ownership = { default: 2 };
|
||
handouts.doc.flags = { ringbrp: { brand: true } };
|
||
for (const pg of handouts.pages) pg.ownership = { default: 2 };
|
||
|
||
const advId = idFor("adventure:open_day");
|
||
const adventure = {
|
||
_id: advId,
|
||
name: "OPEN DAY — Eleven Minutes",
|
||
img: IC("journal") ?? ICON.journal,
|
||
caption: "The prologue. Two and a quarter hours, played as civilians.",
|
||
description:
|
||
"<p><em>For eleven minutes the building was on a different street. You were inside "
|
||
+ "it.</em></p>"
|
||
+ "<p>A decommissioned telephone exchange, a heritage open day, a tea urn, and a "
|
||
+ "retired engineer who has waited thirty years to show somebody how the machine "
|
||
+ "worked. Nothing attacks anybody. Nothing wants anything. Eleven minutes pass on "
|
||
+ "a timetable nobody can alter, and afterwards each of them writes down what they "
|
||
+ "saw and does not cross any of it out.</p>"
|
||
+ "<p>Played with the ten <strong>“(before)”</strong> pregens from the "
|
||
+ "Pregens compendium — the duty roster as civilians, before any of this. Eight GM "
|
||
+ "pages, seven handouts, and the five people they meet.</p>",
|
||
actors: castDocs,
|
||
items: [],
|
||
journal: [
|
||
{ ...gmJournal.doc, pages: gmJournal.pages },
|
||
{ ...handouts.doc, pages: handouts.pages }
|
||
],
|
||
scenes: [], combats: [], tables: [], macros: [], cards: [], playlists: [], folders: [],
|
||
sort: 0, ...BASE()
|
||
};
|
||
|
||
await writePack("prologue", [[`!adventures!${advId}`, adventure]]);
|
||
console.log(` prologue OPEN DAY — ${castDocs.length} NPCs, `
|
||
+ `${O.OPENDAY_PAGES.length} GM pages, ${O.OPENDAY_HANDOUTS.length} handouts`);
|
||
}
|
||
|
||
// The player primer. Its own pack, OBSERVER by default, because it is the one thing
|
||
// in the system written to be handed to players before anybody has read a rule.
|
||
{
|
||
const { PRIMER_PAGES } = await import("./primer-text.mjs");
|
||
const j = journalDoc("The Custodians — Read this first", "primer", PRIMER_PAGES);
|
||
j.doc.ownership = { default: 2 };
|
||
j.doc.flags = { ringbrp: { brand: true } };
|
||
for (const pg of j.pages) pg.ownership = { default: 2 };
|
||
const entries = [[`!journal!${j.id}`, j.doc]];
|
||
for (const pg of j.pages) entries.push([`!journal.pages!${j.id}.${pg._id}`, pg]);
|
||
await writePack("primer", entries);
|
||
console.log(` primer ${PRIMER_PAGES.length} pages, player-visible`);
|
||
}
|
||
|
||
// the slice journal
|
||
{
|
||
const { SLICE_PAGES } = await import("./slice-text.mjs");
|
||
const j = journalDoc("The Ilfracombe Crossing", "slice", SLICE_PAGES);
|
||
j.doc.flags = { ringbrp: { brand: true } };
|
||
const entries = [[`!journal!${j.id}`, j.doc]];
|
||
for (const p of j.pages) entries.push([`!journal.pages!${j.id}.${p._id}`, p]);
|
||
await writePack("slice", entries);
|
||
}
|
||
|
||
// The Quiet Arts. Their own pack, because they are not gear and not talents.
|
||
{
|
||
const { POWERS } = await import("./content.mjs");
|
||
const { powerCostFor } = await import("../rules.mjs");
|
||
const entries = POWERS.map(a => {
|
||
const id = idFor(`power:${a.key}`);
|
||
return [`!items!${id}`, {
|
||
_id: id, name: a.name, type: "power", img: IC(`power_${a.art}`) ?? ICON.talent,
|
||
system: {
|
||
description: `<p><em>${a.summary}</em></p><p>${a.rule}</p><p><strong>Pushed.</strong> ${a.push}</p>`,
|
||
artId: a.art, summary: a.summary, rule: a.rule, pushRule: a.push,
|
||
value: 0,
|
||
coherenceCost: powerCostFor(a.art),
|
||
pushCost: powerCostFor(a.art, { pushed: true }),
|
||
origin: "", experienceTicked: false
|
||
},
|
||
effects: [], ...BASE(),
|
||
flags: { ringbrp: { key: a.key } }
|
||
}];
|
||
});
|
||
await writePack("powers", entries);
|
||
console.log(` powers ${entries.length} Arts`);
|
||
}
|
||
|
||
// The rules, generated from rules.mjs so the book cannot drift from the engine.
|
||
{
|
||
const { RULES_PAGES } = await import("./rules-text.mjs");
|
||
const j = journalDoc("The Custodians — Rules", "rules", RULES_PAGES);
|
||
j.doc.flags = { ringbrp: { brand: true } };
|
||
const entries = [[`!journal!${j.id}`, j.doc]];
|
||
for (const p of j.pages) entries.push([`!journal.pages!${j.id}.${p._id}`, p]);
|
||
await writePack("rules", entries);
|
||
console.log(` rules ${RULES_PAGES.length} pages`);
|
||
}
|
||
|
||
// the configured expedition, with its project, discovery and contact group
|
||
{
|
||
const expId = idFor("actor:ilfracombe_case");
|
||
const projId = idFor("owned:expedition:project");
|
||
const discId = idFor("owned:expedition:discovery");
|
||
const grpId = idFor("owned:expedition:group_hanging");
|
||
|
||
const project = {
|
||
_id: projId, name: "Permeability field initiation", type: "project", img: ICON.skill,
|
||
system: {
|
||
description: "<p>The question the whole expedition turns on.</p>",
|
||
projectId: "regulator_operation_project",
|
||
question: "How is a threshold held open past its window?",
|
||
status: "active", maxResearchers: 3, currentStep: 0,
|
||
yieldsDiscoveryId: "regulator_operation",
|
||
steps: [
|
||
{ stepType: "field", objective: "Recover an intact regulator control plate from the abandoned vehicle.",
|
||
skillFamilyId: "engineering", skillSpecialisationId: "field_systems",
|
||
difficulty: "average", resolveInPlay: true, hours: null, hoursRemaining: null,
|
||
facility: null, state: "pending", isSetback: false },
|
||
{ stepType: "laboratory", objective: null,
|
||
skillFamilyId: "engineering", skillSpecialisationId: "field_systems",
|
||
difficulty: "difficult", resolveInPlay: false, hours: 200, hoursRemaining: 200,
|
||
facility: "portable", state: "pending", isSetback: false },
|
||
{ stepType: "armchair", objective: null,
|
||
skillFamilyId: "research", skillSpecialisationId: "",
|
||
difficulty: "average", resolveInPlay: false, hours: 160, hoursRemaining: 160,
|
||
facility: null, computerAccess: true, state: "pending", isSetback: false }
|
||
],
|
||
complications: []
|
||
}, effects: [], ...BASE()
|
||
};
|
||
|
||
const discovery = {
|
||
_id: discId, name: "Initiating a permeability field", type: "discovery", img: ICON.device,
|
||
system: {
|
||
description: "<p>Not yet earned. Completing the project creates it.</p><p>It does two things: it unlocks <strong>operate</strong>, and it drops <strong>repair</strong> from Difficult-with-no-analogue to Average at −20% familiarity. Research does not merely grant permission — it makes the work possible.</p>",
|
||
discoveryId: "regulator_operation", earned: false,
|
||
question: "How is a threshold held open past its window?",
|
||
unlocks: [{ documentId: "crossing_regulator", functionId: "operate" }],
|
||
publishable: true, published: false
|
||
}, effects: [], ...BASE()
|
||
};
|
||
|
||
const group = {
|
||
_id: grpId, name: "The Quiet Neighbours", type: "group", img: ICON.actor,
|
||
system: {
|
||
description: "<p>Roughly thirty of them. They have lived beside the crossing point for four generations and have rules about it that nobody has written down.</p>",
|
||
groupId: "quiet_neighbours", speciesId: "baseline", population: 30,
|
||
npcTemplateId: "quiet_neighbours_npc",
|
||
taboos: [{ id: "ground_speech", text: "Will not converse with anyone standing on the ground. Addressing them from ground level is the offence — embarrassment, not hostility.", penaltyIfViolated: -30 }],
|
||
desires: [{ id: "medicine", text: "Antibiotics. Their own ran out two generations ago and they have watched the same three illnesses take people ever since.", offerBonusStep: "easy" }],
|
||
correctOfferForm: "A sealed course of antibiotics, handed over by someone standing above ground level — a stair, a vehicle roof, a low wall.",
|
||
/* Structured so the engine can actually apply them. `text` is what the GM
|
||
reads out; `effect` is what the phase resolver acts on. */
|
||
rewardsByStanding: {
|
||
"1": [
|
||
{ id: "r1_guide_in", text: "They walk you to the beacon by a path that avoids the road.",
|
||
effect: { type: "cancelModifier", phase: 2 } },
|
||
{ id: "r1_shelter", text: "You may shelter with them. The next phase costs nothing.",
|
||
effect: { type: "waiveCost", phase: 2 } }
|
||
],
|
||
"2": [
|
||
{ id: "r2_guide", text: "Ambaro walks the cliff path with you and knows where it gives way.",
|
||
effect: { type: "skillBonus", skillFamilyId: "navigate", value: 20, phases: [2, 3] } },
|
||
{ id: "r2_cliff", text: "The cliff path is walked in daylight, on their timing.",
|
||
effect: { type: "cancelModifier", phase: 3 } }
|
||
],
|
||
"3": [
|
||
{ id: "r3_supplies", text: "They give what they can spare, which is more than they can spare.",
|
||
effect: { type: "track", track: "supply", delta: 2 } },
|
||
{ id: "r3_truth", text: "They tell you what happened to the first team.",
|
||
effect: { type: "reveal", note: "The first team went down to the shore on the fourth night and did not come back up. Two of them were carrying something between them." } }
|
||
]
|
||
}
|
||
}, effects: [], ...BASE()
|
||
};
|
||
|
||
const exp = {
|
||
_id: expId, name: "Case File: ILFRACOMBE", type: "expedition", img: ICON.actor,
|
||
system: {
|
||
integrityMode: "abstract",
|
||
supply: { value: 8, max: 10 }, power: { value: 6, max: 10 }, integrity: { value: 10, max: 10 },
|
||
// The case file ships with its team. Bare ids, which is the shape the phase
|
||
// resolver reads; an empty members list made the case file unusable on import.
|
||
assets: [], members: PREGENS.map(g => g.key), // stable keys: raw ids do not survive import
|
||
journey: {
|
||
destinationId: "ilfracombe", destinationName: "Ilfracombe",
|
||
destinationKnowledge: "rumoured", currentLeg: 0, elapsedDays: 0,
|
||
siteTableId: "sites", hazardTableId: "hazards",
|
||
legs: [
|
||
{ index:0, terrain:"Silted river flats", mode:"foot", difficulty:"difficult", modifier:0, days:4, explored:true, resolved:false, forcedEncounterId:"", costSupply:1, costPower:0 },
|
||
{ index:1, terrain:"Disused railway cutting", mode:"foot", difficulty:"difficult", modifier:10, days:3, explored:true, resolved:false, forcedEncounterId:"The Quiet Neighbours — contact at the END of this phase", costSupply:1, costPower:0 },
|
||
{ index:2, terrain:"Overgrown allotments", mode:"foot", difficulty:"difficult", modifier:-10, days:5, explored:false, resolved:false, forcedEncounterId:"", costSupply:1, costPower:0 },
|
||
{ index:3, terrain:"Cliff path above the cove", mode:"foot", difficulty:"difficult", modifier:-20, days:6, explored:false, resolved:false, forcedEncounterId:"Cleaners at the midpoint", costSupply:1, costPower:0 }
|
||
]
|
||
},
|
||
standings: [{ groupId:"quiet_neighbours", value:0, impression:0, encounters:0,
|
||
knownTaboos:[], knownDesires:[], pidginEstablished:false, obligations:[] }],
|
||
notes: "<p>Critical-path invariant: the eye-storm never takes the brone, and Power can never be exhausted to a dead end — the rim-wall site is a Power-recovery installation.</p>"
|
||
},
|
||
items: [projId, discId, grpId], effects: [],
|
||
prototypeToken: { name: "Case File: ILFRACOMBE", displayName: 20, actorLink: true,
|
||
width: 1, height: 1, texture: { src: ICON.actor }, sight: { enabled: false },
|
||
disposition: 1, bar1: { attribute: "supply" }, bar2: { attribute: "power" }, flags: {} },
|
||
...BASE()
|
||
};
|
||
|
||
const entries = [[`!actors!${expId}`, exp]];
|
||
for (const it of [project, discovery, group]) entries.push([`!actors.items!${expId}.${it._id}`, it]);
|
||
await writePack("expedition", entries);
|
||
}
|
||
|
||
console.log("RingBRP | packs built");
|