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:
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user