import G from './state.js'; import { PRODUCT_BY_ID } from '../data/products.js'; import { ARCH_BY_ID, STORY_NPCS, NAMES_F, NAMES_M, SURNAMES } from '../data/customers.js'; import { purchaseWillingness, sellToCustomer } from './economy.js'; import { findPath, isWalkable, cellWorld, worldCell, doorCells, randomBrowseNear, counterQueueSpot, rebuildGrid } from './pathfinding.js'; import { clamp, rand, randi, chance, uid, pickWeighted, choice, emit, toast } from '../util.js'; import { footfallMult, TIME } from './daycycle.js'; // ============================================================ // CUSTOMER AI // FSM: arriving -> entering -> browsing -> seeking -> evaluating -> // queuing -> paying -> leaving (+ special: stealing, complaining) // ============================================================ const MAX_CUSTOMERS = 14; export function spawnTimerTick(dtMin) { // Poisson-ish arrivals during open hours if (!G.shop.isOpen) return; if (G.customers.length >= MAX_CUSTOMERS) return; const served = G.data.today.served; const dayTarget = 6 + G.shop.rep * 0.55 + G.attractionScore() * 0.35; const remaining = Math.max(0, dayTarget - served); const openFrac = clamp((TIME.closeAt - G.shop.minutes) / (TIME.closeAt - TIME.openAt), 0, 1); if (remaining <= 0 || openFrac <= 0) return; // rush-hour shaping: lunch + evening bumps const m = G.shop.minutes; let shape = 1; if ((m > 720 && m < 840) || (m > 1020 && m < 1140)) shape = 1.7; // noon & after-work rush if (m < 600) shape = 0.6; const ratePerMin = (remaining / (TIME.closeAt - m)) * 1.6 * shape * footfallMult(); if (Math.random() < ratePerMin * dtMin) spawnCustomer(); } function pickArchetype() { const d = G.data; const entries = []; for (const a of ARCH_BY_ID ? Object.values(ARCH_BY_ID) : []) { if (a.repMin && d.shop.rep < a.repMin) continue; let w = a.weight; // story flavor boosts if (a.id === 'tourist' && G.isFestivalDay()) w *= 2.5; if (a.id === 'adventurer' && d.demandMods['tag:adventure']) w *= 2; if (a.id === 'wealthy') w *= 0.4 + G.luxuryScore() / 12; if (a.id === 'noble' && !G.shelves().length) w = 0; if (w > 0) entries.push({ ...a, w }); } if (!entries.length) return ARCH_BY_ID.regular; return pickWeighted(entries); } export function spawnCustomer() { const arch = pickArchetype(); const diffPat = { cozy: 1.25, normal: 1, business: 0.9, tycoon: 0.8 }[G.data.difficulty] || 1; const c = { id: uid(), archId: arch.id, name: chance(0.5) ? choice(NAMES_F) : choice(NAMES_M), surname: SURNAMES.length && chance(0.6) ? choice(SURNAMES) : '', budget: Math.round(rand(arch.budget[0], arch.budget[1]) * (0.8 + G.shop.rep / 120)), tolerance: arch.tolerance, patienceMax: rand(arch.patience[0], arch.patience[1]) * diffPat, patience: 0, mood: 0.55 + rand(-0.15, 0.25), loyalty: 10 + randi(0, 20), likes: arch.likes, dislikes: arch.dislikes, storyId: null, state: 'arriving', t: 0, // state timer path: null, pathI: 0, pos: spawnOutsidePos(), facing: 0, speed: rand(1.5, 2.1), wants: [], // product ids considered targetFurn: null, queueIndex: -1, bought: [], emote: null, emoteT: 0, bubble: null, bubbleT: 0, animPhase: Math.random() * 10, stealAttempted: false, }; // story npc takeover — some customers ARE the named folk if (chance(0.3)) { const npc = choice(STORY_NPCS); c.storyId = npc.id; c.name = npc.name; c.archId = npc.arch; c.likes = ARCH_BY_ID[npc.arch].likes; c.dislikes = ARCH_BY_ID[npc.arch].dislikes; c.tolerance = ARCH_BY_ID[npc.arch].tolerance; c.loyalty = clamp(G.relOf(npc.id), 0, 100); } // elder Tuesday medicine ritual if (c.archId === 'elder' && new Date().getDay() === 2) c.wants = ['potion']; if (c.storyId === 'alder' && G.shop.day % 7 === 2) { c.wants = ['potion', 'bandage']; c.state = 'arriving'; } if (c.archId === 'knight') c.knight = true; if (arch.thief) c.thief = true; G.customers.push(c); return c; } function spawnOutsidePos() { // appear on the path south of the door, outside const [[dx]] = doorCells(); const [wx] = cellWorld(dx, 0); return [wx + rand(-2.5, 2.5), G.gridSize()[1] / 2 + rand(3, 7)]; } // ---------- desire selection ---------- export function chooseDesire(c) { const stocked = []; for (const f of G.shelves()) { for (const pid of Object.keys(G.data.shelfStock[f.id] || {})) { if ((G.data.shelfStock[f.id][pid] || 0) > 0) stocked.push({ f, pid }); } } if (!stocked.length) return null; // score each option by archetype taste const scored = stocked.map(({ f, pid }) => { const p = PRODUCT_BY_ID[pid]; let s = 0.4 + G.demandMult(pid) * 0.3; if (c.likes.includes(p.cat)) s += 0.9; if (c.dislikes.includes(p.cat)) s -= 1.2; if (c.likes.includes('medicine') && p.tags.includes('medicine')) s += 0.8; if (c.rareSeeker && p.rarity >= 3) s += 1.2; if (c.nobleWantsLuxury) s += p.cat === 'luxury' ? 2 : -0.5; if (p.tags.includes('luxury')) s += G.luxuryScore() * 0.02 * (c.dislikes.includes('luxury') ? -1 : 1); s *= rand(0.75, 1.25); if (G.priceOf(pid) > c.budget) s -= 1.4; return { f, pid, s }; }).filter(o => o.s > 0); if (!scored.length) return null; scored.sort((a, b) => b.s - a.s); const top = scored.slice(0, Math.min(3, scored.length)); const sel = top[Math.floor(Math.random() * Math.min(2, top.length))]; return sel; } // ---------- FSM update ---------- export function updateCustomer(c, dtSec) { c.t += dtSec; if (c.emoteT > 0) c.emoteT -= dtSec; else c.emote = null; if (c.bubbleT > 0) c.bubbleT -= dtSec; else c.bubble = null; switch (c.state) { case 'arriving': { const [dx, dz] = doorCells()[0]; const [tx, tz] = cellWorld(dx, dz); moveTo(c, tx, tz + 1.2, dtSec); if (dist(c.pos, [tx, tz + 1.2]) < 0.35) { enterShop(c); c.bubble = choice(['Hello!', '*looks around*', 'Ooh~']); c.bubbleT = 2; } break; } case 'browsing': { c.patience -= dtSec * 0.4; // browsing burns little patience if (c.path && c.pathI < c.path.length) { followPath(c, dtSec); return; } if (!c.desire) c.desire = chooseDesire(c); if (!c.desire || c.t > 26) { // nothing desired → maybe impulse buy or leave grumbling if (c.desire === null && c.impulseOK !== false && chance(0.35)) { leave(c, 'Nothing I need…'); } else leave(c, c.desire ? 'Just looking!' : 'Empty shelves…'); return; } // walk to the shelf holding the desire const [fx, fz] = furnFrontCell(c.desire.f); const path = findPath(...worldCell(c.pos[0], c.pos[1]), fx, fz); if (!path) { leave(c, 'Can’t reach it!'); return; } setPath(c, path); c.state = 'seeking'; break; } case 'seeking': { if (c.path && c.pathI < c.path.length) { followPath(c, dtSec); return; } c.state = 'evaluating'; c.t = 0; c.emote = '🤔'; c.emoteT = 2.5; break; } case 'evaluating': { if (c.t > rand(1.2, 2.6)) decidePurchase(c); break; } case 'queuing': { c.patience -= dtSec; const spot = counterQueueSpot(Math.max(0, c.queueIndex)); if (dist(c.pos, spot) > 0.25) moveTo(c, spot[0], spot[1], dtSec); else faceTowards(c, 0, -2); if (c.patience <= 0) { releaseQueue(c); complainAndLeave(c, 'This line is TOO long!'); return; } tryCheckout(c); break; } case 'paying': { if (c.t > 0.9) finishLeave(c, payLine(c)); break; } case 'leaving': { const [dx, dz] = doorCells()[0]; const [tx, tz] = cellWorld(dx, dz); if (dist(c.pos, [tx, tz]) > 0.45) { if (!c.leavePath) { const p = findPath(...worldCell(c.pos[0], c.pos[1]), dx, dz); c.leavePath = p || []; c.leaveI = 0; } if (c.leaveI < c.leavePath.length) { const [gx, gz] = cellWorld(c.leavePath[c.leaveI][0], c.leavePath[c.leaveI][1]); moveDir(c, gx - c.pos[0], gz - c.pos[1], dtSec); if (dist(c.pos, [gx, gz]) < 0.18) c.leaveI++; } else { moveTo(c, tx, tz + 2, dtSec); } } else { removeCustomer(c); } break; } } // thief behavior — strike while unattended if (c.thief && !c.stealAttempted && c.state !== 'leaving' && c.state !== 'paying') { const attended = counterAttended(); if (!attended && chance(dtSec * 0.22)) attemptTheft(c); } } function payLine(c) { if (c.bought.length >= 3) return 'My pockets are full!'; if (c.mood > 0.8) return choice(['Lovely shop!', 'See you tomorrow!', 'Wonderful!']); if (c.mood < 0.35) return 'Hmph.'; return choice(['Thanks!', 'Have a good one!', 'Bye!']); } function enterShop(c) { rebuildGridIfNeeded(); c.state = 'browsing'; c.t = 0; c.patience = c.patienceMax; emit('doorChime'); // wander to a random browse point first const [W, H] = G.gridSize(); const spot = randomBrowseNear(Math.floor(W / 2), Math.floor(H / 2), 3); if (spot) { const path = findPath(...worldCell(c.pos[0], c.pos[1]), spot[0], spot[1]); if (path) setPath(c, path); } } function decidePurchase(c) { const desire = c.desire; if (!desire) { leave(c, 'Nothing for me…'); return; } const stockNow = (G.data.shelfStock[desire.f.id] || {})[desire.pid] || 0; if (stockNow <= 0) { // someone took the last one! complainAndLeave(c, `No more ${PRODUCT_BY_ID[desire.pid].name}?!`); return; } // knight refuses elven goods if (c.knight) { const elvenSuppliers = ['alchemist', 'wizard']; const isElven = PRODUCT_BY_ID[desire.pid].tags.includes('elven'); if (isElven) { c.emote = '😠'; c.emoteT = 3; complainAndLeave(c, 'I’ll not buy ELVEN wares!'); return; } } const w = purchaseWillingness(c, desire.pid); if (w > 0.5 || (w > 0.32 && chance(0.6))) { // BUY → join queue joinQueue(c, desire); } else if (w > 0.2) { c.bubble = 'That’s a little expensive…'; c.bubbleT = 2.6; G.data.today.complaints++; c.mood -= 0.12; c.desire = null; // look for something else c.state = 'browsing'; c.t = 0; } else { complainAndLeave(c, 'Too rich for my blood!'); } } function joinQueue(c, desire) { const queue = G.customers.filter(x => x.state === 'queuing').sort((a, b) => a.queueIndex - b.queueIndex); let idx = queue.length; // reuse freed slots for (let i = 0; i < queue.length; i++) if (queue[i].queueIndex !== i) { idx = i; break; } c.queueIndex = idx; c.buyIntent = desire; c.state = 'queuing'; c.patience = c.patienceMax * 0.9; const [fx, fz] = furnFrontCell(desire.f); const path = findPath(...worldCell(c.pos[0], c.pos[1]), ...(spotCell(idx))); setPath(c, path || []); if (idx === 0) c.emote = '🙋'; } function spotCell(idx) { const counter = G.data.furniture.find(f => f.type === 'counter'); const [W, H] = G.gridSize(); if (!counter) return [Math.floor(W / 2), H - 3]; const x = counter.cx + (idx % 2); const z = Math.min(H - 2, counter.cz + 2 + Math.floor(idx / 2)); return isWalkable(x, z) ? [x, z] : [Math.max(1, Math.min(W - 2, x)), Math.min(H - 2, z)]; } function releaseQueue(c) { // shift everyone behind forward for (const o of G.customers) if (o.state === 'queuing' && o.queueIndex > c.queueIndex) { o.queueIndex--; o.path = null; } c.queueIndex = -1; } let checkoutCd = {}; function tryCheckout(c) { if (c.queueIndex !== 0) return; const cdKey = 'checkout'; if ((checkoutCd[cdKey] ?? 0) > 0) { checkoutCd[cdKey] -= 1 / 60; return; } const att = counterAttended(); // 'cashier' | 'near' | 'inside' | null if (!att) { // nobody home… hope fades c.patience -= 0.02; return; } const cashierSkill = G.staffByRole('cashier').reduce((m, s) => Math.max(m, s.skill), 30); const base = att === 'cashier' ? clamp(1.5 - cashierSkill / 140, 0.45, 1.5) : att === 'near' ? 1.1 : 2.3; checkoutCd[cdKey] = base; const intent = c.buyIntent; const res = sellToCustomer(c, intent.f, intent.pid); if (res) { c.bought.push(intent.pid); c.mood = clamp(c.mood + (res.satisfaction - 0.5) * 0.5, 0.05, 1); c.emote = res.satisfaction > 0.66 ? '😊' : res.satisfaction > 0.4 ? '🙂' : '😕'; c.emoteT = 2.5; emit('saleFx', { customer: c, price: res.price, satisfaction: res.satisfaction }); // loyal regulars may buy a second item! if (c.loyalty > 45 && chance(0.35) && c.bought.length < 3) { const second = chooseDesire(c); if (second && (G.data.shelfStock[second.f.id] || {})[second.pid]) { c.desire = second; c.state = 'browsing'; c.t = 0; releaseQueue(c); return; } } } releaseQueue(c); c.state = 'paying'; c.t = 0; } export function counterAttended() { // a cashier on duty always counts… if (G.staffByRole('cashier').length > 0) return 'cashier'; // …otherwise the owner has to be around const counter = G.data.furniture.find(f => f.type === 'counter'); if (!counter) return null; const [px, pz] = cellWorld(counter.cx, counter.cz); const pdx = G.data.player.x - px, pdz = G.data.player.z - pz; const d2 = pdx * pdx + pdz * pdz; if (d2 < 5) return 'near'; const [W, H] = G.gridSize(); const inside = Math.abs(G.data.player.x) < W / 2 + 0.4 && Math.abs(G.data.player.z) < H / 2 + 0.4; return inside ? 'inside' : null; } function attemptTheft(c) { c.stealAttempted = true; // guard deterrence const guards = G.staffByRole('guard'); const guardPower = guards.reduce((m, g) => Math.max(m, g.skill / 100), 0); if (guards.length && chance(guardPower)) { c.bubble = 'EEK! A guard!'; c.bubbleT = 2.5; c.emote = '😱'; c.emoteT = 3; leave(c, 'Wrong shop, wrong day!'); toast(`${c.name} tried to steal but your guard glared them down!`, 'good', '🛡️'); G.addRep(0.5); return; } // find priciest stocked item let best = null, bp = -1; for (const f of G.shelves()) { for (const pid of Object.keys(G.data.shelfStock[f.id] || {})) { const v = G.priceOf(pid); if ((G.data.shelfStock[f.id][pid] || 0) > 0 && v > bp) { bp = v; best = { f, pid }; } } } if (!best) return; deleteFromShelf(best.f.id, best.pid, 1); G.data.today.thefts++; const loss = G.priceOf(best.pid); G.data.today.theftLoss += loss; c.hasStolen = true; c.bubble = '*slinks away*'; c.bubbleT = 2.5; c.emote = '😈'; c.emoteT = 3; toast(`${c.name} stole ${PRODUCT_BY_ID[best.pid].name} (-${loss}g)! Hire a guard or watch the counter!`, 'bad', '🕵️'); G.addRep(-0.4); emit('theftFx', { customer: c }); c.state = 'leaving'; c.leavePath = null; } export function deleteFromShelf(fid, pid, qty) { const stock = G.data.shelfStock[fid]; if (!stock?.[pid]) return; stock[pid] -= qty; if (stock[pid] <= 0) delete stock[pid]; emit('stockChanged'); } function complainAndLeave(c, msg) { c.bubble = msg; c.bubbleT = 3; c.emote = '😠'; c.emoteT = 3; G.data.today.complaints++; c.mood = clamp(c.mood - 0.25, 0.05, 1); if (c.storyId) G.bumpRel(c.storyId, -1.5); G.addRep(-0.15); leave(c, msg); } function leave(c, bubble) { releaseQueue(c); c.state = 'leaving'; c.leavePath = null; if (bubble && !c.bubble) { c.bubble = bubble; c.bubbleT = 2.5; } // memory: satisfaction shapes future loyalty const sat = clamp(c.mood, 0, 1); if (sat > 0.65) c.loyalty = clamp(c.loyalty + 4, 0, 100); else if (sat < 0.35) c.loyalty = clamp(c.loyalty - 5, 0, 100); if (c.storyId) G.bumpRel(c.storyId, sat > 0.6 ? 1 : sat < 0.35 ? -1 : 0); } function finishLeave(c, bubble) { if (bubble) { c.bubble = bubble; c.bubbleT = 2.2; } c.state = 'leaving'; c.leavePath = null; const sat = clamp(c.mood, 0, 1); if (sat > 0.65) c.loyalty = clamp(c.loyalty + 4, 0, 100); else if (sat < 0.35) c.loyalty = clamp(c.loyalty - 5, 0, 100); if (c.storyId) G.bumpRel(c.storyId, sat > 0.6 ? 1 : sat < 0.35 ? -1 : 0); } export function removeCustomer(c) { const i = G.customers.indexOf(c); if (i >= 0) G.customers.splice(i, 1); emit('customerRemoved', c); } // ---------- movement helpers ---------- function setPath(c, path) { c.path = path; c.pathI = 0; } function followPath(c, dtSec) { if (!c.path || c.pathI >= c.path.length) return; const [gx, gz] = cellWorld(c.path[c.pathI][0], c.path[c.pathI][1]); moveDir(c, gx - c.pos[0], gz - c.pos[1], dtSec); if (dist(c.pos, [gx, gz]) < 0.16) c.pathI++; } function moveTo(c, tx, tz, dtSec) { moveDir(c, tx - c.pos[0], tz - c.pos[1], dtSec); } function moveDir(c, dx, dz, dtSec) { const len = Math.hypot(dx, dz); if (len < 1e-4) return; const sp = c.speed * dtSec; const step = Math.min(sp, len); c.pos[0] += (dx / len) * step; c.pos[1] += (dz / len) * step; c.targetFacing = Math.atan2(dx, dz); c.animPhase += dtSec * 9; } function faceTowards(c, x, z) { c.targetFacing = Math.atan2(x - c.pos[0], z - c.pos[1]); } export function dist(a, b) { return Math.hypot(a[0] - b[0], a[1] - b[1]); } // front-of-furniture reachable cell function furnFrontCell(f) { const [W, H] = G.gridSize(); const rot = f.rot % 2; const candidates = rot === 0 ? [[f.cx, f.cz + 1], [f.cx + 1, f.cz + 1], [f.cx, f.cz - 1], [f.cx + 1, f.cz - 1]] : [[f.cx - 1, f.cz], [f.cx - 1, f.cz + 1], [f.cx + 1, f.cz], [f.cx + 1, f.cz + 1]]; for (const [x, z] of candidates) if (isWalkable(x, z)) return [x, z]; return [Math.floor(W / 2), Math.floor(H / 2)]; } let lastGridFurnCount = -1; function rebuildGridIfNeeded() { if (lastGridFurnCount !== G.data.furniture.length) { rebuildGrid(); lastGridFurnCount = G.data.furniture.length; } } export function forceGridRebuild() { rebuildGrid(); lastGridFurnCount = G.data.furniture.length; } // ---------- end of day cleanup ---------- export function clearCustomers() { for (const c of [...G.customers]) removeCustomer(c); }