Skip to content

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 (schemaseedmodelpersistencemetrics) 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.

  • No dependencies, no build step. Plain ES modules (.js), <script type="module">. package.json exists ONLY for {"type":"module"} + npm-less test/serve scripts — never npm install anything.
  • Logic/UI split: files under src/ EXCEPT app.js and src/views/* must NOT touch the DOM (document, window, localStorage) — pure functions/classes only, so node --test can run them. Persistence takes an injected storage object; it never references localStorage directly.
  • All logic-core code is TDD’d with node --test. Run: node --test from tools/design-bible/.
  • Served, not file:// (ES modules need a server). Local: python3 -m http.server 8080 in tools/design-bible/, open http://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 under tools/design-bible/.

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.js

Task 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 --test runner 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
Terminal window
git add tools/design-bible
git commit -m "chore(bible): scaffold static tool + node --test harness"

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') and export 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 or undefined.

  • 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
Terminal window
git add src/schema.js tests/schema.test.js
git 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 has id (kebab slug) + name + the category’s fields. data has a key for every category in CATEGORIES.

  • 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,scale
const 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
Terminal window
git add src/seed.js tests/seed.test.js
git 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-clones bible (shape {schemaVersion, data}) into this.bible.
    • list(categoryKey) -> Entry[]
    • get(categoryKey, id) -> Entry | undefined
    • add(categoryKey) -> Entry — creates a new entry with default field values + a unique id (new-1, new-2, …) and name '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) -> void
    • setField(categoryKey, id, fieldName, value) -> void
    • validate() -> 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
Terminal window
git add src/model.js tests/model.test.js
git 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) -> voidstorage.setItem(STORAGE_KEY, JSON.stringify(bible)).
    • load(storage, fallback) -> bible — parse stored JSON; if absent or unparseable, return fallback.
    • exportJSON(bible) -> string — pretty JSON (2-space).
    • importJSON(text) -> bible — parse + shape-check (schemaVersion number and data object), throws Error on bad shape.
  • storage is any { getItem(k), setItem(k,v) }. Tests pass a Map-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
Terminal window
git add src/persistence.js tests/persistence.test.js
git 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), with cooldown_s <= 0 treated as a continuous weapon at 10 ticks/s (so orbitals/beams get a finite number); pierce>0 or area>0 multiply by an effectiveTargets factor (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 level 1..levels where 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 with xp_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
Terminal window
git add src/metrics.js tests/metrics.test.js
git commit -m "feat(bible): computed balance metrics (DPS, effHP, TTK, pacing, reaction preview, sanity)"

Files:

  • Create: tools/design-bible/src/views/nav.js
  • Create: tools/design-bible/src/views/list.js

Interfaces:

  • renderNav(container, { categories, activeKey, onSelect }) — paints .navitem per category; clicking calls onSelect(key). categories = [{key,label}].

  • renderList(container, { entries, activeId, onSelect, onAdd, onDuplicate, onRemove, query, onQuery }) — a search input (value query, fires onQuery(text)), an “+ Add” button (onAdd()), and a .listitem per entry showing entry.name with duplicate/delete buttons (onDuplicate(id), onRemove(id)); clicking the item calls onSelect(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
Terminal window
git add src/views/nav.js src/views/list.js
git 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 in category.fields, renders a .field row with a typed input bound to entry[field.name]; on change calls onChange(field.name, value) (value coerced: int/number→Number, bool→checkbox, tags→multi from model.list('tags'), ref→<select> of model.list(field.ref) ids, enum→<select> of options). Below the fields, renders a .metrics panel via metricsFor(category.key, entry, model).

  • metricsFor(categoryKey, entry, model) — returns an array of {label, value}: for weapons → DPS + TTK-vs-swarmer; for enemies → effective HP + TTK-from-pulse; for reactions → preview @1/3/6 stacks; for run_structure/empty → []. Uses metrics.js.

  • Consumes metrics.js (Task 6) and model (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
Terminal window
git add src/views/detail.js
git commit -m "feat(bible): schema-driven detail editor + computed-metrics panel"

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 (from model.list('elements')); each cell shows the matching reaction’s name (looked up by id === aura-applied) or · if none; clicking a cell calls onPick(auraId, appliedId) (so the app can select/create that reaction in the detail pane). Diagonal cells labeled reinforce.

  • 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
Terminal window
git add src/views/matrix.js
git commit -m "feat(bible): 14×14 reaction matrix view"

Files:

  • Create: tools/design-bible/src/views/table.js

Interfaces:

  • renderTable(container, { category, entries, model, onChange, onSelect }) — a table.bulk with a column per field plus a leading name/id column; each cell is an editable input bound like the detail editor (reusing the same coercion), calling onChange(id, fieldName, value); clicking the name opens the entry (onSelect(id)). For weapons/enemies add 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
Terminal window
git add src/views/table.js
git 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), all views/*.

  • Behavior: on load, model = new BibleModel(load(localStorage, SEED)). Holds UI state { activeKey, activeId, query, viewMode }. A render() repaints nav + (list or table) + detail/matrix. Every model mutation calls persist() (save(localStorage, model.bible)) then render(). Top bar has: Export (download exportJSON), Import (file input → importJSON → new model), Reset (back to SEED), and a Table/Detail toggle. When activeKey === 'reactions', the list pane shows the matrix; clicking a matrix cell selects the reaction with that id, creating it via model.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 .navitem count === 16
  • click the “Elements” nav, assert #list .listitem count === 14
  • click the first element, assert #detail .field count > 5 and a .metrics-free element is fine
  • click “Weapons”, click “pulse”, assert #detail .metrics contains “DPS”
  • click “Reactions”, assert a table.matrix renders 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
Terminal window
git add src/app.js
git commit -m "feat(bible): app bootstrap — wire model+views+persistence, export/import, matrix, table"

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 computed
metrics (DPS, effective-HP, TTK). The exported JSON (`schemaVersion: 1`) is the data the
game will consume.
## Run
```bash
cd tools/design-bible
npm run serve # python3 -m http.server 8080
# open http://localhost:8080/ (ES modules need a server — not file://)
Terminal window
cd tools/design-bible
npm 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.
  • src/schema.js categories + field types · src/seed.js default data
  • src/model.js state+CRUD · src/persistence.js save/load/export/import · src/metrics.js computed balance
  • src/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**
```bash
git add README.md
git commit -m "docs(bible): README — run, test, data workflow"

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). ✓