feat: <dice-tray> roller wired to rule-packs

Implement Task 10: dice-tray Web Component with:
- Polyhedral die picker (d4-d20, d100)
- Deterministic roll() using injected RNG
- Rule-pack interpretation for successes/ones/verdict
- Event emission on roll completion
- Reactive rendering on die type/count change

TDD: 3 passing tests (picker render, roll+interpret+verdict, die-type switching)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 14:05:52 +01:00
co-authored by Claude Opus 4.8
parent 579dea996d
commit 549cb190d2
2 changed files with 93 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
import { rollPool } from '../dice/roller.js';
import { getRulePack } from '../dice/rulepacks/index.js';
const DICE = [4, 6, 8, 10, 12, 20, 100];
export class DiceTray extends HTMLElement {
constructor() {
super();
this._sides = 8;
this._count = 3;
this._systemId = 'year-zero';
this.rng = Math.random;
this.last = null;
}
get sides() { return this._sides; }
set sides(v) { this._sides = Number(v); this.render(); }
get count() { return this._count; }
set count(v) { this._count = Number(v); }
set systemId(v) { this._systemId = v; }
get systemId() { return this._systemId; }
connectedCallback() { this.render(); }
roll() {
const { results, total } = rollPool([{ sides: this._sides, count: this._count }], this.rng);
const pack = getRulePack(this._systemId);
const verdict = pack ? pack.interpret(results) : null;
this.last = { results, total, verdict };
this.render();
this.dispatchEvent(new CustomEvent('rolled', { detail: this.last, bubbles: true }));
}
render() {
const picker = DICE.map((n) =>
`<button class="die ${n === this._sides ? 'sel' : ''}" data-sides="${n}">d${n}</button>`).join('');
const felt = this.last
? this.last.results.map((r) => `<span class="rolled" data-value="${r.value}">${r.value}</span>`).join('')
: '<span class="hint">Tap Roll</span>';
const verdict = this.last?.verdict
? `${this.last.verdict.successes} success${this.last.verdict.successes === 1 ? '' : 'es'}` +
(this.last.verdict.ones ? ` · ${this.last.verdict.ones} stress` : '')
: '';
this.innerHTML = `
<div class="dice-tray">
<div class="picker">${picker}</div>
<div class="felt">${felt}</div>
<div class="verdict" data-role="verdict">${verdict}</div>
<button class="roll" data-role="roll">Roll</button>
</div>`;
this.querySelectorAll('[data-sides]').forEach((b) =>
b.addEventListener('click', () => { this.sides = Number(b.dataset.sides); }));
this.querySelector('[data-role=roll]').addEventListener('click', () => this.roll());
}
}
customElements.define('dice-tray', DiceTray);
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect, beforeEach } from 'vitest';
import '../../src/components/dice-tray.js';
function seq(values) { let i = 0; return () => values[i++ % values.length]; }
describe('<dice-tray>', () => {
let el;
beforeEach(() => {
document.body.innerHTML = '';
el = document.createElement('dice-tray');
el.systemId = 'year-zero';
document.body.appendChild(el);
});
it('renders the polyhedral picker', () => {
expect(el.querySelector('[data-sides="20"]')).not.toBe(null);
expect(el.querySelector('[data-sides="4"]')).not.toBe(null);
});
it('rolls, interprets via the rule-pack, and reports successes', () => {
el.sides = 8; el.count = 3;
el.rng = seq([0.9, 0.0, 0.7]); // d8 -> 8, 1, 6 => 2 successes, 1 one
let detail = null;
el.addEventListener('rolled', (e) => { detail = e.detail; });
el.roll();
expect(el.last.verdict.successes).toBe(2);
expect(el.last.verdict.ones).toBe(1);
expect(detail.verdict.successes).toBe(2);
expect(el.querySelector('[data-role=verdict]').textContent).toContain('2 success');
});
it('changes die type when a picker button is clicked', () => {
el.querySelector('[data-sides="20"]').click();
expect(el.sides).toBe(20);
});
});