- 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)
127 lines
4.5 KiB
JavaScript
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));
|
|
}
|