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:
+133
@@ -0,0 +1,133 @@
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
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',
|
||||
'--disable-renderer-backgrounding', '--disable-background-timer-throttling'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1024, height: 600 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message.slice(0, 160)));
|
||||
|
||||
await page.goto('http://127.0.0.1:4940', { waitUntil: 'networkidle2', timeout: 90000 });
|
||||
await new Promise(r => setTimeout(r, 4000));
|
||||
await page.screenshot({ path: '/tmp/s1_menu.png' });
|
||||
await page.evaluate(() => document.getElementById('m-new').click());
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
await page.evaluate(() => { const i = document.querySelector('#wiz-name'); i.value = 'Test Shop'; document.querySelector('#wiz-start').click(); });
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
await page.screenshot({ path: '/tmp/s2_game.png' });
|
||||
|
||||
// ---- run a profitable day deterministically ----
|
||||
const day1 = await page.evaluate(async () => {
|
||||
const G = window.__TS.G;
|
||||
const eco = window.__TS.eco;
|
||||
const ai = window.__TS.ai;
|
||||
const dc = window.__TS.day;
|
||||
const stf = window.__TS.staff;
|
||||
const shelves = G.data.furniture.filter(f => f.type === 'shelf');
|
||||
eco.buyFromSupplier('baker', 'bread', 6);
|
||||
eco.buyFromSupplier('farmer', 'apple', 6);
|
||||
eco.buyFromSupplier('smith', 'potion', 0); // invalid catalog — should not crash
|
||||
eco.stockShelf(shelves[0], 'bread', 6);
|
||||
eco.stockShelf(shelves[0], 'apple', 6);
|
||||
// hire a cashier to prove staff flow
|
||||
G.data.candidates.push({ id: 'testcash', name: 'Tessa', role: 'cashier', skill: 70, traits: ['friendly'], salary: 40, loyalty: 65 });
|
||||
stf.hireCandidate('testcash');
|
||||
|
||||
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) {
|
||||
dc.updateDay(0.005);
|
||||
ai.spawnTimerTick(0.5);
|
||||
for (const c of [...G.customers]) ai.updateCustomer(c, 0.35);
|
||||
}
|
||||
return {
|
||||
served: G.data.today.served, revenue: Math.round(G.data.today.revenue),
|
||||
staff: G.data.staff.length,
|
||||
};
|
||||
});
|
||||
console.log('DAY1:', JSON.stringify(day1));
|
||||
|
||||
// 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, hist: window.__TS.G.data.history.revenue.length }), 400);
|
||||
}, 500);
|
||||
}, 400);
|
||||
}));
|
||||
console.log('FLOW:', JSON.stringify(flow));
|
||||
|
||||
// ---- panel smoke tests ----
|
||||
const panels = {};
|
||||
for (const id of ['shop', 'inventory', 'suppliers', 'staff', 'town', 'quests', 'analytics', 'goals']) {
|
||||
panels[id] = await page.evaluate((pid) => new Promise(res => {
|
||||
try {
|
||||
document.querySelector(`.nav-btn[data-panel="${pid}"]`).click();
|
||||
setTimeout(() => {
|
||||
const body = document.querySelector('.side-panel .sp-body');
|
||||
res({ ok: !!body && body.children.length > 0, len: body?.innerHTML.length || 0 });
|
||||
}, 350);
|
||||
} catch (e) { res({ error: e.message }); }
|
||||
}), id);
|
||||
}
|
||||
console.log('PANELS:', JSON.stringify(panels));
|
||||
|
||||
// negotiation modal
|
||||
const neg = await page.evaluate(() => new Promise(res => {
|
||||
document.querySelector('.nav-btn[data-panel="suppliers"]').click();
|
||||
setTimeout(() => {
|
||||
document.querySelector('[data-neg="farmer"]')?.click();
|
||||
setTimeout(() => {
|
||||
const visible = !document.getElementById('modal-root').classList.contains('hidden');
|
||||
const btns = [...document.querySelectorAll('.choice-btn')].length;
|
||||
// try "pay immediately" (always succeeds)
|
||||
[...document.querySelectorAll('.choice-btn')].find(b => b.textContent.includes('Pay immediately'))?.click();
|
||||
setTimeout(() => res({ visible, btns, rel: window.__TS.G.supplierRel('farmer') }), 300);
|
||||
}, 300);
|
||||
}, 400);
|
||||
}));
|
||||
console.log('NEGOTIATION:', JSON.stringify(neg));
|
||||
await page.evaluate(() => { document.querySelector('.sp-close')?.click(); document.querySelector('#neg-close')?.click(); });
|
||||
|
||||
// decorate mode: place a rug programmatically through UI path
|
||||
const decor = await page.evaluate(async () => {
|
||||
const G = window.__TS.G;
|
||||
window.__TS.emit('decorateStart');
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
// simulate placing a plant via data + refresh (placement raycast is pointer-driven)
|
||||
const before = G.data.furniture.length;
|
||||
G.addGold(-18);
|
||||
G.data.furniture.push({ id: 'testplant', type: 'plant', cx: 3, cz: 3, rot: 0 });
|
||||
const ai = window.__TS.ai;
|
||||
ai.forceGridRebuild();
|
||||
const { refreshAllFurniture } = await import('/src/gfx/furniture3d.js');
|
||||
refreshAllFurniture();
|
||||
window.__TS.emit('decorateDone');
|
||||
return { added: G.data.furniture.length - before, gold: Math.round(G.gold) };
|
||||
});
|
||||
console.log('DECOR:', JSON.stringify(decor));
|
||||
|
||||
// save / load roundtrip
|
||||
const saveLoad = await page.evaluate(() => new Promise(res => {
|
||||
Promise.resolve(window.__TS.saveApi).then(({ saveTo, loadFrom }) => {
|
||||
saveTo('slot1');
|
||||
const d = loadFrom('slot1');
|
||||
res({ savedDay: d?.shop?.day, savedGold: Math.round(d?.shop?.gold), nameOk: !!d?.meta?.name });
|
||||
});
|
||||
}));
|
||||
console.log('SAVELOAD:', JSON.stringify(saveLoad));
|
||||
await page.screenshot({ path: '/tmp/s3_after.png' });
|
||||
|
||||
console.log('PAGEERRORS:', errors.length ? errors.join(' | ') : 'none');
|
||||
await browser.close();
|
||||
Reference in New Issue
Block a user