Files
RingBRP/tools/rules-text.mjs
T
slaguru666andClaude Opus 5 43508259ac Consolidation pass after a Codex review
Four real defects, each verified against the source before acting on it.

Trade expertise was coupled to agency rank. A probationary burglar came out at 35-50%
and a veteran at 65-80%, as though fifteen years of picking locks were something the
department conferred. TRADE_BANDS is now fixed at 50-65/30-50 at every rank, and
INDUCTION scales with service instead (20-32 probationary to 45-60 veteran) because
that half genuinely is the department's. The two axes were the wrong way round. This
is the thing I flagged myself after PAPER HARBOUR and then left alone — flagging a
defect is not the same as fixing it.

Bonus points were sprayed across every skill in the game. grantFullSkillList runs
immediately before the loop and puts all fifty-nine skills on the sheet, and the loop
picked from actor.items, so a veteran's ninety points landed anywhere. The comment two
hundred lines above has always said "over the core skills"; the code never did.

"Random species" passed "baseline" into generators that support random perfectly well.

No behavioural tests. The strongest point in the review: the three guards are static
and none of them can tell whether a rule is READ, which is the only kind of defect this
project has ever shipped. tools/check-behaviour.mjs now runs 28 deterministic tests,
gated into the build, covering R-231/232/243/245/246, graded defences and the lamp
rule. Proved it bites by re-breaking R-246.

The register moved to postings.mjs, a Foundry-neutral module the engine, rulebook,
guards and tests all import normally — they were previously pulled out of the engine
with a regex and eval, which worked and was a trap. My first version of that used
`export … from`, which re-exports without local bindings, so ROLES was undefined and
the system threw on init: node --check passes that, loading it in Foundry does not.

Also: the contact doc comment still said "only OFFER moves standing"; check-kits
accepted mas/app, which are the DISPLAY names of siz/cha, so a posting declaring one
would have passed and produced an undefined characteristic; package.json disagreed with
the manifest; README counts were three revisions stale and are now generated at build.

Deferred with reasons: splitting the 6,700-line engine into modules. Right, and a
multi-session refactor whose risk is exactly the silent breakage demonstrated above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:57:20 +01:00

786 lines
43 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* THE RULES, generated from the rules themselves.
*
* Every number in this journal is computed from rules.mjs at build time and every
* name is read from lang/en.json, so the book cannot say one thing while the engine
* does another. That failure has happened five times in this project already; a
* rulebook written by hand would have been the sixth.
*
* Written to be learned in order and abandoned early: pages 1 to 3 are the whole
* playable game, 4 to 6 are what happens when it goes wrong, and the rest is
* reference you look up rather than read.
*/
import { readFile } from "node:fs/promises";
import {
freeCarryFor, hitPointsFor, majorWoundFor, damageModifierFor, locationMaxHp,
reflexRating, awarenessRating, reactionBaseFrom, reactionLoadFrom, reactionPrintedFrom,
encumbranceFrom, woundEffectsFor, bleedRateFor,
defencePenaltyFor, defenceOutcomeFor, ARTS, powerCostFor,
SCOPE_TIERS, scopeStanceFor, scrutinyFor, authorityOutcomeFor,
dossierTrustFor, widenScopeCostFor, DOSSIER, MERGE_STAGES,
skillBaseFrom, encumbrancePenaltyFor, ENCUMBERED_CATEGORIES,
DYING, dyingLimitFor, LAST_ENTRY,
FALL, fallDamageFor, ASPHYXIA, breathRoundsFor, FIRE, BLAST, BLAST_COVER,
BURST, burstHitsFor, TWO_WEAPON, twoWeaponPenaltyFor,
FIRE_MODE, SECOND_SHOT, SUSTAIN, SIGHT, LIGHTING, LIGHT_FLOOR, LAMP,
SHIELD, shieldWearFor, COVERAGE,
STATIONS, SPACE_BANDS, SPACE_RANGE_MOD, HEAT, heatEffectsFor, spaceHitFor,
powerStartingRating, brawlFallbackFor, bleedStoppedBy,
crossingArtPoolFor, crossingGrantsArt, crossingCostFor,
REACTION, BLEED, CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom,
MAJOR_WOUND_TABLE, COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY
} from "../rules.mjs";
// Postings, trades and induction are plain data and are imported as such.
import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS } from "../postings.mjs";
const LANG = JSON.parse(await readFile(new URL("../lang/en.json", import.meta.url), "utf8"));
const L = key => LANG[key] ?? key;
const pc = n => `${n > 0 ? "+" : ""}${n}%`;
/* ---------- small builders so the tables below stay readable ---------- */
const table = (head, rows) =>
`<table><thead><tr>${head.map(h => `<th>${h}</th>`).join("")}</tr></thead><tbody>` +
rows.map(r => `<tr>${r.map((c, i) => `<td${i ? ' style="text-align:right"' : ""}>${c}</td>`).join("")}</tr>`).join("") +
`</tbody></table>`;
/* ---------- derived figures, computed not typed ---------- */
const D = REACTION.die;
const band = 2; // two agents one lane apart, for the odds quoted below
const oddsOneLane = (() => {
let w = 0, t = 0, n = D * D;
for (let a = 1; a <= D; a++) for (let b = 1; b <= D; b++) {
const x = REACTION.laneMultiplier + a;
if (x > b) w++; else if (x === b) t++;
}
return { win: (100 * w / n).toFixed(0), tie: (100 * t / n).toFixed(0) };
})();
const damageLadder = [6, 12, 20, 28, 36, 45, 55, 65, 75]
.map(t => [`${t}`, damageModifierFor(t)]);
const woundRows = MAJOR_WOUND_TABLE.map(r => {
const eff = woundEffectsFor([{ key: r.key }]);
const bits = [];
if (eff.all) bits.push(`${pc(eff.all)} to everything`);
if (eff.manipulation) bits.push(`${pc(eff.manipulation)} hands, melee and ranged`);
if (eff.physical) bits.push(`${pc(eff.physical)} physical`);
if (eff.perception) bits.push(`${pc(eff.perception)} perception`);
if (eff.communication) bits.push(`${pc(eff.communication)} communication`);
if (eff.movFactor !== 1 && eff.movFactor > 0) bits.push(`MOV x${eff.movFactor}`);
if (eff.bleed) bits.push(`<strong>${eff.bleed} hp every round</strong>`);
if (eff.lostAction) bits.push("lose your next action");
if (eff.unconscious) bits.push("<strong>unconscious</strong>");
else if (eff.defenceOnly) bits.push("<strong>out of the fight</strong>, MOV 1");
return [`${r.min}`, L(`RINGBRP.Wound.${r.key}.title`), bits.join(" · ") || "—"];
});
const cohRows = COHERENCE_BANDS.map((b, i) => {
const hi = i === 0 ? 10 : COHERENCE_BANDS[i - 1].min - 1;
const rng = b.min === hi ? `${b.min}` : `${hi}–${b.min}`;
const eff = [b.all ? `${pc(b.all)} everything` : "", b.comm ? `${pc(b.comm)} communication` : ""]
.filter(Boolean).join(", ") || "none";
return [rng, L(b.label), eff];
});
/* Ceyla, from the shipped pregens — real numbers, so the worked example is checkable */
const CEYLA = { dex: 10, readiness: 71, con: 12, siz: 13, carried: 15.4, free: 12 };
const cReflex = reflexRating(CEYLA.dex);
const cAware = awarenessRating(CEYLA.readiness);
const cBase = reactionBaseFrom(CEYLA.dex, CEYLA.readiness);
const cEnc = encumbranceFrom(CEYLA.carried, CEYLA.free);
const cLoad = reactionLoadFrom(cEnc.srLoad);
const cPrinted = reactionPrintedFrom(cBase, cEnc.srLoad);
const cHp = hitPointsFor(CEYLA.con, CEYLA.siz);
const cMw = majorWoundFor(cHp);
export const RULES_PAGES = [
{ name: "1 · The whole game", content: `
<p><em>You are a Custodian. Something is here that should not be. Go there, sort it
out, come back. It is the coming back that gets harder.</em></p>
<h2>The only rule you must know</h2>
<p>Everything you attempt has a percentage. <strong>Roll 1D100 and get equal to or
under it.</strong> That is the whole engine. Everything else is a modifier to that
number or a consequence of the result.</p>
${table(["Roll", "Result", "What it means"], [
["01 to <sup>1</sup>&frasl;<sub>20</sub> of your skill", "<strong>Critical</strong>", "the best version of what you wanted"],
["up to <sup>1</sup>&frasl;<sub>5</sub> of your skill", "<strong>Special</strong>", "notably better than asked for"],
["up to your skill", "<strong>Success</strong>", "you do it"],
["over your skill", "Failure", "you do not"],
["the top 5% of the range", "<strong>Fumble</strong>", "you do it wrong, and it costs"]
])}
<p>A skill of 60 criticals on 01–03, specials on 04–12, succeeds to 60, and fumbles
on 98–00. The sheet prints those four numbers next to every skill so you never work
them out.</p>
<p><strong>Two rules override everything.</strong> <strong>96 to 99 never succeed</strong>,
however good you are, and <strong>00 is always a fumble</strong>. So no skill is ever
a certainty: a 100% expert still comes unstuck one roll in twenty. And no skill is
ever hopeless either — the floor is 1%, so there is always the chance of a
critical.</p>
<h2>The three things that change the number</h2>
<ol>
<li><strong>Difficulty</strong> — set by the GM before you roll. Average leaves it
alone, Difficult halves it, Easy doubles it.</li>
<li><strong>Circumstance</strong> — the situation, from ${pc(CIRCUMSTANCE[0].mod)} to
${pc(CIRCUMSTANCE.at(-1).mod)}, capped at &plusmn;40% however many apply.</li>
<li><strong>Your condition</strong> — wounds, what you are carrying, and how far you
have drifted from your own reality. All three are printed on the sheet and applied
automatically.</li>
</ol>
<p>Difficulty first, then everything else. A skill can never be reduced below 1%.</p>
<h2>Where the numbers come from</h2>
${table(["Thing", "How", "Ceyla, for example"], [
["Hit points", "(CON + SIZ) &divide; 2, rounded up", `${CEYLA.con} + ${CEYLA.siz} &rarr; <strong>${cHp}</strong>`],
["Major Wound", "half your hit points, rounded up", `<strong>${cMw}</strong> in one blow`],
["Damage modifier", "STR + SIZ on a ladder", "see the reference page"],
["Reaction", "your best route to acting first", `<strong>${cPrinted}</strong>`]
])}
<h2>That is enough to play</h2>
<p>Page 2 is fighting and page 3 is being hurt. Pages 4 and 5 are the setting doing
its work. The last page is a table you look things up in and never read.</p>` },
{ name: "2 · Fighting", content: `
<h2>Who goes first</h2>
<p>Your sheet prints one number: <strong>Reaction</strong>. Add <strong>1D${D}</strong>
to it. Highest acts first; a tie goes to the higher DEX. That is it — no
per-weapon arithmetic, and the die is rolled fresh every round.</p>
<p>Reaction is <strong>the better of two routes</strong>, because being quick and
being alert are both ways of acting first:</p>
${table(["Route", "How", "Ceyla"], [
["Reflex", `DEX &divide; ${REACTION.dexDivisor}, rounded up`, `${CEYLA.dex} &rarr; ${cReflex}`],
["Awareness", `Readiness &divide; ${REACTION.readinessDivisor}, rounded up`, `${CEYLA.readiness} &rarr; <strong>${cAware}</strong>`]
])}
<p><strong>Readiness</strong> is the best of your Tradecraft, Insight, Spot or
Listen, floored at INT &times; 3 so a clever but untrained agent is never flat. Take
the better of the two routes, multiply by ${REACTION.laneMultiplier}, and subtract
what you are carrying. Ceyla: the better of ${cReflex} and ${cAware} is ${cAware},
&times; ${REACTION.laneMultiplier} = ${cBase}, less ${cLoad} for
${cEnc.over} kg over her free carry &rarr; <strong>${cPrinted}</strong>.</p>
<p>An analyst can be quicker than a shooter. That is deliberate.</p>
<h3>Moving the marker</h3>
<p>Only three things move you on the rail during a fight, and they cap at
&plusmn;${REACTION.modCap}:</p>
<ul>
<li><strong>Aim</strong> +1 or +2 — spend a round aiming, act earlier the next one.
It is spent when it pays out.</li>
<li><strong>Edge</strong> +1 — equipment, a talent, high ground, a warning.</li>
<li><strong>Drag</strong> &minus;1 — surprised, in the dark, waist-deep in water.</li>
</ul>
<p>One lane apart wins about ${oddsOneLane.win}% of the time, ties ${oddsOneLane.tie}%.
Two lanes apart cannot be caught by the die at all.</p>
<h2>Your turn</h2>
<p><strong>One offensive action</strong>, plus as many defensive reactions as you like
at an escalating penalty. Attacking, aiming, taking cover, reloading, giving First
Aid and operating a device each use the action.</p>
<p><strong>Movement is free</strong> — 15 m if you also act, 30 m if you do not. So
you may move and take cover, but not take cover and shoot.</p>
<h3>Defending</h3>
<p>Defences are not actions and you may make as many as you like. The catch is that
they get harder, and you may not mix them: having chosen to dodge, you dodge.</p>
${table(["Defence this round", "Modifier"],
[0,1,2,3].map(n => [`${n + 1}${["st","nd","rd","th"][Math.min(n,3)]}`,
defencePenaltyFor(n) ? `${defencePenaltyFor(n)}%` : "no penalty"]))}
<p><strong>Dodge starts at DEX × 2</strong> and training raises it from there —
a DEX 13 agent who has never been taught to get out of the way still dodges on
${skillBaseFrom("dex*2", { dex: { value: 13 } })}%. Every posting can defend itself;
some are simply much better at it.</p>
<p><strong>Dodge</strong> uses the skill of that name and avoids the blow.
<strong>Parry</strong> uses the weapon in your hand and stops it. Standing in front
of three people is therefore possible, and stupid.</p>
<p>A defence is <strong>graded against the blow it answers</strong>. It stops a blow
of its own quality or worse outright. Against something better it still helps, but
only by as much as it was outclassed by — so an ordinary parry will not cancel a
critical, it will only reduce it. <strong>Only a critical stops a critical.</strong></p>
${table(["Blow", "vs critical", "vs special", "vs success", "vs failed defence"],
["critical", "special", "success"].map(atk =>
[`<strong>${atk}</strong>`, ...["critical", "special", "success", "failure"].map(def => {
const o = defenceOutcomeFor(atk, def);
return o.turnedAside ? "<em>turned aside</em>" : `lands as ${o.landsAt}`;
})]))}
<p>Read the reduced blow as the result for everything that follows: a critical cut
down to a special no longer ignores armour, it merely halves it.</p>
<h2>Access</h2>
<p>There is no hacking skill and nothing to break into. What an agent has is a
<strong>borrowed authority</strong>: a named credential belonging to somebody real,
with a scope of things that person could plausibly do, a Trust that erodes as it is
used, and a red flag or two describing how the real owner behaves.</p>
<p><strong>Inside its scope there is no roll.</strong> A facilities contractor opening
a plant room is not an act of intrusion, it is Tuesday. It costs 1 Trust and it works.
What costs you is asking the record to believe something one step beyond what the
person was for.</p>
${table(["What you are asking", "Against a credential for routine work"],
SCOPE_TIERS.slice(1).map(t => {
const stance = scopeStanceFor("routine", t);
return [L(`RINGBRP.Scope.${t}`),
stance === "in-scope" ? "<strong>no roll</strong> — 1 Trust"
: stance === "stretch" ? "a stretch — roll for it"
: "<strong>impossible</strong>, at any skill"];
}))}
<p>Two tiers beyond the credential is not difficult, it is <em>impossible</em>. No
percentage on any sheet gets a contractor issuing accounts. That is the rule that
stops one agent quietly owning every system in the setting.</p>
<h3>Behaving unlike them</h3>
<p>Each red flag you break is ${pc(scrutinyFor(1))} on the roll. The system is not
testing whether you are clever. It is testing whether you are behaving like the
person whose name is on the record.</p>
${table(["Result", "What happens to the credential"], [
["<strong>Critical</strong>", "it works, and Trust goes UP by 1"],
["<strong>Special</strong>", "it works and costs nothing"],
["<strong>Success</strong>", "it works; Trust down 1"],
["<strong>Failure</strong>", "refused; Trust down 1"],
["<strong>Fumble</strong>", "<strong>burned</strong> — and somebody real is going to be asked about it"]
])}
<p>At <strong>0 Trust</strong> the account is flagged and will not pass again until a
scene is spent rehabilitating it. Burned is permanent.</p>
<h3>The dossier</h3>
<p>A credential is not found, it is assembled. Up to ${DOSSIER.maxItems} dossier
items, <strong>each a separate scene using a different skill</strong>, and at most
${DOSSIER.maxComputerUse} of them at a keyboard — so access is something the whole
team builds and not one specialist's party trick. A dossier item never makes the
later roll easier; it makes the credential better. Trust starts at
${dossierTrustFor(0)} and rises to ${dossierTrustFor(DOSSIER.maxItems)} with a full
dossier, and the item that establishes what the person is actually <em>for</em>
widens the scope.</p>
<h3>Making it true</h3>
<p>You may spend Coherence to widen a scope, because reality can be made to remember
that the person always held that duty. The first costs
${widenScopeCostFor(0)} and each one after costs more — ${widenScopeCostFor(1)}, then
${widenScopeCostFor(2)}. It works. It always works.</p>
<p>Push a credential past what its Trust will carry and your file and theirs converge
one step, and <strong>nothing in this game spends anything to converge them back</strong>:
a circular arrives addressed to them; your pension record shows service you did not do;
colleagues are certain you chaired a meeting you did not attend; and at the end,
Registry holds one file where there were two, and yours is the copy.</p>
${table(["Times you pushed it past its Trust", "What has happened to your file"],
MERGE_STAGES.map((k, i) => [String(i), L(k)]))}
<h2>Shooting and hitting</h2>
${table(["Range", "Modifier"], RANGE_LADDER.map(r =>
[L(`RINGBRP.Range.${r.id}`) === `RINGBRP.Range.${r.id}` ? r.id : L(`RINGBRP.Range.${r.id}`),
r.mod === null ? "not possible" : pc(r.mod)]))}
<p>Cover <em>penalises</em>, it does not block: solid partial cover is
${pc(-30)} to the attacker and never removes line of sight.</p>
<h2>What a hit does</h2>
${table(["Result", "Damage", "Armour"], [
["<strong>Critical</strong>", "maximum weapon damage, plus a rolled damage modifier", "<strong>ignored entirely</strong>"],
["<strong>Special</strong>", "normal", "counts half, rounded down"],
["Success", "normal", "full"],
["Fumble", "none", "you drop or jam it; a firearm needs a round to clear"]
])}
<p>The critical is the pressure valve. Good armour makes an ordinary hit survivable
and does nothing at all about a critical.</p>` },
{ name: "2b · The world, and how it kills you", content: `
<h2>Falling</h2>
<p>A die of damage for every ${FALL.metresPerDie} metres, to a random location, and
<strong>armour does not help</strong> — the ground is not a weapon. Landing on
something soft halves the distance, and so does a controlled descent — but they do not
stack, because they are the same mitigation twice. A descent harness makes any drop a
controlled one, which is what it is for.</p>
${table(["Fall", "Damage"], [3, 6, 12, 20, 30, 45].map(m =>
[`${m} m`, fallDamageFor(m).formula]))}
<h2>Not breathing</h2>
<p>You hold on for CON rounds with warning, half that without. After that it is
${ASPHYXIA.perRound} point a round and it does not stop, because the water does not
get bored. Armour is irrelevant and so is your Major Wound: this one runs straight to
zero and then the dying count begins.</p>
${table(["CON", "Ready for it", "Caught out"], [8, 12, 16, 18].map(c =>
[String(c), `${breathRoundsFor(c)} rounds`, `${breathRoundsFor(c, {prepared:false})} rounds`]))}
<h2>Fire</h2>
<p>Fire is not a hit, it is a condition with a size. Worn armour helps on the round
you <em>catch</em> and never again, because after that the fire is inside it.</p>
${table(["Fire", "Damage a round", "Burns for"],
FIRE.map(f => [L(f.label), f.damage, `${f.rounds} rounds`]))}
<h2>Explosives</h2>
<p>One roll of damage at the centre, shared out by how close you were. <strong>Cover
is the answer; armour is only ever half of one</strong>, and only at contact. The order
is fixed: <em>rolled × range share × cover, rounded down once, and only then does
armour come off</em>.</p>
${table(["Where you were", "Share of the damage", "Armour"],
BLAST.map(b => [L(b.label), `${Math.round(b.share * 100)}%`,
b.armour < 1 ? "half of it" : "all of it"]))}
${table(["Cover", "What gets through"],
Object.entries(BLAST_COVER).map(([k, v]) => [k, v === 0 ? "nothing" : `${Math.round(v * 100)}%`]))}
` },
{ name: "2c · How a weapon fires, and what is bolted to it", content: `
<h2>What a weapon can do with a round</h2>
<p>Every weapon has a <strong>fire mode</strong>, and it is a decision rather than a
drawback. The trade runs one way: the faster a weapon puts rounds out, the less each
individual shot is worth, and the slower it is, the more the one shot you get is worth
taking properly.</p>
${table(["Mode", "To hit", "What it offers"],
Object.values(FIRE_MODE).map(m => [L(m.label), pc(m.attack),
m.burst.length ? "bursts"
: m.sustain ? "the beam can be held on the target"
: m.second ? `a second shot, at ${pc(SECOND_SHOT)} to both`
: m.reloads ? "one shot, then work"
: "one aimed shot a round"]))}
<p>A weapon that cannot fire a burst <strong>cannot be made to</strong>. A muzzle-loader
has no automatic setting however the button is pressed.</p>
<h2>Holding a beam</h2>
<p>A beam does not fire discrete rounds, so counting hits is the wrong shape for it.
What a beam can do instead is stay on the target, and that buys <strong>damage</strong>
— the one firing choice in this game that adds dice rather than adding rolls. It is
paid for twice, in accuracy and in charge.</p>
${table(["", "To hit", "Damage", "Charge"],
Object.values(SUSTAIN).map(s => [L(s.label), pc(s.mod),
s.bonus ? `+${s.bonus}` : "—", `×${s.charge}`]))}
<h2>Sights</h2>
<p>A sight is not a flat bonus, which is why most games end up with everybody carrying
the same scope. A sight is ground for a distance and is a <strong>liability at the
others</strong>. You never choose to apply it: the range band is already known by the
time the modifier is wanted, so a carried sight applies itself.</p>
${table(["Sight", "Short", "Medium", "Long", "Extreme"],
Object.values(SIGHT).filter(s => s.id !== "none").map(s => [L(s.label),
pc(s.bands.short), pc(s.bands.medium), pc(s.bands.long), pc(s.bands.extreme)]))}
<p>The telescopic sight is a <strong>penalty inside ten metres</strong>. That is the
whole point of it, and it is why nobody sensibly carries all four. A sight only counts
if it <em>fits</em> — the scope and the thermal sight are rifle furniture and do nothing
bolted to a service pistol.</p>
<h2>Shields</h2>
<p>A shield is the one piece of armour you <em>hold</em>, and it protects you exactly
as much as you are currently holding it up. <strong>Raised</strong>, it adds its points
to what it covers and it is the only armour you can also defend with — a parry on the
Shield skill. <strong>Lowered</strong>, it is weight.</p>
<p>What stops "always raised" being the only answer is that it occupies a hand and you
are shooting around your own shield: ${pc(SHIELD.ranged)} to your own ranged attacks
while it is up. It is obviously right for a containment officer and obviously wrong for
a marksman, which is the point.</p>
<p><strong>Shields break.</strong> A blow the shield <em>absorbed</em> — stopped
outright, or merely dragged down a step — costs it a point of wear for every step the
attack stood above an ordinary success — so an ordinary blow costs
it nothing, a special costs ${shieldWearFor("special")}, and a critical costs
${shieldWearFor("critical")}. At its limit it is firewood: no points, nothing to parry
with, and it drops out of your hand.</p>
<h2>The dark</h2>
<p>Most crossings do not come with the lights on.</p>
${table(["", "To hit"], Object.values(LIGHTING).map(l => [L(l.label), pc(l.mod)]))}
<p>A lamp buys the dark back to <strong>${L(LIGHTING[LIGHT_FLOOR].label)}</strong> and
never better — and it is a position. The card will say so.</p>
<p><strong>A lamp lights everyone standing in it</strong>, not only the person holding
it. Each one has a reach: ${LAMP.reachM} metres for the issue field lamp, less for a
weapon lamp, a great deal more for a vehicle searchlight. Anyone inside that reach is
in the light, with everything that means in both directions — the pool is one position
and the whole group is standing in it. If your tokens are on the map the game works
this out for you; if they are not, the attack dialog asks.</p>
<p>A thermal sight removes the dark outright <em>and</em> does not announce you, which
is most of what you are paying its weight for.</p>
<h2>Automatic fire</h2>
<p>One roll, not four. The <strong>success level</strong> says how much of the burst
arrived, and <strong>the rounds are gone either way</strong> — that is the honest part
of automatic fire and the part players forget.</p>
${table(["Burst", "Rounds", "To hit", "Critical", "Special", "Success"],
Object.values(BURST).map(b => [L(b.label), String(b.rounds), pc(b.mod),
String(burstHitsFor("critical", b.id)), String(burstHitsFor("special", b.id)),
String(burstHitsFor("success", b.id))]))}
<p>A critical burst puts its <em>first</em> round through armour and the rest
normally. However many rounds connect, <strong>a burst is one wounding event</strong>
and rolls the Major Wound table at most once. A fumble is a jam, an empty magazine, or
a muzzle that climbed — and the GM decides what was behind the target.</p>
<h2>Two weapons</h2>
<p>The off hand is a real second attack and it is meant to cost something. Untrained,
it costs you in <em>both</em> hands, because a person waving two knives is worse with
each of them.</p>
${table(["", "Main hand", "Off hand"], [
["Untrained", pc(twoWeaponPenaltyFor().main), pc(twoWeaponPenaltyFor().off)],
["<strong>Ambidextrous</strong>", pc(twoWeaponPenaltyFor({trained:true}).main),
pc(twoWeaponPenaltyFor({trained:true}).off)]
])}
<p>Ambidextrous buys <strong>consistency, not a free attack</strong> — both hands at
the same penalty rather than a full-strength main hand and a cheap extra, which made it
the only sane build for anyone who could hold two things. The off-hand weapon must be
the lighter of the two and no heavier than ${TWO_WEAPON.offHandMaxKg} kg, which is the
rule that stops two poleaxes.</p>
` },
{ name: "3 · Getting hurt", content: `
<h2>Hit points and locations</h2>
<p>Damage comes off your total <strong>and</strong> the location it struck. Each
location has its own capacity, a fraction of your total.</p>
<ul>
<li>Damage in a location beyond its capacity <strong>disables</strong> it.</li>
<li>Twice its capacity <strong>destroys</strong> it.</li>
</ul>
<p>A disabled leg halves your movement and costs ${pc(-30)} physical; a disabled arm
costs ${pc(-30)} to anything done with hands, which includes shooting. The figure on
the Combat tab colours in as this happens.</p>
<h2>Major Wounds</h2>
<p>Take <strong>half your hit points or more in a single blow</strong> and roll
1D10 on this table. Ceyla's threshold is ${cMw}.</p>
${table(["1D10", "Wound", "What it does to you"], woundRows)}
<p>These stack, with each other and with a shattered limb, and they are applied to
every roll you make. The Skills tab shows a red bar when you are carrying any.</p>
<h2>Zero hit points</h2>
<p>Hit points do not go below zero. Zero starts a clock instead.</p>
${table(["Hit points", "You are"], [
["above 2", "<strong>up</strong> — no restriction from injury alone"],
["2 or below", "<strong>down</strong> — out of the fight, still conscious, still yours"],
["0", "<strong>dying</strong> — unconscious, no actions, no defences, and the count begins"]
])}
<p>Each round at zero adds ${DYING.perRound} to the count. When the count reaches your
own <strong>Major Wound</strong> threshold you are <strong>dead</strong> — so the same
number that decides how much damage you can take standing up also decides how long you
have on the floor. A minimum of ${DYING.minCount} rounds applies however slight you are.</p>
<p><strong>Being hurt again at zero</strong> breaks any stabilisation and costs a
round of the count. Hit points cannot go below zero, so this is what further damage to
a body on the floor actually does — and it is why leaving somebody stable in a place
that is still dangerous is not the same as getting them out.</p>
<p><strong>Stabilising</strong> takes ${DYING.stabiliseWith.join(" or ")} and stops the
count. It does not put you back on your feet: you are still at zero and still
unconscious until somebody heals you above it. Healing above zero clears the count.
A <em>fumbled</em> attempt over a dying agent costs them a round they do not have.</p>
<h3>The last entry</h3>
<p>A Custodian's death is a filing question, because everything here is. What the
record ends up saying depends on how well filed they were when it happened.</p>
${table(["Coherence at death", "What the record says"],
LAST_ENTRY.map((e, i) => {
const upper = i === 0 ? "10" : String(LAST_ENTRY[i - 1].atLeast - 1);
return [e.atLeast === 0 ? "0" : `${e.atLeast}–${upper}`, L(e.label)];
}))}
<p>The badly filed are not recorded as dead at all. That is the department's problem
first, and then somebody else's.</p>
<h2>Bleeding</h2>
${table(["Wound", "Per round", "Stopped by"], Object.entries(BLEED).map(([k, v]) =>
[L(`RINGBRP.Wound.${k}.title`), `${v.perRound} hp`,
v.stop.map(s => `${s.difficulty ? `<strong>${s.difficulty}</strong> ` : ""}${s.family.replace("_", " ")}`).join(", or ")]))}
<p>Blood loss is charged at the end of every round and does not stop on its own.
Someone has to spend an action. That is the decision the rule exists to create.</p>
<h2>Healing</h2>
<p>First Aid restores a few points and closes a bleed. Real recovery takes days at
home, or hours in an autodoc. Nothing in the field puts a destroyed limb back.</p>` },
{ name: "4 · Coherence", content: `
<p><em>You are from here. Every time you do something that only makes sense somewhere
else, you are a little less from here.</em></p>
<p>Coherence runs from <strong>10 down to 0</strong>. Ten is anchored. Zero is
displaced, and displacement is permanent.</p>
${table(["Coherence", "Band", "Effect"], cohRows)}
<p>Communication degrades first and worst. You can still do the job for a while, and
the people you are doing it for stop recognising you long before you stop being
useful.</p>
<h2>What it costs</h2>
${table(["Occurrence", "Cost"], Object.values(COHERENCE_COSTS)
.sort((a, b) => a.cost - b.cost)
.map(c => [L(c.label), `&minus;${c.cost}`]))}
<p>Charged once per occurrence. The first two losses cost you nothing at all — and
the third drops you out of Anchored, by which point you have stopped watching.</p>
<h2>What brings it back</h2>
<p>Only at home, and never faster than one step at a time: a debrief
+${COHERENCE_RECOVERY.debrief}, a week of downtime +${COHERENCE_RECOVERY.downtimeWeek},
time with an <strong>anchor</strong> +${COHERENCE_RECOVERY.anchorObject}. An anchor is
a person or object that remembers you as you were.</p>
<h2>The way home is never closed</h2>
<p>Being Unmoored makes the return dearer, never impossible. Another agent may always
aim it for you at no penalty; alone you may always aim it at
${pc(COHERENCE_BANDS.find(b => b.id === "unmoored").returnPenalty)}; and a failed
return does not strand you either — you arrive near your target rather than at it.
There is no state in which an agent cannot get home. There are only states in which
getting home costs more than the case was worth.</p>` },
{ name: "5 · The Quiet Arts", content: `
<p><em>Some agents come back with something. It is not a gift and it is not
training — it is the crossing, left in a person.</em></p>
<h2>What an Art is</h2>
<p>A crossing is an <strong>induced filing error</strong>: you persuade the world you
are somewhere else and it moves you to resolve the contradiction. An Art is the same
trick, performed small and on purpose — a misfiling you commit deliberately.</p>
<p>So there are no fireballs. Every Art is a sentence about the record: what is filed
here, what was, what is about to be, and what could be persuaded not to have been.</p>
<h2>Using one</h2>
<p>Roll under its rating like any skill. It is governed by POW, and your own
<strong>Coherence penalises it</strong> — the further you have drifted, the worse you
are at the thing that made you drift.</p>
<p><strong>The cost is Coherence, and it is charged whether or not it works</strong>,
because the misfiling is committed the moment you try. Push an Art past its ordinary
reach and it costs more.</p>
${table(["Art", "It says", "Ordinary", "Pushed"],
Object.keys(ARTS).map(k => [
L(`RINGBRP.Art.${k}.name`),
{ thread: "what was together is still together",
echo: "when is loosely filed",
hollow: "this is not filed here",
witness: "the record agrees you belong",
unmake: "this was not present",
ledger: "read the record; amend it" }[k],
`&minus;${powerCostFor(k)}`, `&minus;${powerCostFor(k, { pushed: true })}`
]))}
<h2>Fumbling one</h2>
<p>A fumble is not a failure. <strong>The misfiling sticks.</strong> Something small
and true about the world is now different, and the GM never says which. That is the
cost of doing on purpose the thing the agency spends its budget undoing.</p>
<h2>Where they come from</h2>
<p><strong>Nobody learns an Art.</strong> They are not taught, bought, chosen at
character creation or found in a manual. A character who wants one has exactly one
route: <strong>go across, and have the way home go wrong.</strong></p>
<h3>Coming back</h3>
<p>The return is a second crossing and it has to be aimed. Roll Transposition.
Being Unmoored makes it dearer; another agent can always aim it for you at no
penalty. <strong>There is no result that leaves you across.</strong></p>
${table(["Return", "What happens", "Coherence", "An Art?"], [
["Success or better", "home, where and when you meant", "&mdash;", "no"],
["Failure", "you arrive near your target, not at it",
`&minus;${crossingCostFor("failure")}`, "only if you were already Unmoored"],
["Fumble", "you come back, and not the same",
`&minus;${crossingCostFor("fumble")}`, "<strong>always</strong>"]
])}
<h3>What follows you home</h3>
<p>You do not choose the Art, and the pool is decided by <strong>how far you had
already drifted when it went wrong</strong>. The worse the state you were in, the
worse the thing that comes back with you.</p>
${table(["Coherence at the time", "What can arrive"], [
["8 and up &mdash; Anchored, or Loose", crossingArtPoolFor(10).map(a => L(`RINGBRP.Art.${a}.name`)).join(", ")],
["4 to 3 &mdash; Adrift", crossingArtPoolFor(4).map(a => L(`RINGBRP.Art.${a}.name`)).join(", ")],
["2 and below &mdash; Unmoored", crossingArtPoolFor(1).map(a => L(`RINGBRP.Art.${a}.name`)).join(", ")]
])}
<p>Which is the trap the setting has been building the whole time: the agents most
likely to come back with an Art are the ones least able to afford using it.</p>` },
{ name: "5b · The postings", content: `
<h2>The postings</h2>
<p>Twenty-five of them. An agency this old has never got round to abolishing anything,
so the register still carries a Night Warden and an Antiquities Officer alongside the
Signals and Disposal Officers. Each posting has a key characteristic, the skills it
trains, the talents it leans on, and a loadout containing at least one thing issued to
no other posting.</p>
${table(["Posting", "Key", "What it is"],
Object.values(ROLES).map(r => [`<strong>${r.label}</strong>`, r.stat.toUpperCase(), r.blurb ?? ""]))}
<p>None of this is a class. A posting is what the department has you down as, and the
skills follow from the job rather than the other way round.</p>
<h2>Or a trade, and six weeks</h2>
<p>The department does not only promote from within, because there is no degree in
crossings. It recruits <strong>a doctor, a joiner, a detective sergeant</strong> — and
puts them through the training centre.</p>
${table(["Trade", "Key", "What they were"],
Object.values(TRADES).map(r => [`<strong>${r.label}</strong>`, r.stat.toUpperCase(), r.blurb ?? ""]))}
<p>A trade character is built in two halves, and the join is the interesting part.</p>
<p><strong>The trade is prior career, and it does not move with rank.</strong> It is
rated at ${TRADE_BANDS.core[0]}–${TRADE_BANDS.core[1]}% for what you did for a living
and ${TRADE_BANDS.support[0]}–${TRADE_BANDS.support[1]}% for what came with it, whether
you were sworn in last month or ten years ago. Fifteen years of picking locks is not
something the department conferred and it does not improve because somebody was
promoted.</p>
<p><strong>Induction is the department's, so it does.</strong> ${INDUCTION.blurb} It
teaches ${INDUCTION.skills.map(k => LANG[`RINGBRP.Skill.${k}`] ?? k).join(", ")}, and
issues a sidearm, a vest, cordon kit, a cover identity and a ward on top of standing
issue.</p>
${table(["Rank", "Induction at"],
Object.entries(INDUCTION.bandByTier).map(([k, b]) =>
[TIERS[k] ? TIERS[k].label : k, `${b[0]}–${b[1]}%`]))}
<p>So a surgeon recruited last year is a far better surgeon than they are a Custodian,
and the distance between those two numbers is most of what it feels like to play one —
and it is a distance that closes with service rather than one they are stuck with.</p>
` },
{ name: "6 · A case", content: `
<p>One case is one session. It has three tracks, and they are the pressure.</p>
<ul>
<li><strong>Containment</strong> — how well the anomaly is still contained. Falls when
the public sees something, when a body is left where it will be found, when a local
authority gets involved.</li>
<li><strong>Resources</strong> — kit, ammunition, favours, cover identities. Spent,
not regained mid-case.</li>
<li><strong>Clearance</strong> — how far the agency will back you. Falls when you go
off the brief. At zero the case becomes deniable and you are on your own.</li>
</ul>
<h2>The shape of a session</h2>
<p><strong>Briefing &rarr; Approach &rarr; Investigation &rarr; Confrontation &rarr;
Extraction.</strong> Each phase has a difficulty, a cost and the chance of a forced
encounter. Agents are briefed on a place, a time and a thing to do. They cross. They
find the brief was, as always, incomplete. They sort it out or they do not. They come
back and are debriefed, and the debrief is where coherence is recovered and where the
GM finds out what the players think they did.</p>
<h2>Afterwards</h2>
<p>Every skill you used successfully under pressure is ticked. At the end of the case
each ticked skill rolls to improve: beat your current rating on 1D100 and it goes up
1D6. Ticks clear either way.</p>
<p>Cases do not need to connect. The running thread of a campaign is not a plot — it
is the four coherence tracks going down.</p>` },
{ name: "6b · Space, and everybody in it", content: `
<h2>Stations</h2>
<p>The usual way to run a spacecraft fight is that one player flies and everybody else
watches. This does not do that. A craft has <strong>stations</strong>; every agent
takes one, every station uses a skill they already have, and the round is one roll
each in the normal reaction order. There is no map and no vector arithmetic.</p>
${table(["Station", "Rolls", "Does", "Heat"],
Object.values(STATIONS).map(st => [L(st.label), st.skill.replace(/_/g, " "),
st.effect, st.heat === 0 ? "—" : (st.heat > 0 ? `+${st.heat}` : String(st.heat))]))}
<p>The Physician has a chair. So does the Translation Officer. That is the point.</p>
<h2>Range is a band</h2>
${table(["Band", "Gunnery"], SPACE_BANDS.map(b =>
[L(`RINGBRP.SpaceBand.${b}`),
SPACE_RANGE_MOD[b] === null ? "nothing to shoot at" : pc(SPACE_RANGE_MOD[b])]))}
<p>The helm moves you <strong>one band a round</strong>, in or out, and a critical
moves two. Knife range is where a small craft wins and where one bad round ends it.</p>
<h2>Heat is the whole game</h2>
<p>Shooting and manoeuvring make the craft hot. Gunnery is charged whether or not it
hits, because the shot left. Engines vent it — 3, or more on a good roll — and
everything else is free.</p>
${table(["Heat", "What it costs you"], [
["0–3", "nothing"],
[`${HEAT.loudFrom}–${HEAT.blindFrom - 1}`, `loud: everything can see you, ${pc(heatEffectsFor(HEAT.loudFrom).penalty)} on sensors and gunnery`],
[`${HEAT.blindFrom}–${HEAT.slowFrom - 1}`, `blind as well, ${pc(heatEffectsFor(HEAT.blindFrom).penalty)}`],
[`${HEAT.slowFrom}–${HEAT.max}`, "and the helm cannot change band at all"]
])}
<p>Marking a target with Sensors adds one hit to the next gunnery roll, which is the
reason that chair is worth filling. And the anchor is a station: transposing out of a
fight is always available, it costs Heat, and it is not tidy.</p>
` },
{ name: "7 · Reference", content: `
<h2>Damage modifier</h2>
<p>STR + SIZ, added to melee and thrown damage.</p>
${table(["STR + SIZ", "Modifier"], damageLadder)}
<h2>Carrying it all</h2>
<p>Free carry is STR + SIZ in kilograms. Past that you are encumbered, and it costs
you ${pc(encumbrancePenaltyFor(1, "phys"))} per step on
${ENCUMBERED_CATEGORIES.length} skill categories — the physical ones — and nothing at
all on the rest. Thinking is not heavier when your pack is.</p>
${table(["Category", "Encumbered?"],
["phys", "melee", "ranged", "manip", "mental", "social"].map(c =>
[c, ENCUMBERED_CATEGORIES.includes(c) ? `yes, ${pc(encumbrancePenaltyFor(1, c))} a step` : "no"]))}
<h2>Bleeding</h2>
<p>A wound that bleeds takes it off you every round until somebody stops it, and what
stops it depends on how bad it is.</p>
${table(["Wound", "Per round", "Stopped by"],
Object.entries(BLEED).map(([k, v]) => [k, String(v.perRound),
v.stop.map(x => x.difficulty ? `${x.family} (${x.difficulty})` : x.family).join(", ")]))}
<h2>Circumstance</h2>
${table(["Situation", "Modifier"], CIRCUMSTANCE.map(c => [L(c.label).replace(/\s*\([^)]*\)/, ""), pc(c.mod)]))}
<p>Capped at &plusmn;40% no matter how many apply.</p>
<h2>Encumbrance</h2>
<p>Your free carry is <strong>STR + MAS</strong> in kilogrammes — the same pair that
sets your damage modifier. Over that:</p>
${table(["Over free carry", "Skills", "MOV", "Reaction"],
[0, 1, 5, 10, 15, 20, 30].map(kg => {
const e = encumbranceFrom(100 + kg, 100);
return [`${kg} kg`, e.skillPenalty ? pc(-e.skillPenalty) : "—",
e.movPenalty ? `&minus;${e.movPenalty}` : "—",
reactionLoadFrom(e.srLoad) ? `&minus;${reactionLoadFrom(e.srLoad)}` : "—"];
}))}
<p>Skills and MOV move in whole steps of 10 kg. Reaction is exact — every
kilogramme costs, so no weight is ever free.</p>
<p>The skill penalty applies to <strong>physical, melee, ranged and manipulation</strong>
only. A heavy pack does not make you worse at Knowledge.</p>
<h2>Location capacity</h2>
<p>A fraction of your total hit points, rounded up. For a ${cHp}-hit-point agent:</p>
${table(["Location", "Share", "Capacity"], [
["Head", "40%", `${locationMaxHp(cHp, 0.4)}`],
["Chest", "50%", `${locationMaxHp(cHp, 0.5)}`],
["Abdomen and legs", "42%", `${locationMaxHp(cHp, 0.42)}`],
["Arms", "35%", `${locationMaxHp(cHp, 0.35)}`]
])}
<h2>Rolls that ignore your skill</h2>
${table(["Roll", "Result"], [
["96–99", "always fails, whatever your skill"],
["00", "always a fumble"],
["01", "always at least a critical chance — the floor is 1%"]
])}
<h2>The turn, in order</h2>
<ol>
<li>Roll 1D${D}, add your printed Reaction, apply Aim, Edge or Drag.</li>
<li>Highest first. Ties to higher DEX.</li>
<li>On your turn: one action, free movement, unlimited defences at a rising penalty.</li>
<li>To attack: skill, then difficulty, then range and circumstance, then roll.</li>
<li>On a hit: damage, less armour by success level, to a rolled location.</li>
<li>Half your hit points in one blow? Roll 1D10 for a Major Wound.</li>
<li>End of round: anyone bleeding loses their hit points.</li>
</ol>` },
{ name: "8 · A round, worked through", content: `
<p>Ceyla Marspont, agency analyst, at a boathouse door with two Cleaners inside.
Every number here is the one her sheet actually prints.</p>
<h2>Initiative</h2>
<p>DEX ${CEYLA.dex} gives Reflex <strong>${cReflex}</strong>. Her Tradecraft is
${CEYLA.readiness}, giving Awareness <strong>${cAware}</strong> — she is not fast, she
is <em>ready</em>. The better route is ${cAware}, &times; ${REACTION.laneMultiplier} =
${cBase}. She is carrying ${CEYLA.carried} kg against a free carry of ${CEYLA.free},
so ${cEnc.over} kg over costs her ${cLoad}. Her sheet prints
<strong>${cPrinted}</strong>.</p>
<p>She rolls 1D${D} and gets 4: <strong>${Math.round((cPrinted + 4) * 10) / 10}</strong>.
The Cleaners print 12 and roll 5 and 2. Ceyla acts first, comfortably.</p>
<h2>The shot</h2>
<p>Redaction lance, Energy Weapon (Pistol) at 53%. The Cleaner is 25 m away —
medium range, ${pc(-10)}. Nothing else applies, so she needs
<strong>43</strong> or under. Her bands: critical on 01–02, special to 08, success to
43, fumble on 97+.</p>
<p>She rolls <strong>07</strong>. A <strong>special</strong>.</p>
<h2>The damage</h2>
<p>The lance does 1D4+6. She rolls 8. A special means the Cleaner's stab vest counts
<strong>half</strong> — 2 becomes 1 — so <strong>7</strong> gets through. The location
roll says the leg.</p>
<p>The Cleaner has 12 hit points, so a Major Wound needs 6. Seven is more than six:
<strong>roll 1D10</strong>. It comes up 6 — <strong>${L("RINGBRP.Wound.deepBleeding.title")}</strong>.
He is now losing <strong>${bleedRateFor([{ key: "deepBleeding" }])} hit points every
round</strong> until someone spends an action on him, and his friend has to decide
whether that someone is going to be him.</p>
<h2>What it cost her</h2>
<p>The lance is transposed materiel. Using it costs
<strong>1 Coherence</strong>, charged automatically. Ceyla drops from 10 to 9. Nothing
happens yet — the first two losses are free — which is exactly how the third one
arrives without anybody noticing.</p>` }
];