Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37b4043746 | ||
|
|
7ec7efd29b | ||
|
|
5aaae186e0 |
@@ -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
@@ -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
@@ -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 @@
|
|||||||
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
@@ -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 @@
|
|||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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}} → 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>
|
||||||
@@ -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 ?? {})) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user