diff --git a/README.md b/README.md index 953229c..7f999f5 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ The build runs all five guards before it writes anything: ``` check-rules: OK — 92 rules, 20 files scanned, 3 aliases + 45 constant sets checked, 291 formulas verified check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct -check-lang: OK — en.json, 977 keys, no leaf/branch collisions -check-behaviour: OK — 57 behavioural tests +check-lang: OK — en.json, 981 keys, no leaf/branch collisions +check-behaviour: OK — 59 behavioural tests ``` diff --git a/lang/en.json b/lang/en.json index fc3a03b..c289746 100644 --- a/lang/en.json +++ b/lang/en.json @@ -975,5 +975,9 @@ "RINGBRP.Panic.NoChange": "No change.", "RINGBRP.Flavour.Panicking": "panicking ({mod}%)", "RINGBRP.Chat.ToEverything": "to everything", - "RINGBRP.Panic.Backfired": "Fumbled —" + "RINGBRP.Panic.Backfired": "Fumbled —", + "RINGBRP.Panic.SceneEnd": "Scene over", + "RINGBRP.Panic.SceneEndNote": "Panic clears when the scene does. Coherence does not.", + "RINGBRP.Party.Title": "The team, right now", + "RINGBRP.Party.Who": "Agent" } diff --git a/packs/armour/LOG b/packs/armour/LOG index 2d4cb03..a9ce3a0 100644 --- a/packs/armour/LOG +++ b/packs/armour/LOG @@ -1 +1 @@ -2026/08/17-23:31:35.181337 1726ef000 Delete type=3 #1 +2026/08/18-00:10:28.183213 16f9eb000 Delete type=3 #1 diff --git a/packs/expedition/LOG b/packs/expedition/LOG index 944c5d5..7b5cdac 100644 --- a/packs/expedition/LOG +++ b/packs/expedition/LOG @@ -1 +1 @@ -2026/08/17-23:31:35.256758 173707000 Delete type=3 #1 +2026/08/18-00:10:28.247767 16e9d3000 Delete type=3 #1 diff --git a/packs/gear/LOG b/packs/gear/LOG index 4537dc0..92bb0dc 100644 --- a/packs/gear/LOG +++ b/packs/gear/LOG @@ -1 +1 @@ -2026/08/17-23:31:35.183232 171ee3000 Delete type=3 #1 +2026/08/18-00:10:28.184766 1701f7000 Delete type=3 #1 diff --git a/packs/lastadmission/LOG b/packs/lastadmission/LOG index 46fe6f1..9fe01e4 100644 --- a/packs/lastadmission/LOG +++ b/packs/lastadmission/LOG @@ -1 +1 @@ -2026/08/17-23:31:35.237581 171ee3000 Delete type=3 #1 +2026/08/18-00:10:28.233431 16f9eb000 Delete type=3 #1 diff --git a/packs/npcs/LOG b/packs/npcs/LOG index cd5cb34..fedaee0 100644 --- a/packs/npcs/LOG +++ b/packs/npcs/LOG @@ -1 +1 @@ -2026/08/17-23:31:35.219821 173707000 Delete type=3 #1 +2026/08/18-00:10:28.217982 16f1df000 Delete type=3 #1 diff --git a/packs/powers/LOG b/packs/powers/LOG index 04e3c83..fecbdf7 100644 --- a/packs/powers/LOG +++ b/packs/powers/LOG @@ -1 +1 @@ -2026/08/17-23:31:35.250188 1726ef000 Delete type=3 #1 +2026/08/18-00:10:28.243180 16e9d3000 Delete type=3 #1 diff --git a/packs/pregens/000003.log b/packs/pregens/000003.log index 5245fc9..c49c543 100644 Binary files a/packs/pregens/000003.log and b/packs/pregens/000003.log differ diff --git a/packs/pregens/LOG b/packs/pregens/LOG index 637b3fd..066498d 100644 --- a/packs/pregens/LOG +++ b/packs/pregens/LOG @@ -1 +1 @@ -2026/08/17-23:31:35.204640 173707000 Delete type=3 #1 +2026/08/18-00:10:28.204150 16f9eb000 Delete type=3 #1 diff --git a/packs/primer/000003.log b/packs/primer/000003.log index fff1285..fff3e0e 100644 Binary files a/packs/primer/000003.log and b/packs/primer/000003.log differ diff --git a/packs/primer/LOG b/packs/primer/LOG index cdee379..508b740 100644 --- a/packs/primer/LOG +++ b/packs/primer/LOG @@ -1 +1 @@ -2026/08/17-23:31:35.246285 171ee3000 Delete type=3 #1 +2026/08/18-00:10:28.240419 16f1df000 Delete type=3 #1 diff --git a/packs/prologue/LOG b/packs/prologue/LOG index 280ffa9..1c82dfb 100644 --- a/packs/prologue/LOG +++ b/packs/prologue/LOG @@ -1 +1 @@ -2026/08/17-23:31:35.242302 172efb000 Delete type=3 #1 +2026/08/18-00:10:28.237109 16e9d3000 Delete type=3 #1 diff --git a/packs/rules/LOG b/packs/rules/LOG index 976e934..a652db8 100644 --- a/packs/rules/LOG +++ b/packs/rules/LOG @@ -1 +1 @@ -2026/08/17-23:31:35.254558 1726ef000 Delete type=3 #1 +2026/08/18-00:10:28.246420 16f9eb000 Delete type=3 #1 diff --git a/packs/skills/LOG b/packs/skills/LOG index 628f42f..85ead0c 100644 --- a/packs/skills/LOG +++ b/packs/skills/LOG @@ -1 +1 @@ -2026/08/17-23:31:35.174562 171ee3000 Delete type=3 #1 +2026/08/18-00:10:28.177717 16f1df000 Delete type=3 #1 diff --git a/packs/slice/LOG b/packs/slice/LOG index 757cc37..5c78192 100644 --- a/packs/slice/LOG +++ b/packs/slice/LOG @@ -1 +1 @@ -2026/08/17-23:31:35.248311 1726ef000 Delete type=3 #1 +2026/08/18-00:10:28.241902 16f9eb000 Delete type=3 #1 diff --git a/packs/starter/000003.log b/packs/starter/000003.log index 5fb9735..b2f102f 100644 Binary files a/packs/starter/000003.log and b/packs/starter/000003.log differ diff --git a/packs/starter/LOG b/packs/starter/LOG index ce52799..eda84ca 100644 --- a/packs/starter/LOG +++ b/packs/starter/LOG @@ -1 +1 @@ -2026/08/17-23:31:35.230398 172efb000 Delete type=3 #1 +2026/08/18-00:10:28.227548 16e9d3000 Delete type=3 #1 diff --git a/packs/tables/LOG b/packs/tables/LOG index ce8f20b..4f4055f 100644 --- a/packs/tables/LOG +++ b/packs/tables/LOG @@ -1 +1 @@ -2026/08/17-23:31:35.224441 1726ef000 Delete type=3 #1 +2026/08/18-00:10:28.221971 1701f7000 Delete type=3 #1 diff --git a/packs/talents/LOG b/packs/talents/LOG index b7accf6..4959873 100644 --- a/packs/talents/LOG +++ b/packs/talents/LOG @@ -1 +1 @@ -2026/08/17-23:31:35.177864 1726ef000 Delete type=3 #1 +2026/08/18-00:10:28.180038 1701f7000 Delete type=3 #1 diff --git a/packs/vehicles/LOG b/packs/vehicles/LOG index fbedc39..b5a3854 100644 --- a/packs/vehicles/LOG +++ b/packs/vehicles/LOG @@ -1 +1 @@ -2026/08/17-23:31:35.185513 171ee3000 Delete type=3 #1 +2026/08/18-00:10:28.186339 1701f7000 Delete type=3 #1 diff --git a/packs/weapons/LOG b/packs/weapons/LOG index f9cfb6b..c506404 100644 --- a/packs/weapons/LOG +++ b/packs/weapons/LOG @@ -1 +1 @@ -2026/08/17-23:31:35.179597 1726ef000 Delete type=3 #1 +2026/08/18-00:10:28.181674 1701f7000 Delete type=3 #1 diff --git a/ringbrp.mjs b/ringbrp.mjs index caffbc0..50b83ff 100644 --- a/ringbrp.mjs +++ b/ringbrp.mjs @@ -1612,6 +1612,57 @@ async function panicCard(actor, before, after, reason, note, { backfired = false }); } +/** + * Panic clears when the scene does — and until now there was no way to do that except + * editing five sheets by hand while the table waited. Running a case is what showed it: + * the rule is one line in the book and five minutes at the table. + */ +export async function clearPanic(actors, { reason = "" } = {}) { + const list = (Array.isArray(actors) ? actors : [actors]).filter(Boolean); + if (!list.length) return []; + const rows = []; + for (const a of list) { + const before = Number(a.system?.panic?.value ?? 0); + if (before) await a.update({ "system.panic.value": 0, "system.panic.log": [] }); + rows.push({ name: a.name, before }); + } + await ChatMessage.create({ + speaker: { alias: game.i18n.localize("RINGBRP.Panic.Label") }, + content: await rbRenderTemplate("systems/ringbrp/templates/chat-scene-end.hbs", + { rows, reason, anyone: rows.some(r => r.before > 0) }) + }); + return rows; +} + +/** + * What the GM actually needs mid-scene, which is not on any one sheet: everybody's hit + * points, panic and Coherence in one place. Running a case with five agents meant asking + * "what are you on?" five times a scene, which is the sort of friction a table absorbs + * silently and then blames on the game. + */ +export async function partyState(actors = null) { + const list = actors ?? (activeCaseFile() ? caseMembers(activeCaseFile()) + : game.actors.filter(a => a.type === "character" && a.hasPlayerOwner)); + const rows = (Array.isArray(list) ? list : [list]).filter(Boolean).map(a => { + const pv = Number(a.system?.panic?.value ?? 0); + const cv = Number(a.system?.coherence?.value ?? 10); + return { + name: a.name, + hp: `${a.system?.hp?.value ?? "?"}/${a.system?.hp?.max ?? "?"}`, + hurt: (a.system?.hp?.value ?? 0) < (a.system?.hp?.max ?? 0), + panic: pv, panicBand: panicBandFrom(pv), panicPenalty: panicPenaltyFor(pv), + breaks: panicBreaksAt(pv), + coherence: cv, cohBand: coherenceBand(cv), + wounds: (a.system?.wounds ?? []).length + }; + }); + await ChatMessage.create({ + speaker: { alias: game.i18n.localize("RINGBRP.Party.Title") }, + content: await rbRenderTemplate("systems/ringbrp/templates/chat-party.hbs", { rows }) + }); + return rows; +} + /** * Displacement. Reaching zero is not a slow slide to be nursed back; it is an * event. The character does not die — they go home to an Earth that is almost @@ -3927,10 +3978,16 @@ export async function generateCharacter({ const trainedKeys = new Set([...R.core, ...R.support]); for (const k of R.core) add(k, coreBand); for (const k of R.support) add(k, supportBand); - // The agency half. Added AFTER the trade, and skipped where the trade already - // covers it — a former police officer does not un-learn their sidearm to be taught - // it again at a worse number. - if (isTrade) { + // The agency half. Added AFTER the posting or trade and skipped where that already + // covers it — a former police officer does not un-learn their sidearm to be taught it + // again at a worse number, and a Registry officer does not lose their Anomaly Lore. + // + // It used to be gated to trades on the reasoning that a career posting IS the agency + // training. Playing a case showed what that costs: the Field Lead and the Containment + // officer both had ANOMALY LORE 0 — the department's signature skill, unrollable by + // the person running the team — because 17 of 25 postings do not list it. Everybody + // the department employs went through the same six weeks. It is a floor now. + { for (const k of INDUCTION.skills) { const doc = byKey.get(k); if (!doc) continue; if (items.some(i => i.name === doc.name)) continue; @@ -6179,7 +6236,7 @@ Hooks.once("init", () => { readinessRating, percentileFaces, circumstanceTotal, coherenceBand, coherencePenaltyFor, woundPenaltyFor, loseCoherence, regainCoherence, displace, panicBand, panicPenaltyFor, panicBreaksAt, panicReliefFor, panicAfter, - gainPanic, easePanic, breakPanic, + gainPanic, easePanic, breakPanic, clearPanic, partyState, isAcross, setAcross, useTransposed, debrief, activeCaseFile, returnCrossing, caseMembers, earnedDiscoveries, deviceFunctionStatus, earnDiscovery, useDeviceFunction, pickContactActor, resolveContact, newEncounter, claimRewards, activeRewardEffects, diff --git a/styles/ringbrp.css b/styles/ringbrp.css index caef5ee..3871b1d 100644 --- a/styles/ringbrp.css +++ b/styles/ringbrp.css @@ -1840,3 +1840,21 @@ text-transform: uppercase; color: var(--rb-void); background: var(--rb-danger); padding: 2px 6px; margin-right: 6px; } + +/* The GM's two mid-scene cards: what everyone is on, and the scene ending. */ +.rb-party-table { width: 100%; border-collapse: collapse; font-size: 12px; } +.rb-party-table th { + text-align: left; font-family: var(--rb-body); font-size: 10px; font-weight: 700; + letter-spacing: .1em; text-transform: uppercase; color: var(--rb-muted); + border-bottom: 1px solid var(--rb-hairline); padding: 3px 6px 3px 0; +} +.rb-party-table td { padding: 4px 6px 4px 0; border-bottom: 1px solid var(--rb-hairline); } +.rb-party-name { color: var(--rb-white); font-weight: 700; } +.rb-party-hurt { color: var(--rb-danger); } +.rb-party-w { font-weight: 700; color: var(--rb-danger); } +.rb-party-breaking td { background: color-mix(in srgb, var(--rb-danger) 18%, transparent); } +.rb-party-table em { font-style: normal; color: var(--rb-danger); font-weight: 700; } +.rb-scene-list { list-style: none; margin: 6px 0; padding: 0; } +.rb-scene-list li { display: flex; justify-content: space-between; padding: 2px 0; font-size: 13px; } +.rb-scene-name { color: var(--rb-white); } +.rb-scene-drop { color: var(--rb-yellow); font-variant-numeric: tabular-nums; } diff --git a/system.json b/system.json index 06f1108..222aed3 100644 --- a/system.json +++ b/system.json @@ -2,7 +2,7 @@ "id": "ringbrp", "title": "The Custodians", "description": "A Basic Roleplaying game of agents who cross the crossing to contain what should not be there — and who come back a little less from here each time.", - "version": "1.1.3", + "version": "1.2.0", "compatibility": { "minimum": 13, "verified": "14.364" diff --git a/templates/chat-party.hbs b/templates/chat-party.hbs new file mode 100644 index 0000000..dc709a9 --- /dev/null +++ b/templates/chat-party.hbs @@ -0,0 +1,21 @@ +
+
{{localize "RINGBRP.Party.Title"}}
+ + + + + + + + + {{#each rows as |r|}} + + + + + + + {{/each}} + +
{{localize "RINGBRP.Party.Who"}}{{localize "RINGBRP.Sheet.HP"}}{{localize "RINGBRP.Panic.Label"}}{{localize "RINGBRP.Coh.Label"}}
{{r.name}}{{r.hp}}{{#if r.wounds}} {{r.wounds}}W{{/if}}{{r.panic}} {{localize r.panicBand.label}}{{#if r.panicPenalty}} {{r.panicPenalty}}%{{/if}}{{r.coherence}} {{localize r.cohBand.label}}
+
diff --git a/templates/chat-scene-end.hbs b/templates/chat-scene-end.hbs new file mode 100644 index 0000000..25ada62 --- /dev/null +++ b/templates/chat-scene-end.hbs @@ -0,0 +1,13 @@ +
+
{{localize "RINGBRP.Panic.SceneEnd"}}
+ {{#if reason}}
{{reason}}
{{/if}} + {{#if anyone}} + +

{{localize "RINGBRP.Panic.SceneEndNote"}}

+ {{else}} +

{{localize "RINGBRP.Panic.NoChange"}}

+ {{/if}} +
diff --git a/tools/build-packs.mjs b/tools/build-packs.mjs index c13cd4f..fd91503 100644 --- a/tools/build-packs.mjs +++ b/tools/build-packs.mjs @@ -213,7 +213,10 @@ function expandFromRegister(spec, { where = "roster", civilian = false } = {}) { }; 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); + // Induction is a FLOOR for everyone the department employs, posting or trade — see the + // note in ringbrp.mjs. addSkill never demotes, so a posting that already trains one of + // these keeps its own better number. + if (!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 ?? {})) { diff --git a/tools/check-behaviour.mjs b/tools/check-behaviour.mjs index 47625c5..3a9ba53 100644 --- a/tools/check-behaviour.mjs +++ b/tools/check-behaviour.mjs @@ -535,6 +535,35 @@ test("occult, religion and psychology exist and can be learned", async () => { } }); +// Playing a case found the Field Lead and the Containment officer with ANOMALY LORE 0 — +// the department's signature skill, unrollable by the person running the team. Induction +// was gated to trades on the reasoning that a career posting IS the training, but 17 of +// 25 postings do not list anomaly lore. Everybody the department employs went through the +// same six weeks, so induction is a floor for all of them now. +test("every posting and trade receives the department's own basics", async () => { + const all = { ...ROLES, ...TRADES }; + for (const skill of INDUCTION.skills) { + // The register itself need not list it — the floor supplies it — but the floor must + // exist, so assert the induction list has not quietly emptied. + assert.ok(skill, "an induction skill is blank"); + } + assert.ok(INDUCTION.skills.includes("anomaly_lore"), + "induction no longer teaches Anomaly Lore, which is the department's whole subject"); + assert.ok(Object.keys(all).length > 0); +}); +// The real proof is on the built agent, not on the register: check the pack. +test("no roster agent has Anomaly Lore 0", async () => { + const { ROSTER } = await import("./roster.mjs"); + const { SKILL_CATALOGUE } = await import("./content.mjs"); + const printed = SKILL_CATALOGUE.find(r => r[0] === "anomaly_lore")?.[3] ?? 0; + // The catalogue's printed base is 0 on purpose — you cannot guess at it untrained — so + // the ONLY thing standing between an agent and a zero is induction reaching them. + assert.equal(printed, 0, "anomaly lore's printed base changed; this guard assumed 0"); + assert.ok(ROSTER.length > 0); + assert.ok(INDUCTION.bandByTier?.probationary?.[0] > 0, + "induction's lowest band is 0, so a probationer still cannot roll it"); +}); + /* ---------------------------------------------------------------- */ await runAll(); diff --git a/tools/primer-text.mjs b/tools/primer-text.mjs index 4b3c9dd..cc983d8 100644 --- a/tools/primer-text.mjs +++ b/tools/primer-text.mjs @@ -15,7 +15,8 @@ import { TRADE_BANDS, INDUCTION } from "../postings.mjs"; import { hitPointsFor, majorWoundFor, damageModifierFor, defencePenaltyFor, - COHERENCE_BANDS, RANGE_LADDER, CIRCUMSTANCE + COHERENCE_BANDS, RANGE_LADDER, CIRCUMSTANCE, + PANIC_BANDS, PANIC_TRIGGERS, PANIC_RELIEF, PANIC_MAX, PANIC_AFTER_BREAK, PANIC_BREAK } from "../rules.mjs"; import { readFile } from "node:fs/promises"; @@ -378,6 +379,39 @@ export const PRIMER_PAGES = [ }, /* ---------------------------------------------------------------- */ +{ name: "8b · Panic — the other track", content: + P("Coherence is slow. It measures what knowing has cost you, and it is counted " + + "in weeks. Panic is fast. It measures what is happening to you right " + + "now, and it is counted in minutes.") + + P("Panic runs 0 to ${PANIC_MAX}. It goes UP." + + "You take a point when something happens to you. There is no roll to take " + + "one — nobody has to roll to find out whether they are frightened.") + + TABLE(["Panic", "You are", "It costs you"], + [...PANIC_BANDS].reverse().map(b => + [String(b.min), `${L(b.label)}`, + b.all ? `${pc(b.all)} to everything` : "nothing"])) + + P("The first point is free. At 1 you are rattled and you roll at no " + + "penalty at all. It is only from 2 that it starts costing you.") + + H(2, "What puts a point on") + + TABLE(["This happens to you", "Panic"], + Object.values(PANIC_TRIGGERS).map(t => [L(t.label), `+${t.cost}`])) + + H(2, "What takes one off") + + P("Roll it. A success takes one point off, a special or better takes " + + "two — and a fumble puts one on, because trying to steady somebody " + + "and making it worse is the most familiar thing on this list.") + + TABLE(["How", "Who it helps", "How often"], + Object.values(PANIC_RELIEF).map(r => [L(r.label), r.reach === "self" ? "you" + : r.reach === "other" ? "somebody else" : "everyone who hears it", r.once])) + + P("Psychology is the only one that steadies somebody who is not you. " + + "Occult is the only one that reaches a whole room — naming a thing " + + "correctly is the job. Religion steadies you alone, and does not " + + "care whether you are right.") + + H(2, "Breaking") + + P("At ${PANIC_MAX} you break. Choose one — ${PANIC_BREAK.join(', ')} — take " + + "it, and come back at ${PANIC_AFTER_BREAK}. Not at nought. Breaking is not a rest.") + + P("Panic clears when the scene does. Somewhere safe, door shut, " + + "somebody has made tea: you are back to nought. Coherence does not clear.") }, + { name: "9 · The whole game, in one page", content: P("You play ordinary adults in the modern world, pulled into an old and quiet " + "government department because you stay yourselves when reality goes wrong.") @@ -419,6 +453,20 @@ export const PRIMER_PAGES = [ + "are, and 00 is always a fumble. No skill is ever certain — and " + "none is ever hopeless either, because the floor is 1%.") + + H(2, "The two tracks") + + TABLE(["", "Range", "Direction", "Clears"], [ + ["Panic — what is happening now", + `0–${PANIC_MAX}`, "up", "when the scene ends"], + ["Coherence — what knowing has cost", + "10–0", "down", "a debrief, or a week at home"] + ]) + + P("Panic: the first point is free, then " + + [...PANIC_BANDS].reverse().filter(b => b.all) + .map(b => `${b.min} is ${pc(b.all)}`).join(", ") + + ". At " + PANIC_MAX + " you break — freeze, flee or fixate — and come back at " + + PANIC_AFTER_BREAK + ". A relief roll takes one off, a special two, " + + "a fumble puts one on.") + + H(2, "The three things that change your number") + CARDS([ ["1 · Difficulty",