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:
@@ -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);
|
||||
Reference in New Issue
Block a user