Hazards, automatic fire, two weapons, a bestiary, and space combat

Stress test first: 150 characters across every posting, tier and species against
fourteen invariants each, then the edges (characteristics at 1/3/21, every
location destroyed, twelve pikes carried, hp set to -99).

R-227: a destroyed head was a nap. The printed effect has always read "For
anything that keeps its brain there, this is death" and the code set unconscious
and stopped. destructionOutcomeFor() now says what destruction means.

THE WORLD: falling (a die per 3 m, armour does not apply), drowning (CON rounds
then a point a round, straight past the Major Wound threshold), fire (a condition
with a size; armour helps on the round you catch and never after), explosives
(one roll at the centre, shared by band, cover beats armour).

AUTOMATIC FIRE: one roll, the success level says how much of the burst arrived,
and the rounds are spent either way. TWO WEAPONS: -20/-40, or 0/-20 with the new
Ambidextrous talent; the off hand takes the lighter weapon and nothing over 2 kg.

BESTIARY: 21 entries in three families — folklore, horror, and what comes back
from further along — each with characteristics, natural armour, tactics and a
POWER written as a rule with a cost.

SPACE: eight stations, each on a skill somebody already has, so the Physician has
a chair. Range is a band; the helm moves one a round, in or out. Heat is the
pressure and took two rounds of playtest tuning to stop being a countdown.

64 rules, 198 spot-checked formulas, a 12-page rulebook in reading order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-08-11 08:02:21 +01:00
co-authored by Claude Opus 5
parent 79caf143bd
commit 226f853ee6
119 changed files with 1183 additions and 99 deletions
+285
View File
@@ -413,6 +413,291 @@ export function extractionCostFor(level) {
/* Dying, and death */
/* -------------------------------------------- */
/* -------------------------------------------- */
/* Space combat: STATIONS */
/* -------------------------------------------- */
/**
* SPACE COMBAT WITHOUT A PILOT MINIGAME.
*
* The usual failure is that one player flies and everybody else watches. This does
* not do that. A craft has STATIONS; every agent takes one, every station uses a
* skill somebody already has, and the round is one roll each resolved in the normal
* reaction order. There is no vector arithmetic and no map — range is a BAND, the
* same ladder the rest of the game uses.
*
* The craft has three things that can be spent: HULL (what it can take), SIGNATURE
* (how findable it is) and HEAT (what it has spent making itself effective). Heat is
* the pressure: almost every good outcome adds some, and a craft at maximum Heat is
* blind, loud and slow until it sheds it. That is the interesting decision and it
* belongs to whoever is on Engines, not to the pilot.
*/
export const SPACE_BANDS = ["docked", "knife", "close", "standoff", "distant", "gone"];
export function spaceBandRank(band) {
const i = SPACE_BANDS.indexOf(String(band));
return i < 0 ? 2 : i;
}
/** Closing and opening move ONE band a round. Nothing crosses the table at once. */
export function spaceBandAfter(band, delta) {
const n = Math.max(0, Math.min(SPACE_BANDS.length - 1, spaceBandRank(band) + (Number(delta) || 0)));
return SPACE_BANDS[n];
}
/**
* The stations. Each names the skill it uses, what a success does, and what it costs
* the craft in Heat — so a GM can run the whole subsystem off this table.
*/
// `heat` is what the station costs when it ACTS. Only Gunnery is charged for
// trying, because the shot leaves whether or not it lands — the same honesty as the
// burst rule. Playtested: with every station heating, one engineer could not keep up
// and Heat simply climbed to the ceiling by round four, which is a countdown rather
// than a decision. Looking at something is now free; shooting and running are not.
export const STATIONS = {
helm: { id: "helm", skill: "pilot", label: "RINGBRP.Station.helm",
effect: "band", heat: 1 },
guns: { id: "guns", skill: "energy_weapon", label: "RINGBRP.Station.guns",
effect: "damage", heat: 2, heatOnFailure: true },
sensors: { id: "sensors", skill: "computer_use", label: "RINGBRP.Station.sensors",
effect: "mark", heat: 0 },
engines: { id: "engines", skill: "engineering", label: "RINGBRP.Station.engines",
effect: "heat", heat: -3 },
damage: { id: "damage", skill: "repair", label: "RINGBRP.Station.damage",
effect: "hull", heat: 0 },
signals: { id: "signals", skill: "persuade", label: "RINGBRP.Station.signals",
effect: "talk", heat: 0 },
medical: { id: "medical", skill: "first_aid", label: "RINGBRP.Station.medical",
effect: "crew", heat: 0 },
anchor: { id: "anchor", skill: "transposition", label: "RINGBRP.Station.anchor",
effect: "escape", heat: 2 }
};
/** Heat is the whole tension. At the ceiling the craft is blind, loud and slow. */
export const HEAT = { max: 10, blindFrom: 6, loudFrom: 4, slowFrom: 8 };
export function heatEffectsFor(heat) {
const h = Math.max(0, Number(heat) || 0);
return {
heat: h,
loud: h >= HEAT.loudFrom, // signature climbs; everything can see you
blind: h >= HEAT.blindFrom, // sensors and gunnery at a penalty
slow: h >= HEAT.slowFrom, // helm cannot change band at all
penalty: h >= HEAT.blindFrom ? -20 : (h >= HEAT.loudFrom ? -10 : 0)
};
}
/**
* Gunnery at range. Close is easy and dangerous for everybody; distant is a poor
* bet. Knife range is where a small craft wins and where one bad round ends it.
*/
export const SPACE_RANGE_MOD = { docked: 0, knife: 20, close: 10, standoff: -10,
distant: -30, gone: null };
export function spaceRangeModFor(band) {
const m = SPACE_RANGE_MOD[band];
return m === undefined ? -10 : m;
}
/** A hit from a station roll. Marked targets are the reason Sensors has a chair. */
export function spaceHitFor(level, { marked = false } = {}) {
const base = { critical: 3, special: 2, success: 1, failure: 0, fumble: 0 }[level] ?? 0;
return base > 0 && marked ? base + 1 : base;
}
/** What a craft is, in the four numbers that matter. */
export function craftConditionFor({ hull = 0, hullMax = 1, heat = 0 } = {}) {
const frac = (Number(hull) || 0) / Math.max(1, Number(hullMax) || 1);
const h = heatEffectsFor(heat);
if (frac <= 0) return { state: "lost", ...h };
if (frac <= 0.25) return { state: "failing", ...h };
if (frac <= 0.6) return { state: "hurt", ...h };
return { state: "sound", ...h };
}
/* -------------------------------------------- */
/* Automatic fire, and two weapons */
/* -------------------------------------------- */
/**
* AUTOMATIC FIRE.
*
* Not a stream of separate attacks — that is four rolls and a stalled table. One
* roll, and the SUCCESS LEVEL decides how much of the burst arrived, which reuses the
* band the whole system already turns on. Rounds are spent whether or not they hit,
* because that is the honest part of automatic fire and the part players forget.
*
* A burst is harder to place than a single shot and easier to place SOMETHING with,
* so the modifier is negative and the payoff is hits.
*/
export const BURST = {
short: { id: "short", rounds: 3, mod: -10, label: "RINGBRP.Burst.short" },
long: { id: "long", rounds: 6, mod: -20, label: "RINGBRP.Burst.long" },
full: { id: "full", rounds: 10, mod: -30, label: "RINGBRP.Burst.full" }
};
/** How many of a burst connect, by the band the one roll produced. */
export function burstHitsFor(level, size) {
const b = BURST[size] ?? BURST.short;
const third = Math.max(1, Math.floor(b.rounds / 3));
switch (level) {
case "critical": return b.rounds; // all of it, and the first ignores armour
case "special": return Math.max(1, Math.floor(b.rounds * 2 / 3));
case "success": return third;
case "fumble": return 0; // and see BURST_FUMBLE
default: return 0;
}
}
/** Every round leaves the weapon regardless. This is the cost of the option. */
export function burstRoundsSpent(size, loaded) {
const b = BURST[size] ?? BURST.short;
return Math.min(Number(loaded) || 0, b.rounds);
}
/** A fumbled burst is the interesting one: it is a jam, or it is worse. */
export const BURST_FUMBLE = ["RINGBRP.Burst.Jam", "RINGBRP.Burst.Empty", "RINGBRP.Burst.Wild"];
/**
* TWO WEAPONS.
*
* The off hand is a real second attack and is meant to cost something. Untrained it
* is a heavy penalty on BOTH hands, because a person waving two knives is worse with
* each of them. The Ambidextrous talent removes the main-hand penalty entirely and
* halves the off-hand one; nothing removes the off-hand penalty altogether.
*
* The off-hand weapon must be lighter than the main hand, which is the rule that
* stops two poleaxes.
*/
export const TWO_WEAPON = { main: -20, off: -40, trainedMain: 0, trainedOff: -20,
offHandMaxKg: 2.0 };
export function twoWeaponPenaltyFor({ trained = false } = {}) {
return trained
? { main: TWO_WEAPON.trainedMain, off: TWO_WEAPON.trainedOff }
: { main: TWO_WEAPON.main, off: TWO_WEAPON.off };
}
/** Can this actually be paired? The off hand takes the lighter thing, and not much. */
export function canPairWeapons(mainKg, offKg) {
const m = Number(mainKg) || 0, o = Number(offKg) || 0;
if (o > TWO_WEAPON.offHandMaxKg) return false;
return o <= m;
}
/* -------------------------------------------- */
/* The ways the world kills you */
/* -------------------------------------------- */
/**
* FALLING.
*
* BRP-standard: a die of damage per three metres, to a random location, and armour
* does not help because the ground is not a weapon. A fall onto something soft or a
* controlled landing halves the distance BEFORE the dice are counted, which is where
* Hard Landing and a descent harness earn their keep.
*/
export const FALL = { metresPerDie: 3, die: 6, maxDice: 20 };
export function fallDamageFor(metres, { soft = false, controlled = false } = {}) {
let m = Math.max(0, Number(metres) || 0);
if (soft) m /= 2;
if (controlled) m /= 2;
const dice = Math.min(FALL.maxDice, Math.floor(m / FALL.metresPerDie));
return { dice, formula: dice > 0 ? `${dice}d${FALL.die}` : "0", ignoresArmour: true };
}
/**
* DROWNING, and anything else that stops you breathing.
*
* You hold on for CON rounds if you had warning and half that if you did not. After
* that it is one point of damage per round and it does not stop, because the water
* does not get bored. Armour is irrelevant and so is a Major Wound: this is the one
* track that runs straight to zero.
*/
export const ASPHYXIA = { perRound: 1, unpreparedFraction: 0.5 };
export function breathRoundsFor(con, { prepared = true } = {}) {
const c = Math.max(1, Number(con) || 1);
return Math.max(1, Math.floor(prepared ? c : c * ASPHYXIA.unpreparedFraction));
}
/**
* FIRE.
*
* Fire is not one hit, it is a condition with a size, and the size is what matters.
* Armour protects on the round you are set alight and never again — sealed suits
* excepted, which is the entire reason to own one.
*/
// `armour` means "worn protection helps on the round you CATCH". It never helps
// afterwards, because by then the fire is inside it. Only a sealed suit is different
// and that is the whole reason to sign one out.
export const FIRE = [
{ id: "spark", label: "RINGBRP.Fire.spark", damage: "1d3", rounds: 1, armour: true },
{ id: "clothes", label: "RINGBRP.Fire.clothes", damage: "1d6", rounds: 3, armour: true },
{ id: "pool", label: "RINGBRP.Fire.pool", damage: "2d6", rounds: 4, armour: true },
{ id: "engulf", label: "RINGBRP.Fire.engulf", damage: "3d6", rounds: 6, armour: true }
];
export function fireBandFor(id) {
return FIRE.find(f => f.id === id) ?? FIRE[1];
}
/**
* EXPLOSIVES.
*
* One roll of damage at the centre, halved at each band outward, and cover is worth
* more than armour — which is the lesson every agency learns and writes down.
* A charge placed deliberately, with time, hits the centre band automatically.
*/
export const BLAST = [
{ id: "contact", label: "RINGBRP.Blast.contact", share: 1, armour: 0.5 },
{ id: "near", label: "RINGBRP.Blast.near", share: 0.5, armour: 1 },
{ id: "far", label: "RINGBRP.Blast.far", share: 0.25, armour: 1 }
];
export function blastBandFor(metres, radius) {
const m = Math.max(0, Number(metres) || 0), r = Math.max(1, Number(radius) || 1);
if (m <= r / 2) return BLAST[0];
if (m <= r) return BLAST[1];
if (m <= r * 2) return BLAST[2];
return null; // outside it entirely
}
export function blastShareFor(rolled, band) {
if (!band) return 0;
return Math.max(0, Math.floor((Number(rolled) || 0) * band.share));
}
/** Cover is the answer to a blast. Armour is only ever half of one. */
export const BLAST_COVER = { none: 1, partial: 0.5, solid: 0.25, sealed: 0 };
export function blastAfterCover(damage, cover = "none") {
const f = BLAST_COVER[cover] ?? 1;
return Math.max(0, Math.floor((Number(damage) || 0) * f));
}
/**
* WHAT DESTROYING A LOCATION ACTUALLY DOES.
*
* The printed effect for a destroyed head reads "For anything that keeps its brain
* there, this is death" — and the code set `unconscious` and stopped. An agent could
* have every location on their body destroyed, head included, and the sheet would
* report them as DOWN. Destruction of something you cannot live without is not a
* penalty, and this is the rule that says so.
*
* DISABLED is not destroyed: a disabled head is a concussion and stays a concussion.
* Only DESTROYED reaches here.
*/
export function destructionOutcomeFor(kind, destroyed = false) {
if (!destroyed) return "none";
switch (kind) {
case "head": return "dead"; // whatever keeps its brain there no longer does
case "ridge": case "vital": return "dying";
default: return "none"; // limbs are ruinous, not fatal
}
}
/**
* WHAT HAPPENS AT ZERO.
*