Files

1531 lines
64 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
* 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 14 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');
}
}
}