- 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)
123 lines
6.0 KiB
JavaScript
123 lines
6.0 KiB
JavaScript
import { el, $, toast } from '../util.js';
|
||
import { G } from '../sim/state.js';
|
||
import { nextDay } from '../sim/daycycle.js';
|
||
import { closeDayAccounting } from '../sim/economy.js';
|
||
import { clearCustomers } from '../sim/customerAI.js';
|
||
import { showModal, closeModal, updateHud, updateTimeControls } from './ui.js';
|
||
import { audio } from '../audio/audio.js';
|
||
import { PRODUCT_BY_ID } from '../data/products.js';
|
||
import { fmtGold } from '../util.js';
|
||
import { emit } from '../util.js';
|
||
import { saveTo, unlockAchievement } from '../save.js';
|
||
import { on } from '../util.js';
|
||
|
||
// ============================================================
|
||
// END-OF-DAY REPORT — the satisfying part
|
||
// ============================================================
|
||
let pendingClose = false;
|
||
|
||
export function initReport() {
|
||
on('requestClose', () => {
|
||
if (pendingClose) return;
|
||
pendingClose = true;
|
||
const n = G.customers.length;
|
||
let m;
|
||
if (n > 0 && G.shop.minutes < 19 * 60 - 5) {
|
||
m = showModal(`
|
||
<h2>⏰ Closing early?</h2>
|
||
<div class="sub">There ${n === 1 ? 'is 1 customer' : `are ${n} customers`} still browsing. Send them home and settle the books?</div>
|
||
<div class="modal-actions">
|
||
<button class="small-btn green" id="rc-yes">Yes, close up!</button>
|
||
<button class="small-btn" id="rc-no">Keep trading</button>
|
||
</div>`);
|
||
} else {
|
||
m = showModal(`
|
||
<h2>🌙 Closing time…</h2>
|
||
<div class="sub">The lamps dim, the last coins clink. Settle today's books?</div>
|
||
<div class="modal-actions">
|
||
<button class="small-btn green" id="rc-yes">Open the ledger 📖</button>
|
||
</div>`);
|
||
}
|
||
m.querySelector('#rc-yes').onclick = () => {
|
||
closeModal();
|
||
doCloseDay();
|
||
};
|
||
m.querySelector('#rc-no')?.addEventListener('click', () => { pendingClose = false; closeModal(); });
|
||
// reset guard when modal dismissed via other means
|
||
setTimeout(() => { if (!document.getElementById('rc-yes')) pendingClose = false; }, 100);
|
||
});
|
||
}
|
||
|
||
function doCloseDay() {
|
||
clearCustomers();
|
||
import('../sim/staff.js').then(({ staffMorning }) => {}); // (morning runs at next day)
|
||
const sum = closeDayAccounting();
|
||
showReport(sum);
|
||
}
|
||
|
||
export function showReport(sum) {
|
||
const d = G.data.today;
|
||
const stars = Math.round(Math.min(5, Math.max(1, (sum.sat * 4.4 + Math.min(1.2, d.served / 14) * 0.8))));
|
||
const starStr = '★'.repeat(stars) + '<span style="opacity:.25">' + '★'.repeat(5 - stars) + '</span>';
|
||
|
||
// achievements checks
|
||
if (d.served >= 15 && stars >= 5) unlockAchievement('perfect_day');
|
||
if (G.data.stats.totalProfit > 0 || sum.profit > 0) unlockAchievement('first_profit');
|
||
if (G.gold >= 1000) unlockAchievement('thousand_gold');
|
||
|
||
const m = showModal(`
|
||
<div class="report">
|
||
<h2 style="margin-bottom:2px">📖 Daily Report</h2>
|
||
<div class="sub">Day ${G.shop.day} · “${['A quiet little day.', 'Steady business!', 'A very good day!', 'The town is buzzing!', 'LEGENDARY!'][Math.min(4, Math.floor(stars - 1))]}”</div>
|
||
<div class="r-stars">${starStr}</div>
|
||
<div class="r-grid">
|
||
<div class="r-cell"><div class="rl">Customers</div><div class="rv" data-count="${d.served}">0</div></div>
|
||
<div class="r-cell"><div class="rl">Revenue</div><div class="rv" data-count="${Math.round(sum.revenue)}" data-suffix="g">0</div></div>
|
||
<div class="r-cell"><div class="rl">Expenses</div><div class="rv" style="color:#c0563f" data-count="${Math.round(sum.expenses)}" data-suffix="g">0</div></div>
|
||
<div class="r-cell"><div class="rl">Profit</div><div class="rv ${sum.profit >= 0 ? 'r-profit' : 'r-loss'}" data-count="${Math.round(sum.profit)}" data-suffix="g">0</div></div>
|
||
<div class="r-cell wide"><div class="rl">Breakdown</div>
|
||
<div class="desc mono">wages −${sum.wages}g · rent −${sum.rent}g${sum.tax ? ` · tax −${sum.tax}g` : ''}${sum.stolen ? ` · 💸 stolen −${Math.round(sum.stolen)}g` : ''}</div></div>
|
||
${sum.bestSeller ? `<div class="r-cell"><div class="rl">Best seller</div><div class="rv" style="font-size:17px">${PRODUCT_BY_ID[sum.bestSeller.pid].emoji} ${PRODUCT_BY_ID[sum.bestSeller.pid].name}</div><div class="desc">+${fmtGold(sum.bestSeller.value)}g (${sum.bestSeller.units} sold)</div></div>` : ''}
|
||
${sum.worstSeller ? `<div class="r-cell"><div class="rl">Needs love</div><div class="rv" style="font-size:17px">${PRODUCT_BY_ID[sum.worstSeller.pid].emoji} ${PRODUCT_BY_ID[sum.worstSeller.pid].name}</div><div class="desc">${sum.worstSeller.units} sold</div></div>` : ''}
|
||
<div class="r-cell wide">
|
||
<div class="rl">Last 7 days revenue</div>
|
||
<div class="r-chart">${(G.data.history.revenue.slice(-7)).map((v, i, arr) =>
|
||
`<div class="rc-col"><div class="rc-bar" style="height:${Math.max(4, v / Math.max(...arr, 1) * 52)}px"></div></div>`).join('')}</div>
|
||
</div>
|
||
<div class="r-cell wide"><div class="rl">Reputation</div>
|
||
<div class="rv" style="font-size:18px">${sum.repDelta >= 0 ? '+' : ''}${sum.repDelta} ⭐</div></div>
|
||
</div>
|
||
<button class="small-btn green" id="next-day" style="width:100%;height:48px;font-size:16px">🌅 Sleep → Day ${G.shop.day + 1}</button>
|
||
</div>
|
||
`, 'report');
|
||
|
||
// animate counters
|
||
m.querySelectorAll('[data-count]').forEach(nodeEl => {
|
||
const target = +nodeEl.dataset.count;
|
||
const suffix = nodeEl.dataset.suffix || '';
|
||
const t0 = performance.now(), dur = 900;
|
||
const tick = (t) => {
|
||
const k = Math.min(1, (t - t0) / dur);
|
||
const eased = 1 - Math.pow(1 - k, 3);
|
||
nodeEl.textContent = fmtGold(target * eased) + suffix;
|
||
if (k < 1) requestAnimationFrame(tick);
|
||
};
|
||
requestAnimationFrame(tick);
|
||
});
|
||
|
||
audio.register();
|
||
if (stars >= 4) setTimeout(() => audio.levelup(), 500);
|
||
|
||
saveTo('autosave');
|
||
|
||
m.querySelector('#next-day').onclick = () => {
|
||
closeModal();
|
||
nextDay();
|
||
import('../sim/staff.js').then(({ staffMorning }) => staffMorning());
|
||
updateHud();
|
||
updateTimeControls();
|
||
emit('newDay');
|
||
toast(`Day ${G.shop.day}. Fresh stock ideas, fresh faces!`, '', '🌅');
|
||
};
|
||
}
|