feat: dice-tray rule-pack selector + per-pack param inputs

This commit is contained in:
2026-07-20 16:33:40 +01:00
parent 01a279798b
commit 47cb9c3b3f
3 changed files with 81 additions and 3 deletions
+29 -3
View File
@@ -1,5 +1,5 @@
import { rollPool } from '../dice/roller.js';
import { getRulePack } from '../dice/rulepacks/index.js';
import { getRulePack, listRulePacks } from '../dice/rulepacks/index.js';
const DICE = [4, 6, 8, 10, 12, 20, 100];
@@ -17,10 +17,23 @@ export class DiceTray extends HTMLElement {
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; }
set systemId(v) { this._systemId = v; this.render(); }
get systemId() { return this._systemId; }
connectedCallback() { this.render(); }
connectedCallback() { this.render(); this.syncParams(); }
syncParams() {
const pack = getRulePack(this._systemId);
const next = {};
if (pack && pack.params) {
for (const p of pack.params) {
const input = this.querySelector(`[data-param="${p.key}"]`);
const raw = input ? input.value : '';
next[p.key] = raw === '' ? p.default : Number(raw);
}
}
this.params = next;
}
roll() {
const { results, total } = rollPool([{ sides: this._sides, count: this._count }], this.rng);
@@ -42,9 +55,18 @@ export class DiceTray extends HTMLElement {
? (pack && pack.summary ? pack.summary(this.last.verdict) : '')
: '';
const packs = listRulePacks();
const packBar = [{ id: '', label: 'Any die' }, ...packs].map((p) =>
`<button class="pack ${p.id === this._systemId ? 'on' : ''}" data-pack-id="${p.id}">${p.label}</button>`).join('');
const active = getRulePack(this._systemId);
const paramInputs = (active && active.params ? active.params : []).map((p) =>
`<label class="param">${p.label}<input type="number" data-param="${p.key}" value="${this.params[p.key] ?? p.default}" /></label>`).join('');
this.innerHTML = `
<div class="dice-tray">
<div class="pack-bar">${packBar}</div>
<div class="picker">${picker}</div>
<div class="params">${paramInputs}</div>
<div class="felt">${felt}</div>
<div class="verdict" data-role="verdict">${verdict}</div>
<button class="roll" data-role="roll">Roll</button>
@@ -52,6 +74,10 @@ export class DiceTray extends HTMLElement {
this.querySelectorAll('[data-sides]').forEach((b) =>
b.addEventListener('click', () => { this.sides = Number(b.dataset.sides); }));
this.querySelectorAll('[data-pack-id]').forEach((b) =>
b.addEventListener('click', () => { this._systemId = b.dataset.packId; this.syncParams(); this.render(); }));
this.querySelectorAll('[data-param]').forEach((inp) =>
inp.addEventListener('input', () => this.syncParams()));
this.querySelector('[data-role=roll]').addEventListener('click', () => this.roll());
}
}
+6
View File
@@ -55,3 +55,9 @@ gm-shell { display: block; }
.prop-overlay { position: fixed; inset: 0; background: #000000ee; display: flex; align-items: center; justify-content: center; z-index: 100; }
.prop-img { max-width: 96vw; max-height: 96vh; object-fit: contain; }
prop-viewer[hidden] { display: none; }
.pack-bar { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
.pack { font-size: 11px; padding: 5px 10px; border-radius: 20px; border: 1px solid #2b3644; background: #161d27; color: #9fb0c2; }
.pack.on { background: #1e3a52; border-color: #3f7fb0; color: #dcefff; }
.params { display: flex; gap: 10px; flex-wrap: wrap; margin: 8px 0; }
.param { font-size: 11px; color: #9fb0c2; display: flex; flex-direction: column; gap: 3px; }
.param input { width: 80px; background: #161d27; border: 1px solid #2a3542; border-radius: 6px; padding: 6px 8px; color: #e7eef6; }
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach } from 'vitest';
import '../../src/components/dice-tray.js';
const seq = (vals) => { let i = 0; return () => vals[i++ % vals.length]; };
describe('<dice-tray> pack selector + params', () => {
let el;
beforeEach(() => {
document.body.innerHTML = '';
el = document.createElement('dice-tray');
document.body.appendChild(el);
});
it('renders a button for each registered pack plus Any die', () => {
expect(el.querySelector('[data-pack-id="coc-coc-d100"]') || el.querySelector('[data-pack-id="coc-d100"]')).not.toBe(null);
expect(el.querySelector('[data-pack-id="vanity-d6"]')).not.toBe(null);
expect(el.querySelector('[data-pack-id=""]')).not.toBe(null); // Any die
});
it('selecting a pack updates systemId and shows its param inputs', () => {
el.querySelector('[data-pack-id="coc-d100"]').click();
expect(el.systemId).toBe('coc-d100');
expect(el.querySelector('[data-param="target"]')).not.toBe(null);
});
it('feeds param inputs into interpret and renders the pack summary', () => {
el.querySelector('[data-pack-id="coc-d100"]').click();
el.sides = 100; el.count = 1;
el.querySelector('[data-param="target"]').value = '40';
el.querySelector('[data-param="target"]').dispatchEvent(new Event('input'));
el.rng = seq([0.95]); // d100 -> floor(0.95*100)+1 = 96
el.roll();
expect(el.last.verdict.target).toBe(40);
expect(el.last.verdict.level).toBe('fumble'); // 96 with target 40
expect(el.querySelector('[data-role=verdict]').textContent).toContain('FUMBLE');
});
it('Any die selection clears the rule-pack (blank verdict)', () => {
el.querySelector('[data-pack-id=""]').click();
expect(el.systemId).toBe('');
el.sides = 20; el.count = 1; el.rng = seq([0.5]);
el.roll();
expect(el.last.verdict).toBe(null);
expect(el.querySelector('[data-role=verdict]').textContent).toBe('');
});
});