Files
tinyshop/src/sim/staff.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

127 lines
4.5 KiB
JavaScript

import G from './state.js';
import { STAFF_TRAITS } from '../data/furniture.js';
import { clamp, chance, randi, choice, emit, toast } from '../util.js';
// ============================================================
// STAFF — hiring, daily work effects, loyalty, drama
// ============================================================
export function hireCandidate(candId) {
const d = G.data;
const cap = G.levelInfo().staffCap;
if (d.staff.length >= cap) return { ok: false, reason: `Staff cap is ${cap}. Expand the shop!` };
const idx = d.candidates.findIndex(c => c.id === candId);
if (idx < 0) return { ok: false, reason: 'Candidate gone' };
const c = d.candidates[idx];
d.staff.push({ ...c, hiredDay: d.shop.day });
d.candidates.splice(idx, 1);
toast(`${c.name} joins as your ${c.role}! Say hi! 👋`, 'good', '🤝');
emit('staffChanged');
return { ok: true };
}
export function fireStaff(id) {
const i = G.data.staff.findIndex(s => s.id === id);
if (i < 0) return;
const s = G.data.staff[i];
G.data.staff.splice(i, 1);
G.addRep(-1);
toast(`${s.name} was let go. Word gets around… (-1 reputation)`, 'bad', '📦');
// friends get sad
for (const o of G.data.staff) o.loyalty = clamp(o.loyalty - 6, 5, 100);
emit('staffChanged');
}
export function giveRaise(s) {
s.salary = Math.round(s.salary * 1.15);
s.loyalty = clamp(s.loyalty + 18, 0, 100);
toast(`${s.name} beams — “Best boss ever!” (+loyalty)`, 'good', '🥰');
emit('staffChanged');
}
// called each morning
export function staffMorning() {
const d = G.data;
for (const s of [...d.staff]) {
// loyalty drift toward fairness
const fairPay = 18 + s.skill * 0.42;
if (s.salary < fairPay * 0.85) s.loyalty -= 2;
else s.loyalty += 1;
s.loyalty = clamp(s.loyalty + randi(-1, 1), 0, 100);
// quit?
if (s.loyalty < 22 && chance(0.5)) {
d.staff.splice(d.staff.indexOf(s), 1);
toast(`${s.name} packed up and QUIT! “I deserve better…”`, 'bad', '🚪');
G.addRep(-0.5);
continue;
}
// raise request
if (s.loyalty < 45 && s.skill > 60 && chance(0.3)) {
emit('raiseRequest', s);
}
// grumpy clashes
const otherGrump = d.staff.find(o => o !== s && o.traits.includes('grumpy') && s.traits.includes('grumpy'));
if (otherGrump && chance(0.25)) {
toast(`${s.name} and ${otherGrump.name} argue about shelf organization… again.`, '', '😤');
s.loyalty -= 1; otherGrump.loyalty -= 1;
}
if (s.traits.includes('sticky') && !s.traits.includes('honest') && chance(0.12)) {
const loss = Math.min(G.gold, randi(4, 14));
G.addGold(-loss);
toast(`You catch ${s.name} pocketing ${loss}g from the till!`, 'bad', '🖐️');
}
}
// friendship boosts between friendly folks
const friendlies = d.staff.filter(s => s.traits.includes('friendly'));
if (friendlies.length === d.staff.length && d.staff.length >= 2) {
for (const s of friendlies) s.loyalty = clamp(s.loyalty + 1, 0, 100);
}
}
// speed multiplier a role benefits from
export function traitSpeed(s) {
let m = 1;
for (const t of s.traits) m *= STAFF_TRAITS[t]?.speed ?? 1;
return m;
}
export function traitSatBonus(s) {
let b = 0;
for (const t of s.traits) b += STAFF_TRAITS[t]?.satBonus ?? 0;
return b;
}
// stocker auto-restock tick (during open hours)
let restockTimer = 0;
export function updateStocker(dtMin) {
const stockers = G.staffByRole('stocker');
if (!stockers.length || !G.shop.isOpen) return;
restockTimer += dtMin;
const interval = 30 / traitSpeed(stockers[0]);
if (restockTimer < interval) return;
restockTimer = 0;
// move most-demanded item from storage to any shelf with room
for (const [pid, lot] of Object.entries(G.data.inventory)) {
if (lot.qty <= 0) continue;
for (const f of G.shelves()) {
const cap = f.type === 'table' ? 6 : f.type === 'pedestal' ? 1 : 12;
const stock = G.data.shelfStock[f.id] ||= {};
const used = Object.values(stock).reduce((a, b) => a + b, 0);
if (used >= cap) continue;
const move = Math.min(lot.qty, cap - used);
lot.qty -= move;
stock[pid] = (stock[pid] || 0) + move;
if (lot.qty <= 0) delete G.data.inventory[pid];
emit('stockChanged');
return; // one box at a time — they're chill
}
}
}
// cleaner keeps beauty up (visual sparkle handled by gfx)
export function cleanerBonus() {
const cleaners = G.staffByRole('cleaner');
if (!cleaners.length) return 0;
return Math.round(cleaners.reduce((a, c) => a + c.skill / 40, 0));
}