diff --git a/README.md b/README.md index 9355b10..3bed62c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/superpowers/plans/2026-07-20-director-slice5.md b/docs/superpowers/plans/2026-07-20-director-slice5.md index 2ad7ba3..6e902cb 100644 --- a/docs/superpowers/plans/2026-07-20-director-slice5.md +++ b/docs/superpowers/plans/2026-07-20-director-slice5.md @@ -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". diff --git a/src/dice/rulepacks/brp-d100.js b/src/dice/rulepacks/brp-d100.js new file mode 100644 index 0000000..a0da7ab --- /dev/null +++ b/src/dice/rulepacks/brp-d100.js @@ -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()}`; + }, +}; diff --git a/src/dice/rulepacks/index.js b/src/dice/rulepacks/index.js index 8ae6def..3a8e209 100644 --- a/src/dice/rulepacks/index.js +++ b/src/dice/rulepacks/index.js @@ -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); } diff --git a/src/scenarios/day-one.js b/src/scenarios/day-one.js index b41b717..265130d 100644 --- a/src/scenarios/day-one.js +++ b/src/scenarios/day-one.js @@ -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 }, diff --git a/tests/dice/brp-d100.test.js b/tests/dice/brp-d100.test.js new file mode 100644 index 0000000..414838c --- /dev/null +++ b/tests/dice/brp-d100.test.js @@ -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); + }); +});