1531 lines
64 KiB
JavaScript
1531 lines
64 KiB
JavaScript
/* ============================================================
|
||
* 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');
|
||
}
|
||
}
|
||
}
|