// ============ guests.js — guest spawning, needs AI, spending ============ import { GUEST_NAMES, GUEST_COLORS, THOUGHTS, WEATHER } from '../core/config.js'; import { makeRng, choice, clamp, uid } from '../core/util.js'; import { findPath, randomNearbyPath, snapToPath } from '../world/path.js'; import { earn } from './economy.js'; import { isNight } from './state.js'; const rng = Math.random; // guests tolerate non-seeded rng export function spawnGuest(state) { const m = state.map; const ex = m.entranceX, ey = m.entranceY; // entrance fee check const willing = state.stats.rating * 0.45; const fee = state.park.entranceFee; if (fee > 0 && fee > willing && rng() > 0.25) return null; // refuses steep fees const g = { id: uid(), kind: 'guest', name: choice(rng, GUEST_NAMES) + ' ' + String.fromCharCode(65 + Math.floor(rng() * 26)) + '.', color: choice(rng, GUEST_COLORS), x: ex + (rng() - 0.5) * 1.6, y: ey + 2.5, path: [], state: 'entering', target: null, hunger: 15 + rng() * 30, thirst: 15 + rng() * 30, toilet: rng() * 20, energy: 70 + rng() * 30, happiness: 55 + rng() * 25, money: 60 + Math.floor(rng() * 140), spendMul: 0.8 + rng() * 0.7, prefThrill: rng(), // 0 = gentle lover, 1 = thrill seeker thoughts: [], bubble: null, bubbleT: 0, rideCd: 4 + rng() * 10, paidEntrance: 0, ridesCount: 0, favRide: null, speed: 1.7 + rng() * 0.9, fleeingT: 0, }; if (fee > 0 && fee <= g.money) { g.money -= fee; g.paidEntrance = fee; earn(state, fee, 'entrance'); } state.guests.push(g); return g; } export function addThought(g, key, extra = {}) { const pool = THOUGHTS[key]; if (!pool) return; let text = choice(rng, pool); if (extra.r) text = text.replace('{r}', extra.r); g.thoughts.unshift(text); if (g.thoughts.length > 6) g.thoughts.pop(); g.bubble = text; g.bubbleT = 4 + rng() * 3; } /** spawn accumulator driven by park conditions */ export function updateSpawning(state, dt) { if (!state.park.open || isNight(state.time)) return; if (state.spells.active.warding_sigil) { /* ward doesn't block guests */ } const cap = state.sandbox ? 380 : 320; if (state.guests.length >= cap) return; const t = state.time.hour; let curve = 0; if (t < 9) curve = (t - 7) / 3; else if (t < 11) curve = 0.7; else if (t < 17) curve = 1.0; else if (t < 19) curve = 0.6; else curve = Math.max(0, (20.5 - t) / 2); if (curve <= 0) return; const w = WEATHER[state.weather.cur]; let pull = campaignPullCache(state); const ratingFactor = clamp(state.stats.rating / 400, 0.2, 1.6); let rate = (0.55 + pull * 0.22) * curve * w.spawnMul * ratingFactor; state.guestSpawnAcc += rate * dt; while (state.guestSpawnAcc >= 1) { state.guestSpawnAcc -= 1; if (state.guests.length >= cap) break; spawnGuest(state); } } let _pullCache = { t: 0, v: 0 }; function campaignPullCache(state) { if (!state.campaigns.length) return 0; if (performance.now() - _pullCache.t > 2000) { _pullCache.t = performance.now(); } let p = 0; for (const c of state.campaigns) p += c.pull; return p; } // ---------------- per-guest update ---------------- export function updateGuests(state, dt) { updateSpawning(state, dt); const m = state.map; for (let i = state.guests.length - 1; i >= 0; i--) { const g = state.guests[i]; stepGuest(state, g, dt); if (g.state === 'gone') state.guests.splice(i, 1); } } function stepGuest(state, g, dt) { const m = state.map; if (g.rideCd > 0) g.rideCd -= dt; // bubble timer if (g.bubbleT > 0) g.bubbleT -= dt; // needs rise g.hunger = clamp(g.hunger + dt * 0.55, 0, 100); g.thirst = clamp(g.thirst + dt * 0.75, 0, 100); g.toilet = clamp(g.toilet + dt * 0.42 * (g.thirst / 60 + 0.5), 0, 100); if (g.state !== 'riding') g.energy = clamp(g.energy - dt * 0.28, 0, 100); // flee override if (g.fleeingT > 0) { g.fleeingT -= dt; moveAlong(state, g, dt, 1.8); if (g.path.length) return; if (g.fleeingT > 0) wanderTo(state, g, 6); return; } // happiness dynamics let hd = -dt * 0.06; if (g.hunger > 85 || g.thirst > 88 || g.toilet > 90) hd -= dt * 0.5; hd -= WEATHER[state.weather.cur].happyDrain * dt; if (state.stats.magicCount > 0) hd += dt * 0.01 * Math.min(state.stats.magicCount, 10); if (state.spells.active.joy_aura) hd += dt * 1.6; g.happiness = clamp(g.happiness + hd, 0, 100); switch (g.state) { case 'entering': { // walk into plaza then start deciding moveAlong(state, g, dt); if (!g.path.length) { g.state = 'walking'; decide(state, g); } break; } case 'walking': { moveAlong(state, g, dt); if (!g.path.length) arrive(state, g); break; } case 'buying': { g.targetT -= dt; if (g.targetT <= 0) completePurchase(state, g); break; } case 'resting': { g.targetT -= dt; g.energy = clamp(g.energy + dt * 6, 0, 100); g.happiness = clamp(g.happiness + dt * 0.4, 0, 100); if (g.targetT <= 0) { g.state = 'walking'; decide(state, g); } break; } case 'queuing': case 'riding': // managed by rides module break; case 'leaving': { moveAlong(state, g, dt); if (!g.path.length) { if (g.y > state.map.size - 2.2) g.state = 'gone'; else decideLeave(state, g); } break; } } // random leave chance late day if (isNight(state.time) && (g.state === 'walking') && rng() < dt * 0.25) decideLeave(state, g); if ((g.happiness < 12 || g.money < 3) && g.state === 'walking' && rng() < dt * 0.08) { if (g.happiness < 12) addThought(g, g.money < 3 ? 'broke' : 'happy_park'); decideLeave(state, g); } } function moveAlong(state, g, dt, mul = 1) { if (!g.path.length) return; const [tx, ty] = g.path[0]; const dx = tx - g.x, dy = ty - g.y; const d = Math.hypot(dx, dy); const stepLen = g.speed * mul * dt; if (d <= stepLen) { g.x = tx; g.y = ty; g.path.shift(); } else { g.x += dx / d * stepLen; g.y += dy / d * stepLen; } } function arrive(state, g) { const tgt = g.target; if (!tgt) { decide(state, g); return; } if (tgt.kind === 'shop') { const shop = state.shops.find(s => s.id === tgt.id); if (!shop) { decide(state, g); return; } if (shop.damaged > 0) { addThought(g, 'vandal'); decide(state, g); return; } g.state = 'buying'; g.targetT = 1.2; return; } if (tgt.kind === 'bench') { g.state = 'resting'; g.targetT = 4 + rng() * 4; return; } if (tgt.kind === 'ride') { const ride = state.rides.find(r => r.id === tgt.id); if (!ride || ride.status !== 'open') { decide(state, g); return; } joinQueue(state, g, ride); return; } decide(state, g); } export function finishRide(state, g, ride, verdict) { g.ridesCount++; g.rideCd = 8 + rng() * 14; g.energy = clamp(g.energy - 6, 0, 100); // excitement enjoyment depends on taste match const fit = 1 - Math.abs(ride.intensity / 10 - g.prefThrill); let gain = (verdict === 'great' ? 14 : verdict === 'good' ? 9 : verdict === 'meh' ? 3 : -6) * clamp(fit + 0.4, 0.2, 1.3); g.happiness = clamp(g.happiness + gain, 0, 100); if (verdict === 'great') addThought(g, 'great_ride', { r: ride.name }); else if (verdict === 'good') addThought(g, 'good_ride', { r: ride.name }); else if (verdict === 'meh') addThought(g, 'meh_ride', { r: ride.name }); else addThought(g, 'scary', { r: ride.name }); if (verdict !== 'bad') g.favRide = ride.type; // nausea side effects if (ride.nausea > 3.5 && rng() < ride.nausea / 26) { const tx = Math.round(g.x), ty = Math.round(g.y); if (state.map.inBounds(tx, ty)) state.map.vomit[state.map.idx(tx, ty)] = 1; g.happiness = clamp(g.happiness - 8, 0, 100); } // litter from food items bought earlier if (rng() < 0.25) dropLitter(state, g); // walk out of the ride exit area before deciding g.state = 'walking'; wanderTo(state, g, 3); } function completePurchase(state, g) { const shop = state.shops.find(s => s.id === g.target?.id); g.state = 'walking'; if (!shop) { decide(state, g); return; } const fortuneMul = state.spells.active.fortune_rain ? 1.6 : 1; const price = Math.round(shop.price * g.spendMul * fortuneMul); if (g.money >= price) { g.money -= price; shop.stock--; shop.sold++; shop.income += price; earn(state, price, 'shopSales'); const def = shop.def; if (def.need === 'hunger') g.hunger = Math.max(0, g.hunger - def.needFix); if (def.need === 'thirst') g.thirst = Math.max(0, g.thirst - def.needFix); if (def.need === 'toilet') g.toilet = Math.max(0, g.toilet - def.needFix); if (def.need === 'health') g.happiness = clamp(g.happiness + 10, 0, 100); if (def.happyBoost) g.happiness = clamp(g.happiness + def.happyBoost, 0, 100); g.happiness = clamp(g.happiness + 4, 0, 100); if (def.need === 'hunger' || def.need === 'thirst') { // carrying food/drink may become litter g.carryingFood = true; } if (def.need === 'toilet' && rng() < 0.02) {/* nothing */ } } else if (rng() < 0.3) addThought(g, 'broke'); decide(state, g); } export function dropLitter(state, g) { const tx = Math.round(g.x), ty = Math.round(g.y); if (!state.map.inBounds(tx, ty)) return; // bins nearby prevent litter for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) { const o = state.map.getObject(tx + dx, ty + dy); if (o && o.kind === 'scenery') { const sc = state.sceneryList.find(s => s.id === o.id); if (sc && sc.def.antiLitter) return; } } const i = state.map.idx(tx, ty); if (state.map.pathType[i]) state.map.litter[i] = clamp(state.map.litter[i] + 0.6, 0, 1.5); g.carryingFood = false; } // ---------------- decisions ---------------- function decide(state, g) { // urgent needs first if (g.toilet > 82) { if (tryGoShop(state, g, s => s.type === 'toilet')) return; } if (g.hunger > 78) { if (tryGoShop(state, g, s => s.type === 'food')) { return; } } if (g.thirst > 74) { if (tryGoShop(state, g, s => s.type === 'drinks' || s.type === 'icecream')) return; } if (g.energy < 22) { if (tryGoBench(state, g)) return; if (rng() < 0.5) { decideLeave(state, g); return; } } // ride? if (g.rideCd <= 0) { const ride = pickRide(state, g); if (ride) { goTarget(state, g, { kind: 'ride', id: ride.id }, ride.entranceX, ride.entranceY, ride); return; } } // casual shopping if (rng() < 0.3 && tryGoShop(state, g, s => s.type === 'souvenir' || s.type === 'balloon')) return; // satisfy needs opportunistically if (g.hunger > 50 && tryGoShop(state, g, s => s.type === 'food')) return; if (g.thirst > 48 && tryGoShop(state, g, s => s.type === 'drinks' || s.type === 'icecream')) return; if (g.toilet > 55 && tryGoShop(state, g, s => s.type === 'toilet')) return; // wander wanderTo(state, g, 8 + Math.floor(rng() * 8)); } function pickRide(state, g) { const cands = state.rides.filter(r => r.status === 'open' && r.queue.length < 24); if (!cands.length) return null; // score: intensity preference match + closeness + low queue let best = null, bestScore = -1; for (const r of cands) { const fit = 1 - Math.abs((r.intensity || 0) / 10 - g.prefThrill) * 2; if (fit < 0.05 && rng() < 0.8) continue; // wrong vibe usually skip const d = Math.hypot(r.entranceX - g.x, r.entranceY - g.y); const score = fit * 2 + Math.max(0, 1.2 - d / 40) + (r.queue.length < 6 ? 0.4 : 0) + rng() * 0.6 - (r.price * g.spendMul > g.money ? 5 : 0) + (r.type === g.favRide ? 0.8 : 0); if (score > bestScore) { bestScore = score; best = r; } } return bestScore > 0.3 ? best : null; } function goTarget(state, g, target, tx, ty, rideObj) { const [sx, sy] = snapToPath(state.map, g.x, g.y); let ex = tx, eyy = ty; if (rideObj) { // aim for a path tile adjacent to ride entrance const adj = adjacentPath(state.map, rideObj.entranceX, rideObj.entranceY); if (adj) { ex = adj[0]; eyy = adj[1]; } else { wanderTo(state, g, 4); return false; } } const p = findPath(state.map, sx, sy, ex, eyy, 9000); if (!p) { wanderTo(state, g, 5); return false; } g.path = p; g.target = target; g.state = 'walking'; return true; } function tryGoShop(state, g, pred) { const opts = state.shops.filter(s => pred(s) && !s.damaged && s.stock > 0); if (!opts.length) return false; opts.sort((a, b) => Math.hypot(a.x - g.x, a.y - g.y) - Math.hypot(b.x - g.x, b.y - g.y)); for (let k = 0; k < Math.min(3, opts.length); k++) { const s = opts[k]; const adj = adjacentPath(state.map, s.x, s.y); if (!adj) continue; if (goTarget(state, g, { kind: 'shop', id: s.id }, adj[0], adj[1])) return true; } return false; } function tryGoBench(state, g) { const cands = state.sceneryList.filter(sc => sc.def.rest); if (!cands.length) return false; cands.sort((a, b) => Math.hypot(a.x - g.x, a.y - g.y) - Math.hypot(b.x - g.x, b.y - g.y)); const b = cands[0]; const adj = adjacentPath(state.map, b.x, b.y); if (!adj) return false; return goTarget(state, g, { kind: 'bench', id: b.id }, adj[0], adj[1]); } export function wanderTo(state, g, r) { const [sx, sy] = snapToPath(state.map, g.x, g.y); const dest = randomNearbyPath(state.map, rng, sx, sy, 2, r); if (!dest) { /* isolated: stay */ g.path = []; return false; } const p = findPath(state.map, sx, sy, dest[0], dest[1], 3000); if (p) { g.path = p; g.target = { kind: 'wander' }; if (g.state !== 'queuing' && g.state !== 'riding' && g.state !== 'buying' && g.state !== 'resting') g.state = 'walking'; } return !!p; } export function decideLeave(state, g) { const m = state.map; const gx = m.entranceX, gy = Math.min(m.size - 1, m.entranceY + 3); const [sx, sy] = snapToPath(m, g.x, g.y); const p = findPath(m, sx, sy, gx, gy, 12000); if (p) { g.path = p; g.state = 'leaving'; g.target = { kind: 'exit' }; } else { addThought(g, 'no_exit'); wanderTo(state, g, 6); } } export function adjacentPath(map, x, y, prefer = null) { const dirs = prefer ? [[prefer[0], prefer[1]], ...[[1, 0], [0, 1], [-1, 0], [0, -1]].filter(d => d[0] !== prefer[0] || d[1] !== prefer[1])] : [[1, 0], [0, 1], [-1, 0], [0, -1]]; for (const [dx, dy] of dirs) { if (map.isPath(x + dx, y + dy)) return [x + dx, y + dy]; } return null; } // queue joining shared with rides module import { joinQueue } from './rides.js'; export function scareGuests(state, x, y, radius) { for (const g of state.guests) { if (g.state === 'riding' || g.state === 'queuing') continue; const d = Math.hypot(g.x - x, g.y - y); if (d < radius) { g.happiness = clamp(g.happiness - 14, 0, 100); addThought(g, 'monster'); // flee away from x,y toward entrance-ish random direction const ang = Math.atan2(g.y - y, g.x - x) + (rng() - 0.5); const fx = Math.round(clamp(g.x + Math.cos(ang) * 8, 1, state.map.size - 2)); const fy = Math.round(clamp(g.y + Math.sin(ang) * 8, 1, state.map.size - 2)); const dest = nearestWalkableNear(state.map, fx, fy); if (dest) { const p = findPath(state.map, Math.round(g.x), Math.round(g.y), dest[0], dest[1], 2500); if (p) { g.path = p; g.fleeingT = 3 + rng() * 3; g.state = 'walking'; } } } } } function nearestWalkableNear(map, x, y) { for (let r = 0; r < 10; r++) { for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) { if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue; if (map.isWalkable(x + dx, y + dy)) return [x + dx, y + dy]; } } return null; }