Files
deepseek ac00687480 Arcane Tycoon — Heroes & Magic theme park tycoon game
Complete browser game inspired by OpenRCT2 with fantasy twist:
- Custom roller coaster designer with physics-based ratings + on-ride POV
- 10 animated rides, 7 shops, 16 scenery items, path network & guest AI
- Heroes guild vs monster invasions (5 classes, XP/gear/bosses)
- Magic spell system (8 spells), research tree, economy/marketing/loans
- Day-night cycle, weather, park rating, awards, 4 scenarios
- Save/load slots + autosave, procedural WebAudio SFX/music
- Isometric canvas renderer, minimap, diagnostics overlay
- Test suites: smoke(13), linkcheck, inputcheck, framecheck, rendercheck, flow
2026-08-23 06:59:21 +00:00

267 lines
11 KiB
JavaScript

// ============ 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);