Files
tinyshop/src/ui/report.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

123 lines
6.0 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 { 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!`, '', '🌅');
};
}