feat: dedicated BRP dice pack; Day One uses it instead of coc-d100

Add brp-d100 rule-pack (Basic Roleplaying roll-under): fumble on 100,
critical <= skill/20, special <= skill/5, success <= skill — bands
transcribed from the Continuum Day One console's verdict() logic.
Register it and switch day-one meta.system from coc-d100 to brp-d100.

tests/dice/brp-d100.test.js covers the ladder, low-skill rounding, and
the fumble-only-on-100 edge. 147 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-07-20 22:48:28 +01:00
co-authored by Claude Opus 4.8
parent 9d0e6c7ec4
commit 49e9dcda46
6 changed files with 79 additions and 6 deletions
+2 -2
View File
@@ -53,7 +53,7 @@ Components are dumb renderers that emit events; the shell wires them together.
| `src/core/scenario.js` | Scenario validator |
| `src/core/format.js` | Elapsed + drift display formatting |
| `src/dice/roller.js` | Seedable dice core (pools, modifiers) |
| `src/dice/rulepacks/` | System interpreters (Year-Zero, CoC d100, VANITY d6, Panic & Glory, Dee Sanction) + registry |
| `src/dice/rulepacks/` | System interpreters (Year-Zero, CoC d100, BRP d100, VANITY d6, Panic & Glory, Dee Sanction) + registry |
| `src/npc/` | NPC generator (`generator.js`) + genre table packs (`packs/noir.js`) |
| `src/art/` | Pencil-art library search (`search.js`) + tagged manifest |
| `src/clues/safety-net.js` | Clue safety-net engine (essential-gap + solvability) |
@@ -97,7 +97,7 @@ compress if you reach a beat behind schedule.
3. **Slice 3** — dice rule-packs (CoC d100, VANITY d6-pool, Panic & Glory, Dee Sanction) + tray pack selector. ✅
4. **Slice 4** — the clue **safety-net** (essential-clue gap tracker + fallbacks). ✅
5. **Slice 5** — the convention **hub** (all slots, live "live now / up next / done", deep-links into each scenario). ✅ All **six** Continuum 2026 slots ported and scheduled with real times.
6. **Later** — remaining tray tools (break timer, parking-lot note, wake-lock); online art "Generate"; a dedicated BRP dice pack (Day One currently borrows the d100 pack); markdown → scenario-data generator; native iPad wrapper.
6. **Later** — remaining tray tools (break timer, parking-lot note, wake-lock); online art "Generate"; markdown → scenario-data generator; native iPad wrapper.
## Docs
@@ -70,7 +70,10 @@ jsdom. No new dependencies. Fully offline.
👥 rail chip. Reveal state is ephemeral component state. `tests/components/cast-tray.test.js` +
`gm-shell-cast.test.js` cover it.
- **BRP dice pack built.** `brp-d100` (`src/dice/rulepacks/brp-d100.js`) — fumble on 100, critical
≤ skill/20, special ≤ skill/5, success ≤ skill (bands transcribed from the Day One console). Day
One's `meta.system` switched from `coc-d100` to `brp-d100`. `tests/dice/brp-d100.test.js` covers it.
## Still deferred (backlog)
- A dedicated **BRP** dice pack — Day One currently borrows `coc-d100` (d100 roll-under).
- Break-timer / parking-lot / wake-lock tray tools; online art "Generate".
+23
View File
@@ -0,0 +1,23 @@
// Basic Roleplaying (BRP) roll-under d100 ladder vs a skill %.
// Bands match the Continuum "Day One" console: fumble on 100, critical ≤ skill/20,
// special ≤ skill/5, success ≤ skill (the fractions rounded up).
export const brpD100 = {
id: 'brp-d100',
label: 'd100 (Basic Roleplaying)',
params: [{ key: 'target', label: 'Skill %', default: 50 }],
interpret(results, params = {}) {
const value = results[0]?.value ?? 0;
const target = params.target ?? 50;
let level;
if (value === 100) level = 'fumble';
else if (value <= Math.ceil(target / 20)) level = 'critical';
else if (value <= Math.ceil(target / 5)) level = 'special';
else if (value <= target) level = 'success';
else level = 'failure';
const success = ['critical', 'special', 'success'].includes(level);
return { value, target, level, success };
},
summary(v) {
return `${v.value} vs ${v.target}% → ${v.level.toUpperCase()}`;
},
};
+2 -1
View File
@@ -1,10 +1,11 @@
import { yearZero } from './year-zero.js';
import { cocD100 } from './coc-d100.js';
import { brpD100 } from './brp-d100.js';
import { vanityD6 } from './vanity-d6.js';
import { panicGlory } from './panic-glory.js';
import { deeSanction } from './dee-sanction.js';
const packs = new Map([[yearZero.id, yearZero], [cocD100.id, cocD100], [vanityD6.id, vanityD6], [panicGlory.id, panicGlory], [deeSanction.id, deeSanction]]);
const packs = new Map([[yearZero.id, yearZero], [cocD100.id, cocD100], [brpD100.id, brpD100], [vanityD6.id, vanityD6], [panicGlory.id, panicGlory], [deeSanction.id, deeSanction]]);
export function getRulePack(id) { return packs.get(id) || null; }
export function registerRulePack(pack) { packs.set(pack.id, pack); }
+2 -2
View File
@@ -1,8 +1,8 @@
// DAY ONE — "London Falls" · Continuum 2026 · Fri Slot 1.
// Timeline transcribed from gm-utility/day-one-console.html (SCHED array).
// System is BRP; mapped to the d100 roller pack (no dedicated BRP pack yet).
// System is BRP (Basic Roleplaying) — uses the dedicated brp-d100 rule-pack.
export default {
meta: { id: 'day-one', title: 'DAY ONE — London Falls', system: 'coc-d100', players: 6, playMinutes: 210, slot: 'Fri · Slot 1' },
meta: { id: 'day-one', title: 'DAY ONE — London Falls', system: 'brp-d100', players: 6, playMinutes: 210, slot: 'Fri · Slot 1' },
timeline: [
{ id: 'intro', label: 'Intro done — six placed, alert fired, players converging', targetMin: 15 },
{ id: 'market', label: 'MARKET BREAKS — vendor bitten in full view, stampede (force it now if not yet)', targetMin: 60, hardTrigger: true },
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { brpD100 } from '../../src/dice/rulepacks/brp-d100.js';
import { getRulePack } from '../../src/dice/rulepacks/index.js';
const roll = (v) => [{ sides: 100, value: v }];
describe('brp-d100 rule-pack', () => {
it('grades the ladder against a 50% skill (crit ≤3, special ≤10, success ≤50)', () => {
expect(brpD100.interpret(roll(3), { target: 50 }).level).toBe('critical'); // ceil(50/20)=3
expect(brpD100.interpret(roll(4), { target: 50 }).level).toBe('special');
expect(brpD100.interpret(roll(10), { target: 50 }).level).toBe('special'); // ceil(50/5)=10
expect(brpD100.interpret(roll(11), { target: 50 }).level).toBe('success');
expect(brpD100.interpret(roll(50), { target: 50 }).level).toBe('success');
expect(brpD100.interpret(roll(51), { target: 50 }).level).toBe('failure');
});
it('fumbles only on 100 (a plain miss otherwise, even at 96–99)', () => {
expect(brpD100.interpret(roll(100), { target: 50 }).level).toBe('fumble');
expect(brpD100.interpret(roll(99), { target: 50 }).level).toBe('failure');
expect(brpD100.interpret(roll(100), { target: 100 }).level).toBe('fumble'); // 100 fumbles even at skill 100
});
it('rounds the crit/special fractions up for low skills', () => {
// skill 25: crit ≤ ceil(25/20)=2, special ≤ ceil(25/5)=5
expect(brpD100.interpret(roll(2), { target: 25 }).level).toBe('critical');
expect(brpD100.interpret(roll(5), { target: 25 }).level).toBe('special');
expect(brpD100.interpret(roll(6), { target: 25 }).level).toBe('success');
});
it('marks crit/special/success as success, fumble/failure as not', () => {
expect(brpD100.interpret(roll(3), { target: 50 }).success).toBe(true);
expect(brpD100.interpret(roll(50), { target: 50 }).success).toBe(true);
expect(brpD100.interpret(roll(51), { target: 50 }).success).toBe(false);
expect(brpD100.interpret(roll(100), { target: 50 }).success).toBe(false);
});
it('defaults skill to 50 and summarises', () => {
const v = brpD100.interpret(roll(4));
expect(v.target).toBe(50);
expect(brpD100.summary(v)).toBe('4 vs 50% → SPECIAL');
});
it('is registered', () => {
expect(getRulePack('brp-d100')).toBe(brpD100);
});
});