// ============ smoke.mjs — headless integration test of game logic ============ // Run: node tests/smoke.mjs import assert from 'node:assert'; // ---- minimal DOM stubs for modules that reference them at import time ---- const noop = () => { }; globalThis.document = { getElementById: () => null, createElement: () => ({ style: {}, setAttribute: noop, appendChild: noop, addEventListener: noop, classList: { add: noop, remove: noop, toggle: noop }, children: [] }), addEventListener: noop, body: { appendChild: noop }, }; globalThis.window = globalThis; globalThis.localStorage = { _m: {}, getItem(k) { return this._m[k] ?? null; }, setItem(k, v) { this._m[k] = String(v); }, removeItem(k) { delete this._m[k]; }, }; globalThis.performance = globalThis.performance || { now: () => Date.now() }; globalThis.requestAnimationFrame = noop; const results = []; function test(name, fn) { try { fn(); results.push(['PASS', name]); } catch (e) { console.error(`FAIL: ${name}\n`, e); results.push(['FAIL', name + ' — ' + e.message]); process.exitCode = 1; } } async function testAsync(name, fn) { try { await fn(); results.push(['PASS', name]); } catch (e) { console.error(`FAIL: ${name}\n`, e); results.push(['FAIL', name + ' — ' + e.message]); process.exitCode = 1; } } // ---- imports under test ---- const stateMod = await import('../js/game/state.js'); const guests = await import('../js/game/guests.js'); const staff = await import('../js/game/staff.js'); const rides = await import('../js/game/rides.js'); const coaster = await import('../js/game/coaster.js'); const heroes = await import('../js/game/heroes.js'); const magic = await import('../js/game/magic.js'); const research = await import('../js/game/research.js'); const economy = await import('../js/game/economy.js'); const saveSys = await import('../js/game/save.js'); const pathf = await import('../js/world/path.js'); const cfg = await import('../js/core/config.js'); const { newGame, getState, advanceTime, recomputeStats, checkObjectives, parkValue } = stateMod; const { updateGuests } = guests; const { updateStaff } = staff; const { updateRides } = rides; const { updateBattles, cacheScenario } = heroes; const { tickSpells } = magic; let st; function sim(seconds, dt = 1 / 30) { const steps = Math.round(seconds / dt); for (let i = 0; i < steps; i++) { advanceTime(st, dt); tickSpells(st, dt); updateGuests(st, dt); updateStaff(st, dt); updateRides(st, dt); updateBattles(st, dt); research && null; // research accrual inline (tickResearch lives in state.js) } } test('newGame creates map & entrance', () => { st = newGame('meadows'); cacheScenario(st, cfg.SCENARIOS.find(s => s.id === 'meadows')); assert(st.map, 'map exists'); assert.equal(st.cash, 30000); assert(st.map.isPath(st.map.entranceX, st.map.entranceY - 3), 'entrance corridor paved'); recomputeStats(st); assert(st.stats.rating >= 0 && st.stats.rating <= 999); }); const BX = st.map.entranceX - 6, BY = st.map.entranceY - 10; // shared build area pave(BX, BY, BX + 12, BY + 8); test('pathfinding works on paved paths', () => { const m = st.map; const p = pathf.findPath(m, m.entranceX, m.entranceY - 1, BX + 2, BY + 2); assert(p !== null, 'path found from gate to build area'); }); // helper: pave a rectangle of paths function pave(x0, y0, x1, y1) { for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) { if (st.map.isBuildable(x, y) && !st.map.objects[st.map.idx(x, y)]) st.map.pathType[st.map.idx(x, y)] = 1; } } test('shops and rides can be placed next to paths', () => { const m = st.map; const bx = BX, by = BY; const shop = stateMod.addShopObj(st, 'food', bx + 2, by + 2); assert(shop, 'shop placed'); assert(m.getObject(bx + 2, by + 2).kind === 'shop'); const ride = stateMod.addRideObj(st, 'carousel', bx + 6, by + 2, {}); assert(ride, 'ride placed'); ride.status = 'open'; ride.price = 3; st.shops[0].price = 5; }); test('guests spawn, walk, buy and ride over simulated minutes', () => { const before = st.guests.length; sim(240); // 4 in-game hours at speed… advanceTime uses HOUR_RATE so 240s ≈ 12h assert(st.guests.length > before, `guests arrived (${st.guests.length})`); assert(st.guests.length > 3, 'several guests present'); const shop = st.shops[0]; const ride = st.rides.find(r => r.type === 'carousel'); assert(shop.sold > 0 || shop.income > 0, 'shop made sales'); assert(ride.totalRiders > 0 || ride.queue.length > 0, `ride used (riders=${ride.totalRiders}, queue=${ride.queue.length})`); assert(st.cash > 29500, 'cash roughly intact or growing'); }); test('custom coaster build → finish → test → open', () => { const m = st.map; // find a clear area near plaza const ox = m.entranceX + 6, oy = m.entranceY - 14; pave(ox - 2, oy - 2, ox + 9, oy + 9); // station adjacent to a path tile we just laid const sessRes = coaster.startCoasterSession(st, ox, oy, 0); assert(!sessRes.error, 'session started: ' + (sessRes.error || '')); // circuit: E E E N N N W W W S S S back to start (rectangle) const seq = ['straight', 'straight', 'curveL', 'straight', 'straight', 'curveL', 'straight', 'straight', 'straight', 'straight', 'curveL', 'straight', 'straight', 'curveL', 'straight']; for (const pc of seq) { const r = coaster.addPiece(st, pc); if (r.error) { const s = coaster.getSession(st); throw new Error(`piece ${pc}: ${r.error} | target=${JSON.stringify(coaster.nextCellFor(s, pc))} | pieces=[${s.pieces.map(p => `${p.type}@${p.x},${p.y},${p.z}d${p.dir}`).join(' ; ')}]`); } } assert(coaster.isCircuitClosed(coaster.getSession(st)), 'circuit closed'); const fin = coaster.finishCoaster(st); if (!fin.ok && !fin.ride) throw new Error('finish failed: ' + fin.error); const cr = fin.ride || st.rides.find(r => r.isCustomCoaster); assert(cr.track.length >= 12, 'track stored'); assert(cr.excite > 0, 'excitement computed'); assert(!coaster.sessionActive(st), 'session ended'); // test cycle assert(rides.startTest(st, cr), 'testing started'); sim(cr.cycleDur + 2); assert.equal(cr.status, 'closed', 'test completed → closed'); // open to public (jump back to morning so guests are out & about) st.time.hour = 9; assert(rides.setRideOpen(st, cr, true)); sim(90); console.log(` coaster entrance=(${cr.entranceX},${cr.entranceY}) q=${cr.queue.length} riders=${cr.riders.length} total=${cr.totalRiders}`); assert(cr.queue.length > 0 || cr.totalRiders > 0 || cr.riders.length > 0, `custom coaster attracts guests (q=${cr.queue.length} tr=${cr.totalRiders})`); }); test('staff hire & mechanics repair breakdowns', () => { const s = staff.hireStaff(st, 'handyman'); assert(s, 'hired'); const mech = staff.hireStaff(st, 'mechanic'); assert(mech, 'mech hired'); // force breakdown const r = st.rides.find(r => r.type === 'carousel'); rides.breakDown(st, r); assert.equal(r.status, 'broken'); sim(40); assert(r.status === 'closed' || r.status === 'open' || r.reliability > 0, 'repair flow ran without error'); }); test('heroes fight off an invasion', () => { const m = st.map; const gx = BX + 10, gy = BY + 6; // inside paved area, touches paths const gd = heroes.buildGuild(st, gx, gy); assert(gd, 'guild built at ' + gx + ',' + gy); st.cash += 5000; const rec = heroes.recruitHero(st, 'knight'); assert(rec.ok, 'knight recruited: ' + (rec.error || '')); heroes.recruitHero(st, 'ranger'); heroes.spawnWave(st); // manual wave assert(st.monsters.length >= 2, 'monsters spawned'); sim(120); // let heroes fight assert(st.heroStats.kills > 0 || st.monsters.length < 3, `battle progressed (kills=${st.heroStats.kills}, left=${st.monsters.length})`); sim(180); assert(st.monsters.length === 0, 'invasion cleared'); assert(st.invasion.repelled >= 1 || st.heroStats.kills >= st.monsters.length, 'wave resolved'); }); test('magic spells cast & expire', async () => { st.mana = st.manaMax; const res = magic.castSpell(st, 'joy_aura'); assert(res.ok, 'cast ok'); assert(st.spells.active.joy_aura > 0, 'active'); assert(st.spells.cds.joy_aura > 0, 'cooldown set'); assert(magic.castSpell(st, 'joy_aura').ok === false, 'cannot double-cast during cd'); sim(30); assert(!st.spells.active.joy_aura, 'expired after duration'); st.research.unlocked.push('monster_bane', 'transmute'); st.mana = st.manaMax; st.monsters.push({ id: 999, kind: 'monster', type: 'slime', def: cfg.MONSTER_TYPES.slime, x: 10, y: 10, hp: 30, maxHp: 30, atkCd: 1, speed: 1 }); magic.castSpell(st, 'monster_bane'); assert(st.monsters.find(mm => mm.id === 999).hp < 30, 'bane damaged monster'); st.mana = st.manaMax; const c0 = st.cash; magic.castSpell(st, 'transmute'); assert(st.cash >= c0 + 900, 'transmute granted gold'); }); test('research unlocks purchasable with RP', () => { st.research.rp += 200; assert(research.buyUnlock(st, 'drop_tower'), 'bought drop_tower'); assert(stateMod.getState().research.unlocked.includes('drop_tower')); assert(research.isUnlocked(st, 'drop_tower')); }); test('finance month close archives history & charges wages', () => { st.staff.push({ id: 555, wage: 50 }); // temp staff entry const cashBefore = st.cash; economy.monthClose(st); assert(st.finance.history.length >= 1, 'history archived'); assert(st.cash <= cashBefore, 'wages charged'); st.staff.pop(); }); await testAsync('save/load roundtrip preserves world', async () => { saveSys.saveTo(st, 'slot1'); const raw = localStorage.getItem('arcane_tycoon_save_slot1'); assert(raw, 'saved bytes exist'); const loaded = saveSys.loadFrom('slot1'); assert(loaded, 'deserialized'); assert.equal(loaded.map.size, st.map.size); assert.equal(loaded.cash, st.cash); assert.equal(loaded.rides.length, st.rides.length); assert.equal(loaded.guests.length, st.guests.length); assert(loaded.map.isPath(st.map.entranceX, st.map.entranceY - 3), 'paths survive'); const r0 = loaded.rides.find(r => r.isCustomCoaster); if (st.rides.some(r => r.isCustomCoaster)) { assert(r0, 'custom coaster survived'); assert(r0.def, 'def re-linked'); assert(r0.track?.length > 5, 'track data survives'); } }); test('objectives & end-state evaluation run clean', () => { checkObjectives(st); assert(typeof st.won === 'boolean' && typeof st.lost === 'boolean'); assert(parkValue(st) > 0); }); test('performance: 300 sim-seconds with full park under 15s wall time', () => { const t0 = performance.now(); sim(300); const elapsed = performance.now() - t0; console.log(` perf: ${elapsed.toFixed(0)}ms for 300s of sim (${st.guests.length} guests, ${st.rides.length} rides)`); assert(elapsed < 15000, `too slow: ${elapsed}ms`); }); // summary console.log('\n=== SMOKE RESULTS ==='); for (const [s, n] of results) console.log(`${s === 'PASS' ? '✔' : '✘'} ${n}`); const fails = results.filter(r => r[0] === 'FAIL').length; console.log(fails ? `\n${fails} FAILURES` : '\nALL TESTS PASSED'); process.exit(fails ? 1 : 0);