3 Commits
Author SHA1 Message Date
slaguru666andClaude Opus 5 37b4043746 Play a full case, then fix what it found
Ran THE HOLLOW ROAD with five agents — approach, crossing, contact, the thing on
the road, extraction, debrief — with panic and Coherence both live. It ran clean
and it found one thing that mattered.

ANOMALY LORE 0. Marta Holloway, Field Lead, Senior Field Officer, could not roll
the department's signature skill. Nor could Okonkwo. Induction was gated to
TRADES on the reasoning that a career posting is itself the agency training —
but 17 of the 25 postings do not list anomaly lore, its printed base is 0 on
purpose, and nothing else supplies it. So the people who joined the department
straight out of school were the only ones who never received its basic training,
which is exactly backwards.

Induction is a FLOOR for everybody now, posting or trade, in the live generator
and the pack builder both. It never demotes: Bhattacharya keeps Registry's 63,
Holloway goes 0 → 43, Okonkwo 0 → 33. All twenty built agents verified non-zero.

Two GM tools the case showed were missing, both found by feeling the friction
rather than by reading anything:

  - clearPanic(actors). Panic clears when the scene does — one line in the book
    and, until now, five sheets edited by hand while the table waited. It leaves
    Coherence alone, which is the whole point of having two tracks.
  - partyState(). Everybody's hit points, panic and Coherence in one card. With
    five agents the alternative is asking "what are you on?" five times a scene,
    which is friction a table absorbs silently and then blames on the game.

And the player-facing gap: the primer never mentioned panic. Players got a
one-page aide-memoire for a track they now meet in the first scene. It has a
page of its own and a row on the cheat sheet, both generated from the same
tables as the rules.

One thing I reported as a bug and withdraw: Okonkwo's "Firearm (Pistol) 5%" was
me rolling the wrong weapon. He carries a marksman rifle at 53%. check-kits was
right.

Version 1.2.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 00:10:28 +01:00
slaguru666andClaude Opus 5 7ec7efd29b Replay the scene with the fumble rule; expose panicReliefFor
Ran the east corridor again with fresh dice and every relief attempt routed
through easePanic, fumbles included.

The rule did not fire this time. All three attempts failed cleanly — Psychology
58 against 40, and two POW×5 rolls at 66 and 76 — so nobody was punished for
trying. That is worth reporting as loudly as a hit would have been: two
play-throughs in and a fumbled relief has come up once, which is about right for
something meant to be a memorable disaster rather than a tax.

Braithwaite still broke at 5 on "it said your name" and fled. Marrow went down
in a single round to a special with the entry bar — 19 through armour — against
the 4.5-round average the thirty-run tuning gave, so that was dice, not balance.

Then the question the rule actually raises. Relief rolls are made at your own
panic penalty, so the worse you are the worse your odds AND the wider your fumble
range — and now a fumble adds a point. That is a feedback loop, so I measured it
rather than trusting it, 300 runs per row:

    from 2, POW×5 60%   280 calm ·  0 broke
    from 3, POW×5 60%   227 calm ·  2 broke
    from 4, POW×5 60%   138 calm · 31 broke
    from 4, POW×5 50%    96 calm · 35 broke
    from 4, POW×5 85%   267 calm ·  5 broke

Recovery dominates everywhere. The worst case — overwhelmed, weak POW, no help —
spirals into breaking about one time in eight, which is a bad night rather than a
trap. No change needed.

Also: panicReliefFor and panicAfter were missing from the API, which I only
noticed because measuring the loop needed them and the alternative was
re-implementing the rule in the test — the exact duplication check-rules exists
to forbid. Both exposed.

Version 1.1.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:31:35 +01:00
slaguru666andClaude Opus 5 5aaae186e0 A fumbled relief attempt puts a point on
Trying to steady somebody and making it worse is the most familiar thing on that
list, and until now it was the only outcome that cost nothing.

Done as a NEGATIVE relief rather than a second code path: panicReliefFor returns
-1 on a fumble, and easePanic already subtracts what it is given, so subtracting
a negative raises the track. One number, one direction, no branch that could
drift from the other. The clamp still holds — fumbling at 5 stays at 5.

The chat card had to be told, because otherwise it prints the word "Psychology"
over a number that has just gone UP and the table reads that as a bug rather than
as the rule. It now leads with a red FUMBLED tag.

Guarded three ways: a spot-check in check-rules for the -1 itself, and two
behaviour tests — one that the sign survives easePanic's subtraction and
actually raises the track, one that it cannot push past the top. A test on the
constant alone would have passed even if easePanic had healed on a fumble.

Verified live: 2 → fumble → 3 → success → 2 → special → 0, and a fumble at 5
staying at 5, with the cards reading correctly.

Version 1.1.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:25:46 +01:00
35 changed files with 267 additions and 37 deletions
+3 -3
View File
@@ -180,9 +180,9 @@ The build runs all five guards before it writes anything:
<!-- guards:start --> <!-- guards:start -->
``` ```
check-rules: OK — 92 rules, 20 files scanned, 3 aliases + 45 constant sets checked, 290 formulas verified 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-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, 976 keys, no leaf/branch collisions check-lang: OK — en.json, 981 keys, no leaf/branch collisions
check-behaviour: OK — 55 behavioural tests check-behaviour: OK — 59 behavioural tests
``` ```
<!-- guards:end --> <!-- guards:end -->
+6 -1
View File
@@ -974,5 +974,10 @@
"RINGBRP.Panic.Breaks": "BREAKS — freeze, flee, or fixate. Choose, take it, come back at 3.", "RINGBRP.Panic.Breaks": "BREAKS — freeze, flee, or fixate. Choose, take it, come back at 3.",
"RINGBRP.Panic.NoChange": "No change.", "RINGBRP.Panic.NoChange": "No change.",
"RINGBRP.Flavour.Panicking": "panicking ({mod}%)", "RINGBRP.Flavour.Panicking": "panicking ({mod}%)",
"RINGBRP.Chat.ToEverything": "to everything" "RINGBRP.Chat.ToEverything": "to everything",
"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"
} }
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.401379 16e463000 Delete type=3 #1 2026/08/18-00:10:28.183213 16f9eb000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.473673 16fc87000 Delete type=3 #1 2026/08/18-00:10:28.247767 16e9d3000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.403187 16e463000 Delete type=3 #1 2026/08/18-00:10:28.184766 1701f7000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.456562 16ec6f000 Delete type=3 #1 2026/08/18-00:10:28.233431 16f9eb000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.438818 16ec6f000 Delete type=3 #1 2026/08/18-00:10:28.217982 16f1df000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.468218 16f47b000 Delete type=3 #1 2026/08/18-00:10:28.243180 16e9d3000 Delete type=3 #1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.424479 16f47b000 Delete type=3 #1 2026/08/18-00:10:28.204150 16f9eb000 Delete type=3 #1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.464811 16f47b000 Delete type=3 #1 2026/08/18-00:10:28.240419 16f1df000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.461075 16f47b000 Delete type=3 #1 2026/08/18-00:10:28.237109 16e9d3000 Delete type=3 #1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.471824 16e463000 Delete type=3 #1 2026/08/18-00:10:28.246420 16f9eb000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.395237 16e463000 Delete type=3 #1 2026/08/18-00:10:28.177717 16f1df000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.466508 16ec6f000 Delete type=3 #1 2026/08/18-00:10:28.241902 16f9eb000 Delete type=3 #1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.449085 16ec6f000 Delete type=3 #1 2026/08/18-00:10:28.227548 16e9d3000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.443432 16ec6f000 Delete type=3 #1 2026/08/18-00:10:28.221971 1701f7000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.397941 16fc87000 Delete type=3 #1 2026/08/18-00:10:28.180038 1701f7000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.405177 16fc87000 Delete type=3 #1 2026/08/18-00:10:28.186339 1701f7000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.399814 16fc87000 Delete type=3 #1 2026/08/18-00:10:28.181674 1701f7000 Delete type=3 #1
+69 -8
View File
@@ -1580,7 +1580,10 @@ export async function easePanic(actor, reliefKey, level = "success", { note = ""
const before = Number(actor.system?.panic?.value ?? 0); const before = Number(actor.system?.panic?.value ?? 0);
const after = panicAfter(before, -back); const after = panicAfter(before, -back);
await actor.update({ "system.panic.value": after }); await actor.update({ "system.panic.value": after });
await panicCard(actor, before, after, game.i18n.localize(String(spec?.label ?? reliefKey ?? "")), note); // A fumble comes back as negative relief, so the card has to say so — otherwise it
// reads "Psychology" over a number that just went up, and the table reads it as a bug.
await panicCard(actor, before, after, game.i18n.localize(String(spec?.label ?? reliefKey ?? "")),
note, { backfired: back < 0 });
return panicBandFrom(after); return panicBandFrom(after);
} }
@@ -1596,19 +1599,70 @@ export async function breakPanic(actor, choice = "freeze") {
return panicBandFrom(after); return panicBandFrom(after);
} }
async function panicCard(actor, before, after, reason, note) { async function panicCard(actor, before, after, reason, note, { backfired = false } = {}) {
const band = panicBandFrom(after); const band = panicBandFrom(after);
await ChatMessage.create({ await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }), speaker: ChatMessage.getSpeaker({ actor }),
content: await rbRenderTemplate("systems/ringbrp/templates/chat-panic.hbs", { content: await rbRenderTemplate("systems/ringbrp/templates/chat-panic.hbs", {
actorName: actor.name, before, after, reason, note, actorName: actor.name, before, after, reason, note,
band, breaks: panicBreaksAt(after), penalty: panicPenaltyFor(after), band, breaks: panicBreaksAt(after), penalty: panicPenaltyFor(after), backfired,
choices: PANIC_BREAK, choices: PANIC_BREAK,
pips: Array.from({ length: PANIC_MAX }, (_, i) => i < after) pips: Array.from({ length: PANIC_MAX }, (_, i) => i < after)
}) })
}); });
} }
/**
* 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 * 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 * event. The character does not die — they go home to an Earth that is almost
@@ -3924,10 +3978,16 @@ export async function generateCharacter({
const trainedKeys = new Set([...R.core, ...R.support]); const trainedKeys = new Set([...R.core, ...R.support]);
for (const k of R.core) add(k, coreBand); for (const k of R.core) add(k, coreBand);
for (const k of R.support) add(k, supportBand); for (const k of R.support) add(k, supportBand);
// The agency half. Added AFTER the trade, and skipped where the trade already // 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 // covers it — a former police officer does not un-learn their sidearm to be taught it
// it again at a worse number. // again at a worse number, and a Registry officer does not lose their Anomaly Lore.
if (isTrade) { //
// 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) { for (const k of INDUCTION.skills) {
const doc = byKey.get(k); if (!doc) continue; const doc = byKey.get(k); if (!doc) continue;
if (items.some(i => i.name === doc.name)) continue; if (items.some(i => i.name === doc.name)) continue;
@@ -6175,7 +6235,8 @@ Hooks.once("init", () => {
reactionLoadFrom, reactionPrintedFrom, reactionLoadFrom, reactionPrintedFrom,
readinessRating, percentileFaces, circumstanceTotal, readinessRating, percentileFaces, circumstanceTotal,
coherenceBand, coherencePenaltyFor, woundPenaltyFor, loseCoherence, regainCoherence, displace, coherenceBand, coherencePenaltyFor, woundPenaltyFor, loseCoherence, regainCoherence, displace,
panicBand, panicPenaltyFor, panicBreaksAt, gainPanic, easePanic, breakPanic, panicBand, panicPenaltyFor, panicBreaksAt, panicReliefFor, panicAfter,
gainPanic, easePanic, breakPanic, clearPanic, partyState,
isAcross, setAcross, useTransposed, debrief, activeCaseFile, returnCrossing, caseMembers, isAcross, setAcross, useTransposed, debrief, activeCaseFile, returnCrossing, caseMembers,
earnedDiscoveries, deviceFunctionStatus, earnDiscovery, useDeviceFunction, pickContactActor, earnedDiscoveries, deviceFunctionStatus, earnDiscovery, useDeviceFunction, pickContactActor,
resolveContact, newEncounter, claimRewards, activeRewardEffects, resolveContact, newEncounter, claimRewards, activeRewardEffects,
+9 -1
View File
@@ -358,10 +358,18 @@ export function panicAfter(value, delta) {
return Math.max(0, Math.min(PANIC_MAX, (Number(value) || 0) + (Number(delta) || 0))); return Math.max(0, Math.min(PANIC_MAX, (Number(value) || 0) + (Number(delta) || 0)));
} }
/** A success takes one point, a special or better takes two. */ /**
* A success takes one point, a special or better takes two — and a FUMBLE puts one on.
*
* Returned as a negative relief so the whole thing stays one number and one direction:
* easePanic subtracts what this gives it, so -1 subtracts a negative and the track goes
* up. Trying to talk somebody down and making it worse is the most familiar thing in
* this list, and until now it was the only outcome that cost nothing.
*/
export function panicReliefFor(level) { export function panicReliefFor(level) {
if (level === "critical" || level === "special") return 2; if (level === "critical" || level === "special") return 2;
if (level === "success") return 1; if (level === "success") return 1;
if (level === "fumble") return -1;
return 0; return 0;
} }
+27
View File
@@ -1831,3 +1831,30 @@
color: var(--rb-void); background: var(--rb-danger); color: var(--rb-void); background: var(--rb-danger);
padding: 5px 8px; margin: 6px 0 0; padding: 5px 8px; margin: 6px 0 0;
} }
/* A fumbled relief puts a point ON. The card must say which way it went, or it reads
as "Psychology" printed over a number that just rose, and the table reads that as a
bug rather than as the rule. */
.rb-panic-worse {
font-family: var(--rb-body); font-size: 11px; font-weight: 700; letter-spacing: .08em;
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; }
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "ringbrp", "id": "ringbrp",
"title": "The Custodians", "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.", "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.1", "version": "1.2.0",
"compatibility": { "compatibility": {
"minimum": 13, "minimum": 13,
"verified": "14.364" "verified": "14.364"
+1 -1
View File
@@ -13,7 +13,7 @@
<span class="rb-panic-band rb-panic-{{band.id}}">{{localize band.label}}</span> <span class="rb-panic-band rb-panic-{{band.id}}">{{localize band.label}}</span>
</div> </div>
<p class="rb-panic-reason">{{reason}}{{#if note}} — <em>{{note}}</em>{{/if}}</p> <p class="rb-panic-reason">{{#if backfired}}<span class="rb-panic-worse">{{localize "RINGBRP.Panic.Backfired"}}</span> {{/if}}{{reason}}{{#if note}} — <em>{{note}}</em>{{/if}}</p>
<p class="rb-panic-note">{{localize band.note}}</p> <p class="rb-panic-note">{{localize band.note}}</p>
{{#if penalty}}<p class="rb-panic-pen">{{penalty}}% {{localize "RINGBRP.Chat.ToEverything"}}</p>{{/if}} {{#if penalty}}<p class="rb-panic-pen">{{penalty}}% {{localize "RINGBRP.Chat.ToEverything"}}</p>{{/if}}
+21
View File
@@ -0,0 +1,21 @@
<div class="ringbrp-chat rb-party">
<div class="rb-chat-title">{{localize "RINGBRP.Party.Title"}}</div>
<table class="rb-party-table">
<thead><tr>
<th>{{localize "RINGBRP.Party.Who"}}</th>
<th>{{localize "RINGBRP.Sheet.HP"}}</th>
<th>{{localize "RINGBRP.Panic.Label"}}</th>
<th>{{localize "RINGBRP.Coh.Label"}}</th>
</tr></thead>
<tbody>
{{#each rows as |r|}}
<tr class="{{#if r.breaks}}rb-party-breaking{{/if}}">
<td class="rb-party-name">{{r.name}}</td>
<td class="{{#if r.hurt}}rb-party-hurt{{/if}}">{{r.hp}}{{#if r.wounds}} <span class="rb-party-w">{{r.wounds}}W</span>{{/if}}</td>
<td class="rb-panic-{{r.panicBand.id}}">{{r.panic}} {{localize r.panicBand.label}}{{#if r.panicPenalty}} <em>{{r.panicPenalty}}%</em>{{/if}}</td>
<td>{{r.coherence}} {{localize r.cohBand.label}}</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
+13
View File
@@ -0,0 +1,13 @@
<div class="ringbrp-chat rb-scene-end">
<div class="rb-chat-title">{{localize "RINGBRP.Panic.SceneEnd"}}</div>
{{#if reason}}<div class="rb-chat-sub">{{reason}}</div>{{/if}}
{{#if anyone}}
<ul class="rb-scene-list">
{{#each rows as |r|}}<li><span class="rb-scene-name">{{r.name}}</span>
<span class="rb-scene-drop">{{r.before}} &rarr; 0</span></li>{{/each}}
</ul>
<p class="rb-panic-note">{{localize "RINGBRP.Panic.SceneEndNote"}}</p>
{{else}}
<p class="rb-panic-note">{{localize "RINGBRP.Panic.NoChange"}}</p>
{{/if}}
</div>
+4 -1
View File
@@ -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.core) addSkill(k, coreV);
for (const k of R.support) addSkill(k, supportV); 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); if (!civilian) addSkill("language:trade_cant", 55);
// Floors only ever RAISE a rating, so a posting already better stays better. // Floors only ever RAISE a rating, so a posting already better stays better.
for (const [id, floor] of Object.entries(spec.floors ?? {})) { for (const [id, floor] of Object.entries(spec.floors ?? {})) {
+43 -2
View File
@@ -482,13 +482,25 @@ test("breaking does not reset you to calm", () => {
assert.ok(RULES.PANIC_AFTER_BREAK > 0, "breaking wipes the track clean"); assert.ok(RULES.PANIC_AFTER_BREAK > 0, "breaking wipes the track clean");
assert.ok(RULES.PANIC_AFTER_BREAK < RULES.PANIC_MAX, "breaking leaves you still broken"); assert.ok(RULES.PANIC_AFTER_BREAK < RULES.PANIC_MAX, "breaking leaves you still broken");
}); });
test("a special takes two points, a failure takes none", () => { test("a special takes two points, a failure takes none, a fumble ADDS one", () => {
assert.equal(RULES.panicReliefFor("special"), 2); assert.equal(RULES.panicReliefFor("special"), 2);
assert.equal(RULES.panicReliefFor("critical"), 2); assert.equal(RULES.panicReliefFor("critical"), 2);
assert.equal(RULES.panicReliefFor("success"), 1); assert.equal(RULES.panicReliefFor("success"), 1);
assert.equal(RULES.panicReliefFor("failure"), 0); assert.equal(RULES.panicReliefFor("failure"), 0);
assert.equal(RULES.panicReliefFor("fumble"), 0); // Negative relief. Trying to steady somebody and making it worse is the most familiar
// thing on the list and it used to be the only outcome that cost nothing.
assert.equal(RULES.panicReliefFor("fumble"), -1);
}); });
// The sign has to survive the arithmetic easePanic does, or a fumble quietly heals.
test("a fumbled relief puts a point ON, through the same subtraction", () => {
const before = 2;
const after = RULES.panicAfter(before, -RULES.panicReliefFor("fumble"));
assert.equal(after, 3, "a fumbled relief did not raise panic");
assert.equal(RULES.panicAfter(before, -RULES.panicReliefFor("success")), 1);
});
// And it must still not be able to push past the top.
test("a fumbled relief at 5 stays at 5", () =>
assert.equal(RULES.panicAfter(5, -RULES.panicReliefFor("fumble")), RULES.PANIC_MAX));
// Every band and every trigger and relief must be sayable, or the chat card prints a key. // Every band and every trigger and relief must be sayable, or the chat card prints a key.
test("every panic band, trigger and relief has a label", async () => { test("every panic band, trigger and relief has a label", async () => {
const { readFile } = await import("node:fs/promises"); const { readFile } = await import("node:fs/promises");
@@ -523,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(); await runAll();
+1
View File
@@ -196,6 +196,7 @@ const checks = [
["panicReliefFor('success')", RULES.panicReliefFor("success"), 1], ["panicReliefFor('success')", RULES.panicReliefFor("success"), 1],
["panicReliefFor('special')", RULES.panicReliefFor("special"), 2], ["panicReliefFor('special')", RULES.panicReliefFor("special"), 2],
["panicReliefFor('failure')", RULES.panicReliefFor("failure"), 0], ["panicReliefFor('failure')", RULES.panicReliefFor("failure"), 0],
["panicReliefFor('fumble')", RULES.panicReliefFor("fumble"), -1],
// a lost location must cost something, for every kind the species tables use // a lost location must cost something, for every kind the species tables use
["locationEffectsFor([]) is inert", RULES.locationEffectsFor([]).all, 0], ["locationEffectsFor([]) is inert", RULES.locationEffectsFor([]).all, 0],
["leg disabled → physical", RULES.locationEffectsFor([{kind:"leg",disabled:true}]).physical, -30], ["leg disabled → physical", RULES.locationEffectsFor([{kind:"leg",disabled:true}]).physical, -30],
+49 -1
View File
@@ -15,7 +15,8 @@
import { TRADE_BANDS, INDUCTION } from "../postings.mjs"; import { TRADE_BANDS, INDUCTION } from "../postings.mjs";
import { import {
hitPointsFor, majorWoundFor, damageModifierFor, defencePenaltyFor, 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"; } from "../rules.mjs";
import { readFile } from "node:fs/promises"; 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 <em>knowing</em> has cost you, and it is counted "
+ "in weeks. <strong>Panic is fast.</strong> It measures what is happening to you right "
+ "now, and it is counted in minutes.")
+ P("<span class=\"rb-big-number\">Panic runs 0 to ${PANIC_MAX}. It goes UP.</span>"
+ "You take a point when something happens to you. <strong>There is no roll to take "
+ "one</strong> — 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), `<strong>${L(b.label)}</strong>`,
b.all ? `${pc(b.all)} to everything` : "nothing"]))
+ P("<strong>The first point is free.</strong> 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("<strong>Roll it.</strong> A success takes one point off, a special or better takes "
+ "two — and a <strong>fumble puts one on</strong>, 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("<strong>Psychology</strong> is the only one that steadies somebody who is not you. "
+ "<strong>Occult</strong> is the only one that reaches a whole room — naming a thing "
+ "correctly is the job. <strong>Religion</strong> steadies you alone, and does not "
+ "care whether you are right.")
+ H(2, "Breaking")
+ P("At ${PANIC_MAX} you break. Choose one — <strong>${PANIC_BREAK.join(', ')}</strong> — take "
+ "it, and come back at <strong>${PANIC_AFTER_BREAK}</strong>. Not at nought. Breaking is not a rest.")
+ P("<strong>Panic clears when the scene does.</strong> 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: { name: "9 · The whole game, in one page", content:
P("You play ordinary adults in the modern world, pulled into an old and quiet " 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.") + "government department because you stay yourselves when reality goes wrong.")
@@ -419,6 +453,20 @@ export const PRIMER_PAGES = [
+ "are, and <strong>00 is always a fumble</strong>. No skill is ever certain — and " + "are, and <strong>00 is always a fumble</strong>. No skill is ever certain — and "
+ "none is ever hopeless either, because the floor is 1%.") + "none is ever hopeless either, because the floor is 1%.")
+ H(2, "The two tracks")
+ TABLE(["", "Range", "Direction", "Clears"], [
["<strong>Panic</strong> — what is happening now",
`0–${PANIC_MAX}`, "up", "when the scene ends"],
["<strong>Coherence</strong> — 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 => `<strong>${b.min}</strong> 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, "
+ "<strong>a fumble puts one on</strong>.")
+ H(2, "The three things that change your number") + H(2, "The three things that change your number")
+ CARDS([ + CARDS([
["1 · Difficulty", ["1 · Difficulty",
+3 -1
View File
@@ -546,7 +546,9 @@ ${table(["This happens", "Panic"], panicTrigRows)}
<h2>Getting rid of one</h2> <h2>Getting rid of one</h2>
<p>Roll it. A success takes one point off; a <strong>special or better takes two</strong>. <p>Roll it. A success takes one point off; a <strong>special or better takes two</strong>.
A failure takes nothing and costs nothing but the moment you spent on it.</p> A failure costs nothing but the moment you spent on it — and a <strong>fumble puts a
point ON</strong>. Trying to steady somebody and making it worse is the most familiar
thing on this list.</p>
${table(["How", "Reaches", "How often"], panicReliefRows)} ${table(["How", "Reaches", "How often"], panicReliefRows)}
<p><strong>Occult is the odd one.</strong> Naming a thing correctly, out loud, helps <p><strong>Occult is the odd one.</strong> Naming a thing correctly, out loud, helps