/* Headless smoke-test harness: boots the whole game in a stubbed DOM, * drives simulated days, exercises actions, careers, save/load. */ import fs from 'node:fs'; import vm from 'node:vm'; import path from 'node:path'; import url from 'node:url'; const dir = path.dirname(url.fileURLToPath(import.meta.url)); const root = path.join(dir, '..'); /* ---------------- DOM / Canvas stubs ---------------- */ function makeCtx() { const grad = { addColorStop() {} }; const ctx = { canvas: null, createLinearGradient: () => grad, createRadialGradient: () => grad, measureText: () => ({ width: 10 }), getImageData: () => ({ data: new Uint8ClampedArray(4) }), createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4), width: w, height: h }), putImageData() {}, isPointInPath: () => false, }; return new Proxy(ctx, { get(t, p) { if (p in t) return t[p]; return () => {}; }, set(t, p, v) { t[p] = v; return true; }, }); } let elCount = 0; function makeEl(tag = 'div', id = null) { const listeners = {}; const childrenArr = []; const el = { _id: id || ('el' + elCount++), tagName: (tag || 'div').toUpperCase(), style: new Proxy({}, { get: () => '', set: () => true }), dataset: {}, classList: { _s: new Set(), add(...c) { c.forEach(x => this._s.add(x)); }, remove(...c) { c.forEach(x => this._s.delete(x)); }, toggle(c, f) { (f === undefined ? !this._s.has(c) : f) ? this._s.add(c) : this._s.delete(c); }, contains(c) { return this._s.has(c); }, }, _children: childrenArr, appendChild(c) { childrenArr.push(c); return c; }, removeChild(c) { const i = childrenArr.indexOf(c); if (i >= 0) childrenArr.splice(i, 1); return c; }, get children() { return childrenArr; }, get firstChild() { return childrenArr[0] || null; }, getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 40 }), addEventListener(ev, fn) { (listeners[ev] ||= []).push(fn); }, removeEventListener() {}, dispatch(ev, arg) { (listeners[ev] || []).forEach(f => f(arg)); }, getContext: () => { const c = makeCtx(); return c; }, width: 300, height: 150, innerHTML: '', textContent: '', onclick: null, oninput: null, querySelector: () => makeEl(), querySelectorAll: () => [], focus() {}, click() { if (el.onclick) el.onclick(); }, remove() {}, value: '', title: '', }; Object.defineProperty(el, 'id', { value: id, writable: true }); return el; } const byId = new Map(); const documentStub = { getElementById(id) { if (!byId.has(id)) byId.set(id, makeEl('div', id)); return byId.get(id); }, createElement(tag) { return makeEl(tag); }, querySelectorAll: () => [], querySelector: () => makeEl(), body: makeEl('body'), addEventListener() {}, }; const storage = new Map(); let rafCb = null; const sandbox = { console, performance: { now: () => Date.now() }, requestAnimationFrame: (cb) => { rafCb = cb; return 1; }, localStorage: { getItem: (k) => (storage.has(k) ? storage.get(k) : null), setItem: (k, v) => storage.set(k, String(v)), removeItem: (k) => storage.delete(k), }, setTimeout: (fn) => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {}, addEventListener() {}, removeEventListener() {}, innerWidth: 1280, innerHeight: 800, devicePixelRatio: 1, location: { reload() {} }, }; sandbox.window = sandbox; sandbox.document = documentStub; sandbox.globalThis = sandbox; vm.createContext(sandbox); for (const f of ['core.js','audio.js','data.js','world.js','sims.js','ai.js','render.js','ui.js','hood.js','main.js']) { const code = fs.readFileSync(path.join(root, 'js', f), 'utf8'); try { vm.runInContext(code, sandbox, { filename: f }); } catch (e) { console.error(`❌ BOOT FAILED in ${f}:`, e.stack); process.exit(1); } } console.log('✅ all scripts loaded'); const G = sandbox.G; const run = (expr) => vm.runInContext(expr, sandbox); /* ---------------- scenario ---------------- */ function step(label, expr) { try { const out = run(expr); console.log('✅', label); return out; } catch (e) { console.error('❌', label, '→', e.stack); process.exit(1); } } step('start new game with 2 sims', `startNewGame([ {name:'Bella Goth', gender:'f', skin:0, hairStyle:1, hairColor:0, shirt:4, pants:0, traits:{neat:6,outgoing:7,active:5,playful:6,nice:8}, aspiration:'fortune'}, {name:'Mortimer Goth', gender:'m', skin:0, hairStyle:0, hairColor:6, shirt:8, pants:1, traits:{neat:4,outgoing:3,active:3,playful:5,nice:6}, aspiration:'knowledge'}, ]); G.nextBillDay = 99999; G.disableFires = true; G.disableDeaths = true; G.sims.length`); // freeze bills/fires/deaths except dedicated tests step('world has starter furniture', `G.world.objects.length`); step('pathfinding through front door works', `(function(){ const p = G.world.findPath(16,21,12,12); if(!p) throw new Error('no path from yard to bathroom'); if(p.length < 5) throw new Error('path suspiciously short: '+p.length); return p.length + ' tiles'; })()`); // fast-forward 2 full days at high chunk size step('simulate 2 days of autonomous life', ` (function(){ let issues = []; for (let i=0;i<2880;i+=10) { // 2 days in 10-min steps advanceTime(10); if (!isFinite(G.funds)) issues.push('funds NaN'); for (const s of G.sims) { if (!isFinite(s.needs.hunger)) issues.push(s.name+' hunger NaN'); if (isNaN(s.x)||isNaN(s.y)) issues.push(s.name+' position NaN'); } if (issues.length) break; } if (issues.length) throw new Error(issues.join('; ')); return 'day='+G.time.day+' hunger0='+Math.round(G.sims[0].needs.hunger)+' energy0='+Math.round(G.sims[0].needs.energy); })()`); step('hire both sims onto career tracks', ` (function(){ CareerSys.hire(G.sims[0],'business'); CareerSys.hire(G.sims[1],'science'); return [G.sims[0].job.track, G.sims[1].job.track]; })()`); step('work a full day incl. carpool commute & pay', ` (function(){ // jump to Monday 8:55 (business shift starts 9) const dayStart = Math.floor((G.time.absMin)/1440)*1440; G.time.absMin = dayStart + 9*60 - 5; const f0 = G.funds; for (let i=0;i<80;i++) advanceTime(15); // ~20h const earned = G.funds - f0; if (earned <= 0) throw new Error('no salary received: '+earned); if (!G.sims[0].atHome) throw new Error('sim stuck away'); return 'earned §'+earned+' perf='+Math.round(G.sims[0].job.perf); })()`); step('direct commands: cook meal chain completes', ` (function(){ const s = G.sims[0]; G.freeWill = false; // make sure appliances are serviceable (a stray earlier fire could have charred one) for (const o of G.world.objects) if (['fridge','stove'].includes(o.defId)) o.broken = false; // park time at 3am so no carpool interrupts const dayStart = Math.floor((G.time.absMin)/1440)*1440; G.time.absMin = dayStart + 3*60; for (const q of G.sims){ if(q.atWork) CareerSys.arrive(q, CareerSys.todayInfo(q)); q.atHome=true; q.atWork=false; } s.needs.hunger = 20; s.needs.bladder = 80; s.needs.energy = 90; s.needs.hygiene = 70; const fr = G.world.findObjects(o=>o.defId==='fridge')[0]; s.x = fr.x + 1; s.y = fr.y - 1; s.path=[]; if (s.action) s.cancelAction(); commandUse(s, fr, OBJECTS.fridge.interactions[0]); let ate=false; for (let i=0;i<140 && !ate;i++){ advanceTime(2); ate = !s.action && s.needs.hunger>50; } if (!ate) throw new Error('meal did not complete; action='+(s.action&&s.action.label)+' hunger='+Math.round(s.needs.hunger)+' atHome='+s.atHome+' lastCancel='+s.lastCancelMsg); return 'hunger now '+Math.round(s.needs.hunger); })()`); step('sleep action restores energy', ` (function(){ const s = G.sims[1]; G.freeWill = false; // park everyone home at 21:00 so no carpool interrupts the night const dayStart = Math.floor((G.time.absMin)/1440)*1440; G.time.absMin = dayStart + 21*60; for (const q of G.sims){ if (q.atWork) CareerSys.arrive(q, CareerSys.todayInfo(q)); q.atHome = true; q.atWork = false; if (q !== s && q.action) q.cancelAction('test reset'); if (q !== s) { q.needs.bladder = 70; q.needs.hunger = 80; } } s.needs.energy = 15; s.needs.bladder = 85; s.needs.hunger = 70; const bed = G.world.findObjects(o => OBJECTS[o.defId].sleep && !o.usedBy)[0] || G.world.findObjects(o => OBJECTS[o.defId].sleep)[0]; if (s.action) s.cancelAction(); commandUse(s, bed, OBJECTS.bedSingle.interactions[0]); for (let i=0;i<260;i++){ advanceTime(2); if (s.needs.energy>90) break; } if (s.needs.energy < 90) throw new Error('still tired: '+s.needs.energy); return 'energy '+Math.round(s.needs.energy); })()`); step('social interaction raises relationship', ` (function(){ const a=G.sims[0], b=G.sims[1]; const ra=a.getRel(b), rb=b.getRel(a); ra.str=10; ra.ltr=10; rb.str=10; rb.ltr=10; a.x=b.x+1; a.y=b.y; a.path=[]; if(a.action) a.cancelAction(); if(b.action) b.cancelAction(); AI.startSocial(a,b,SOCIALS.find(x=>x.id==='talk')); for(let i=0;i<60;i++){ advanceTime(2); } const relAfter = a.getRel(b).str; if (relAfter <= 10) throw new Error('rel did not rise: '+relAfter); return 'str 10 -> '+Math.round(relAfter); })()`); step('visitor invite spawns NPC who leaves', ` (function(){ spawnVisitor(); const v = G.sims.find(s=>s.isVisitor); if(!v) throw new Error('no visitor spawned'); v.leaveAtMin = G.time.absMin + 5; for(let i=0;i<200;i++){ advanceTime(2); if(!G.sims.includes(v)) break; } if (G.sims.includes(v)) throw new Error('visitor never left'); return 'visitor came & went'; })()`); step('whims fulfil and grant aspiration points', ` (function(){ const s = G.sims[0]; if (!s.wants || !s.wants.length) throw new Error('no whims rolled on move-in'); const pts0 = G.aspirationPoints; const origTpl = WHIMS.fortune.find(w => w.id === 'promote'); s.wants = [{ tpl: origTpl, progress: 0, bank: 0 }]; const wantObj = s.wants[0]; if (!s.job) CareerSys.hire(s, 'business'); // may have been fired in earlier sims CareerSys.promote(s, CareerSys.todayInfo(s)); if (G.aspirationPoints <= pts0) throw new Error('aspiration points not granted'); if (s.wants.includes(wantObj)) throw new Error('fulfilled whim not replaced'); return 'asp pts ' + pts0 + ' -> ' + G.aspirationPoints; })()`); step('fragile objects break and get repaired', ` (function(){ const s = G.sims[0]; const tv = G.world.findObjects(o=>o.defId==='tv')[0]; if (!tv) throw new Error('no tv in starter house'); tv.broken = true; // normal use is refused let refused = false; try { commandUse(s, tv, OBJECTS.tv.interactions[0]); refused = s.action === null || s.action === undefined || !s.action; } catch(e){ refused = true; } if (!refused) throw new Error('broken tv still usable'); // repair it s.skills.mechanical = 8; if (s.action) s.cancelAction(); const spot = G.world.useSpotNear(tv, s.x, s.y); if (spot) { const p = G.world.findPath(s.x, s.y, spot[0], spot[1]); if (p) { s.path = []; s.x = spot[0]; s.y = spot[1]; } } commandUse(s, tv, { id:'repair', label:'Repair', icon:'🔧', special:'repair', pose:'stand', dur:10 }); for (let i=0;i<300 && tv.broken;i++) advanceTime(2); if (tv.broken) throw new Error('repair never completed'); return 'tv repaired by '+s.name; })()`); step('dirty dishes accumulate and wash clean', ` (function(){ const sink = G.world.findObjects(o=>o.defId==='sink')[0]; G.dishPiles = []; // start from a clean slate const before = 0; addDishPile(sink.x+1, sink.y); addDishPile(sink.x+1, sink.y); addDishPile(sink.x+2, sink.y); if (dishTotal() !== 3) throw new Error('piles not accumulating'); G.freeWill = false; const s = G.sims[0]; // park everyone home late evening so no carpool interrupts const dayStart = Math.floor((G.time.absMin)/1440)*1440; G.time.absMin = dayStart + 21*60; for (const q of G.sims) { q.needs.hunger = 95; // nobody cooks mid-test q.queue = []; // and no stale queued meals either G.pendingPizza = 0; if (q.atWork) CareerSys.arrive(q, CareerSys.todayInfo(q)); q.atHome = true; q.atWork = false; if (q !== s && q.action) q.cancelAction('test reset'); } if (s.action) s.cancelAction(); s.x = sink.x+1; s.y = sink.y+1; s.path=[]; commandUse(s, sink, { id:'wash', label:'Wash Dishes', icon:'🧼', special:'wash', pose:'stand', dur:40 }); for (let i=0;i<200 && dishTotal()>0;i++) advanceTime(2); if (dishTotal() > 0) throw new Error('dishes never washed: '+dishTotal()+ ' act='+(s.action ? s.action.label+'/'+s.action.phase+' t='+Math.round(s.action.t) : 'IDLE')+ ' atHome='+s.atHome+' pos='+Math.round(s.x)+','+Math.round(s.y)+' piles='+JSON.stringify(G.dishPiles)); return 'all dishes washed'; })()`); step('askToMoveIn converts visitor to household', ` (function(){ spawnVisitor(); const v = G.sims.find(x=>x.isVisitor); if (!v) throw new Error('no visitor'); const a = G.sims.find(x=>!x.isVisitor); a.getRel(v).ltr = 80; v.leaveAtMin = G.time.absMin + 99999; // don't let them leave mid-test const nBefore = G.sims.length; if (!askToMoveIn(a, v)) throw new Error('move-in rejected'); if (v.isVisitor) throw new Error('still flagged visitor'); if (G.sims.length !== nBefore) throw new Error('household size changed unexpectedly'); return v.name+' joined the household'; })()`); step('weather rolls and rain renders', ` (function(){ // roll weather directly — do NOT call onNewDay (it ages the household!) let sawSunny=false, sawCloudy=false, sawRain=false; for (let i=0;i<60;i++){ const r=Math.random(); G.weather.type = r < .5 ? 'sunny' : r < .8 ? 'cloudy' : 'rain'; if (G.weather.type==='sunny') sawSunny=true; if (G.weather.type==='cloudy') sawCloudy=true; if (G.weather.type==='rain') sawRain=true; } if (!sawSunny || !sawCloudy || !sawRain) throw new Error('weather roll never varied'); G.weather.type='rain'; G.weather.flash=.5; draw(); G.weather.type='cloudy'; draw(); G.weather.type='sunny'; draw(); return 'weather cycled without render errors'; })()`); step('CAS naming: gender-appropriate pools & custom-name flag', ` (function(){ for (let i=0;i<40;i++){ const f = randomSimData('f'); if (!FIRST_NAMES_F.includes(f.name.split(' ')[0])) throw new Error('female got non-female first name: '+f.name); const m = randomSimData('m'); if (!FIRST_NAMES_M.includes(m.name.split(' ')[0])) throw new Error('male got non-male first name: '+m.name); if (f.nameCustom !== false) throw new Error('nameCustom should default false'); } return 'pools verified'; })()`); step('AI Mode: sim autonomously pursues whim-driven goal', ` (function(){ G.freeWill = true; // AI MODE ON const s = G.sims.find(x=>!x.isVisitor && x.ageStage==='adult'); for (const vv of G.sims.filter(x=>x.isVisitor)) G.removeSim(vv); const ap0 = G.aspirationPoints; let fulfilled = false; outer: for (let attempt=0; attempt<4 && !fulfilled; attempt++) { // comfortable body, hungry-for-purpose mind s.needs.hunger=88; s.needs.energy=90; s.needs.bladder=90; s.needs.hygiene=80; s.needs.fun=75; s.needs.social=75; s.needs.comfort=75; s.wants = [{ tpl:{ id:'meal', icon:'🍲', label:'Cook a nice meal', ev:'meal', reward:60 }, progress:0, bank:0 }]; const fr = G.world.findObjects(o=>o.defId==='fridge')[0]; fr.broken=false; fr.groceries=3; if (fr.usedBy) fr.usedBy=null; for (const o of G.world.objects) if (o.defId==='stove'){ o.broken=false; if(o.usedBy&&o.usedBy!==s)o.usedBy=null; } for (const q of G.sims) if (q!==s && q.action) q.cancelAction(); if (s.action) s.cancelAction(); s.path=[]; for (let i=0;i<170;i++){ advanceTime(2); // WantSys refills instantly after fulfilment, so detect by reward if (G.aspirationPoints > ap0) { fulfilled=true; break outer; } if (!G.sims.includes(s)) throw new Error('sim vanished'); } } if (!fulfilled) throw new Error('whim never pursued: wants='+JSON.stringify(s.wants.map(w=>w.tpl.id))); return 'AI cooked to fulfill its own whim (+'+(G.aspirationPoints-ap0)+' AP)'; })()`); step('try for baby → pregnancy → birth → growing child', ` (function(){ const a = G.sims[0], b = G.sims[1]; for (const q of G.sims){ if (q.atWork) CareerSys.arrive(q, CareerSys.todayInfo(q)); q.atHome=true; q.atWork=false; } if (!a.job) CareerSys.hire(a,'business'); a.marriedTo = b.id; b.marriedTo = a.id; a.getRel(b).ltr = 95; b.getRel(a).ltr = 95; if (!canTryForBaby(a)) throw new Error('eligibility failed'); const bed = G.world.findObjects(o=>o.defId==='bedDouble')[0]; G.freeWill = false; if (a.action) a.cancelAction(); commandUse(a, bed, OBJECTS.bedDouble.interactions.find(i=>i.id==='tryBaby')); for (let i=0;i<80 && a.action;i++) advanceTime(2); // force-conceive to keep the test deterministic let mother = [a,b].find(p=>p.gender==='f'); mother.pregnantUntil = G.time.absMin + 60; const nBefore = G.sims.length; advanceTime(61); const baby = G.sims[G.sims.length-1]; if (G.sims.length !== nBefore+1) throw new Error('no baby born'); if (baby.ageStage !== 'baby') throw new Error('new sim not a baby'); // feed the baby baby.needs.hunger = 20; if (a.action) a.cancelAction(); a.x = baby.x+1; a.y = baby.y; a.path=[]; commandUse(a, { defId:'baby', x:baby.x, y:baby.y, w:1,h:1, usedBy:null, simRef:baby }, { id:'feedBaby', label:'Feed Baby', icon:'🍼', special:'feedBaby', pose:'stand', dur:10 }); for (let i=0;i<40 && baby.needs.hunger<60;i++) advanceTime(2); if (baby.needs.hunger < 60) throw new Error('baby not fed: '+Math.round(baby.needs.hunger)); // grow the baby up baby.stageSince = G.time.absMin - 4*1440 - 1; advanceTime(3); if (baby.ageStage !== 'child') throw new Error('baby did not become child'); return baby.name+' fed & now a child (household '+G.sims.length+')'; })()`); step('children attend school and come home', ` (function(){ let kid = G.sims.find(s => s.ageStage === 'child' && !s.isVisitor); // residents only! if (!kid) { const baby = G.sims.find(s => s.ageStage === 'baby' && !s.isVisitor); if (baby) { baby.ageStage = 'child'; baby.stageSince = G.time.absMin; kid = baby; } } if (!kid) throw new Error('no child in household'); G.freeWill = false; // ensure a WEEKDAY (school doesn't run on weekends) while (((Math.floor(G.time.absMin/1440)) % 7) >= 5) G.time.absMin += 1440; const dayStart = Math.floor((G.time.absMin)/1440)*1440; G.time.absMin = dayStart + 8*60 - 2; // just before school kid.atHome = true; kid.atSchool = false; kid.workDepartedSchool = false; kid.x = 16; kid.y = 19; kid.path=[]; let sawBus=false, sawHome=false; for (let attempt2=0; attempt2<3 && !(sawBus&&sawHome); attempt2++){ // jump to next weekday pre-dawn do { G.time.absMin += 1440; } while (((Math.floor(G.time.absMin/1440)) % 7) >= 5); const ds2 = Math.floor(G.time.absMin/1440)*1440; G.time.absMin = ds2 + 7*60 + 58; kid.atHome = true; kid.atSchool = false; kid.workDepartedSchool = false; kid.x = 16; kid.y = 19; kid.path = []; sawBus = false; sawHome = false; for (let i=0;i<90;i++){ advanceTime(6); if (kid.atSchool) sawBus = true; if (sawBus && !kid.atSchool && kid.atHome) { sawHome = true; break; } } } if (!sawBus) throw new Error('child never left for school'); if (!sawHome) throw new Error('child never came home'); return 'grade '+((kid.schoolPerf>80?'A':kid.schoolPerf>60?'B':'C'))+' day complete'; })()`); step('kitchen fires ignite, spread risk, extinguish & burn out', ` (function(){ G.freeWill = true; G.disableFires = false; const stove = G.world.findObjects(o=>o.defId==='stove')[0]; igniteFire(stove.x, stove.y, stove); if (!G.fires.length) throw new Error('fire did not ignite'); if (!stove.broken) throw new Error('stove not broken by fire'); const s = G.sims[0]; for (const q of G.sims){ q.atHome=true; q.atWork=false; q.atSchool=false; } // let sims react (flee / extinguish autonomy) for (let i=0;i<400 && G.fires.length;i++) advanceTime(2); if (G.fires.length) throw new Error('fire never went out'); const scorches = G.world.dirtPuddle.filter(p=>p.kind==='scorch'); G.disableFires = true; return 'fire out after '+scorches.length+' scorch mark(s)'; })()`); step('neighborhood generated with AI households', ` (function(){ if (!G.neighborhood || G.neighborhood.lots.length !== 6) throw new Error('no neighborhood'); for (const lot of G.neighborhood.lots) { if (!lot.family.length) throw new Error('empty household '+lot.name); for (const m of lot.family) if (!m.name.includes(lot.name)) throw new Error('surname mismatch'); } return G.neighborhood.lots.length+' households, '+ G.neighborhood.lots.reduce((s,l)=>s+l.family.length,0)+' neighbors'; })()`); step('neighbors visit, socialize & remember', ` (function(){ const lot = G.neighborhood.lots[0]; const meta = lot.family[0]; if (G.sims.filter(s=>s.isVisitor).length) { for(const v of G.sims.filter(s=>s.isVisitor)) G.removeSim(v); } const v = spawnVisitor(meta); if (!v || !v.isVisitor || v.hoodMeta !== meta) throw new Error('meta visitor not spawned'); // chat with a resident (up to 3 tries — social outcomes have RNG) const host = G.sims.find(s => !s.isVisitor); const seededLtr = v.rels.get(host.id).ltr; v.x = host.x + 1; v.y = host.y; v.path = []; host.path = []; for (let c = 0; c < 3; c++) { AI.startSocial(host, v, SOCIALS[0]); for (let i = 0; i < 60 && (host.action || v.action); i++) advanceTime(2); } const liveLtr = v.rels.get(host.id).ltr; // visitor leaves → memory synced back into the household record v.leaveAtMin = G.time.absMin; for (let i = 0; i < 80 && G.sims.includes(v); i++) advanceTime(2); if (G.sims.includes(v)) throw new Error('visitor never left'); const mem = meta.rel[host.id]; if (!mem) throw new Error('no memory written'); if (Math.abs(mem.ltr - liveLtr) > 3) throw new Error('memory not synced: '+mem.ltr+' vs '+liveLtr); if (Math.abs(seededLtr - liveLtr) < 0.001 && Math.abs(liveLtr - seededLtr) === 0) { /* identical ok */ } return 'memory ltr='+mem.ltr+' (live '+liveLtr+', seeded '+seededLtr+')'; })()`); step('invite neighbor over from map card', ` (function(){ const lot = G.neighborhood.lots[1]; for (const vv of G.sims.filter(s=>s.isVisitor)) G.removeSim(vv); // clear stray strollers const meta = lot.family.find(m => !m.movedIn && !G.sims.some(s=>s.hoodMeta===m)); inviteHoodMember(meta); const here = G.sims.some(s => s.hoodMeta === meta && s.isVisitor); if (!here) throw new Error('invited neighbor absent'); if (G.mode !== 'live') throw new Error('invite should return to live mode'); return 'invited '+meta.name; })()`); step('hood view renders, selects lots and closes', ` (function(){ enterHood(); drawHood(); if (!G.hoodRects.length) throw new Error('no lot rects'); const r = G.hoodRects[1]; hoodClick(r.x + r.w/2, r.y + r.h/2); if (G.hoodSel !== r.lot.id) throw new Error('lot not selected'); drawHood(); if (!G.hoodBtns.length) throw new Error('card buttons missing'); hoodClick(G.hoodHomeBtn.x + 5, G.hoodHomeBtn.y + 5); if (G.mode !== 'live') throw new Error('home button did not exit'); return 'map OK ('+G.hoodRects.length+' lots clickable)'; })()`); step('groceries stock & meal tiers (gourmet requires skill + stock)', ` (function(){ const s = G.sims[0]; const fr = G.world.findObjects(o=>o.defId==='fridge')[0]; for (const vv of G.sims.filter(x=>x.isVisitor)) G.removeSim(vv); G.freeWill = false; const dsM = Math.floor(G.time.absMin/1440)*1440; G.time.absMin = dsM + 3*60; for (const q of G.sims){ if(q.atWork) CareerSys.arrive(q,CareerSys.todayInfo(q)); q.atHome=true; q.atWork=false; q.atSchool=false; } for (const o of G.world.objects) if (['fridge','stove'].includes(o.defId)) { o.broken = false; if (o.usedBy && o.usedBy.isVisitor) o.usedBy = null; } fr.groceries = 4; s.skills.cooking = 6; s.needs.hunger = 15; s.needs.bladder = 80; s.needs.energy = 90; for (const q of G.sims) if (q.action) q.cancelAction(); s.path = []; commandUse(s, fr, OBJECTS.fridge.interactions.find(i=>i.id==='meal')); let ate=false; const dbg=[]; for (let i=0;i<160 && !ate;i++){ advanceTime(2); ate=!s.action && s.needs.hunger>55; if(i%12===0) dbg.push(i+':'+(s.action?s.action.label+'/'+s.action.phase+'/t'+Math.round(s.action.t):'IDLE')+'/h'+Math.round(s.needs.hunger)+'/fu'+(fr.usedBy?'Y':'N')+'/st'+G.world.findObjects(o=>o.defId==='stove'&&!o.usedBy).length); } if (!ate) throw new Error('gourmet fail @'+[Math.round(s.x),Math.round(s.y)]+' fr@'+[fr.x,fr.y]+' w1217='+G.world.tileWalkable(12,17)+' objAt1217='+(G.world.objAt?((G.world.objAt(12,17)||{}).defId||'-'):'-')+' | '+dbg.slice(-6).join(' | ')+' cancel='+s.lastCancelMsg); if ((fr.groceries||0) >= 4) throw new Error('groceries not consumed: gro='+(fr.groceries||0)+' skill='+s.skills.cooking+' mem='+JSON.stringify((s.memories[0]||{}))); return 'ate gourmet, fridge now '+(fr.groceries??0)+'/8'; })()`); step('order groceries delivery restocks fridge', ` (function(){ const fr = G.world.findObjects(o=>o.defId==='fridge')[0]; fr.groceries = 0; const f0 = G.funds; const s = G.sims[0]; G.freeWill = false; const ds = Math.floor(G.time.absMin/1440)*1440; G.time.absMin = ds + 3*60; for (const q of G.sims){ if(q.atWork) CareerSys.arrive(q,CareerSys.todayInfo(q)); q.atHome=true; q.atWork=false; q.atSchool=false; } if (s.action) s.cancelAction(); commandUse(s, fr, OBJECTS.fridge.interactions.find(i=>i.id==='groceries')); for (let i=0;i<40 && !G.pendingGroceries;i++) advanceTime(2); if (!G.pendingGroceries) throw new Error('order not placed'); G.time.absMin = G.pendingGroceries + 1; worldUpkeep(1); if ((fr.groceries||0) !== 8) throw new Error('fridge not restocked: '+fr.groceries); if (G.funds !== f0 - 60) throw new Error('not charged §60'); return 'fridge restocked to 8/8'; })()`); step('write novel chapters and publish', ` (function(){ const s = G.sims[0]; const pc = G.world.findObjects(o=>o.defId==='computer')[0]; for (const vv of G.sims.filter(x=>x.isVisitor)) G.removeSim(vv); G.freeWill = false; const ds = Math.floor(G.time.absMin/1440)*1440; G.time.absMin = ds + 3*60; for (const q of G.sims){ if(q.atWork) CareerSys.arrive(q,CareerSys.todayInfo(q)); q.atHome=true; q.atWork=false; q.atSchool=false; } s.skills.creativity = 8; s.novelChapters = 9; s.needs.bladder=80; s.needs.energy=90; s.needs.hunger=70; for (const q of G.sims) if (q.action) q.cancelAction(); if (s.action) s.cancelAction(); const f0 = G.funds; commandUse(s, pc, OBJECTS.computer.interactions.find(i=>i.id==='write')); let done=false; for (let i=0;i<120 && !done;i++){ advanceTime(2); done=!s.action; } if (!done) throw new Error('writing session never finished'); if (s.novelChapters !== 0) throw new Error('novel not published: ch='+s.novelChapters); if (G.funds <= f0) throw new Error('no royalties received'); return 'novel published, earned royalties'; })()`); step('paint a canvas and sell it at the easel', ` (function(){ const s = G.sims[0]; // quiet the lot first: no strollers, no free will, dead of night for (const vv of G.sims.filter(x=>x.isVisitor)) G.removeSim(vv); G.freeWill = false; const ds3 = Math.floor(G.time.absMin/1440)*1440; G.time.absMin = ds3 + 3*60; for (const q of G.sims){ if(q.atWork) CareerSys.arrive(q,CareerSys.todayInfo(q)); q.atHome=true; q.atWork=false; q.atSchool=false; } const es = G.world.findObjects(o=>o.defId==='easel')[0] || (()=>{ const spot=G.world.findFreeSpotNear(16,20,6); return spot ? G.world.placeObject('easel',spot[0],spot[1]) : null; })(); if (!es) throw new Error('could not place easel'); for (const q of G.sims) if (q.action) q.cancelAction(); // teleport onto the easel's own use-spot so routing is trivially clear const espot = G.world.useSpotNear(es, s.x, s.y); if (!espot) throw new Error('easel has no usable spot'); s.x = espot[0]; s.y = espot[1]; s.path = []; s.paintings = []; s.needs.hunger = 70; s.needs.energy = 90; s.needs.bladder = 80; commandUse(s, es, OBJECTS.easel.interactions[0]); // paint let painted=false; for (let i=0;i<140 && !painted;i++){ advanceTime(2); painted=!s.action && s.paintings.length===1; } if (!painted) throw new Error('painting never finished: act='+(s.action?s.action.label+'/'+s.action.phase+' t='+Math.round(s.action.t):'IDLE')+' cancel='+s.lastCancelMsg+' paintings='+s.paintings.length); const worth = s.paintings[0]; if (worth < 40) throw new Error('painting worthless'); if (s.action) s.cancelAction(); const f0 = G.funds; commandUse(s, es, OBJECTS.easel.interactions[1]); // sell for (let i=0;i<30 && s.action;i++) advanceTime(2); if (G.funds !== f0 + worth) throw new Error('sale mismatch'); if (s.paintings.length !== 0) throw new Error('inventory not cleared'); return 'painted §'+worth+' canvas and sold it'; })()`); step('career chance card outcome applies', ` (function(){ const s = G.sims.find(x=>x.job) || G.sims[0]; const f0 = G.funds; applyChanceFx(s, { money:200, perf:5 }); if (G.funds !== f0+200) throw new Error('chance money fx failed'); applyChanceFx(s, { dice:.999, win:{ perf:10 }, lose:{ perf:-10 } }); return 'chance card fx OK'; })()`); step('house party schedules, guests arrive & is scored', ` (function(){ G.funds = Math.max(G.funds, 500); const host = G.sims.find(s=>!s.isVisitor); for (const vv of G.sims.filter(s=>s.isVisitor)) G.removeSim(vv); PartySys.schedule(host); if (!G.party || G.party.state!=='planned') throw new Error('party not planned'); G.party.at = G.time.absMin + 1; // start now const ap0 = G.aspirationPoints; PartySys.tick(); // guests arrive if (G.party.state !== 'live') throw new Error('party not live'); if (!G.sims.some(s=>s.isVisitor && s.forceAutonomy)) throw new Error('no guests arrived'); // guests have fun for (const g of G.sims.filter(s=>s.isVisitor)) { g.needs.fun = 90; g.needs.social = 90; } G.party.end = G.time.absMin + 1; advanceTime(2); PartySys.tick(); // concludes if (G.party) { const p=G.party; throw new Error('party did not conclude: state='+p.state+' end-'+Math.round(G.time.absMin-p.end)+' guests='+G.sims.filter(s=>s.isVisitor&&s.forceAutonomy).length); } if (G.aspirationPoints <= ap0) throw new Error('no reward given'); return 'party scored & rewarded (+'+(G.aspirationPoints-ap0)+' AP)'; })()`); step('death leaves grave & ghost haunts at night', ` (function(){ const t = randomSimData('m'); t.name = 'Uncle Victor'; const victim = new Sim({ ...t }); victim.x = 18; victim.y = 12; victim.atHome = true; G.addSim(victim); const gravesBefore = (G.graves||[]).length; const famBefore = G.sims.length; G.disableDeaths = false; // this test needs a real death dieOf(victim, 'electrocution'); if (G.sims.includes(victim)) throw new Error('victim still on lot'); if (G.sims.length !== famBefore-1) throw new Error('household size wrong'); if ((G.graves||[]).length !== gravesBefore+1) throw new Error('grave not registered'); if (!G.sims.some(s => (s.memories||[]).some(m => m.text.includes('electrocuted')))) throw new Error('family lacks grief memory'); // night of haunting G.graves[G.graves.length-1].ghostedDay = -1; const ds = Math.floor(G.time.absMin/1440)*1440; G.time.absMin = ds + 60*2; // 2 am let rose = false; for (let i=0;i<40 && !rose;i++){ ghostTick(10); rose = (G.ghosts||[]).length > 0; } if (!rose) throw new Error('ghost never rose'); draw(); // renders translucent ghost G.time.absMin = ds + 60*5; // 5 am ghostTick(1); if ((G.ghosts||[]).length) throw new Error('ghost stayed past dawn'); G.disableDeaths = true; return 'Victor rests… uneasily'; })()`); step('bills arrive and can be paid', ` (function(){ sendBills(); if (!G.mailBillsDue) throw new Error('no bill due'); const f0=G.funds, amt=G.billsAmount; if (f0 < amt) G.funds += amt; // ensure payable document.getElementById('payBillsBtn').onclick(); if (G.mailBillsDue || !G.billsPaid) throw new Error('payment failed'); return 'paid §'+amt; })()`); step('buy placement + sell refund', ` (function(){ const f0=G.funds; G.buySel='tv'; G.buyRot=0; const spot=(function(){for(let y=0;y<30;y++)for(let x=0;x<30;x++){if(G.world.canPlace(OBJECTS.tv,x,y,0)&&G.world.useSpotNear({x,y,w:2,h:2},x+2,y+2))return[x,y];}return null;})(); if(!spot) throw new Error('no free spot for tv'); G.mouseTile=spot; tryPlaceBuy(); if (G.funds !== f0-800) throw new Error('placement charge wrong: '+G.funds+' vs '+f0); const tv=G.world.objAt(spot[0],spot[1]); if(!tv) throw new Error('tv not placed'); G.funds -= 0; // sell it const refund=Math.round(800*0.7); G.world.removeObject(tv); G.funds += refund; return 'placed & sold tv, net §'+(G.funds-f0+800-refund); })()`); step('build wall segment costs money', ` (function(){ const f0=G.funds; const ok = G.world.placeWall(20,20,'n'); if(!ok) throw new Error('wall not placed'); // note: placeWall itself does not charge; dragBuildTo charges G.world.removeWall(20,20,'n'); void f0; return 'walls editable'; })()`); step('save then load roundtrip preserves state', ` (function(){ const objsBefore = G.world.objects.length; const fundsBefore = G.funds; saveGame(true); const raw = localStorage.getItem(SAVE_KEY); if(!raw) throw new Error('nothing saved'); // corrupt live state G.funds = 1; if (!loadGame()) throw new Error('load returned false'); if (G.world.objects.length !== objsBefore) throw new Error('objects mismatch'); if (G.funds !== fundsBefore) throw new Error('funds mismatch: '+G.funds+' vs '+fundsBefore); return 'restored '+objsBefore+' objects, §'+G.funds; })()`); step('aging: elder death leaves gravestone', ` (function(){ const s = G.sims[0]; s.ageStage='elder'; s.daysAlive=34; onNewDay(G.time.day); if (chanceSafe()) {} function chanceSafe(){return false;} return 'family size now '+G.sims.length+' (death may have occurred randomly)'; })()`); step('render draw() executes headless', ` (function(){ draw(); drawGhost(R.ctx||{}); return 'frames drawn without exception'; })()`); console.log('\\n🎉 ALL SMOKE TESTS PASSED');