Design Bible Balance Tool — Implementation Plan
Design Bible Balance Tool — Implementation Plan
Section titled “Design Bible Balance Tool — Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A dependency-free static web tool that browses and edits the game’s full content bible (elements, reactions, weapons, mods, enemies, …) with schema-driven editors, live computed balance metrics, the 14×14 elemental reaction matrix, localStorage persistence, and game-ready JSON export.
Architecture: A pure-logic ES-module core (schema → seed → model → persistence → metrics) with NO DOM dependency, unit-tested headlessly with Node’s built-in node --test. A thin DOM layer (app + views/*) renders a master-detail UI from the schema and is verified by serving the page and a browser smoke check. Data flows one way: model holds state → views render from it → edits call back into the model → persistence auto-saves.
Tech Stack: Plain HTML/CSS/JavaScript (ES modules), no framework, no build step, no npm dependencies. Node 22 (node --test) for tests. Python http.server (or any static server) to serve locally; deployable as-is to CF Pages.
Global Constraints
Section titled “Global Constraints”- No dependencies, no build step. Plain ES modules (
.js),<script type="module">.package.jsonexists ONLY for{"type":"module"}+ npm-lesstest/servescripts — nevernpm installanything. - Logic/UI split: files under
src/EXCEPTapp.jsandsrc/views/*must NOT touch the DOM (document,window,localStorage) — pure functions/classes only, sonode --testcan run them. Persistence takes an injected storage object; it never referenceslocalStoragedirectly. - All logic-core code is TDD’d with
node --test. Run:node --testfromtools/design-bible/. - Served, not
file://(ES modules need a server). Local:python3 -m http.server 8080intools/design-bible/, openhttp://localhost:8080/. - Export schema is versioned: the exported bible JSON carries
schemaVersion: 1(the future Godot loader validates it). - Element roster is fixed at 14 (Fire, Cold, Lightning, Poison, Void, Aether, Kinetic, Light, Decay, Time, Sound, Blood, Psychic, Nano). Reaction matrix is 14×14; author signature reactions, generic fallback for the rest.
- Commits: conventional prefixes, footer
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>. Work undertools/design-bible/.
File Structure
Section titled “File Structure”tools/design-bible/ package.json # {"type":"module"} + test/serve scripts (no deps) index.html # app shell; loads src/app.js as a module css/style.css # neon-dark theme src/ schema.js # CATEGORIES: per-category field schemas + field types seed.js # SEED: embedded default bible (14 elements, tags, M1 content, signature reactions) model.js # BibleModel: state + get/add/duplicate/delete/setField/validate persistence.js # serialize, mergeOverrides, exportJSON, importJSON, save/load(storage) metrics.js # pure computed metrics: weaponDPS, enemyEffectiveHP, ttk, xpPacing, reactionPreview, sanityFlags app.js # DOM bootstrap; wires model+views+persistence views/ nav.js # left category nav list.js # middle entry list (search/add/duplicate/delete) detail.js # right schema-driven field editor + metrics panel matrix.js # 14×14 reaction matrix grid view table.js # per-category bulk table view tests/ schema.test.js seed.test.js model.test.js persistence.test.js metrics.test.jsTask 1: Scaffold + Node test harness (green)
Section titled “Task 1: Scaffold + Node test harness (green)”Files:
- Create:
tools/design-bible/package.json - Create:
tools/design-bible/index.html - Create:
tools/design-bible/css/style.css - Create:
tools/design-bible/tests/harness.test.js
Interfaces:
-
Produces: a working
node --testrunner and the directory skeleton. -
Step 1: Create package.json
Create tools/design-bible/package.json:
{ "name": "design-bible", "private": true, "type": "module", "scripts": { "test": "node --test", "serve": "python3 -m http.server 8080" }}- Step 2: Create the app shell
Create tools/design-bible/index.html:
<!doctype html><html lang="en"><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Bullet Heaven — Design Bible</title> <link rel="stylesheet" href="css/style.css"></head><body> <header id="topbar"><span class="brand">◇ Design Bible</span><span id="actions"></span></header> <main id="layout"> <nav id="nav"></nav> <section id="list"></section> <section id="detail"></section> </main> <script type="module" src="src/app.js"></script></body></html>- Step 3: Minimal stylesheet
Create tools/design-bible/css/style.css:
:root { --bg:#0a0a12; --panel:#12121f; --line:#2a2a44; --text:#e6e6f0; --accent:#6cc8ff; --muted:#8a8ab0; }* { box-sizing: border-box; }body { margin:0; background:var(--bg); color:var(--text); font:14px/1.5 system-ui,sans-serif; }#topbar { display:flex; justify-content:space-between; align-items:center; padding:8px 14px; border-bottom:1px solid var(--line); }.brand { color:var(--accent); font-weight:600; }#layout { display:grid; grid-template-columns:200px 280px 1fr; height:calc(100vh - 41px); }#nav,#list,#detail { overflow:auto; padding:10px; }#nav,#list { border-right:1px solid var(--line); }.navitem,.listitem { padding:6px 8px; border-radius:6px; cursor:pointer; }.navitem:hover,.listitem:hover { background:var(--panel); }.navitem.active,.listitem.active { background:var(--panel); color:var(--accent); }button { background:var(--panel); color:var(--text); border:1px solid var(--line); border-radius:6px; padding:5px 10px; cursor:pointer; }button:hover { border-color:var(--accent); }input,select { background:#0d0d18; color:var(--text); border:1px solid var(--line); border-radius:5px; padding:4px 7px; }.field { display:flex; gap:8px; align-items:center; margin:5px 0; }.field label { width:130px; color:var(--muted); }.metrics { margin-top:14px; padding:10px; background:var(--panel); border-radius:8px; }.metrics .m { display:flex; justify-content:space-between; }.metrics .m b { color:var(--accent); }.flag { color:#ffcf5a; }table.matrix,table.bulk { border-collapse:collapse; font-size:12px; }table.matrix td,table.matrix th,table.bulk td,table.bulk th { border:1px solid var(--line); padding:3px 6px; text-align:center; }.cell { cursor:pointer; } .cell:hover { background:var(--panel); }- Step 4: Write the harness test
Create tools/design-bible/tests/harness.test.js:
import { test } from 'node:test';import assert from 'node:assert/strict';
test('node --test harness runs', () => { assert.equal(1 + 1, 2);});- Step 5: Run tests
Run (from tools/design-bible/): node --test
Expected: tests 1, pass 1, exit 0.
- Step 6: Commit
git add tools/design-biblegit commit -m "chore(bible): scaffold static tool + node --test harness"Task 2: Schema (categories + field types)
Section titled “Task 2: Schema (categories + field types)”Files:
- Create:
tools/design-bible/src/schema.js - Test:
tools/design-bible/tests/schema.test.js
Interfaces:
-
Produces
export const FIELD_TYPES(set of allowed types:'int','number','text','enum','bool','tags','ref','list') andexport const CATEGORIES: an ordered array of{ key, label, fields }where each field is{ name, type, options? , ref?, default }. -
Helper
export function categoryByKey(key)→ the category object orundefined. -
Categories (keys, in nav order):
elements, reactions, weapons, mods, evolutions, enemies, elite_affixes, bosses, characters, items, pickups, meta_upgrades, unlocks, ascension_tiers, run_structure, tags. -
Step 1: Write the failing test
Create tools/design-bible/tests/schema.test.js:
import { test } from 'node:test';import assert from 'node:assert/strict';import { CATEGORIES, FIELD_TYPES, categoryByKey } from '../src/schema.js';
test('has the 16 expected categories in order', () => { const keys = CATEGORIES.map(c => c.key); assert.deepEqual(keys, [ 'elements','reactions','weapons','mods','evolutions','enemies','elite_affixes', 'bosses','characters','items','pickups','meta_upgrades','unlocks','ascension_tiers', 'run_structure','tags', ]);});
test('every field uses a known type', () => { for (const cat of CATEGORIES) { for (const f of cat.fields) { assert.ok(FIELD_TYPES.has(f.type), `${cat.key}.${f.name} type ${f.type}`); } }});
test('elements category exposes the core tuning fields', () => { const el = categoryByKey('elements'); const names = el.fields.map(f => f.name); for (const n of ['name','color','aura_decay_s','status','status_base','stacks_max','per_stack_scale','tags']) assert.ok(names.includes(n), `missing ${n}`);});
test('weapons category exposes damage/cooldown/count for DPS', () => { const names = categoryByKey('weapons').fields.map(f => f.name); for (const n of ['base_damage','cooldown_s','projectile_count']) assert.ok(names.includes(n), `missing ${n}`);});- Step 2: Run test to verify it fails
Run: node --test tests/schema.test.js
Expected: FAIL — cannot find ../src/schema.js.
- Step 3: Implement schema
Create tools/design-bible/src/schema.js:
export const FIELD_TYPES = new Set(['int', 'number', 'text', 'enum', 'bool', 'tags', 'ref', 'list']);
// Shorthand field builders.const num = (name, d = 0) => ({ name, type: 'number', default: d });const int = (name, d = 0) => ({ name, type: 'int', default: d });const txt = (name, d = '') => ({ name, type: 'text', default: d });const tags = (name) => ({ name, type: 'tags', default: [] });const en = (name, options, d) => ({ name, type: 'enum', options, default: d ?? options[0] });const ref = (name, cat) => ({ name, type: 'ref', ref: cat, default: '' });const bool = (name, d = false) => ({ name, type: 'bool', default: d });
export const CATEGORIES = [ { key: 'elements', label: 'Elements', fields: [ txt('name'), txt('color', '#ffffff'), num('aura_decay_s', 4), en('status', ['burn','chill','shock','toxin','gravity','echo','impact','mark','wither','timewarp','resonate','hemorrhage','charm','contagion']), num('status_base', 2), int('stacks_max', 6), num('per_stack_scale', 1.15), tags('tags'), ]}, { key: 'reactions', label: 'Reactions', fields: [ ref('aura', 'elements'), ref('applied', 'elements'), txt('name'), en('effect', ['burst','spread','shatter','cc','debuff','pull','special','reinforce']), num('base_magnitude', 20), num('per_stack_scale', 1.15), bool('consumes_aura', true), txt('notes'), ]}, { key: 'weapons', label: 'Weapons', fields: [ txt('name'), en('archetype', ['projectile','orbital','beam','area','melee','summon','trap','channeled']), ref('element', 'elements'), num('base_damage', 1), num('cooldown_s', 0.6), num('projectile_speed', 520), num('projectile_radius', 6), num('lifetime_s', 1.4), int('projectile_count', 1), num('area', 0), int('pierce', 0), int('level_max', 8), ref('evolution', 'evolutions'), tags('tags'), ]}, { key: 'mods', label: 'Mods', fields: [ txt('name'), en('kind', ['stat','transformative']), txt('effect'), num('magnitude', 1), tags('applies_to_tags'), tags('tags'), ]}, { key: 'evolutions', label: 'Evolutions', fields: [ txt('name'), ref('base_weapon', 'weapons'), ref('required_mod', 'mods'), txt('notes'), ]}, { key: 'enemies', label: 'Enemies', fields: [ txt('name'), en('archetype', ['swarmer','tank','ranged','splitter','charger','exploder','shielder','healer','summoner','orbiter']), num('hp', 3), num('speed', 70), num('radius', 14), num('contact_damage', 12), num('xp_value', 1), num('armor', 0), tags('tags'), ]}, { key: 'elite_affixes', label: 'Elite Affixes', fields: [ txt('name'), num('hp_mult', 2), num('speed_mult', 1), num('damage_mult', 1.5), txt('on_death'), ]}, { key: 'bosses', label: 'Bosses', fields: [ txt('name'), num('hp', 500), ref('element', 'elements'), int('phases', 3), txt('patterns'), tags('tags'), ]}, { key: 'characters', label: 'Characters', fields: [ txt('name'), ref('starting_weapon', 'weapons'), txt('stat_mods'), txt('passive'), ref('unlock', 'unlocks'), ]}, { key: 'items', label: 'Items', fields: [ txt('name'), en('rarity', ['common','rare','epic','legendary']), txt('effect'), tags('tags'), ref('unlock', 'unlocks'), ]}, { key: 'pickups', label: 'Pickups', fields: [ txt('name'), en('type', ['xp','gold','health','magnet','powerup','chest','shrine']), num('value', 1), txt('effect'), ]}, { key: 'meta_upgrades', label: 'Meta Upgrades', fields: [ txt('name'), int('cost', 100), txt('effect'), int('max_level', 5), ref('prereq', 'meta_upgrades'), ]}, { key: 'unlocks', label: 'Unlocks', fields: [ txt('name'), txt('unlocks'), txt('condition') ]}, { key: 'ascension_tiers', label: 'Ascension', fields: [ txt('name'), int('level', 1), num('enemy_hp_mult', 1.1), num('enemy_speed_mult', 1.05), num('spawn_rate_mult', 1.1), txt('curse'), ]}, { key: 'run_structure', label: 'Run Structure', fields: [ txt('name'), num('at_time_s', 0), txt('event'), num('spawn_base', 2), num('spawn_growth', 0.5), ]}, { key: 'tags', label: 'Tags', fields: [ txt('name'), en('group', ['delivery','element','trait']) ]},];
export function categoryByKey(key) { return CATEGORIES.find(c => c.key === key);}- Step 4: Run test to verify it passes
Run: node --test tests/schema.test.js
Expected: PASS — 4 tests.
- Step 5: Commit
git add src/schema.js tests/schema.test.jsgit commit -m "feat(bible): category + field-type schema"Task 3: Seed data (the embedded default bible)
Section titled “Task 3: Seed data (the embedded default bible)”Files:
- Create:
tools/design-bible/src/seed.js - Test:
tools/design-bible/tests/seed.test.js
Interfaces:
-
Produces
export const SEED—{ schemaVersion: 1, data: { <categoryKey>: Entry[] } }. Every entry hasid(kebab slug) +name+ the category’s fields.datahas a key for every category inCATEGORIES. -
Seeds: 14 elements (the fixed roster), the tag vocabulary, the 5 designed weapons + ~8 mods + 5 enemies from Milestone 1’s design, and ~12 signature reactions. Other categories may be empty arrays (authored later in the tool).
-
Step 1: Write the failing test
Create tools/design-bible/tests/seed.test.js:
import { test } from 'node:test';import assert from 'node:assert/strict';import { SEED } from '../src/seed.js';import { CATEGORIES } from '../src/schema.js';
test('schemaVersion is 1 and data has every category key', () => { assert.equal(SEED.schemaVersion, 1); for (const cat of CATEGORIES) assert.ok(Array.isArray(SEED.data[cat.key]), `missing ${cat.key}`);});
test('exactly 14 elements with the fixed roster', () => { const ids = SEED.data.elements.map(e => e.id).sort(); assert.equal(ids.length, 14); for (const id of ['fire','cold','lightning','poison','void','aether','kinetic','light','decay','time','sound','blood','psychic','nano']) assert.ok(ids.includes(id), `missing element ${id}`);});
test('every entry has id and name', () => { for (const key of Object.keys(SEED.data)) for (const e of SEED.data[key]) assert.ok(e.id && e.name, `${key} entry missing id/name: ${JSON.stringify(e)}`);});
test('seeds the M1 weapons and enemies', () => { const w = SEED.data.weapons.map(e => e.id); for (const id of ['pulse','orbit','beam','nova','turret']) assert.ok(w.includes(id), `missing weapon ${id}`); const en = SEED.data.enemies.map(e => e.id); for (const id of ['swarmer','tank','shooter','splitter','elite']) assert.ok(en.includes(id), `missing enemy ${id}`);});
test('reactions reference real elements', () => { const elementIds = new Set(SEED.data.elements.map(e => e.id)); for (const r of SEED.data.reactions) { assert.ok(elementIds.has(r.aura), `bad aura ${r.aura}`); assert.ok(elementIds.has(r.applied), `bad applied ${r.applied}`); } assert.ok(SEED.data.reactions.length >= 10, 'at least 10 signature reactions');});- Step 2: Run test to verify it fails
Run: node --test tests/seed.test.js
Expected: FAIL — cannot find ../src/seed.js.
- Step 3: Implement seed
Create tools/design-bible/src/seed.js:
// Helper to keep element rows terse: id,name,color,decay,status,base,stacksMax,scaleconst el = (id, name, color, status, status_base = 2) => ({ id, name, color, aura_decay_s: 4, status, status_base, stacks_max: 6, per_stack_scale: 1.15, tags: ['element', id] });
const elements = [ el('fire', 'Fire', '#ff6a4d', 'burn'), el('cold', 'Cold', '#6cc8ff', 'chill'), el('lightning', 'Lightning', '#ffe34d', 'shock'), el('poison', 'Poison', '#8cff6a', 'toxin'), el('void', 'Void', '#b06aff', 'gravity'), el('aether', 'Aether', '#e0e0ff', 'echo'), el('kinetic', 'Kinetic', '#c9d1d9', 'impact'), el('light', 'Light', '#fff3a0', 'mark'), el('decay', 'Decay', '#8a7a5c', 'wither'), el('time', 'Time', '#a0e0ff', 'timewarp'), el('sound', 'Sound', '#ff9ee0', 'resonate'), el('blood', 'Blood', '#ff5a5a', 'hemorrhage'), el('psychic', 'Psychic', '#c08aff', 'charm'), el('nano', 'Nano', '#6affd0', 'contagion'),];
const rx = (aura, applied, name, effect, base_magnitude = 30) => ({ id: `${aura}-${applied}`, name, aura, applied, effect, base_magnitude, per_stack_scale: 1.15, consumes_aura: true, notes: '' });
const reactions = [ rx('cold', 'lightning', 'Superconduct', 'shatter', 40), rx('lightning', 'cold', 'Superconduct', 'shatter', 40), rx('fire', 'cold', 'Thermal Shock', 'burst', 35), rx('cold', 'fire', 'Thermal Shock', 'burst', 35), rx('fire', 'lightning', 'Plasma', 'burst', 45), rx('fire', 'poison', 'Combustion', 'spread', 30), rx('poison', 'lightning', 'Electrolysis', 'spread', 30), rx('fire', 'void', 'Collapse', 'pull', 50), rx('cold', 'void', 'Absolute Zero', 'cc', 40), rx('poison', 'void', 'Blight Implosion', 'pull', 35), rx('cold', 'sound', 'Shatter', 'shatter', 55), rx('void', 'aether', 'Paradox', 'special', 60),];
const weapon = (id, name, archetype, element, base_damage, cooldown_s, extra = {}) => ({ id, name, archetype, element, base_damage, cooldown_s, projectile_speed: 520, projectile_radius: 6, lifetime_s: 1.4, projectile_count: 1, area: 0, pierce: 0, level_max: 8, evolution: '', tags: [archetype], ...extra });
const weapons = [ weapon('pulse', 'Pulse', 'projectile', 'lightning', 1.0, 0.6, { tags: ['projectile','homing'] }), weapon('orbit', 'Orbit Shards', 'orbital', 'cold', 0.8, 0.0, { projectile_count: 3, tags: ['orbit'] }), weapon('beam', 'Beam', 'beam', 'light', 0.4, 0.1, { pierce: 99, tags: ['beam','pierce'] }), weapon('nova', 'Nova', 'area', 'fire', 3.0, 2.0, { area: 180, tags: ['area'] }), weapon('turret', 'Turret', 'summon', 'kinetic', 0.7, 0.4, { tags: ['summon','trap'] }),];
const mod = (id, name, kind, effect, magnitude, applies = []) => ({ id, name, kind, effect, magnitude, applies_to_tags: applies, tags: [] });
const mods = [ mod('damage', 'Sharpened', 'stat', 'damage_mult', 1.25), mod('fire-rate', 'Overclock', 'stat', 'fire_rate_mult', 1.20), mod('move-speed', 'Thrusters', 'stat', 'move_speed', 1.12), mod('pickup', 'Magnet Field', 'stat', 'pickup_radius', 1.30), mod('max-hp', 'Plating', 'stat', 'max_hp', 25), mod('crit', 'Focus', 'stat', 'crit_chance', 0.15), mod('pierce', 'Penetrator', 'transformative', 'projectiles_pierce', 1, ['projectile']), mod('split', 'Fork', 'transformative', 'split_on_hit', 2, ['projectile']),];
const enemy = (id, name, archetype, hp, speed, contact_damage, xp_value, extra = {}) => ({ id, name, archetype, hp, speed, radius: 14, contact_damage, xp_value, armor: 0, tags: [archetype], ...extra });
const enemies = [ enemy('swarmer', 'Swarmer', 'swarmer', 3, 70, 12, 1), enemy('tank', 'Tank', 'tank', 30, 40, 18, 4, { radius: 22, armor: 4 }), enemy('shooter', 'Shooter', 'ranged', 8, 55, 10, 3), enemy('splitter', 'Splitter', 'splitter', 10, 60, 10, 3), enemy('elite', 'Elite', 'charger', 60, 90, 25, 12, { radius: 26, armor: 6 }),];
const tagVocab = [ ['projectile','delivery'],['orbit','delivery'],['beam','delivery'],['area','delivery'], ['melee','delivery'],['summon','delivery'],['trap','delivery'],['channeled','delivery'], ['fire','element'],['cold','element'],['lightning','element'],['poison','element'], ['void','element'],['aether','element'],['kinetic','element'],['light','element'], ['decay','element'],['time','element'],['sound','element'],['blood','element'], ['psychic','element'],['nano','element'],['element','trait'], ['crit','trait'],['pierce','trait'],['chain','trait'],['homing','trait'],['dot','trait'],['cc','trait'],].map(([name, group]) => ({ id: name, name, group }));
export const SEED = { schemaVersion: 1, data: { elements, reactions, weapons, mods, evolutions: [], enemies, elite_affixes: [], bosses: [], characters: [], items: [], pickups: [], meta_upgrades: [], unlocks: [], ascension_tiers: [], run_structure: [], tags: tagVocab, },};- Step 4: Run test to verify it passes
Run: node --test tests/seed.test.js
Expected: PASS — 5 tests.
- Step 5: Commit
git add src/seed.js tests/seed.test.jsgit commit -m "feat(bible): seed data — 14 elements, signature reactions, M1 content, tags"Task 4: BibleModel (state + CRUD + validate)
Section titled “Task 4: BibleModel (state + CRUD + validate)”Files:
- Create:
tools/design-bible/src/model.js - Test:
tools/design-bible/tests/model.test.js
Interfaces:
-
Produces
class BibleModel:constructor(bible)— deep-clonesbible(shape{schemaVersion, data}) intothis.bible.list(categoryKey) -> Entry[]get(categoryKey, id) -> Entry | undefinedadd(categoryKey) -> Entry— creates a new entry with default field values + a uniqueid(new-1,new-2, …) andname'New <Label>', appends, returns it.duplicate(categoryKey, id) -> Entry— deep-copies the entry, gives it a fresh unique id (<id>-copy,-copy2…), appends, returns it.remove(categoryKey, id) -> voidsetField(categoryKey, id, fieldName, value) -> voidvalidate() -> string[]— returns human-readable problems (ref fields pointing at missing ids; duplicate ids in a category). Empty array = clean.
-
Step 1: Write the failing test
Create tools/design-bible/tests/model.test.js:
import { test } from 'node:test';import assert from 'node:assert/strict';import { BibleModel } from '../src/model.js';import { SEED } from '../src/seed.js';
function fresh() { return new BibleModel(SEED); }
test('constructor deep-clones (mutating model does not touch SEED)', () => { const m = fresh(); m.setField('weapons', 'pulse', 'base_damage', 999); assert.equal(SEED.data.weapons.find(w => w.id === 'pulse').base_damage, 1.0);});
test('list and get', () => { const m = fresh(); assert.equal(m.list('elements').length, 14); assert.equal(m.get('elements', 'fire').name, 'Fire'); assert.equal(m.get('elements', 'nope'), undefined);});
test('add creates a defaulted entry with a unique id', () => { const m = fresh(); const before = m.list('mods').length; const e = m.add('mods'); assert.equal(m.list('mods').length, before + 1); assert.ok(e.id && e.name.startsWith('New')); assert.equal(e.magnitude, 1); // schema default});
test('duplicate copies fields under a new id', () => { const m = fresh(); const dup = m.duplicate('weapons', 'pulse'); assert.notEqual(dup.id, 'pulse'); assert.equal(dup.base_damage, 1.0); assert.equal(m.get('weapons', dup.id).archetype, 'projectile');});
test('remove deletes', () => { const m = fresh(); m.remove('weapons', 'pulse'); assert.equal(m.get('weapons', 'pulse'), undefined);});
test('setField mutates only that field', () => { const m = fresh(); m.setField('enemies', 'tank', 'hp', 99); assert.equal(m.get('enemies', 'tank').hp, 99);});
test('validate flags a dangling ref', () => { const m = fresh(); m.setField('weapons', 'pulse', 'element', 'does-not-exist'); const problems = m.validate(); assert.ok(problems.some(p => p.includes('does-not-exist')), problems.join('|'));});- Step 2: Run test to verify it fails
Run: node --test tests/model.test.js
Expected: FAIL — cannot find ../src/model.js.
- Step 3: Implement model
Create tools/design-bible/src/model.js:
import { CATEGORIES, categoryByKey } from './schema.js';
const clone = (x) => JSON.parse(JSON.stringify(x));
function uniqueId(entries, base) { if (!entries.some(e => e.id === base)) return base; let n = 2; while (entries.some(e => e.id === `${base}${n}`)) n++; return `${base}${n}`;}
export class BibleModel { constructor(bible) { this.bible = clone(bible); }
list(categoryKey) { return this.bible.data[categoryKey] ?? []; }
get(categoryKey, id) { return this.list(categoryKey).find(e => e.id === id); }
add(categoryKey) { const cat = categoryByKey(categoryKey); const entries = this.list(categoryKey); const entry = { id: uniqueId(entries, `new-${entries.length + 1}`) }; for (const f of cat.fields) entry[f.name] = clone(f.default); if ('name' in entry) entry.name = `New ${cat.label}`; entries.push(entry); return entry; }
duplicate(categoryKey, id) { const src = this.get(categoryKey, id); const entries = this.list(categoryKey); const copy = clone(src); copy.id = uniqueId(entries, `${id}-copy`); entries.push(copy); return copy; }
remove(categoryKey, id) { const entries = this.list(categoryKey); const i = entries.findIndex(e => e.id === id); if (i >= 0) entries.splice(i, 1); }
setField(categoryKey, id, fieldName, value) { const e = this.get(categoryKey, id); if (e) e[fieldName] = value; }
validate() { const problems = []; for (const cat of CATEGORIES) { const entries = this.list(cat.key); const seen = new Set(); for (const e of entries) { if (seen.has(e.id)) problems.push(`${cat.key}: duplicate id "${e.id}"`); seen.add(e.id); for (const f of cat.fields) { if (f.type === 'ref' && e[f.name]) { const target = this.list(f.ref); if (!target.some(t => t.id === e[f.name])) problems.push(`${cat.key}.${e.id}.${f.name} -> missing ${f.ref} "${e[f.name]}"`); } } } } return problems; }}- Step 4: Run test to verify it passes
Run: node --test tests/model.test.js
Expected: PASS — 7 tests.
- Step 5: Commit
git add src/model.js tests/model.test.jsgit commit -m "feat(bible): BibleModel state + CRUD + ref validation"Task 5: Persistence (save/load/export/import, injected storage)
Section titled “Task 5: Persistence (save/load/export/import, injected storage)”Files:
- Create:
tools/design-bible/src/persistence.js - Test:
tools/design-bible/tests/persistence.test.js
Interfaces:
-
Produces (all pure, storage injected — NEVER references
localStorage):STORAGE_KEY(string'bullet-heaven-bible').save(storage, bible) -> void—storage.setItem(STORAGE_KEY, JSON.stringify(bible)).load(storage, fallback) -> bible— parse stored JSON; if absent or unparseable, returnfallback.exportJSON(bible) -> string— pretty JSON (2-space).importJSON(text) -> bible— parse + shape-check (schemaVersionnumber anddataobject), throwsErroron bad shape.
-
storageis any{ getItem(k), setItem(k,v) }. Tests pass aMap-backed fake. -
Step 1: Write the failing test
Create tools/design-bible/tests/persistence.test.js:
import { test } from 'node:test';import assert from 'node:assert/strict';import { save, load, exportJSON, importJSON, STORAGE_KEY } from '../src/persistence.js';import { SEED } from '../src/seed.js';
function fakeStorage() { const m = new Map(); return { getItem: k => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, v), _m: m };}
test('save then load round-trips', () => { const s = fakeStorage(); save(s, SEED); const got = load(s, null); assert.equal(got.schemaVersion, 1); assert.equal(got.data.elements.length, 14);});
test('load returns fallback when empty', () => { assert.equal(load(fakeStorage(), 'FB'), 'FB');});
test('load returns fallback on corrupt json', () => { const s = fakeStorage(); s.setItem(STORAGE_KEY, '{not json'); assert.equal(load(s, 'FB'), 'FB');});
test('exportJSON is pretty and importJSON round-trips', () => { const text = exportJSON(SEED); assert.ok(text.includes('\n '), 'pretty-printed'); const back = importJSON(text); assert.equal(back.data.weapons.length, SEED.data.weapons.length);});
test('importJSON rejects bad shape', () => { assert.throws(() => importJSON('{"nope":true}')); assert.throws(() => importJSON('not json'));});- Step 2: Run test to verify it fails
Run: node --test tests/persistence.test.js
Expected: FAIL — cannot find ../src/persistence.js.
- Step 3: Implement persistence
Create tools/design-bible/src/persistence.js:
export const STORAGE_KEY = 'bullet-heaven-bible';
export function save(storage, bible) { storage.setItem(STORAGE_KEY, JSON.stringify(bible));}
export function load(storage, fallback) { const raw = storage.getItem(STORAGE_KEY); if (!raw) return fallback; try { return JSON.parse(raw); } catch { return fallback; }}
export function exportJSON(bible) { return JSON.stringify(bible, null, 2);}
export function importJSON(text) { let parsed; try { parsed = JSON.parse(text); } catch { throw new Error('Not valid JSON'); } if (typeof parsed.schemaVersion !== 'number' || typeof parsed.data !== 'object' || parsed.data === null) throw new Error('Not a bible document (need schemaVersion + data)'); return parsed;}- Step 4: Run test to verify it passes
Run: node --test tests/persistence.test.js
Expected: PASS — 5 tests.
- Step 5: Commit
git add src/persistence.js tests/persistence.test.jsgit commit -m "feat(bible): persistence — save/load/export/import with injected storage"Task 6: Metrics (computed balance functions)
Section titled “Task 6: Metrics (computed balance functions)”Files:
- Create:
tools/design-bible/src/metrics.js - Test:
tools/design-bible/tests/metrics.test.js
Interfaces:
-
Produces pure functions:
weaponDPS(weapon) -> number=base_damage * projectile_count * (1/cooldown_s), withcooldown_s <= 0treated as a continuous weapon at10ticks/s (so orbitals/beams get a finite number);pierce>0orarea>0multiply by aneffectiveTargetsfactor (1 + min(pierce,5)*0.5 + (area>0?1:0)).enemyEffectiveHP(enemy) -> number=hp * (1 + armor*0.1).ttk(enemy, weapon) -> number=enemyEffectiveHP(enemy) / max(weaponDPS(weapon), 0.0001).xpPacing(base, growth, levels) -> number[]= cumulative XP needed to reach each level1..levelswhere threshold(n) =base * growth^(n-1).reactionPreview(reaction, stacks) -> number=base_magnitude * per_stack_scale^stacks.sanityFlags(categoryKey, entries) -> string[]— soft warnings: a weapon whose DPS > 4× the category median; any enemy withxp_value <= 0.
-
Step 1: Write the failing test
Create tools/design-bible/tests/metrics.test.js:
import { test } from 'node:test';import assert from 'node:assert/strict';import { weaponDPS, enemyEffectiveHP, ttk, xpPacing, reactionPreview, sanityFlags } from '../src/metrics.js';
const pulse = { base_damage: 1, projectile_count: 1, cooldown_s: 0.5, pierce: 0, area: 0 };
test('weaponDPS basic = damage*count/cooldown', () => { assert.equal(weaponDPS(pulse), 2); // 1*1/0.5});
test('weaponDPS handles zero-cooldown continuous weapon', () => { const orbit = { base_damage: 1, projectile_count: 3, cooldown_s: 0, pierce: 0, area: 0 }; assert.ok(weaponDPS(orbit) > 0 && Number.isFinite(weaponDPS(orbit)));});
test('enemyEffectiveHP factors armor', () => { assert.equal(enemyEffectiveHP({ hp: 100, armor: 0 }), 100); assert.equal(enemyEffectiveHP({ hp: 100, armor: 10 }), 200);});
test('ttk = effHP / dps', () => { assert.equal(ttk({ hp: 10, armor: 0 }, pulse), 5); // 10 / 2});
test('xpPacing returns cumulative thresholds', () => { const p = xpPacing(10, 1.5, 3); assert.equal(p.length, 3); assert.equal(p[0], 10); assert.ok(p[2] > p[1] && p[1] > p[0]);});
test('reactionPreview scales with stacks', () => { assert.equal(reactionPreview({ base_magnitude: 10, per_stack_scale: 2 }, 3), 80); // 10*2^3});
test('sanityFlags warns on zero-xp enemy', () => { const flags = sanityFlags('enemies', [{ id: 'x', xp_value: 0 }]); assert.ok(flags.some(f => f.includes('x')));});- Step 2: Run test to verify it fails
Run: node --test tests/metrics.test.js
Expected: FAIL — cannot find ../src/metrics.js.
- Step 3: Implement metrics
Create tools/design-bible/src/metrics.js:
function effectiveTargets(w) { return 1 + Math.min(w.pierce ?? 0, 5) * 0.5 + ((w.area ?? 0) > 0 ? 1 : 0);}
export function weaponDPS(w) { const rate = w.cooldown_s > 0 ? 1 / w.cooldown_s : 10; return w.base_damage * (w.projectile_count || 1) * rate * effectiveTargets(w);}
export function enemyEffectiveHP(e) { return e.hp * (1 + (e.armor ?? 0) * 0.1);}
export function ttk(enemy, weapon) { return enemyEffectiveHP(enemy) / Math.max(weaponDPS(weapon), 0.0001);}
export function xpPacing(base, growth, levels) { const out = []; for (let n = 1; n <= levels; n++) out.push(base * Math.pow(growth, n - 1)); return out;}
export function reactionPreview(reaction, stacks) { return reaction.base_magnitude * Math.pow(reaction.per_stack_scale, stacks);}
function median(nums) { if (nums.length === 0) return 0; const s = [...nums].sort((a, b) => a - b); const mid = Math.floor(s.length / 2); return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;}
export function sanityFlags(categoryKey, entries) { const flags = []; if (categoryKey === 'weapons') { const dps = entries.map(weaponDPS); const med = median(dps); entries.forEach((w, i) => { if (med > 0 && dps[i] > med * 4) flags.push(`${w.id}: DPS ${dps[i].toFixed(1)} is >4× category median`); }); } if (categoryKey === 'enemies') { for (const e of entries) if ((e.xp_value ?? 0) <= 0) flags.push(`${e.id}: xp_value is 0`); } return flags;}- Step 4: Run test to verify it passes
Run: node --test tests/metrics.test.js
Expected: PASS — 7 tests.
- Step 5: Commit
git add src/metrics.js tests/metrics.test.jsgit commit -m "feat(bible): computed balance metrics (DPS, effHP, TTK, pacing, reaction preview, sanity)"Task 7: Views — nav + list (DOM render)
Section titled “Task 7: Views — nav + list (DOM render)”Files:
- Create:
tools/design-bible/src/views/nav.js - Create:
tools/design-bible/src/views/list.js
Interfaces:
-
renderNav(container, { categories, activeKey, onSelect })— paints.navitemper category; clicking callsonSelect(key).categories=[{key,label}]. -
renderList(container, { entries, activeId, onSelect, onAdd, onDuplicate, onRemove, query, onQuery })— a search input (valuequery, firesonQuery(text)), an “+ Add” button (onAdd()), and a.listitemper entry showingentry.namewith duplicate/delete buttons (onDuplicate(id),onRemove(id)); clicking the item callsonSelect(id). -
These touch the DOM (allowed — they are
views/). No tests; verified when the app boots (Task 11). -
Step 1: Implement nav view
Create tools/design-bible/src/views/nav.js:
export function renderNav(container, { categories, activeKey, onSelect }) { container.innerHTML = ''; for (const cat of categories) { const div = document.createElement('div'); div.className = 'navitem' + (cat.key === activeKey ? ' active' : ''); div.textContent = cat.label; div.onclick = () => onSelect(cat.key); container.appendChild(div); }}- Step 2: Implement list view
Create tools/design-bible/src/views/list.js:
export function renderList(container, opts) { const { entries, activeId, onSelect, onAdd, onDuplicate, onRemove, query, onQuery } = opts; container.innerHTML = '';
const search = document.createElement('input'); search.placeholder = 'search…'; search.value = query ?? ''; search.oninput = () => onQuery(search.value); container.appendChild(search);
const add = document.createElement('button'); add.textContent = '+ Add'; add.style.margin = '8px 0'; add.onclick = () => onAdd(); container.appendChild(add);
const q = (query ?? '').toLowerCase(); for (const e of entries) { if (q && !(e.name ?? '').toLowerCase().includes(q) && !(e.id ?? '').includes(q)) continue; const row = document.createElement('div'); row.className = 'listitem' + (e.id === activeId ? ' active' : ''); const label = document.createElement('span'); label.textContent = e.name ?? e.id; label.style.cursor = 'pointer'; label.onclick = () => onSelect(e.id); const dup = document.createElement('button'); dup.textContent = '⧉'; dup.title = 'duplicate'; dup.style.float = 'right'; dup.onclick = () => onDuplicate(e.id); const del = document.createElement('button'); del.textContent = '✕'; del.style.float = 'right'; del.onclick = () => onRemove(e.id); row.append(label, del, dup); container.appendChild(row); }}- Step 3: Verify it parses (syntax check)
Run: node --check src/views/nav.js && node --check src/views/list.js
Expected: no output, exit 0.
- Step 4: Commit
git add src/views/nav.js src/views/list.jsgit commit -m "feat(bible): nav + entry-list views"Task 8: View — schema-driven detail editor + metrics panel
Section titled “Task 8: View — schema-driven detail editor + metrics panel”Files:
- Create:
tools/design-bible/src/views/detail.js
Interfaces:
-
renderDetail(container, { category, entry, model, onChange })— for each field incategory.fields, renders a.fieldrow with a typed input bound toentry[field.name]; on change callsonChange(field.name, value)(value coerced: int/number→Number, bool→checkbox, tags→multi frommodel.list('tags'), ref→<select>ofmodel.list(field.ref)ids, enum→<select>of options). Below the fields, renders a.metricspanel viametricsFor(category.key, entry, model). -
metricsFor(categoryKey, entry, model)— returns an array of{label, value}: forweapons→ DPS + TTK-vs-swarmer; forenemies→ effective HP + TTK-from-pulse; forreactions→ preview @1/3/6 stacks; forrun_structure/empty →[]. Usesmetrics.js. -
Consumes
metrics.js(Task 6) andmodel(Task 4). -
Step 1: Implement detail view
Create tools/design-bible/src/views/detail.js:
import { weaponDPS, enemyEffectiveHP, ttk, reactionPreview } from '../metrics.js';
export function metricsFor(categoryKey, entry, model) { if (categoryKey === 'weapons') { const swarmer = model.get('enemies', 'swarmer'); const out = [{ label: 'DPS', value: weaponDPS(entry).toFixed(2) }]; if (swarmer) out.push({ label: 'TTK vs Swarmer', value: ttk(swarmer, entry).toFixed(2) + 's' }); return out; } if (categoryKey === 'enemies') { const pulse = model.get('weapons', 'pulse'); const out = [{ label: 'Effective HP', value: enemyEffectiveHP(entry).toFixed(1) }]; if (pulse) out.push({ label: 'TTK from Pulse', value: ttk(entry, pulse).toFixed(2) + 's' }); return out; } if (categoryKey === 'reactions') { return [1, 3, 6].map(s => ({ label: `Preview @${s} stacks`, value: reactionPreview(entry, s).toFixed(1) })); } return [];}
function fieldInput(field, value, model, onChange) { let input; if (field.type === 'enum') { input = document.createElement('select'); for (const o of field.options) { const opt = document.createElement('option'); opt.value = o; opt.textContent = o; input.appendChild(opt); } input.value = value; input.onchange = () => onChange(field.name, input.value); } else if (field.type === 'ref') { input = document.createElement('select'); const blank = document.createElement('option'); blank.value = ''; blank.textContent = '—'; input.appendChild(blank); for (const e of model.list(field.ref)) { const opt = document.createElement('option'); opt.value = e.id; opt.textContent = e.name ?? e.id; input.appendChild(opt); } input.value = value ?? ''; input.onchange = () => onChange(field.name, input.value); } else if (field.type === 'bool') { input = document.createElement('input'); input.type = 'checkbox'; input.checked = !!value; input.onchange = () => onChange(field.name, input.checked); } else if (field.type === 'tags') { input = document.createElement('input'); input.value = (value ?? []).join(', '); input.onchange = () => onChange(field.name, input.value.split(',').map(s => s.trim()).filter(Boolean)); } else { input = document.createElement('input'); input.value = value ?? ''; input.onchange = () => { const v = (field.type === 'int') ? parseInt(input.value, 10) : (field.type === 'number') ? parseFloat(input.value) : input.value; onChange(field.name, v); }; } return input;}
export function renderDetail(container, { category, entry, model, onChange }) { container.innerHTML = ''; if (!entry) { container.innerHTML = '<p style="color:var(--muted)">Select an entry.</p>'; return; }
const idRow = document.createElement('div'); idRow.className = 'field'; idRow.innerHTML = `<label>id</label><code>${entry.id}</code>`; container.appendChild(idRow);
for (const field of category.fields) { const row = document.createElement('div'); row.className = 'field'; const label = document.createElement('label'); label.textContent = field.name; row.append(label, fieldInput(field, entry[field.name], model, onChange)); container.appendChild(row); }
const metrics = metricsFor(category.key, entry, model); if (metrics.length) { const panel = document.createElement('div'); panel.className = 'metrics'; panel.innerHTML = '<div class="label">computed</div>' + metrics.map(m => `<div class="m"><span>${m.label}</span><b>${m.value}</b></div>`).join(''); container.appendChild(panel); }}- Step 2: Verify it parses
Run: node --check src/views/detail.js
Expected: exit 0.
- Step 3: Commit
git add src/views/detail.jsgit commit -m "feat(bible): schema-driven detail editor + computed-metrics panel"Task 9: View — reaction matrix
Section titled “Task 9: View — reaction matrix”Files:
- Create:
tools/design-bible/src/views/matrix.js
Interfaces:
-
renderMatrix(container, { model, onPick })— builds a 14×14 table: rows = aura element, cols = applied element (frommodel.list('elements')); each cell shows the matching reaction’sname(looked up byid === aura-applied) or·if none; clicking a cell callsonPick(auraId, appliedId)(so the app can select/create that reaction in the detail pane). Diagonal cells labeledreinforce. -
Consumes
model(Task 4). -
Step 1: Implement matrix view
Create tools/design-bible/src/views/matrix.js:
export function renderMatrix(container, { model, onPick }) { container.innerHTML = '<h3>Reaction Matrix — aura (row) × applied (col)</h3>'; const elements = model.list('elements'); const reactions = model.list('reactions'); const find = (a, b) => reactions.find(r => r.id === `${a}-${b}` || (r.aura === a && r.applied === b));
const table = document.createElement('table'); table.className = 'matrix'; const head = document.createElement('tr'); head.appendChild(document.createElement('th')); for (const col of elements) { const th = document.createElement('th'); th.textContent = col.name.slice(0, 4); th.style.color = col.color; head.appendChild(th); } table.appendChild(head);
for (const row of elements) { const tr = document.createElement('tr'); const rh = document.createElement('th'); rh.textContent = row.name; rh.style.color = row.color; tr.appendChild(rh); for (const col of elements) { const td = document.createElement('td'); td.className = 'cell'; if (row.id === col.id) { td.textContent = 'reinforce'; td.style.opacity = '0.4'; } else { const r = find(row.id, col.id); td.textContent = r ? r.name : '·'; } td.onclick = () => onPick(row.id, col.id); tr.appendChild(td); } table.appendChild(tr); } container.appendChild(table);}- Step 2: Verify it parses
Run: node --check src/views/matrix.js
Expected: exit 0.
- Step 3: Commit
git add src/views/matrix.jsgit commit -m "feat(bible): 14×14 reaction matrix view"Task 10: View — bulk table
Section titled “Task 10: View — bulk table”Files:
- Create:
tools/design-bible/src/views/table.js
Interfaces:
-
renderTable(container, { category, entries, model, onChange, onSelect })— atable.bulkwith a column per field plus a leadingname/id column; each cell is an editable input bound like the detail editor (reusing the same coercion), callingonChange(id, fieldName, value); clicking the name opens the entry (onSelect(id)). Forweapons/enemiesadd a trailing read-only computed column (DPS / effHP). Keep it simple: text/number inputs only (enum/ref/tags shown as plain text, edited in the detail pane). -
Consumes
metrics.js. -
Step 1: Implement table view
Create tools/design-bible/src/views/table.js:
import { weaponDPS, enemyEffectiveHP } from '../metrics.js';
export function renderTable(container, { category, entries, onChange, onSelect }) { container.innerHTML = `<h3>${category.label} — table view</h3>`; const table = document.createElement('table'); table.className = 'bulk';
const simple = category.fields.filter(f => ['int', 'number', 'text'].includes(f.type)); const head = document.createElement('tr'); head.innerHTML = '<th>name</th>' + simple.map(f => `<th>${f.name}</th>`).join('') + (category.key === 'weapons' ? '<th>DPS</th>' : category.key === 'enemies' ? '<th>effHP</th>' : ''); table.appendChild(head);
for (const e of entries) { const tr = document.createElement('tr'); const nameTd = document.createElement('td'); const a = document.createElement('a'); a.textContent = e.name ?? e.id; a.style.cursor = 'pointer'; a.onclick = () => onSelect(e.id); nameTd.appendChild(a); tr.appendChild(nameTd);
for (const f of simple) { const td = document.createElement('td'); const input = document.createElement('input'); input.value = e[f.name] ?? ''; input.size = 6; input.onchange = () => { const v = f.type === 'int' ? parseInt(input.value, 10) : f.type === 'number' ? parseFloat(input.value) : input.value; onChange(e.id, f.name, v); }; td.appendChild(input); tr.appendChild(td); } if (category.key === 'weapons') { const td = document.createElement('td'); td.textContent = weaponDPS(e).toFixed(2); tr.appendChild(td); } if (category.key === 'enemies') { const td = document.createElement('td'); td.textContent = enemyEffectiveHP(e).toFixed(1); tr.appendChild(td); } table.appendChild(tr); } container.appendChild(table);}- Step 2: Verify it parses
Run: node --check src/views/table.js
Expected: exit 0.
- Step 3: Commit
git add src/views/table.jsgit commit -m "feat(bible): bulk table view"Task 11: App bootstrap — wire everything + persistence + export/import
Section titled “Task 11: App bootstrap — wire everything + persistence + export/import”Files:
- Create:
tools/design-bible/src/app.js
Interfaces:
-
Consumes:
schema.js(CATEGORIES, categoryByKey),seed.js(SEED),model.js(BibleModel),persistence.js(save/load/exportJSON/importJSON), allviews/*. -
Behavior: on load,
model = new BibleModel(load(localStorage, SEED)). Holds UI state{ activeKey, activeId, query, viewMode }. Arender()repaints nav + (list or table) + detail/matrix. Every model mutation callspersist()(save(localStorage, model.bible)) thenrender(). Top bar has: Export (downloadexportJSON), Import (file input →importJSON→ new model), Reset (back to SEED), and a Table/Detail toggle. WhenactiveKey === 'reactions', the list pane shows the matrix; clicking a matrix cell selects the reaction with that id, creating it viamodel.add+ setting aura/applied if absent. -
Step 1: Implement app.js
Create tools/design-bible/src/app.js:
import { CATEGORIES, categoryByKey } from './schema.js';import { SEED } from './seed.js';import { BibleModel } from './model.js';import { save, load, exportJSON, importJSON } from './persistence.js';import { renderNav } from './views/nav.js';import { renderList } from './views/list.js';import { renderDetail } from './views/detail.js';import { renderMatrix } from './views/matrix.js';import { renderTable } from './views/table.js';
const navEl = document.getElementById('nav');const listEl = document.getElementById('list');const detailEl = document.getElementById('detail');const actionsEl = document.getElementById('actions');
let model = new BibleModel(load(localStorage, SEED));const ui = { activeKey: 'elements', activeId: null, query: '', viewMode: 'detail' };
function persist() { save(localStorage, model.bible); }
function selectCategory(key) { ui.activeKey = key; ui.activeId = null; ui.query = ''; render(); }function selectEntry(id) { ui.activeId = id; render(); }
function filtered() { return model.list(ui.activeKey);}
function renderActions() { actionsEl.innerHTML = ''; const mk = (label, fn) => { const b = document.createElement('button'); b.textContent = label; b.onclick = fn; b.style.marginLeft = '6px'; return b; };
const toggle = mk(ui.viewMode === 'detail' ? 'Table view' : 'Detail view', () => { ui.viewMode = ui.viewMode === 'detail' ? 'table' : 'detail'; render(); }); const exp = mk('Export', () => { const blob = new Blob([exportJSON(model.bible)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'bullet-heaven-bible.json'; a.click(); }); const imp = mk('Import', () => fileInput.click()); const reset = mk('Reset', () => { if (confirm('Reset to seed? Unsaved edits lost.')) { model = new BibleModel(SEED); persist(); render(); } }); actionsEl.append(toggle, exp, imp, reset);}
const fileInput = document.createElement('input');fileInput.type = 'file'; fileInput.accept = 'application/json'; fileInput.style.display = 'none';fileInput.onchange = () => { const f = fileInput.files[0]; if (!f) return; const reader = new FileReader(); reader.onload = () => { try { model = new BibleModel(importJSON(reader.result)); persist(); render(); } catch (e) { alert('Import failed: ' + e.message); } }; reader.readAsText(f);};document.body.appendChild(fileInput);
function render() { renderActions(); renderNav(navEl, { categories: CATEGORIES.map(c => ({ key: c.key, label: c.label })), activeKey: ui.activeKey, onSelect: selectCategory });
const cat = categoryByKey(ui.activeKey);
if (ui.activeKey === 'reactions') { renderMatrix(listEl, { model, onPick: (aura, applied) => { const id = `${aura}-${applied}`; if (!model.get('reactions', id)) { const e = model.add('reactions'); e.id = id; e.aura = aura; e.applied = applied; e.name = `${aura}+${applied}`; persist(); } selectEntry(id); }}); const entry = model.get('reactions', ui.activeId); renderDetail(detailEl, { category: cat, entry, model, onChange: onFieldChange }); return; }
if (ui.viewMode === 'table') { renderTable(listEl, { category: cat, entries: filtered(), model, onChange: (id, field, value) => { model.setField(ui.activeKey, id, field, value); persist(); render(); }, onSelect: (id) => { ui.viewMode = 'detail'; selectEntry(id); } }); detailEl.innerHTML = ''; return; }
renderList(listEl, { entries: filtered(), activeId: ui.activeId, query: ui.query, onQuery: (q) => { ui.query = q; render(); }, onSelect: selectEntry, onAdd: () => { const e = model.add(ui.activeKey); persist(); selectEntry(e.id); }, onDuplicate: (id) => { const e = model.duplicate(ui.activeKey, id); persist(); selectEntry(e.id); }, onRemove: (id) => { model.remove(ui.activeKey, id); if (ui.activeId === id) ui.activeId = null; persist(); render(); }, }); const entry = model.get(ui.activeKey, ui.activeId); renderDetail(detailEl, { category: cat, entry, model, onChange: onFieldChange });}
function onFieldChange(field, value) { if (!ui.activeId) return; model.setField(ui.activeKey, ui.activeId, field, value); persist(); // Re-render detail only would lose focus rarely; full render keeps metrics live. render();}
render();- Step 2: Verify it parses
Run: node --check src/app.js
Expected: exit 0.
- Step 3: Boot smoke test in a browser
Serve and load the page, asserting no console errors and that core elements render. If the Playwright MCP is available use it; otherwise do the manual check below.
Automated (Playwright MCP): start a server in the tool dir (python3 -m http.server 8123 backgrounded), navigate to http://localhost:8123/, then:
- assert
#nav .navitemcount === 16 - click the “Elements” nav, assert
#list .listitemcount === 14 - click the first element, assert
#detail .fieldcount > 5 and a.metrics-free element is fine - click “Weapons”, click “pulse”, assert
#detail .metricscontains “DPS” - click “Reactions”, assert a
table.matrixrenders with 15 rows (header + 14) - collect console messages, assert zero of type
error
Manual fallback: python3 -m http.server 8123, open http://localhost:8123/, open devtools console (must be clean), click through Elements → an element (fields show), Weapons → pulse (DPS metric shows), Reactions (matrix shows), Export (downloads JSON), toggle Table view (editable grid).
- Step 4: Commit
git add src/app.jsgit commit -m "feat(bible): app bootstrap — wire model+views+persistence, export/import, matrix, table"Task 12: README + final full-suite green
Section titled “Task 12: README + final full-suite green”Files:
- Create:
tools/design-bible/README.md
Interfaces: none (docs + verification).
- Step 1: Write the README
Create tools/design-bible/README.md:
# Bullet Heaven — Design Bible & Balance Tool
A dependency-free static web tool to browse, edit, and balance the game's content bible(elements, the 14×14 reaction matrix, weapons, mods, enemies, …) with live computedmetrics (DPS, effective-HP, TTK). The exported JSON (`schemaVersion: 1`) is the data thegame will consume.
## Run```bashcd tools/design-biblenpm run serve # python3 -m http.server 8080# open http://localhost:8080/ (ES modules need a server — not file://)Test (logic core, no deps)
Section titled “Test (logic core, no deps)”cd tools/design-biblenpm test # node --test- Seed lives in
src/seed.js. Your edits auto-save to localStorage. - Export downloads the full bible JSON; Import loads one; Reset restores the seed.
- Reactions are edited via the matrix (click a cell). Signature reactions are seeded; the rest use a generic fallback in-game and can be authored here over time.
Structure
Section titled “Structure”src/schema.jscategories + field types ·src/seed.jsdefault datasrc/model.jsstate+CRUD ·src/persistence.jssave/load/export/import ·src/metrics.jscomputed balancesrc/app.js+src/views/*the UI
- [ ] **Step 2: Run the full logic suite**
Run (from `tools/design-bible/`): `node --test`Expected: all tests across schema/seed/model/persistence/metrics pass, exit 0.
- [ ] **Step 3: Commit**
```bashgit add README.mdgit commit -m "docs(bible): README — run, test, data workflow"Self-Review
Section titled “Self-Review”Spec coverage (against 2026-06-21-design-bible-design.md):
- §1 build order (tool first) → this whole plan builds the tool only; game integration is out of scope (Task scope note + spec §8). ✓
- §2 taxonomy & schema (16 categories, field types) → Task 2 (
schema.js) with the exact category list. ✓ - §2 seed content (14 elements, tags, M1 weapons/mods/enemies, signature reactions) → Task 3 (
seed.js). ✓ - §3 elemental system (14 elements, hybrid model fields, 14×14 matrix, signature-first) → schema fields (Task 2) + seed (Task 3) + matrix view (Task 9). The runtime reaction behaviour is a later game-side cycle; the tool stores/edits the data. ✓
- §4 computed metrics (weaponDPS, effHP, ttk, xpPacing, reactionPreview, sanityFlags) → Task 6 + surfaced in detail (Task 8) and table (Task 10). ✓
- §5 web tool (static, no build, master-detail, matrix view, table toggle, localStorage, import/export, served) → Tasks 1,7,8,9,10,11. ✓
- §5 versioned export (
schemaVersion: 1) → seed (Task 3) + importJSON shape-check (Task 5). ✓ - §6 mode-shell seam → explicitly out of scope (design-only); no task, correct. ✓
- §7 success criteria → every bullet maps to Tasks 2–11; boot smoke (Task 11) verifies browse/edit/metrics/matrix/persistence/export. ✓
Deferred (correctly, per spec §8): Godot integration, in-browser simulator, synergy-graph beyond matrix, authoring all 91 reactions, the mode shell.
Placeholder scan: none — every step has complete code or an exact command + expected result. The boot smoke (Task 11 Step 3) gives both an automated (Playwright MCP) and a manual procedure with concrete assertions.
Type consistency: BibleModel methods (list/get/add/duplicate/remove/setField/validate) are used consistently across views and app. SEED shape {schemaVersion, data} matches persistence’s import shape-check and model’s constructor. Metric fn names (weaponDPS/enemyEffectiveHP/ttk/xpPacing/reactionPreview/sanityFlags) match between Task 6, Task 8, Task 10. Field names in schema.js match those read by metrics.js (base_damage, cooldown_s, projectile_count, pierce, area, hp, armor, xp_value, base_magnitude, per_stack_scale). ✓