Files
tinyshop/src/sim/customerAI.js
T
deepseek 0e7c07533e TINY SHOP — cozy 3D shop-management game (Three.js vertical slice)
- Procedural 3D world: dollhouse shop, town, day/night, weather, seasons
- Customer AI with personalities (story NPCs, thieves, weekly regulars)
- Economy: suppliers, negotiation, pricing psychology, daily accounting
- Staff with traits/loyalty, 8 expansion levels, furniture & decoration
- Events with choices, quests, achievements, 4 difficulties, rival shop
- Animated daily report, analytics, save/load (3 slots + autosave)
- Procedural music & SFX (WebAudio), zero external assets
- Test harnesses: simtest (node), verify/check/e2e (headless browser)
2026-08-23 06:58:24 +00:00

502 lines
18 KiB
JavaScript
Raw Blame History

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