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)
This commit is contained in:
2026-08-23 06:58:24 +00:00
commit 0e7c07533e
40 changed files with 10130 additions and 0 deletions
+501
View File
@@ -0,0 +1,501 @@
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);
}