The Sims Online 2D — full game: CAS, build mode, needs, AI Mode (whim-driven autonomy), careers+chance cards, neighborhood AI sims, death/ghosts, meal tiers+sickness, paintings/novels, house parties, memories, weather; 38 headless tests green
This commit is contained in:
@@ -0,0 +1,786 @@
|
||||
/* 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');
|
||||
Reference in New Issue
Block a user