From cb86a780b9e3d71d49c4a9a68a1307de59c1341a Mon Sep 17 00:00:00 2001 From: Tim Evans Date: Mon, 20 Jul 2026 15:17:30 +0100 Subject: [PATCH] feat: seed-to-character generator UI --- src/components/npc-tray.js | 60 +++++++++++++++++++++++++++++++ tests/components/npc-tray.test.js | 40 +++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 src/components/npc-tray.js create mode 100644 tests/components/npc-tray.test.js diff --git a/src/components/npc-tray.js b/src/components/npc-tray.js new file mode 100644 index 0000000..403716c --- /dev/null +++ b/src/components/npc-tray.js @@ -0,0 +1,60 @@ +import { generateNpc } from '../npc/generator.js'; +import { getGenrePack, listGenrePacks } from '../npc/packs/index.js'; + +export class NpcTray extends HTMLElement { + constructor() { + super(); + this.rng = Math.random; + this.genreId = 'noir'; + this.last = null; + this._seedValue = ''; + } + connectedCallback() { this.render(); } + + generate() { + const input = this.querySelector('[data-role=seed]'); + this._seedValue = input ? input.value : ''; + const pack = getGenrePack(this.genreId); + this.last = generateNpc(pack, this._seedValue, this.rng); + this.render(); + } + + render() { + const pills = listGenrePacks().map((p) => + ``).join(''); + const n = this.last; + const card = n ? ` +
+
${n.name}
+
${n.seed}
+
+
Look
${n.look}
+
Manner
${n.manner}
+
Wants
${n.wants}
+
Secret
${n.secret}
+
Voice
${n.voice}
+
+
+ + +
+
` : ''; + + this.innerHTML = ` +
+
${pills}
+ + + ${card} +
`; + + this.querySelectorAll('[data-genre]').forEach((b) => + b.addEventListener('click', () => { this.genreId = b.dataset.genre; this.render(); })); + this.querySelector('[data-role=generate]').addEventListener('click', () => this.generate()); + this.querySelector('[data-role=reroll]')?.addEventListener('click', () => this.generate()); + this.querySelector('[data-role=keep]')?.addEventListener('click', () => { + if (this.last) this.dispatchEvent(new CustomEvent('keep-npc', { detail: this.last, bubbles: true })); + }); + } +} +customElements.define('npc-tray', NpcTray); diff --git a/tests/components/npc-tray.test.js b/tests/components/npc-tray.test.js new file mode 100644 index 0000000..bcd8701 --- /dev/null +++ b/tests/components/npc-tray.test.js @@ -0,0 +1,40 @@ +// tests/components/npc-tray.test.js +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../src/components/npc-tray.js'; + +function seq(values) { let i = 0; return () => values[i++ % values.length]; } + +describe('', () => { + let el; + beforeEach(() => { + document.body.innerHTML = ''; + el = document.createElement('npc-tray'); + document.body.appendChild(el); + }); + + it('renders a seed input and a generate button', () => { + expect(el.querySelector('[data-role=seed]')).not.toBe(null); + expect(el.querySelector('[data-role=generate]')).not.toBe(null); + }); + + it('generates an NPC card from the seed using the noir pack', () => { + el.rng = seq([0.0]); // every pick -> index 0 + el.querySelector('[data-role=seed]').value = 'dock foreman'; + el.querySelector('[data-role=generate]').click(); + expect(el.last.seed).toBe('dock foreman'); + expect(el.last.genre).toMatch(/noir/i); + expect(el.querySelector('[data-role=card]')).not.toBe(null); + expect(el.querySelector('[data-role=npc-name]').textContent.length).toBeGreaterThan(0); + }); + + it('emits keep-npc with the generated npc when Keep is clicked', () => { + el.rng = seq([0.0]); + el.querySelector('[data-role=seed]').value = 'x'; + el.querySelector('[data-role=generate]').click(); + let detail = null; + el.addEventListener('keep-npc', (e) => { detail = e.detail; }); + el.querySelector('[data-role=keep]').click(); + expect(detail).not.toBe(null); + expect(detail.name).toBe(el.last.name); + }); +});