Files
BladeRunner/gm-utility/test/rundown.test.js
T
slaguru666andClaude Opus 4.8 9a8a331635 Implement RUNDOWN v1: rules, ladders, GM sheet, console
- rules/RUNDOWN.md: the complete system, self-contained
- reference/obstacle-ladders.md: eight six-rung ladders across foot,
  ground vehicle and spinner terrain
- reference/converting-from-raw.md: switching from the published chase
  rules mid-campaign or mid-chase, plus rulings the conversion needs
- reference/rundown-gm-sheet.html: one-page printable GM sheet
- gm-utility/rundown-console.html + modules/rundown.js: touch console
  matching the REP-DETECT panel style, no dependencies, runs from file://

Console logic verified by gm-utility/test/rundown.test.js — 42 assertions
driving the real module through a stub DOM, covering LEAD movement and
clamping, round capping, Overtime drift, undo, Collateral pips and every
outcome band.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 23:06:08 +01:00

127 lines
5.8 KiB
JavaScript

// Minimal DOM stub — drives the real rundown.js through its actual code paths.
const fs = require('fs');
class El {
constructor(tag){ this.tag=tag; this._id=''; this.className=''; this._html='';
this._text=''; this.children=[]; this.style={}; this.disabled=false; this.value='';
this.attrs={}; this.handlers={}; }
// real DOM: assigning innerHTML replaces all child nodes
set innerHTML(v){ this._html=String(v); this.children=[]; } get innerHTML(){ return this._html; }
set id(v){ this._id=v; reg[v]=this; } get id(){ return this._id; }
set textContent(v){ this._text=String(v); } get textContent(){ return this._text; }
addEventListener(ev,fn){ (this.handlers[ev]=this.handlers[ev]||[]).push(fn); }
click(){ (this.handlers.click||[]).forEach(f=>f.call(this)); }
appendChild(c){ this.children.push(c); return c; }
setAttribute(k,v){ this.attrs[k]=v; } getAttribute(k){ return this.attrs[k]; }
}
const reg = {};
const IDS = ['leadTrack','leadSub','roundVal','obstacleBox','ladderName','peekBtn','peekOut',
'pursuerVal','preyVal','netVal','pips','collNote','collSub','outcome','overtimeBtn',
'undoBtn','applyBtn','resetBtn','ladderPick'];
IDS.forEach(i => { const e = new El('div'); e.id = i; });
const adjBtns = [['pursuer',-1],['pursuer',1],['prey',-1],['prey',1]].map(([k,d]) => {
const b = new El('button'); b.setAttribute('data-adj', `${k},${d}`); return b;
});
global.document = {
readyState: 'complete',
getElementById: id => reg[id] || null,
createElement: t => new El(t),
querySelectorAll: sel => sel === '[data-adj]' ? adjBtns : [],
addEventListener: () => {},
};
const store = {};
global.localStorage = {
getItem: k => (k in store ? store[k] : null),
setItem: (k,v) => { store[k]=v; },
};
global.window = { confirm: () => true };
eval(fs.readFileSync(process.argv[2] || __dirname + "/../modules/rundown.js",'utf8'));
// ---- helpers ----
const leadNow = () => reg.leadTrack.children.findIndex(c => c.className.includes('cur'));
const roundNow = () => reg.roundVal.innerHTML.replace(/<[^>]*>/g,'');
const outcomeNow = () => (reg.outcome.innerHTML.match(/<div class="nm">([^<]*)<\/div>/)||[])[1];
const rungNow = () => (reg.obstacleBox.innerHTML.match(/Rung (\d)/)||[])[1];
const collNow = () => reg.pips.children.filter(c=>c.className.includes('on')).length;
// drive through the real +/- buttons rather than poking state
const setNet = (p,q) => {
for(let i=0;i<p;i++) adjBtns[1].click();
for(let i=0;i<q;i++) adjBtns[3].click();
};
let pass=0, fail=0;
const eq=(label,got,want)=>{ const ok=String(got)===String(want);
console.log(`${ok?' ok ':' FAIL'} ${label}: got ${got}${ok?'':` want ${want}`}`); ok?pass++:fail++; };
console.log('\n=== 1. initial state (Night Market, start 4) ===');
eq('LEAD', leadNow(), 4); eq('round', roundNow(), '1/6');
eq('rung', rungNow(), 1); eq('outcome band', outcomeNow(), 'LOST, BUT');
console.log('\n=== 2. pursuers net +2 -> LEAD 2, round 2, rung 2 ===');
setNet(2,0); reg.applyBtn.click();
eq('LEAD', leadNow(), 2); eq('round', roundNow(), '2/6');
eq('rung', rungNow(), 2); eq('outcome', outcomeNow(), 'CORNERED');
console.log('\n=== 3. prey wins the round, net -3 -> LEAD 5 ===');
setNet(1,4); reg.applyBtn.click();
eq('LEAD', leadNow(), 5); eq('round', roundNow(), '3/6'); eq('outcome', outcomeNow(), 'GONE');
console.log('\n=== 4. undo restores LEAD 2 / round 2 AND the entered totals ===');
reg.undoBtn.click();
eq('LEAD', leadNow(), 2); eq('round', roundNow(), '2/6');
eq('pursuer entry restored', reg.pursuerVal.textContent, 1);
eq('prey entry restored', reg.preyVal.textContent, 4);
console.log('\n=== 5. LEAD hits 0 -> ENGAGED, chase ends, apply locked ===');
for(let i=0;i<4;i++) adjBtns[2].click(); // clear prey back to 0
eq('prey cleared', reg.preyVal.textContent, 0);
setNet(1,0); // pursuer 1 + 1 = 2, net +2 from LEAD 2
reg.applyBtn.click();
eq('LEAD', leadNow(), 0); eq('outcome', outcomeNow(), 'CHASE OVER — ENGAGED');
eq('apply disabled', reg.applyBtn.disabled, true);
eq('round did NOT advance', roundNow(), '2/6');
console.log('\n=== 6. reset, then run to the round cap ===');
reg.resetBtn.click();
eq('LEAD after reset', leadNow(), 4); eq('round after reset', roundNow(), '1/6');
for (let r=1; r<6; r++) reg.applyBtn.click(); // five net-0 rounds
eq('round at cap', roundNow(), '6/6'); eq('rung at cap', rungNow(), 6);
eq('LEAD unchanged by net 0', leadNow(), 4);
eq('overtime offered', reg.overtimeBtn.disabled, false);
console.log('\n=== 7. round 6 applied with no overtime -> chase ends on the cap ===');
reg.applyBtn.click();
eq('ended at cap', outcomeNow(), 'CHASE OVER — LOST, BUT');
eq('apply disabled', reg.applyBtn.disabled, true);
console.log('\n=== 8. overtime: drift -1 per round, Terminator stands, hard ceiling ===');
reg.overtimeBtn.click();
eq('LEAD drifted on entry', leadNow(), 3);
eq('round label', roundNow(), 'OT1/3');
eq('rung stays Terminator', rungNow(), 6);
reg.applyBtn.click();
eq('OT2 LEAD drift', leadNow(), 2); eq('OT2 label', roundNow(), 'OT2/3');
reg.applyBtn.click();
eq('OT3 LEAD drift', leadNow(), 1); eq('OT3 label', roundNow(), 'OT3/3');
reg.applyBtn.click();
eq('ceiling reached, ended', reg.applyBtn.disabled, true);
eq('final outcome', outcomeNow(), 'CHASE OVER — CORNERED');
console.log('\n=== 9. collateral pips: tap to set, tap current to step back ===');
reg.resetBtn.click();
reg.pips.children[3].click(); eq('tap pip 4 -> 4', collNow(), 4);
reg.pips.children[3].click(); eq('tap pip 4 again -> 3', collNow(), 3);
reg.pips.children[0].click(); eq('tap pip 1 -> 1', collNow(), 1);
console.log('\n=== 10. LEAD clamps at 8 (GHOST), never overflows ===');
reg.resetBtn.click();
setNet(0,9); reg.applyBtn.click();
eq('clamped to 8', leadNow(), 8); eq('outcome', outcomeNow(), 'CHASE OVER — GHOST');
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);