/* ============================================================ * ai.js — Actions (multi-step), autonomy, socials, careers, * visitors, chores. The simulation brain. * ============================================================ */ 'use strict'; /* ============================================================ * Action — generic object-use with phases: goto → use * specials override behavior via switch in tick() * ============================================================ */ class Action { constructor(sim, spec) { this.sim = sim; this.def = spec.def || null; // interaction definition this.obj = spec.obj || null; // target object this.label = spec.label || '…'; this.icon = spec.icon || '❓'; this.pose = spec.pose || 'stand'; this.anim = spec.anim || null; this.fx = spec.fx || {}; this.skill = spec.skill || null; this.dur = spec.dur || 30; this.special = spec.special || null; this.nap = !!spec.nap; this.phase = 'goto'; this.t = 0; // minutes elapsed in phase this.targetTile = null; this.done = false; this.cancelMsg = null; this.data = {}; // scratch (plate target, etc.) this.moodSum = 0; this.moodN = 0; // for career performance sampling this.failedFx = null; } begin() { const sim = this.sim; if (!this.obj && !this.special) return this.finish(); // route to the object's use spot if (this.obj) { let spot = G.world.useSpotNear(this.obj, sim.x, sim.y); // sitting: try to stand directly beside chosen side; fine as-is if (!spot) return this.fail('No way through'); this.targetTile = spot; if (!(Math.round(sim.x) === spot[0] && Math.round(sim.y) === spot[1])) { const path = G.world.findPath(sim.x, sim.y, spot[0], spot[1]); if (!path) return this.fail("Can't reach it"); sim.setPath(path); sim.anim = 'walk'; } else this.phase = 'use'; // TV & similar: prefer sitting on a seat near it if (this.def?.pose === 'sitOrStand') { const tv = this.obj; const seats = G.world.findObjects(o => OBJECTS[o.defId].sit && !o.usedBy && dist2(o.x + o.w / 2, o.y + o.h / 2, tv.x + tv.w / 2, tv.y + tv.h / 2) < 30); if (seats.length) { const s = nearestOf(seats, sim); const sp = G.world.useSpotNear(s, sim.x, sim.y); if (sp) { const p2 = G.world.findPath(Math.round(sim.x), Math.round(sim.y), sp[0], sp[1]); if (p2) { this.targetTile = sp; sim.setPath(p2); this.data.seat = s; } } } } thought(sim, this.icon); } switch (this.special) { case 'cookMeal': return this.beginCook(); case 'sleep': break; // handled like normal but indefinite dur case 'findJob': break; default: break; } if (this.obj) { /* reserve */ if (this.obj.usedBy && this.obj.usedBy !== sim) return this.fail('In use'); } } fail(msg) { this.cancelMsg = msg || 'Cancelled'; this.sim.lastCancelMsg = this.cancelMsg; this.finish(); } finish() { this.done = true; const sim = this.sim; // release EVERY possible reservation (obj, seat, stove, dining chair…) for (const r of [this.obj, this.data.seat, this.data.stove, this.data.eatAt && this.data.eatAt.obj]) { if (r && r.usedBy === sim) r.usedBy = null; } if (sim.action === this) sim.action = null; if (sim.anim !== 'walk') sim.anim = 'idle'; sim.carryPlate = false; } /* ---------- cooking chain ---------- */ /** best meal this cook can manage (fancier tiers consume groceries) */ pickMeal() { const lvl = this.sim.skills.cooking || 0; const stock = this.data.fridge ? (this.data.fridge.groceries || 0) : 0; let best = MEALS[0]; for (const m of MEALS) if (m !== MEALS[0] && m.skill <= lvl && stock > 0) best = m; return best; } beginCook() { const sim = this.sim; this.phase = 'cook-fridge'; this.t = 0; this.data.fridge = this.obj; this.data.meal = this.pickMeal(); // next: stove const stoves = G.world.findObjects(o => o.defId === 'stove' && !o.usedBy); this.data.stove = stoves.length ? nearestOf(stoves, sim) : null; // eating spot: chair near table > stool > sofa > counter front > stand this.data.eatAt = findEatSpot(sim); } cookTick(min) { const sim = this.sim; this.t += min; const cookingLvl = sim.skills.cooking || 0; if (this.phase === 'cook-fridge') { if (this.t >= 6) { this.phase = 'cook-stove'; this.t = 0; if (!this.data.stove) { // no stove free: microwave-ish snack anyway this.phase = 'eat'; this.dur = 14; this.data.meal = MEALS[0]; sim.say('🥫'); return; } const spot = G.world.useSpotNear(this.data.stove, sim.x, sim.y); if (!spot) return this.fail('Stove blocked'); const p = G.world.findPath(Math.round(sim.x), Math.round(sim.y), spot[0], spot[1]); if (!p) return this.fail('Stove unreachable'); sim.setPath(p); } } else if (this.phase === 'cook-stove') { if (!sim.path.length) { sim.anim = 'idle'; this.data.stove.usedBy = sim; // cooking accidents! if (!G.disableFires) { const stoveDirty = this.data.stove.dirty > .5 ? 1.6 : 1; const igniteChance = .0011 * min * Math.max(0, 1 - cookingLvl * .07) * stoveDirty; if (chance(igniteChance)) { igniteFire(this.data.stove.x, this.data.stove.y, this.data.stove); sim.say('😱'); this.finish(); return; } } const need = Math.max(8, 26 - cookingLvl * 1.6); if (this.t >= need) { this.data.stove.usedBy = null; // fancy meals consume a grocery unit if (this.data.meal !== MEALS[0] && this.data.fridge) { this.data.fridge.groceries = Math.max(0, (this.data.fridge.groceries || 0) - 1); } sim.carryPlate = true; sim.gainSkill('cooking', .004 * this.t); this.phase = 'eat'; this.t = 0; // route to eat spot const e = this.data.eatAt; if (e) { const p2 = G.world.findPath(Math.round(sim.x), Math.round(sim.y), Math.round(e.x), Math.round(e.y)); if (p2) sim.setPath(p2); } } } } else if (this.phase === 'eat') { if (!sim.path.length) { // the eating clock starts when the sim actually sits down if (!this.data.arrivedEat) { this.data.arrivedEat = true; this.t = 0; } const e2 = this.data.eatAt; if (!this.data.eatSeated && e2 && e2.obj && !e2.obj.usedBy) { this.data.eatSeated = true; e2.obj.usedBy = sim; } if (e2 && e2.sit && this.data.eatSeated) sim.anim = 'sit'; else sim.anim = 'idle'; sim.carryPlate = false; this.dur = Math.max(10, 24 - cookingLvl * 1.2); // meal-quality scaled fx const meal = this.data.meal || MEALS[0]; const q = .8 + meal.hunger / 60; sim.needs.hunger = clamp(sim.needs.hunger + 3.4 * q * min * (1 + cookingLvl * .06), -5, 100); sim.needs.comfort = clamp(sim.needs.comfort + (.25 + meal.fun * .02) * min, 0, 100); sim.needs.fun = clamp(sim.needs.fun + (meal.fun / this.dur) * min, 0, 100); if (this.t >= this.dur) { if (e2?.obj?.usedBy === sim) e2.obj.usedBy = null; sim.say(meal.emoji); toast(`${meal.emoji} ${sim.name} enjoyed ${meal.name === 'Instant Noodles' ? 'a quick meal' : 'some ' + meal.name}!`); // dodgy cooking can upset the stomach if (cookingLvl < 2 && meal !== MEALS[0] && chance(.16)) makeSick(sim); if (meal.id === 'gourmet') { sim.needs.fun = clamp(sim.needs.fun + 15, 0, 100); sim.addMemory?.('🦞','Cooked a gourmet feast'); } WantSys.notify(sim, 'meal'); addDishPile(sim.x + .5, sim.y + .5); // someone has to wash this! this.finish(); } } } } /* ---------- master tick ---------- */ tick(min) { const sim = this.sim; this.moodSum += sim.moodScore() * min; this.moodN += min; if (this.special === 'cookMeal') return this.cookTick(min); // walking phase if (this.phase === 'goto') { if (!sim.path.length) { // arrived (or never moved) if (this.obj && this.obj.usedBy && this.obj.usedBy !== sim) return this.fail('In use'); this.phase = 'use'; this.t = 0; if (this.obj) this.obj.usedBy = sim; if (this.special === 'sleep') { this.dur = this.nap ? 120 : 1e9; } } return; } // using phase this.t += min; const d = this.def || {}; // poses & fx if (this.data.seat) { sim.anim = 'sit'; if (this.obj && this.obj.usedBy !== sim) { try { this.obj.usedBy = sim; } catch (e) {} } } else if (this.pose === 'sit' && sim.anim !== 'dance') sim.anim = 'sit'; if (this.pose === 'lie') sim.anim = 'lie'; if (this.pose === 'stand' && this.anim) sim.anim = this.anim; else if (this.pose === 'stand' && !this.anim) sim.anim = 'idle'; for (const k in this.fx) { if (k === 'social' && this.fx.social > 0) continue; // solo social only via phone etc. sim.needs[k] = clamp(sim.needs[k] + this.fx[k] * min, 0, 100); } if (this.skill) sim.gainSkill(this.skill.id, (this.skill.rate || .02) * min); // fragile electronics wear out with use if (this.obj && d.fragile && !this.obj.broken && chance((d.fragile || 0) * min * (1 - (sim.skills.mechanical || 0) * .06))) { this.obj.broken = true; AudioSys.sfx('error'); toast(`⚡ The ${OBJECTS[this.obj.defId].name} broke! Someone with mechanical skill should repair it.`, 'bad'); sim.say('😱'); this.finish(); return; } switch (this.special) { case 'sleep': { const quality = this.obj?.defId === 'bedDouble' ? 1.15 : 1; sim.needs.energy = clamp(sim.needs.energy + 0.62 * min * quality, 0, 100); if (Math.random() < .002) sim.say('💤'); const morning = G.time.hour >= 6 && G.time.hour < 11; const rested = sim.needs.energy >= 99; const wokeByBladder = sim.needs.bladder < 8; if ((rested && morning) || wokeByBladder || (this.nap && this.t >= this.dur)) { if (wokeByBladder) sim.say('🚽'); else if (rested && morning) WantSys.notify(sim, 'rested'); this.finish(); } return; } case 'clean': { this.obj.dirty = Math.max(0, this.obj.dirty - min / 22); sim.gainSkill('cleaning', .0015 * min); if (Math.random() < .01) sim.say('✨'); if (this.obj.dirty <= 0) { sim.say('✨'); WantSys.notify(sim, 'cleaned'); this.finish(); } return; } case 'emptyTrash': { this.obj.dirty = Math.max(0, this.obj.dirty - min / 8); sim.gainSkill('cleaning', .002 * min); if (this.obj.dirty <= 0) { sim.say('👍'); WantSys.notify(sim, 'cleaned'); this.finish(); } return; } case 'repair': { // sparks fly while working if (Math.random() < .01) sim.say('⚡'); sim.gainSkill('mechanical', .0022 * min); if (this.t >= this.dur) { const mech = sim.skills.mechanical || 0; const electrical = ['tv','stereo','computer','phone'].includes(this.obj.defId); const shock = !this.data.safe && chance(.45 - mech * .04 + (electrical && mech < 3 ? .18 : 0)); this.data.safe = true; // only one shock attempt per repair visit if (shock) { AudioSys.sfx('thud'); // big shocks on fragile electronics with unskilled hands can be lethal if (electrical && mech < 3 && chance(.07)) { dieOf(sim, 'electrocution'); return; } toast(`💥 ${sim.name} got SHOCKED repairing the ${OBJECTS[this.obj.defId].name}! (higher Mechanical helps)`, 'bad'); sim.say('⚡'); sim.needs.hygiene = clamp(sim.needs.hygiene - 18, 0, 100); sim.needs.energy = clamp(sim.needs.energy - 12, 0, 100); sim.needs.fun = clamp(sim.needs.fun - 10, 0, 100); this.t = 0; // keep trying } else { this.obj.broken = false; AudioSys.sfx('level'); toast(`🔧 ${sim.name} fixed the ${OBJECTS[this.obj.defId].name}!`, 'good'); sim.say('🔧'); WantSys.notify(sim, 'repaired'); this.finish(); } } return; } case 'wash': { const pile = this.data.pile || (this.data.pile = nearestDishPile(sim.x, sim.y)); if (!pile || pile.n <= 0) { sim.say('✨'); this.finish(); return; } pile.n -= min / 6; sim.gainSkill('cleaning', .0018 * min); if (Math.random() < .012) sim.say('🧼'); if (pile.n <= 0) removeDishPile(pile); if (dishTotal() === 0 || this.t >= this.dur + 60) { sim.say('✨'); WantSys.notify(sim, 'cleaned'); this.finish(); } return; } case 'tryBaby': { if (this.t >= this.dur) { const partner = pickBabyPartner(sim); if (!partner) { sim.say('😢'); toast('No suitable partner at home…', 'bad'); this.finish(); return; } if (chance(.6)) { // the female partner carries; same-sex couples adopt let mother = [sim, partner].find(p => p.gender === 'f' && !p.pregnantUntil); if (mother) { mother.pregnantUntil = G.time.absMin + 3 * 1440; toast(`🍼 ${mother.name} is expecting! A bundle of joy arrives in 3 days.`, 'good'); } else { G.pendingAdoption = G.time.absMin + 1440; toast(`📄 Adoption paperwork filed — a baby arrives tomorrow!`, 'good'); } AudioSys.sfx('kiss'); sim.say('💗'); partner.say('💗'); G.aspirationPoints += 100; } else { sim.say('😅'); } this.finish(); } return; } case 'feedBaby': { const baby = this.data.baby || (this.data.baby = this.obj?.simRef); if (!baby || !G.sims.includes(baby)) return this.finish(); baby.needs.hunger = clamp(baby.needs.hunger + 7 * min, 0, 100); baby.beingFed = true; sim.needs.social = clamp(sim.needs.social + .4 * min, 0, 100); if (this.t >= this.dur) { baby.say('😋'); sim.say('🤱'); baby.beingFed = false; this.finish(); } return; } case 'cuddleBaby': { const baby = this.data.baby || (this.data.baby = this.obj?.simRef); if (!baby || !G.sims.includes(baby)) return this.finish(); baby.needs.social = clamp(baby.needs.social + 5 * min, 0, 100); baby.needs.fun = clamp(baby.needs.fun + 3 * min, 0, 100); sim.needs.social = clamp(sim.needs.social + .8 * min, 0, 100); sim.needs.fun = clamp(sim.needs.fun + .5 * min, 0, 100); const ra = sim.getRel(baby), rb = baby.getRel(sim); ra.str = clamp(ra.str + .5 * min, -100, 100); rb.str = clamp(rb.str + .5 * min, -100, 100); if (Math.random() < .02) { baby.say('😄'); AudioSys.sfx('kiss'); } if (this.t >= this.dur) { sim.say('❤️'); this.finish(); } return; } case 'extinguish': { const fire = this.data.fire; if (!fire || !G.fires.includes(fire)) { sim.say('😮‍💨'); this.finish(); return; } fire.t -= min * 9; sim.gainSkill('body', .002 * min); if (chance(.004 * min)) { sim.needs.hygiene = clamp(sim.needs.hygiene - 25, 0, 100); sim.say('😵'); toast(`🔥 ${sim.name} got burned fighting the fire!`, 'bad'); } if (Math.random() < .03) sim.say('💦'); if (!G.fires.includes(fire) || fire.t <= 0) { sim.say('💪'); this.finish(); } else if (this.t >= this.dur * 3) this.finish(); return; } case 'writeNovel': { sim.gainSkill('creativity', .02 * min); if (Math.random() < .01) sim.say('✍️'); if (this.t >= this.dur) { sim.novelChapters = (sim.novelChapters || 0) + 1; const ch = sim.novelChapters; if (ch >= 10) { const pay = 300 + Math.round((sim.skills.creativity || 0) * 40); G.funds += pay; sim.novelChapters = 0; AudioSys.sfx('fanfare'); toast(`📚 ${sim.name} finished a novel and sold it for §${pay}!`, 'good'); sim.addMemory('📚', `Published a novel (§${pay})`); WantSys.notify(sim, 'earn'); } else { toast(`✍️ ${sim.name} wrote chapter ${ch}/10 of their novel.`, ''); sim.say('💻'); } this.finish(); } return; } case 'paint': { sim.gainSkill('creativity', .018 * min); if (Math.random() < .015) sim.say('🖌️'); if (this.t >= this.dur) { const value = 40 + Math.round((sim.skills.creativity || 0) * 22 + randi(0, 30)); sim.paintings.push(value); AudioSys.sfx('level'); toast(`🖼️ ${sim.name} finished a painting worth §${value}! Sell it from the easel.`, 'good'); if (!sim.hadFirstPainting) { sim.hadFirstPainting = true; sim.addMemory('🖼️', 'Painted their first canvas'); } this.finish(); } return; } case 'sellArt': { if (this.t >= this.dur) { const total = (sim.paintings || []).reduce((a, b) => a + b, 0); if (total > 0) { G.funds += total; sim.paintings = []; AudioSys.sfx('cash'); toast(`💵 ${sim.name} sold ${sim.paintings.length === 0 ? 'their artwork' : 'paintings'} for §${total}!`, 'good'); G.aspirationPoints += 20; } else { sim.say('😕'); toast('No paintings to sell yet — paint one first!', ''); } this.finish(); } return; } case 'groceries': { if (G.funds < 60) { toast('❌ Not enough money for groceries!', 'bad'); return this.finish(); } if (this.t >= this.dur) { G.funds -= 60; G.pendingGroceries = G.time.absMin + 45; toast('🛍️ Groceries ordered — delivery in under an hour.', 'good'); this.finish(); } return; } case 'throwParty': { if (G.funds < 50) { toast('❌ A party needs at least §50 for snacks!', 'bad'); return this.finish(); } if (this.t >= this.dur) { PartySys.schedule(sim); this.finish(); } return; } case 'findJob': { // waits for UI; UI closes by finishing action if (!G.ui.jobMenuOpenFor(sim)) { /* keep waiting quietly */ } if (this.t > 240) this.finish(); // gave up browsing return; } case 'pizza': { if (this.t >= 2 && !this.data.ordered) { this.data.ordered = true; if (G.funds < 40) { toast('❌ Not enough money for pizza!', 'bad'); return this.finish(); } G.funds -= 40; G.pendingPizza = G.time.absMin + 45; toast('🍕 Pizza ordered! Arriving soon…'); this.finish(); } return; } case 'inviteOver': { if (this.t >= 2 && !this.data.invited) { this.data.invited = true; spawnVisitor(); WantSys.notify(sim, 'visitor'); this.finish(); } return; } case 'chatPhone': break; // generic fx already applied default: break; } if (this.t >= this.dur) { if (d.id) sim.say(pickEndIcon(d.id)); this.finish(); } } } function pickEndIcon(id) { return ({ pee:'😌', shower:'✨', bathe:'🛁', wash:'🙌', sit:'🙂', nap:'⚡', watch:'📺', dance:'💃', workout:'💦', readLogic:'🧠', readCook:'🍳', readMech:'🔧', readFun:'📖', paint:'🎨', playPiano:'🎵', chess:'♟️', games:'🎮', write:'📖', snack:'🍎', practice:'💬', chatPhone:'📞' })[id] || '👌'; } function nearestOf(objs, sim) { let best = null, bd = 1e9; for (const o of objs) { const d = dist2(o.x + o.w / 2, o.y + o.h / 2, sim.x, sim.y); if (d < bd) { bd = d; best = o; } } return best; } /** chair near a table > stool > sofa/loveseat > null */ function findEatSpot(sim) { const seats = G.world.findObjects(o => OBJECTS[o.defId].sit && !o.usedBy); let bestChair = null, bd = 1e9; for (const s of seats) { if (s.defId !== 'chair') continue; // has a table neighbor? let hasTable = false; for (let dy = -1; dy <= s.h; dy++) for (let dx = -1; dx <= s.w; dx++) { const o = G.world.objAt(s.x + dx, s.y + dy); if (o && o !== s && o.defId === 'table') hasTable = true; } if (!hasTable) continue; const d = dist2(s.x, s.y, sim.x, sim.y); if (d < bd) { bd = d; bestChair = s; } } if (bestChair) return { x: bestChair.x + bestChair.w / 2, y: bestChair.y + bestChair.h / 2, obj: bestChair, sit:true }; const stools = seats.filter(s => s.defId !== 'chair'); if (stools.length) { const s = nearestOf(stools, sim); return { x:s.x+.5, y:s.y+.5, obj:s, sit:true }; } const counters = G.world.findObjects(o => o.defId === 'counter'); if (counters.length) { const c = nearestOf(counters, sim); const sp = G.world.useSpotNear(c, sim.x, sim.y); if (sp) return { x:sp[0], y:sp[1], obj:null }; } return null; } /* ============================================================ * SocialAction — two sims interact face-to-face * ============================================================ */ class SocialAction { constructor(initiator, target, soc) { this.a = initiator; this.b = target; this.soc = soc; this.t = 0; this.done = false; this.phase = 'goto'; } begin() { const a = this.a; const spot = spotBeside(a, this.b); if (!spot) { this.done = true; if (a.action === this) a.action = null; return false; } if (Math.round(a.x) !== spot[0] || Math.round(a.y) !== spot[1]) { const p = G.world.findPath(a.x, a.y, spot[0], spot[1]); if (!p) { this.done = true; if (a.action === this) a.action = null; return false; } a.setPath(p); a.anim = 'walk'; } this.b.busyWith = this.b.busyWith || null; thought(a, this.soc.icon); return true; } tick(min) { const a = this.a, b = this.b; if (b.atWork || !b.atHome) return this.end(); if (this.phase === 'goto' && !a.path.length) { this.phase = 'do'; this.t = 0; a.facing = dirFromDelta(b.x - a.x, b.y - a.y); b.facing = dirFromDelta(a.x - b.x, a.y - b.y); a.anim = 'idle'; b.anim = 'idle'; b.busyWith = a; a.busyWith = b; a.say(this.soc.icon); b.say(this.soc.id === 'insult' || this.soc.id === 'argue' ? '😡' : this.soc.icon); } if (this.phase === 'do') { this.t += min; // relationship changes ramp with duration const frac = min / this.soc.dur; const ra = a.getRel(b), rb = b.getRel(a); const niceModA = .7 + a.traits.nice * .06; ra.str = clamp(ra.str + this.soc.str * frac * niceModA, -100, 100); ra.ltr = clamp(ra.ltr + this.soc.ltr * frac * niceModA, -100, 100); rb.str = clamp(rb.str + this.soc.str * frac * (.7 + b.traits.nice * .06), -100, 100); rb.ltr = clamp(rb.ltr + this.soc.ltr * frac * (.7 + b.traits.nice * .06), -100, 100); a.needs.social = clamp(a.needs.social + 1.6 * min, 0, 100); b.needs.social = clamp(b.needs.social + 1.1 * min, 0, 100); a.needs.fun = clamp(a.needs.fun + .25 * min, 0, 100); if (a.aspiration === 'popularity') G.aspirationPoints += min * .05; if (this.soc.id === 'flirt' && ra.ltr > 70 && chance(.02)) { toast(`💘 ${a.name} and ${b.name} are falling in love!`, 'good'); a.say('😍'); b.say('😍'); WantSys.notify(a, 'love'); WantSys.notify(b, 'love'); } if (this.t >= this.soc.dur) { WantSys.notify(a, 'social', { sid: this.soc.id }); WantSys.notify(b, 'social', { sid: this.soc.id }); if (ra.ltr >= 50 && !ra.friendsToast) { ra.friendsToast = true; rb.friendsToast = true; toast(`🎉 ${a.name} and ${b.name} are now friends!`, 'good'); AudioSys.sfx('level'); WantSys.notify(a, 'friend'); WantSys.notify(b, 'friend'); } if (this.soc.id === 'flirt') AudioSys.sfx('kiss'); this.end(); } } } end() { this.done = true; if (this.a.busyWith === this.b) this.a.busyWith = null; if (this.b.busyWith === this.a) this.b.busyWith = null; if (this.a.action === this) this.a.action = null; this.a.talkCooldown = 45 + rand(0, 60); } } function spotBeside(sim, other) { const ox = Math.round(other.x), oy = Math.round(other.y); let best = null, bd = 1e9; for (const d of DIRS) { const tx = ox + d.dx, ty = oy + d.dy; if (!G.world.tileWalkable(tx, ty)) continue; const dd = dist2(tx, ty, sim.x, sim.y); if (dd < bd) { bd = dd; best = [tx, ty]; } } if (!best) return null; return best; } /* ============================================================ * Autonomy — pick something sensible when idle (Free Will) * ============================================================ */ const AI = { autonomize(sim, min) { if (!G.freeWill && !sim.forceAutonomy || sim.busyWith) return; // occasional idle chatter with nearby sims regardless of need level if (chance(.0009 * min)) { const buddy = this.socialCandidate(sim); if (buddy) { this.startSocial(sim, buddy, choice(['talk','joke','compliment'])); return; } } const N = sim.needs, T = sim.traits; const opts = []; const add = (score, fn) => { if (score > 0) opts.push({ score, fn }); }; /* --- urgent body needs --- */ if (N.bladder < 38) { const t = G.world.findObjects(o => o.defId === 'toilet' && !o.dirty); if (t.length) add(urgency(N.bladder, 38) * 3.0, () => commandUse(sim, t[0], OBJECTS.toilet.interactions[0])); } if (N.hunger < 45) { const f = G.world.findObjects(o => o.defId === 'fridge')[0]; if (f) add(urgency(N.hunger, 45) * 2.6, () => commandUse(sim, f, OBJECTS.fridge.interactions[0])); } if (N.energy < 42) { const beds = G.world.findObjects(o => OBJECTS[o.defId].sleep && !o.usedBy); if (beds.length) add(urgency(N.energy, 42) * 2.4, () => commandUse(sim, beds[0], OBJECTS.bedSingle.interactions[0])); else add(.4, () => AI.passOut(sim)); } if (N.hygiene < 42) { const showers = G.world.findObjects(o => ['shower','bathtub'].includes(o.defId) && !o.usedBy); if (showers.length) add(urgency(N.hygiene, 42) * 2.2, () => { const sh = nearestOf(showers, sim); commandUse(sim, sh, OBJECTS[sh.defId].interactions[0]); }); } /* --- mind needs --- */ if (N.fun < 42) { const w = 1.4 + T.playful * .09; const tvs = G.world.findObjects(o => o.defId === 'tv' && !o.usedBy); if (tvs.length) add(urgency(N.fun, 42) * w, () => commandUse(sim, nearestOf(tvs, sim), OBJECTS.tv.interactions[0])); const stereos = G.world.findObjects(o => o.defId === 'stereo' && !o.usedBy); if (T.active > 5 && stereos.length) add(urgency(N.fun, 42) * w * .9, () => commandUse(sim, nearestOf(stereos, sim), OBJECTS.stereo.interactions[0])); const easels = G.world.findObjects(o => o.defId === 'easel' && !o.usedBy); if (easels.length) add(urgency(N.fun, 42) * w * .6, () => commandUse(sim, easels[0], OBJECTS.easel.interactions[0])); const books = G.world.findObjects(o => o.defId === 'bookshelf' && !o.usedBy); if (books.length) add(urgency(N.fun, 42) * w * .5, () => commandUse(sim, books[0], choice(OBJECTS.bookshelf.interactions))); const pianos = G.world.findObjects(o => o.defId === 'piano' && !o.usedBy); if (pianos.length) add(urgency(N.fun, 42) * w * .6, () => commandUse(sim, pianos[0], OBJECTS.piano.interactions[0])); } if (N.social < 46) { const w = 1.3 + T.outgoing * .12; add(urgency(N.social, 46) * w, () => { const buddy = this.socialCandidate(sim, 14); if (buddy) this.startSocial(sim, buddy, choice(SOCIALS.filter(s => s.str > 0))); else { const ph = G.world.findObjects(o => o.defId === 'phone')[0]; if (ph) commandUse(sim, ph, OBJECTS.phone.interactions[0]); } }); } if (N.comfort < 40) { const seats = G.world.findObjects(o => OBJECTS[o.defId].sit && !o.usedBy); if (seats.length) add(urgency(N.comfort, 40) * 1.2, () => { const s = nearestOf(seats, sim); commandUse(sim, s, { ...OBJECTS[s.defId].interactions[0] }); }); } if (N.room < 36 || (T.neat > 6 && chance(.0006))) { const dirties = G.world.findObjects(o => (o.defId === 'toilet' || o.defId === 'trash') && o.dirty > .25); if (dirties.length) add(1.1 + T.neat * .12, () => { const o = nearestOf(dirties, sim); const inter = OBJECTS[o.defId].interactions.find(i => i.special === 'clean' || i.special === 'emptyTrash'); commandUse(sim, o, inter); }); } if (N.hunger > 90 && N.fun < 80 && chance(.001)) { /* well-off sims pick hobbies */ const tread = G.world.findObjects(o => o.defId === 'treadmill' && !o.usedBy)[0]; if (tread && T.active > 6) add(.5, () => commandUse(sim, tread, OBJECTS.treadmill.interactions[0])); } /* --- pursue PERSONAL GOALS (whims): AI mode purposefulness --- when body & mind are stable enough, sims chase their own wants */ if (N.hunger > 28 && N.energy > 26 && sim.wants && sim.wants.length && !sim.isVisitor) { const w = sim.wants[0]; const goal = 1.45; const announce = (fn) => () => { thought(sim, w.tpl.icon); fn(); }; const freeComp = () => G.world.findObjects(o => o.defId === 'computer' && !o.usedBy)[0]; const freeShelf = () => G.world.findObjects(o => o.defId === 'bookshelf' && !o.usedBy)[0]; const practiceSkill = (target) => { if (target === 'body') { const tr = G.world.findObjects(o => o.defId === 'treadmill' && !o.usedBy)[0]; if (tr) return commandUse(sim, tr, OBJECTS.treadmill.interactions[0]); } if (target === 'creativity') { const c = G.world.findObjects(o => ['easel','piano'].includes(o.defId) && !o.usedBy)[0]; if (c) return commandUse(sim, c, OBJECTS[c.defId].interactions[0]); } if (target === 'cooking' || target === 'mechanical') { const bs = freeShelf(); if (bs) { const inter = OBJECTS.bookshelf.interactions.find(i => i.id === (target === 'cooking' ? 'readCook' : 'readMech')); if (inter) return commandUse(sim, bs, inter); } } const pc = freeComp(); if (pc) commandUse(sim, pc, OBJECTS.computer.interactions.find(i => i.id === 'games')); }; switch (w.tpl.id) { case 'promote': case 'anylevel': case 'maxskill': { let target = null; if (w.tpl.id === 'maxskill') target = SKILLS.map(s => s.id).sort((a, b) => (sim.skills[b] || 0) - (sim.skills[a] || 0))[0]; else if (sim.job) { const info = CareerSys.todayInfo(sim); if (info) target = Object.keys(info.rank.req).sort((a, b) => (sim.skills[a] || 0) - (sim.skills[b] || 0))[0]; } if (!target) target = ['logic','cooking','mechanical','creativity','body','charisma'].sort((a, b) => (sim.skills[a] || 0) - (sim.skills[b] || 0))[0]; add(goal, announce(() => practiceSkill(target))); break; } case 'earn': case 'job': { add(goal, announce(() => { if (!sim.job) { const pc = freeComp(); if (pc) return commandUse(sim, pc, OBJECTS.computer.interactions.find(i => i.special === 'findJob')); } const es3 = G.world.findObjects(o => o.defId === 'easel' && !o.usedBy)[0]; if ((sim.skills.creativity || 0) > 1 && es3) return commandUse(sim, es3, OBJECTS.easel.interactions[0]); const pc2 = freeComp(); if (pc2) commandUse(sim, pc2, OBJECTS.computer.interactions.find(i => i.id === 'write')); })); break; } case 'meal': { const f = G.world.findObjects(o => o.defId === 'fridge' && !o.broken)[0]; if (f) add(goal * .9, announce(() => commandUse(sim, f, OBJECTS.fridge.interactions[0]))); break; } case 'rested': { if (N.energy < 85) { const beds = G.world.findObjects(o => OBJECTS[o.defId].sleep && !o.usedBy); if (beds.length) add(goal * .9, announce(() => commandUse(sim, beds[0], OBJECTS.bedSingle.interactions[0]))); } break; } case 'tidy': { const dirt = G.world.findObjects(o => (o.defId === 'toilet' || o.defId === 'trash') && o.dirty > .4)[0]; const sink = dishTotal() > 0 ? G.world.findObjects(o => o.defId === 'sink')[0] : null; if (dirt || sink) add(goal * .9, announce(() => { if (dirt) { const i = OBJECTS[dirt.defId].interactions.find(x => x.special === 'clean' || x.special === 'emptyTrash'); return commandUse(sim, dirt, i); } commandUse(sim, sink, { id:'wash', label:'Wash Dishes', icon:'🧼', special:'wash', pose:'stand', dur:40 }); })); break; } case 'host': { const ph = G.world.findObjects(o => o.defId === 'phone' && !o.usedBy)[0]; if (ph) add(goal * .85, announce(() => commandUse(sim, ph, OBJECTS.phone.interactions.find(i => i.special === 'inviteOver')))); break; } case 'flirt': case 'hug': case 'love': { add(goal, announce(() => { let best = null, bd = -99; for (const o of G.sims) { if (o === sim || !o.atHome || o.ageStage === 'baby') continue; const l = sim.getRel(o).ltr + (sim.marriedTo === o.id ? 30 : 0); if (l > bd) { bd = l; best = o; } } if (best) this.startSocial(sim, best, SOCIALS.find(s => s.id === (w.tpl.id === 'hug' ? 'hug' : 'flirt')) || SOCIALS[0]); })); break; } case 'chessy': case 'mixer': case 'friend': { add(goal * .95, announce(() => { const buddy = this.socialCandidate(sim, 16); if (buddy) this.startSocial(sim, buddy, choice(SOCIALS.filter(s => s.str > 0))); else { const ph = G.world.findObjects(o => o.defId === 'phone' && !o.usedBy)[0]; if (ph) commandUse(sim, ph, OBJECTS.phone.interactions[0]); } })); break; } } } /* --- chores: repair & dishes --- */ const broken = G.world.findObjects(o => o.broken); if (broken.length && (sim.skills.mechanical || 0) >= 1) { const mech = sim.skills.mechanical || 0; add(1.0 + mech * .12, () => commandUse(sim, nearestOf(broken, sim), { id:'repair', label:'Repair', icon:'🔧', special:'repair', pose:'stand', dur: Math.max(14, 50 - mech * 4), })); } if (dishTotal() > 2 && T.neat >= 3) { const sink = G.world.findObjects(o => o.defId === 'sink')[0]; if (sink) add(.8 + T.neat * .1, () => commandUse(sim, sink, { id:'wash', label:'Wash Dishes', icon:'🧼', special:'wash', pose:'stand', dur:40 })); } /* --- heroics & childcare --- */ if (G.fires.length) { add(4.0, () => { const f = nearestOf(G.fires.map(fl => ({ ...fl, w: 1, h: 1 })), sim); commandUse(sim, { defId:'fire', x: f.x, y: f.y, w:1, h:1, usedBy:null }, { id:'extinguish', label:'Extinguish Fire', icon:'🧯', special:'extinguish', pose:'stand', dur:12 }); if (sim.action && sim.action.special === 'extinguish') sim.action.data.fire = G.fires.find(fl => fl.x === f.x && fl.y === f.y); }); } const upsetBaby = G.sims.find(b => b.ageStage === 'baby' && b.atHome && (b.needs.hunger < 48 || b.needs.social < 42 || b.needs.bladder < 25 || b.needs.energy < 30)); if (upsetBaby && sim.ageStage === 'adult' && !sim.isVisitor) { const hungry = upsetBaby.needs.hunger < 55; add(2.7, () => commandUse(sim, { defId:'baby', x: upsetBaby.x, y: upsetBaby.y, w:1, h:1, usedBy:null, simRef: upsetBaby }, hungry ? { id:'feedBaby', label:'Feed Baby', icon:'🍼', special:'feedBaby', pose:'stand', dur:20 } : { id:'cuddleBaby', label:'Cuddle Baby', icon:'🤱', special:'cuddleBaby', pose:'stand', dur:16 })); } if (!opts.length) { // gentle wander if (chance(.0007 * min)) { const tx = clamp(Math.round(sim.x + rand(-5, 5)), 1, LOT_W - 2); const ty = clamp(Math.round(sim.y + rand(-5, 5)), 1, LOT_H - 2); const p = G.world.findPath(sim.x, sim.y, tx, ty); if (p) { sim.setPath(p); sim.anim = 'walk'; } } return; } // weighted pick among top candidates opts.sort((a, b) => b.score - a.score); const top = opts.slice(0, Math.min(3, opts.length)); let sum = top.reduce((s, o) => s + o.score, 0), r = Math.random() * sum; for (const o of top) { r -= o.score; if (r <= 0) { o.fn(); return; } } }, socialCandidate(sim, radius = 9) { let best = null, bd = radius * radius; for (const o of G.sims) { if (o === sim || !o.atHome || o.atWork || o.anim === 'lie' || o.busyWith) continue; const d = dist2(o.x, o.y, sim.x, sim.y); if (d < bd) { bd = d; best = o; } } return best; }, startSocial(a, b, soc) { if (typeof soc === 'string') soc = SOCIALS.find(s => s.id === soc); if (a.action || a.path.length) return; const act = new SocialAction(a, b, soc); if (act.begin()) a.action = act; }, passOut(sim) { if (sim.action) sim.cancelAction('Passed out'); AudioSys.sfx('thud'); const act = new Action(sim, { label:'Passed Out', icon:'😵', dur:180, pose:'lie', fx:{}, special:null }); act.begin = function () { this.phase = 'use'; sim.anim = 'lie'; }; act.tick = function (min) { this.t += min; sim.needs.energy = clamp(sim.needs.energy + .45 * min, 0, 100); if (this.t >= this.dur || sim.needs.energy > 75) this.finish(); }; sim.action = act; act.begin(); }, visitorLeave(v) { if (v.leavePending) return; toast(`👋 ${v.name} headed home.`); const edge = G.world.findFreeSpotNear(Math.round(v.x), LOT_H - 2, 10) || [Math.round(v.x), LOT_H - 2]; const p = G.world.findPath(v.x, v.y, edge[0], edge[1]); v.leavePending = true; if (p && p.length > 1) { v.setPath(p); v.anim = 'walk'; if (v.action) v.cancelAction('Leaving'); } else G.removeSim(v); }, }; function urgency(val, threshold) { return Math.pow(clamp((threshold - val) / threshold, 0, 1), 1.4) + .05; } /* ============================================================ * Player commands (from UI) * ============================================================ */ function commandUse(sim, obj, inter, opts = {}) { if (!inter) return; if (obj && obj.broken && inter.special !== 'repair') { toast(`⚡ The ${OBJECTS[obj.defId].name} is broken — repair it first!`, 'bad'); return; } if (obj && obj.usedBy && obj.usedBy !== sim) { toast(`${(OBJECTS[obj.defId]||{}).emoji||''} That's being used right now.`, 'bad'); return; } if (sim.action?.special === 'findJob') sim.action.finish(); const spec = { def: inter, obj, label: inter.label, icon: inter.icon, pose: inter.pose || 'stand', fx: inter.fx || {}, skill: inter.skill, dur: inter.dur || 30, special: inter.special, anim: inter.anim, nap: inter.nap, }; const act = new Action(sim, spec); if (opts.queue && sim.queue.length < 4) { sim.queue.push(act); thought(sim, '⏳'); } else { if (sim.action) sim.action.finish(); if (sim.path.length) sim.path = []; sim.action = act; act.begin(); if (act.done) sim.action = null; } } function commandGoHere(sim, x, y) { const p = G.world.findPath(sim.x, sim.y, x, y); if (!p) { toast('🚧 No path there.', 'bad'); return; } if (sim.action) sim.action.finish(); sim.queue = []; sim.setPath(p.slice(0, -1)); // stop ON clicked tile: include endpoint sim.path = p; sim.anim = 'walk'; } /* ============================================================ * Careers — carpool, performance, promotion, firing * ============================================================ */ const CareerSys = { todayInfo(sim) { if (!sim.job) return null; const cdef = CAREERS.find(c => c.id === sim.job.track); const rank = cdef.ranks[Math.min(sim.job.rank, 9)]; const dow = (G.time.day - 1) % 7; const off = rank.offDays.includes(dow); return { cdef, rank, off, dow, start: rank.hours[0], end: rank.hours[1] }; }, /** called once per game-minute from main loop — wrap-safe for night shifts */ tick(min) { const h = G.time.hourFloat; for (const sim of G.sims) { const info = this.todayInfo(sim); if (!info || info.off) continue; const start = info.start, end = info.end; const shiftLen = ((end - start) + 24) % 24; // hours const sinceStart = ((h - start) + 24) % 24; // hours since shift began if (sim.atWork) { sim.job.perf = clamp((sim.job.perf ?? 50) + this.perfDelta(sim, info) * min / 60, 0, 100); // chance cards — one per workday, small probability per minute if (!G.pendingChance && sim.cardDay !== G.time.day && chance(.00045 * min)) { sim.cardDay = G.time.day; G.pendingChance = { simId: sim.id, card: choice(CHANCE_CARDS) }; G.prevSpeedBeforeCard = G.speed || 1; setSpeed(0); Bus.emit('chanceCard'); } if (sinceStart >= shiftLen && sinceStart < shiftLen + 12) this.arrive(sim, info); continue; } if (!sim.atHome) continue; // carpool notice then departure any time during the shift window if (!sim.workDeparted && sinceStart >= 0 && sinceStart < shiftLen) { if (sinceStart < 1 && !sim.notifiedCarpool) { sim.notifiedCarpool = true; toast(`🚐 Carpool for ${sim.name} (${info.rank.title}) is here!`); sim.say('🚐'); } if (sinceStart >= 0.98) this.depart(sim, info); continue; } // missed the shift entirely? if (!sim.workDeparted && sinceStart >= shiftLen && sinceStart < shiftLen + 6) { const missedKey = 'missed' + G.time.day; if (sim[missedKey] !== true) { sim[missedKey] = true; sim.job.perf = clamp((sim.job.perf ?? 50) - 14, 0, 100); sim.workdaysMissed++; toast(`📉 ${sim.name} missed work as ${info.rank.title}! (${sim.workdaysMissed}/2 strikes)`, 'bad'); if (sim.workdaysMissed >= 2) this.fire(sim); } } } }, depart(sim, info) { sim.workDeparted = true; sim.atHome = false; sim.atWork = true; sim.notifiedCarpool = false; sim.workMoodSum = 0; sim.workMoodN = 0; toast(`🚐 ${sim.name} left for work as ${info.rank.title}.`); AudioSys.sfx('horn'); Bus.emit('simsChanged'); }, arrive(sim, info) { sim.atWork = false; sim.atHome = true; sim.workDeparted = false; G.funds += info.rank.salary; const moodAvg = sim.workMoodN ? sim.workMoodSum / sim.workMoodN : 50; const delta = (moodAvg - 50) * .08 + this.skillFit(sim, info) * 2.5; sim.job.perf = clamp((sim.job.perf ?? 50) + delta, 0, 100); toast(`💼 ${sim.name} earned ${fmtMoney(info.rank.salary)} as ${info.rank.title}.`); if (sim.job.perf >= 100) this.promote(sim, info); else if (sim.job.perf <= 0) { sim.workdaysMissed++; if (sim.workdaysMissed >= 2) this.fire(sim); } // place at lot entrance sim.x = G.world.mailbox.x; sim.y = G.world.mailbox.y + 1; sim.path = []; Bus.emit('simsChanged'); Bus.emit('fundsChanged'); }, perfDelta(sim, info) { // sampled while away: approximate with home needs snapshot at depart + skills const mood = sim.workMoodN ? sim.workMoodSum / sim.workMoodN : 60; return (mood - 52) * .05 + this.skillFit(sim, info) * .9; }, skillFit(sim, info) { let fit = 0, n = 0; for (const k in info.rank.req) { fit += clamp(sim.skills[k] / info.rank.req[k], 0, 1.2); n++; } return n ? fit / n : .8; }, promote(sim, info) { if (sim.job.rank >= 9) { sim.job.perf = 85; return; } sim.job.rank++; sim.job.perf = 40; const nr = info.cdef.ranks[sim.job.rank]; toast(`⭐ PROMOTION! ${sim.name} is now ${nr.title} (${fmtMoney(nr.salary)}/day)!`, 'good'); AudioSys.sfx('fanfare'); sim.say('🏆'); G.aspirationPoints += sim.aspiration === 'fortune' ? 150 : 60; if (sim.aspiration === 'fortune') G.funds += 300; WantSys.notify(sim, 'promotion'); }, fire(sim) { const info = this.todayInfo(sim); toast(`❌ ${sim.name} was fired from the ${info.cdef.trackName}!`, 'bad'); AudioSys.sfx('error'); sim.say('😭'); sim.job = null; sim.workdaysMissed = 0; Bus.emit('simsChanged'); }, hire(sim, trackId) { sim.job = { track: trackId, rank: 0, perf: 50 }; sim.workdaysMissed = 0; const c = CAREERS.find(x => x.id === trackId); toast(`📋 ${sim.name} got a job: ${c.ranks[0].title}!`, 'good'); AudioSys.sfx('chime'); sim.say('😊'); WantSys.notify(sim, 'job'); Bus.emit('simsChanged'); }, }; /* ============================================================ * Wants & Aspiration — whims system * ============================================================ */ const WantSys = { roll(sim, count = 2) { if (sim.ageStage === 'baby') return; // babies want nothing but milk and naps if (!sim.wants) sim.wants = []; const pool = WHIMS[sim.aspiration] || []; let guard = 24; while (sim.wants.length < count && guard-- > 0) { const tpl = choice(pool); if (sim.wants.some(w => w.tpl.id === tpl.id)) continue; sim.wants.push({ tpl, progress: 0, bank: 0 }); } }, notify(sim, ev, data = {}) { if (!sim || !sim.wants || G.mode === 'title') return; for (let i = sim.wants.length - 1; i >= 0; i--) { const w = sim.wants[i], t = w.tpl; if (t.ev !== ev) continue; if (t.level && !(data.skill && data.level >= t.level)) continue; if (t.match && data.sid !== t.match) continue; if (t.count) { w.progress++; if (w.progress < t.count) continue; } if (t.amount && ev === 'earn') { w.bank += (data.amount || 0); if (w.bank < t.amount) continue; } // fulfilled! sim.wants.splice(i, 1); G.aspirationPoints += t.reward; toast(`${t.icon} Whim fulfilled: ${sim.name} — ${t.label} (+${t.reward} aspiration)`, 'good'); AudioSys.sfx('chime'); this.roll(sim, Math.min(2, sim.wants.length + 1)); Bus.emit('simsChanged'); } }, }; function spawnVisitor() { if (G.sims.filter(s => s.isVisitor).length >= 2) { toast('🏠 You already have company!'); return; } const data = randomSimData(); data.name = data.name.split(' ')[0] + ' ' + choice(LAST_NAMES); const v = new Sim({ ...data, isVisitor:true, x: G.world.mailbox.x, y: LOT_H - 2 }); v.leaveAtMin = G.time.absMin + 240 + randi(0, 120); v.needs.social = 40; G.addSim(v); toast(`👋 ${v.name} dropped by to visit!`, 'good'); const spot = G.world.findFreeSpotNear(G.world.mailbox.x, LOT_H - 4, 8); if (spot) { const p = G.world.findPath(v.x, v.y, spot[0], spot[1]); if (p) v.setPath(p); } } /* ============================================================ * Dirty dishes — piles accumulate after meals, washed at sinks * ============================================================ */ function addDishPile(x, y) { x = Math.round(x); y = Math.round(y); let pile = G.dishPiles.find(p => dist2(p.x, p.y, x, y) < 9); if (pile) { pile.x = (pile.x * pile.n + x) / (pile.n + 1); pile.y = (pile.y * pile.n + y) / (pile.n + 1); pile.n++; } else G.dishPiles.push({ x, y, n: 1 }); } function removeDishPile(pile) { G.dishPiles = G.dishPiles.filter(p => p !== pile); } function dishTotal() { return G.dishPiles.reduce((s, p) => s + p.n, 0); } function nearestDishPile(x, y) { let best = null, bd = 1e9; for (const p of G.dishPiles) { const d = dist2(p.x, p.y, x, y); if (d < bd) { bd = d; best = p; } } return best; } /* ============================================================ * Death, the Grim Reaper & ghosts * ============================================================ */ const DEATH_CAUSES = { oldage: { icon:'⏳', text:'died of old age' }, starvation: { icon:'🍽️', text:'starved to death' }, electrocution:{ icon:'⚡', text:'was electrocuted' }, fire: { icon:'🔥', text:'burned to death' }, }; function dieOf(sim, cause) { if (sim.dead) return; // test/tuning switch: near-death instead of actual death if (G.disableDeaths) { sim.needs.hunger = Math.max(sim.needs.hunger, 45); sim.needs.energy = Math.max(sim.needs.energy, 45); sim.burnT = 0; sim.starveT = 0; return; } sim.dead = true; const c = DEATH_CAUSES[cause] || DEATH_CAUSES.oldage; AudioSys.sfx('toll'); AudioSys.sfx('error'); toast(`☠️ ${sim.name} ${c.text}! The Grim Reaper has come…`, 'bad'); // mourners for (const s of G.sims) { if (s === sim || s.isVisitor) continue; s.say('😭'); s.needs.social = clamp(s.needs.social - 15, 0, 100); s.addMemory?.(c.icon, `${sim.name} ${c.text}`); if (!s.action || s.action.special !== 'sleep') thought(s, '😢'); } // grave marker at the spot const gx = clamp(Math.round(sim.x), 1, LOT_W - 2), gy = clamp(Math.round(sim.y), 1, LOT_H - 2); try { G.world.placeObject('gravestone', gx, gy); } catch (e) { /* spot blocked — place beside */ try { G.world.placeObject('gravestone', clamp(gx+1,1,LOT_W-2), gy); } catch (_) {} } G.graves ||= []; G.graves.push({ x: gx, y: gy, snap: { name: sim.name, gender: sim.gender, skin: sim.skin, hairColor: sim.hairColor, shirt: sim.shirt, ageStage: sim.ageStage, cause }, }); // family instantly knows grief in relationships? keep simple G.removeSim(sim); } /** ghosts rise from graves between 1–4 am and spook the living */ function ghostTick(min) { const h = G.time.hourFloat; const nightWindow = h >= 1 && h < 4; // spawn from graves if (nightWindow && (G.ghosts || []).length < 3) { for (const g of (G.graves || [])) { if (g.ghostedDay === G.time.day) continue; if (!chance(.02 * min)) continue; g.ghostedDay = G.time.day; G.ghosts.push({ x: g.x, y: g.y, tx: g.x, ty: g.y, snap: g.snap, wobble: Math.random() * 6 }); toast(`👻 ${g.snap.name}'s ghost rises from the grave…`, 'bad'); AudioSys.sfx('thud'); } } // despawn at dawn if (!nightWindow && G.ghosts && G.ghosts.length) { G.ghosts.length = 0; return; } for (const s of G.sims) s.scaredT = Math.max(0, (s.scaredT || 0) - min); for (const gh of (G.ghosts || [])) { // slow drift toward random nearby tile if ((gh.x === gh.tx && gh.y === gh.ty) || chance(.004 * min)) { const d = choice(DIRS); const nx = clamp(Math.round(gh.x + d.dx * randi(1, 2)), 1, LOT_W - 2); const ny = clamp(Math.round(gh.y + d.dy * randi(1, 2)), 1, LOT_H - 2); gh.tx = nx; gh.ty = ny; } gh.x = lerp(gh.x, gh.tx, .05 * min); gh.y = lerp(gh.y, gh.ty, .05 * min); // scare! for (const s of G.sims) { if (!s.atHome || (s.scaredT || 0) > 0) continue; if (dist2(s.x, s.y, gh.x, gh.y) < 2.6 && chance(.01 * min)) { s.scaredT = 30; // cooldown minutes s.cancelAction('Scared by a ghost!'); s.say('😱'); AudioSys.sfx('horn'); s.needs.fun = clamp(s.needs.fun - 12, 0, 100); s.needs.energy = clamp(s.needs.energy - 5, 0, 100); toast(`👻 ${s.name} was scared stiff by ${gh.snap.name}'s ghost!`, 'bad'); s.addMemory?.('👻', `Haunted by ${gh.snap.name}'s ghost`); } } } } /** food poisoning & colds */ function makeSick(sim) { if (!sim || sim.dead) return; if (sim.sickUntil && G.time.absMin < sim.sickUntil) return; sim.sickUntil = G.time.absMin + randi(600, 1000); toast(`🤢 ${sim.name} has come down with something…`, 'bad'); sim.say('🤢'); } /* ============================================================ * Love & household growth — propose marriage / move in * ============================================================ */ function proposeMarriage(a, b) { const r = a.getRel(b); if (r.ltr < 85) return false; a.marriedTo = b.id; b.marriedTo = a.id; r.ltr = clamp(r.ltr + 10, -100, 100); b.getRel(a).ltr = clamp(b.getRel(a).ltr + 10, -100, 100); AudioSys.sfx('fanfare'); toast(`💍 ${b.name} said YES! ${a.name} and ${b.name} are engaged!`, 'good'); a.say('💍'); b.say('💍'); G.aspirationPoints += 200; WantSys.notify(a, 'love'); WantSys.notify(b, 'love'); Bus.emit('simsChanged'); return true; } function askToMoveIn(a, b) { const r = a.getRel(b); if (!b.isVisitor || r.ltr < 65) return false; b.isVisitor = false; b.leavePending = false; b.needs.social = 90; r.ltr = clamp(r.ltr + 15, -100, 100); b.getRel(a).ltr = clamp(b.getRel(a).ltr + 15, -100, 100); AudioSys.sfx('fanfare'); toast(`🏡 ${b.name} has moved in with ${a.name}'s household! Welcome home!`, 'good'); b.say('🏠'); a.say('🤗'); rebuildPortraits(); WantSys.roll(b); selectSim(b); return true; } /* ---------------- babies ---------------- */ function canTryForBaby(sim) { if (!sim || sim.atWork || !sim.atHome || sim.ageStage !== 'adult' || sim.pregnantUntil) return false; return !!pickBabyPartner(sim); } function pickBabyPartner(sim) { let best = null, bd = -1; for (const o of G.sims) { if (o === sim || o.isVisitor || o.atWork || !o.atHome) continue; if (o.ageStage !== 'adult' || o.pregnantUntil) continue; const ltr = sim.getRel(o).ltr + (sim.marriedTo === o.id ? 40 : 0); if (ltr >= 70 && ltr > bd) { bd = ltr; best = o; } } return best; } function giveBirth(mother) { const last = mother.name.split(' ').slice(1).join(' ') || choice(LAST_NAMES); const baby = new Sim({ name: choice(['Cassandra','Michael','Dina','Nina','Skip','Brandi','Riley','Kayla','Goopy','Jenny']) + ' ' + last, gender: chance(.5) ? 'm' : 'f', ageStage: 'baby', stageSince: G.time.absMin, skin: mother.skin, hairColor: mother.hairColor, traits: Object.fromEntries(TRAITS.map(t => [t, randi(0, 10)])), aspiration: 'family', }); baby.needs.hunger = 65; baby.needs.energy = 80; baby.x = clamp(mother.x + rand(-1, 1), 1, LOT_W - 2); baby.y = clamp(mother.y + rand(-1, 1), 1, LOT_H - 2); baby.facing = 0; G.addSim(baby); AudioSys.sfx('fanfare'); toast(`👶 It's a ${baby.gender === 'f' ? 'girl' : 'boy'}! Welcome to the family, ${baby.name}!`, 'good'); mother.say('👶'); WantSys.notify(mother, 'birth'); // family instantly adores the baby for (const s of G.sims) if (s !== baby && !s.isVisitor) s.getRel(baby).ltr = Math.max(s.getRel(baby).ltr, 60); } /* ============================================================ * School — children attend automatically on weekdays * ============================================================ */ const SchoolSys = { info(child) { if (child.ageStage !== 'child') return null; const dow = (G.time.day - 1) % 7; if (dow >= 5) return null; // weekend return { start: 8, end: 15 }; }, tick() { const h = G.time.hourFloat; for (const c of G.sims) { const info = this.info(c); if (!info || c.isVisitor) continue; const sinceStart = ((h - info.start) + 24) % 24; const len = info.end - info.start; if (!c.atSchool && !c.workDepartedSchool && sinceStart < len && c.atHome) { // bus leaves right at the start of school c.atSchool = true; c.workDepartedSchool = true; c.atHome = false; c.schoolMoodSum = 0; c.schoolMoodN = 0; toast(`🚌 ${c.name} caught the school bus.`); AudioSys.sfx('horn'); } else if (c.atSchool && sinceStart >= len && sinceStart < len + 10) { c.atSchool = false; c.workDepartedSchool = false; c.atHome = true; c.x = G.world.mailbox.x; c.y = G.world.mailbox.y + 1; c.path = []; const moodAvg = c.schoolMoodN ? c.schoolMoodSum / c.schoolMoodN : 55; c.schoolPerf = clamp((c.schoolPerf ?? 60) + (moodAvg - 50) * .06, 0, 100); const grade = c.schoolPerf > 80 ? 'A' : c.schoolPerf > 60 ? 'B' : c.schoolPerf > 40 ? 'C' : 'D'; toast(`🎒 ${c.name} is home from school (grade ${grade}).`); } } }, }; /* ============================================================ * Fire! — cooking accidents, spreading flames & heroics * ============================================================ */ function igniteFire(x, y, sourceObj = null) { if (G.fires.length >= 8) return; x = Math.round(x); y = Math.round(y); if (G.fires.some(f => f.x === x && f.y === y)) return; G.fires.push({ x, y, t: 90 }); if (sourceObj) sourceObj.broken = true; AudioSys.sfx('error'); toast(`🔥 FIRE! The kitchen is going up in flames!`, 'bad'); } function fireTick(min) { const raining = G.weather && G.weather.type === 'rain'; for (let i = G.fires.length - 1; i >= 0; i--) { const f = G.fires[i]; f.t -= min * (raining ? 4 : 1); // spread while young if (f.t > 55 && G.fires.length < 8 && chance(.015 * min)) { const d = choice(DIRS); igniteFire(f.x + d.dx, f.y + d.dy); } // char nearby objects for (const o of G.world.objects) { if (dist2(o.x + o.w / 2 - .5, o.y + o.h / 2 - .5, f.x, f.y) < 1.2) { o.fireDmg = (o.fireDmg || 0) + min; if (o.fireDmg > 22 && !o.broken) { o.broken = true; toast(`🔥 The ${OBJECTS[o.defId].name} caught fire and burned!`, 'bad'); } } } // endanger sims for (const s of G.sims) { if (!s.atHome || s.ageStage === 'baby') continue; const d = dist2(s.x, s.y, f.x, f.y); if (d < 1.6) { s.burnT = (s.burnT || 0) + min; if (s.burnT > 45) { dieOf(s, 'fire'); continue; } } else s.burnT = Math.max(0, (s.burnT || 0) - min * .5); if (d < 2.2) { if (!s.fleeing) { s.fleeing = true; s.cancelAction('Fire!'); thought(s, '😱'); const away = G.world.findFreeSpotNear(Math.round(s.x + (s.x - f.x) * 2), Math.round(s.y + (s.y - f.y) * 2), 5); if (away) { const p = G.world.findPath(s.x, s.y, away[0], away[1]); if (p) s.setPath(p); } setTimeout(() => { s.fleeing = false; }, 4000); } if (chance(.006 * min)) { s.needs.hygiene = clamp(s.needs.hygiene - 22, 0, 100); s.needs.fun = clamp(s.needs.fun - 15, 0, 100); s.say('😵'); toast(`🤕 ${s.name} got singed by the flames!`, 'bad'); } } } // burn out if (f.t <= 0) { G.fires.splice(i, 1); G.world.dirtPuddle.push({ x: f.x, y: f.y, kind: 'scorch', t: 800 }); toast('The fire burnt itself out.', ''); } } } /* ============================================================ * Parties — invite the neighborhood, score the vibe * ============================================================ */ const PartySys = { schedule(host) { if (G.party) { toast('🎉 A party is already planned!', ''); return; } const pool = []; for (const lot of G.neighborhood.lots) for (const m of lot.family) if (!m.movedIn && !G.sims.some(s => s.hoodMeta === m)) pool.push(m); const guests = []; while (guests.length < Math.min(3, pool.length)) { const m = choice(pool); if (!guests.includes(m)) guests.push(m); } const dayStart = Math.floor(G.time.absMin / 1440) * 1440; let at = dayStart + 18 * 60; // tonight 18:00 if (G.time.absMin > at - 60) at += 1440; // too late → tomorrow G.funds -= 50; G.party = { hostId: host.id, at, end: at + 240, guests, state: 'planned' }; toast(`🥳 ${host.name} is throwing a party at 6 PM! ${guests.length} neighbors invited.`, 'good'); AudioSys.sfx('chime'); }, tick() { const p = G.party; if (!p) return; if (p.state === 'planned' && G.time.absMin >= p.at - 30) { // guests head over for (const m of p.guests) { if (G.sims.some(s => s.hoodMeta === m)) continue; spawnVisitor(m); } p.state = 'live'; toast(`🎉 The party is starting!`, 'good'); AudioSys.sfx('fanfare'); } if (p.state === 'live') { // keep guests mingling even with free will off for (const g of G.sims) if (g.isVisitor) { g.forceAutonomy = true; g.leaveAtMin = Math.max(g.leaveAtMin || 0, p.end + 20); } if (G.time.absMin >= p.end) this.conclude(); } }, conclude() { const p = G.party; const guests = G.sims.filter(s => s.isVisitor && s.forceAutonomy); for (const g of G.sims) g.forceAutonomy = false; let score = 0; if (guests.length) { score = guests.reduce((a, g) => a + (g.needs.fun + g.needs.social) / 2, 0) / guests.length; } const tier = score > 78 ? ['gold', '🥇 legendary party!', 500] : score > 62 ? ['silver', '🥈 great party!', 250] : score > 45 ? ['bronze', '🥉 decent party.', 100] : ['flop', '💤 kind of a flop…', 0]; G.aspirationPoints += tier[2]; const host = G.simById(p.hostId); if (host) { host.addMemory?.('🎉', `Threw a ${tier[0]} party`); if (tier[2] > 0) WantSys.notify(host, 'social'); } toast(`🎊 Party over — ${tier[1]} (+${tier[2]} aspiration points)`, tier[2] ? 'good' : ''); G.party = null; }, }; /* ============================================================ * Career chance cards * ============================================================ */ function applyChanceFx(sim, fx) { const job = sim.job; for (const [k, v] of Object.entries(fx)) { switch (k) { case 'perf': if (job) job.perf = clamp((job.perf ?? 50) + v, 0, 100); break; case 'money': case 'bonus': G.funds += v; break; case 'energy': sim.needs.energy = clamp(sim.needs.energy + v, 0, 100); break; case 'fun': sim.needs.fun = clamp(sim.needs.fun + v, 0, 100); break; case 'social': sim.needs.social = clamp(sim.needs.social + v, 0, 100); break; case 'skill': sim.gainSkill(v, fx.amt || .5); break; case 'say': sim.say(v); break; case 'firedRisk': if (job && chance(v)) { fireSim(sim); } break; } } } function fireSim(sim) { if (!sim.job) return; toast(`📉 ${sim.name} was FIRED from the ${sim.job.track} career!`, 'bad'); sim.addMemory('📉', `Fired from the ${CAREERS.find(c => c.id === sim.job.track)?.name} career`); sim.job = null; sim.atWork = false; sim.atHome = true; sim.say('😭'); AudioSys.sfx('error'); Bus.emit('simsChanged'); } /* dirt & grime accumulation + pizza delivery — called per minute from main */ function worldUpkeep(min) { // adoption day if (G.pendingAdoption && G.time.absMin >= G.pendingAdoption) { G.pendingAdoption = 0; const parent = G.sims.find(s => !s.isVisitor && s.ageStage === 'adult'); if (parent) giveBirth(parent); } for (const o of G.world.objects) { if (o.defId === 'toilet' && o.usedBy) o.dirty = Math.min(1, o.dirty + min * .004); if (o.defId === 'trash') o.dirty = Math.min(1, o.dirty + min * .0006); for (const s of G.sims) { if (!s.atHome) continue; if ((o.defId === 'trash' || o.defId === 'toilet') && o.dirty > .85 && dist2(s.x, s.y, o.x, o.y) < 4 && chance(.0004)) { s.needs.hygiene = clamp(s.needs.hygiene - 4, 0, 100); s.say('🤢'); } } } if (G.pendingPizza && G.time.absMin >= G.pendingPizza) { G.pendingPizza = 0; const hungry = G.sims.filter(s => s.atHome && s.needs.hunger < 75) .sort((a, b) => a.needs.hunger - b.needs.hunger)[0]; if (hungry) { hungry.needs.hunger = clamp(hungry.needs.hunger + 58, 0, 100); hungry.needs.fun = clamp(hungry.needs.fun + 8, 0, 100); hungry.say('🍕'); toast(`🍕 Pizza arrived — ${hungry.name} grabbed a slice!`); } else toast('🍕 A pizza arrived but nobody was hungry. It vanished mysteriously.'); } // groceries delivery fills the fridge if (G.pendingGroceries && G.time.absMin >= G.pendingGroceries) { G.pendingGroceries = 0; const fridge = G.world.findObjects(o => o.defId === 'fridge')[0]; if (fridge) { fridge.groceries = Math.min(8, (fridge.groceries || 0) + 8); AudioSys.sfx('chime'); toast(`🛍️ Groceries delivered — the fridge is stocked (${fridge.groceries}/8).`, 'good'); } } }