Files

787 lines
35 KiB
JavaScript

/* 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');