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
+152
View File
@@ -0,0 +1,152 @@
import puppeteer from 'puppeteer';
const URL = 'http://127.0.0.1:4939';
const results = {};
const errors = [];
async function runOnce(attempt) {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage',
'--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
});
const page = await browser.newPage();
await page.setViewport({ width: 800, height: 520 });
page.on('pageerror', (e) => errors.push('[pe] ' + e.message.slice(0, 140)));
try {
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 45000 });
await new Promise(r => setTimeout(r, 3500));
results.boot = 'PASS';
// menu screenshot
try { await page.screenshot({ path: `/tmp/v_menu_${attempt}.png` }); results.shotMenu = 'PASS'; } catch { results.shotMenu = 'FAIL'; }
// start game through UI
await page.evaluate(() => document.getElementById('m-new').click());
await new Promise(r => setTimeout(r, 500));
await page.evaluate(() => {
const i = document.querySelector('#wiz-name'); i.value = 'Verify Shop';
document.querySelector('#wiz-start').click();
});
// lighten GPU load for swiftshader
await page.evaluate(() => { window.__TS.G.data.settings.quality = 'low'; });
await new Promise(r => setTimeout(r, 2200));
results.startGame = 'PASS';
// deterministic profitable day
const day = await page.evaluate(async () => {
const T = window.__TS, G = T.G;
const shelves = G.data.furniture.filter(f => f.type === 'shelf');
T.eco.buyFromSupplier('baker', 'bread', 6);
T.eco.buyFromSupplier('farmer', 'apple', 6);
T.eco.stockShelf(shelves[0], 'bread', 6);
T.eco.stockShelf(shelves[0], 'apple', 6);
G.data.candidates.push({ id: 'tc', name: 'Tessa', role: 'cashier', skill: 72, traits: ['friendly'], salary: 40, loyalty: 66 });
T.staff.hireCandidate('tc');
G.shop.rep = 18;
G.shop.minutes = 9 * 60 + 1;
G.shop.isOpen = true; G.shop.phase = 'open';
let steps = 6000;
while (steps-- > 0 && G.shop.isOpen) {
T.day.updateDay(0.005);
T.ai.spawnTimerTick(0.5);
for (const c of [...G.customers]) T.ai.updateCustomer(c, 0.35);
}
return { served: G.data.today.served, revenue: Math.round(G.data.today.revenue), staff: G.data.staff.length };
});
results.simDay = day.served > 0 && day.revenue > 0 ? `PASS (${day.served} served, ${day.revenue}g)` : `FAIL ${JSON.stringify(day)}`;
// close-day → report → next day
const flow = await page.evaluate(() => new Promise(res => {
window.__TS.emit('requestClose');
setTimeout(() => {
document.getElementById('rc-yes')?.click();
setTimeout(() => {
const reportVisible = !document.getElementById('modal-root').classList.contains('hidden');
const stars = document.querySelector('.r-stars')?.textContent.trim();
document.getElementById('next-day')?.click();
setTimeout(() => res({ reportVisible, stars, day2: window.__TS.G.shop.day }), 450);
}, 450);
}, 400);
}));
results.reportFlow = flow.reportVisible && flow.day2 === 2 ? `PASS (${flow.stars})` : `FAIL ${JSON.stringify(flow)}`;
// panels
const panelOk = [];
for (const id of ['shop', 'inventory', 'suppliers', 'staff', 'town', 'quests', 'analytics', 'goals']) {
const ok = await page.evaluate((pid) => new Promise(res => {
document.querySelector(`.nav-btn[data-panel="${pid}"]`).click();
setTimeout(() => {
const body = document.querySelector('.side-panel .sp-body');
res(!!body && body.innerHTML.length > 300);
}, 300);
}), id);
panelOk.push(`${id}:${ok ? '✓' : '✗'}`);
}
results.panels = panelOk.join(' ');
// negotiation
const neg = await page.evaluate(() => new Promise(res => {
document.querySelector('.nav-btn[data-panel="suppliers"]').click();
setTimeout(() => {
document.querySelector('[data-neg="baker"]')?.click();
setTimeout(() => {
const btns = [...document.querySelectorAll('.choice-btn')];
[...btns].find(b => b.textContent.includes('Pay immediately'))?.click();
setTimeout(() => res({ modal: btns.length >= 4 ? 'PASS' : 'FAIL' }), 250);
}, 250);
}, 350);
}));
results.negotiation = neg.modal;
// decorate: place furniture via data path + visuals refresh
const decor = await page.evaluate(() => {
const G = window.__TS.G;
const before = G.data.furniture.length;
G.addGold(-30);
G.data.furniture.push({ id: 'vplant', type: 'plant', cx: 3, cz: 3, rot: 0 });
window.__TS.ai.forceGridRebuild();
window.__TS.refreshFurniture();
return G.data.furniture.length === before + 1 ? 'PASS' : 'FAIL';
});
results.decorate = decor;
// save/load roundtrip
const sl = await page.evaluate(() => new Promise(res => {
window.__TS.saveApi.saveTo('slot1');
const d = window.__TS.saveApi.loadFrom('slot1');
res(d && d.shop.day === 2 && d.meta.name === 'Verify Shop' ? 'PASS' : `FAIL ${d?.shop?.day}/${d?.meta?.name}`);
}));
results.saveLoad = sl;
// achievements
const ach = await page.evaluate(() => {
const a = window.__TS.saveApi.getAchievements();
return Object.keys(a).length >= 1 ? `PASS (${Object.keys(a).length} unlocked)` : `FAIL ${Object.keys(a).length}`;
});
results.achievements = ach;
// gameplay screenshot at busy time
try {
await page.evaluate(() => { window.__TS.G.shop.minutes = 12 * 60; window.__TS.G.shop.isOpen = true; window.__TS.G.timeScale = 3; });
await new Promise(r => setTimeout(r, 2500));
await page.screenshot({ path: `/tmp/v_game_${attempt}.png` });
results.shotGame = 'PASS';
} catch { results.shotGame = 'FAIL'; }
} catch (e) {
results.exception = e.message.slice(0, 120);
} finally {
await browser.close().catch(() => {});
}
}
for (let attempt = 1; attempt <= 3; attempt++) {
console.log(`\n=== attempt ${attempt} ===`);
await runOnce(attempt);
const done = ['simDay', 'reportFlow', 'saveLoad'].every(k => (results[k] || '').startsWith('PASS'))
&& (results.panels || '').includes('✓') && !(results.panels || '').includes('✗');
console.log(JSON.stringify(results, null, 1));
if (done && !results.exception) break;
}
console.log('\nPAGEERRORS:', errors.length ? errors.slice(-6).join(' | ') : 'none');
process.exit(0);