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
This commit is contained in:
2026-08-23 06:59:21 +00:00
commit ac00687480
30 changed files with 6772 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
// ============ flow.mjs — simulate New Game click flow headlessly ============
let fails = 0;
const ok = (cond, msg) => { console.log((cond ? '✔ ' : '✘ ') + msg); if (!cond) fails++; };
const makeCtx = () => new Proxy(function () {}, {
get(t, p) { if (!(p in t)) t[p] = (...a) => makeCtx(); return typeof t[p] === 'function' ? t[p] : t[p]; },
set() { return true; },
apply() { return makeCtx(); },
});
const listenersOf = el => (el._listeners ??= {});
const fakeEl = (id = '') => {
const el = {
id, style: {}, dataset: {}, children: [], files: [],
classList: {
_set: new Set(['hidden']),
add(c) { this._set.add(c); }, remove(c) { this._set.delete(c); },
toggle(c) { this._set.has(c) ? this._set.delete(c) : this._set.add(c); },
contains(c) { return this._set.has(c); },
},
getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
getContext: () => makeCtx(),
appendChild(c) { el.children.push(c); c.parentNode = el; },
remove() { const p = el.parentNode; if (p) p.children = p.children.filter(x => x !== el); },
querySelectorAll: () => [], querySelector: () => null,
addEventListener(ev, fn) { (listenersOf(el)[ev] ??= []).push(fn); },
dispatch(ev, arg = {}) { for (const f of listenersOf(el)[ev] || []) f(arg); },
setAttribute() {}, focus() {},
textContent: '',
};
Object.defineProperty(el, 'innerHTML', {
get() { return ''; },
set(v) { if (!v) el.children = []; },
});
let w = 300, h = 150;
Object.defineProperty(el, 'width', { get: () => w, set: v => { w = v; } });
Object.defineProperty(el, 'height', { get: () => h, set: v => { h = v; } });
return el;
};
globalThis.window = globalThis;
globalThis.innerWidth = 1280; globalThis.innerHeight = 800;
globalThis.requestAnimationFrame = () => 0; globalThis.cancelAnimationFrame = () => {};
globalThis.addEventListener = () => {}; globalThis.removeEventListener = () => {};
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.document = {
els: {},
getElementById(id) { return this.els[id] ?? (this.els[id] = fakeEl(id)); },
createElement: tag => fakeEl(tag),
createTextNode: t => ({ nodeType: 3, textContent: String(t) }),
querySelectorAll: () => [], querySelector: () => null,
addEventListener() {}, body: { appendChild(c) { document.bodyChildren ??= []; document.bodyChildren.push(c); } },
};
const main = await import('../js/main.js');
ok(true, 'main.js evaluated (full graph)');
const dialogs = await import('../js/ui/dialogs.js');
const stateM = await import('../js/game/state.js');
// 1. click New Game
const mmNew = document.getElementById('mm-new');
mmNew.dispatch('click');
ok(document.getElementById('modal-root').classList.contains('hidden') === false, 'scenario picker modal visible after click');
ok(document.getElementById('modal-root').children.length > 0, 'picker content rendered');
// find scenario cards among descendants
const findCards = n => {
let out = [];
for (const c of n.children || []) {
if ((c.className || '').includes && String(c.className).includes('scen-card')) out.push(c);
out = out.concat(findCards(c));
}
return out;
};
const cards = findCards(document.getElementById('modal-root'));
ok(cards.length === 4, `4 scenario cards rendered (${cards.length})`);
// 2. pick first scenario
let picked = null;
cards[0].dispatch('click'); // handler calls onPick(scenarioId) → main.startNewGame
ok(stateM.getState()?.map != null, `game state created (${stateM.getState()?.scenario})`);
ok(document.getElementById('main-menu').classList.contains('hidden'), 'main menu hidden after start');
ok(document.getElementById('topbar').classList.contains('hidden') === false, 'HUD shown after start');
console.log(fails ? `\n${fails} FLOW FAILURES` : '\nNEW GAME FLOW OK');
process.exit(fails ? 1 : 0);
+87
View File
@@ -0,0 +1,87 @@
// ============ framecheck.mjs — run the REAL game loop for thousands of frames ============
let fails = 0;
const ok = (cond, msg) => { console.log((cond ? '✔ ' : '✘ ') + msg); if (!cond) fails++; };
const makeCtx = () => new Proxy(function () {}, {
get(t, p) {
if (!(p in t)) t[p] = (...a) => makeCtx();
const v = t[p];
return typeof v === 'function' ? v : v;
},
set() { return true; },
apply() { return makeCtx(); },
});
const mkEl = (id = '') => ({
id, style: {}, dataset: {}, children: [], _listeners: {},
addEventListener(ev, fn) { (this._listeners[ev] ??= []).push(fn); },
classList: { _s: new Set(['hidden']), add(c) { this._s.add(c); }, remove(c) { this._s.delete(c); }, toggle() {}, contains(c) { return this._s.has(c); } },
appendChild() {}, remove() {}, setAttribute() {},
querySelectorAll: () => [], getBoundingClientRect: () => ({ left: 0, top: 0, width: 180, height: 180 }),
getContext: () => makeCtx(), width: 300, height: 150, textContent: '',
});
Object.defineProperty(mkEl.prototype ?? {}, 'x', { value: 0 });
globalThis.window = globalThis;
globalThis.innerWidth = 1280; globalThis.innerHeight = 800;
globalThis.__rafQ = [];
globalThis.requestAnimationFrame = fn => { globalThis.__rafQ.push(fn); return 1; };
globalThis.cancelAnimationFrame = () => {};
globalThis.addEventListener = () => {}; globalThis.removeEventListener = () => {};
globalThis.localStorage = { _m: {}, getItem(k) { return this._m[k] ?? null; }, setItem(k, v) { this._m[k] = String(v); }, removeItem(k) { delete this._m[k]; } };
const els = {};
const REAL_IDS = new Set(['game','minimap','topbar','toolbar','palette','pal-body','pal-title','pal-close','context-panel','tool-hint','toasts','modal-root','main-menu','mm-new','mm-how','mm-continue','stat-cash','stat-guests','stat-rating','mana-fill','mana-num','stat-weather','stat-date','btn-pause','btn-research','btn-finance','btn-heroes','btn-objectives','btn-park','btn-save','btn-help','minimap-wrap']);
globalThis.document = {
getElementById(id) { if (!REAL_IDS.has(id)) return null; return els[id] ?? (els[id] = mkEl(id)); },
createElement: () => mkEl(),
createTextNode: t => ({ textContent: String(t) }),
querySelectorAll: () => [], querySelector: () => null,
addEventListener() {}, body: { appendChild() {} },
};
await import('../js/main.js');
const stateM = await import('../js/game/state.js');
const heroesM = await import('../js/game/heroes.js');
const cfg = await import('../js/core/config.js');
for (const scenId of ['meadows', 'sandbox']) {
// fresh state through the REAL entry path
document.getElementById('main-menu'); // ensure element exists
const st = stateM.newGame(scenId);
heroesM.cacheScenario(st, cfg.SCENARIOS.find(s => s.id === scenId));
st._speed = 3; // fast-forward
st._paused = false;
// give the park some content to exercise more code paths
const m = st.map;
const ex = m.entranceX, ey = m.entranceY;
for (let y = ey - 12; y < ey; y++) for (let x = ex - 8; x <= ex + 8; x++)
if (m.isBuildable(x, y) && !m.objects[m.idx(x, y)]) m.pathType[m.idx(x, y)] = 1;
stateM.addShopObj(st, 'food', ex - 4, ey - 8);
stateM.addShopObj(st, 'drinks', ex - 2, ey - 8);
stateM.addShopObj(st, 'toilet', ex + 4, ey - 8);
const ride = stateM.addRideObj(st, 'carousel', ex - 7, ey - 11, {});
ride.status = 'open'; ride.price = 2;
let frames = 0, crashed = null, lastErrFrame = -1;
let tNow = performance.now();
for (; frames < 4000; frames++) {
const q = [...globalThis.__rafQ];
globalThis.__rafQ.length = 0;
tNow += 33; // ~30fps
try {
for (const f of q) f(tNow);
} catch (e) {
crashed = e;
lastErrFrame = frames;
break;
}
}
if (crashed) {
ok(false, `[${scenId}] crashed at frame ${lastErrFrame}: ${crashed.message}`);
console.error(crashed.stack?.split('\n').slice(0, 6).join('\n'));
} else {
ok(true, `[${scenId}] ${frames} frames clean · guests=${st.guests.length} cash=${Math.round(st.cash)} hour=${st.time.hour.toFixed(1)}`);
}
}
console.log(fails ? `\n${fails} FRAME FAILURES` : '\nREAL LOOP STABLE');
process.exit(fails ? 1 : 0);
+98
View File
@@ -0,0 +1,98 @@
// ============ inputcheck.mjs — simulate keyboard & mouse against real handlers ============
let fails = 0;
const ok = (cond, msg) => { console.log((cond ? '✔ ' : '✘ ') + msg); if (!cond) fails++; };
const makeCtx = () => new Proxy(function () {}, {
get(t, p) { if (!(p in t)) t[p] = (...a) => makeCtx(); return typeof t[p] === 'function' ? t[p] : t[p]; },
set() { return true; },
apply() { return makeCtx(); },
});
const mkEl = (id = '') => {
const el = {
id, style: {}, dataset: {}, children: [],
_listeners: {},
addEventListener(ev, fn) { (el._listeners[ev] ??= []).push(fn); },
dispatch(ev, arg = {}) { for (const f of el._listeners[ev] || []) f({ preventDefault() {}, stopPropagation() {}, target: el, ...arg }); },
classList: { _s: new Set(), add(c) { this._s.add(c); }, remove(c) { this._s.delete(c); }, toggle() {}, contains: c => false },
appendChild() {}, remove() {}, setAttribute() {},
querySelectorAll: () => [], getBoundingClientRect: () => ({ left: 0, top: 0, width: 180, height: 180 }),
getContext: () => makeCtx(), width: 300, height: 150,
textContent: '',
};
Object.defineProperty(el, 'innerHTML', { get() { return ''; }, set(v) { if (!v) el.children = []; } });
return el;
};
globalThis.window = globalThis;
globalThis.innerWidth = 1280; globalThis.innerHeight = 800;
globalThis.__rafQueue = [];
globalThis.requestAnimationFrame = fn => { __rafQueue.push(fn); return __rafQueue.length; };
globalThis.cancelAnimationFrame = () => {};
globalThis.__winListeners = {};
globalThis.addEventListener = (ev, fn) => { (globalThis.__winListeners[ev] ??= []).push(fn); };
globalThis.removeEventListener = () => {};
globalThis.localStorage = { _m: {}, getItem(k) { return this._m[k] ?? null; }, setItem(k, v) { this._m[k] = String(v); }, removeItem(k) { delete this._m[k]; } };
const els = {};
globalThis.document = {
getElementById(id) { return els[id] ?? (els[id] = mkEl(id)); },
createElement: t => mkEl(t),
createTextNode: t => ({ textContent: String(t) }),
querySelectorAll: () => [], querySelector: () => null,
addEventListener() {}, body: { appendChild() {} },
};
// start a game FIRST (so getState() exists like a real session)
const mainMod = await import('../js/main.js');
const cam = mainMod.cam;
const stateM = await import('../js/game/state.js');
const heroesM = await import('../js/game/heroes.js');
const cfg = await import('../js/core/config.js');
const st = stateM.newGame('sandbox');
heroesM.cacheScenario(st, cfg.SCENARIOS.find(s => s.id === 'sandbox'));
document.getElementById('main-menu').classList.remove ? null : null;
const winDispatch = (ev, arg = {}) => {
for (const f of globalThis.__winListeners[ev] || []) f({ preventDefault() {}, target: { tagName: 'CANVAS' }, ...arg });
};
// ---- KEYBOARD PAN DIRECTIONS (screen-relative) ----
function press(key, frames = 20) {
winDispatch('keydown', { key });
for (let i = 0; i < frames; i++) { const q = [...__rafQueue]; __rafQueue.length = 0; for (const f of q) f(performance.now()); }
winDispatch('keyup', { key });
}
const center = () => { cam.x = 30; cam.y = 40; };
let p;
center(); p = { ...cam }; press('w');
ok(cam.x < p.x && cam.y < p.y, `W pans screen-up (Δ${(cam.x - p.x).toFixed(1)},${(cam.y - p.y).toFixed(1)})`);
center(); p = { ...cam }; press('s');
ok(cam.x > p.x && cam.y > p.y, `S pans screen-down (+${(cam.x - p.x).toFixed(1)},+${(cam.y - p.y).toFixed(1)})`);
center(); p = { ...cam }; press('a');
ok(cam.x < p.x && cam.y > p.y, `A pans screen-left (${(cam.x - p.x).toFixed(1)},+${(cam.y - p.y).toFixed(1)})`);
center(); p = { ...cam }; press('d');
ok(cam.x > p.x && cam.y < p.y, `D pans screen-right (+${(cam.x - p.x).toFixed(1)},${(cam.y - p.y).toFixed(1)})`);
// ---- MOUSE DRAG PAN TEST ----
const canvas = document.getElementById('game');
const camBeforeDrag = { x: cam.x, y: cam.y };
canvas.dispatch('pointerdown', { button: 0, clientX: 600, clientY: 400 });
for (let s = 1; s <= 10; s++) canvas.dispatch('pointermove', { button: 0, clientX: 600 + s * 12, clientY: 400 - s * 6, shiftKey: false });
canvas.dispatch('pointerup', { button: 0, clientX: 720, clientY: 340 });
const dragMoved = Math.abs(cam.x - camBeforeDrag.x) + Math.abs(cam.y - camBeforeDrag.y);
ok(dragMoved > 1, `mouse drag pans camera (delta ${dragMoved.toFixed(1)} tiles)`);
// ---- WHEEL ZOOM TEST ----
const zBefore = cam.zoom;
canvas.dispatch('wheel', { deltaY: -120, clientX: 640, clientY: 400 });
ok(cam.zoom > zBefore, `wheel zooms (${zBefore.toFixed(2)}${cam.zoom.toFixed(2)})`);
canvas.dispatch('wheel', { deltaY: 120, clientX: 640, clientY: 400 });
// ---- MINIMAP CLICK TELEPORT ----
const mm = document.getElementById('minimap');
const mx = cam.x, my = cam.y;
mm.dispatch('pointerdown', { clientX: 90, clientY: 90, target: mm });
ok(Math.abs(cam.x - mx) + Math.abs(cam.y - my) > 3, `minimap click teleports camera (${cam.x.toFixed(0)},${cam.y.toFixed(0)})`);
console.log(fails ? `\n${fails} INPUT FAILURES` : '\nALL CAMERA INPUTS OK');
process.exit(fails ? 1 : 0);
+103
View File
@@ -0,0 +1,103 @@
// ============ linkcheck.mjs — import EVERY module under a DOM stub ============
// Catches missing/misnamed exports (module linking errors) without a browser.
// Run: node tests/linkcheck.mjs
let failures = 0;
const makeCtx = () => new Proxy({}, {
get(t, p) {
if (p === 'canvas') return {};
if (!(p in t)) t[p] = (...args) => makeCtx();
const v = t[p];
return typeof v === 'function' ? v : t[p];
},
set() { return true; },
});
const fakeEl = (id = '') => {
const el = {
id, style: {}, dataset: {}, children: [],
classList: { add() {}, remove() {}, toggle() {}, contains: () => true },
setAttribute() {}, getAttribute: () => null,
addEventListener() {}, removeEventListener() {},
appendChild(c) { el.children.push(c); }, remove() {}, click() {},
querySelectorAll: () => [], querySelector: () => null,
getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
getContext: () => makeCtx(),
focus() {}, select() {},
files: [],
text: '',
_innerHTML: '',
firstChild: null,
};
Object.defineProperty(el, 'innerHTML', {
get() { return el._innerHTML; },
set(v) {
el._innerHTML = String(v);
if (!el._innerHTML) el.children = [];
el.firstChild = el.children[0] ?? null;
},
});
Object.defineProperty(el, 'textContent', {
get() { return el._textContent ?? ''; },
set(v) { el._textContent = String(v); },
});
let width = 300, height = 150;
Object.defineProperty(el, 'width', { get: () => width, set: v => { width = v; } });
Object.defineProperty(el, 'height', { get: () => height, set: v => { height = v; } });
return el;
};
globalThis.window = globalThis;
globalThis.innerWidth = 1280;
globalThis.innerHeight = 800;
globalThis.requestAnimationFrame = () => 0;
globalThis.cancelAnimationFrame = () => {};
globalThis.addEventListener = () => {};
globalThis.removeEventListener = () => {};
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.document = {
getElementById: id => (globalThis.__els ??= {})[id] ?? ((globalThis.__els[id] = fakeEl(id))),
createElement: tag => fakeEl(tag),
querySelectorAll: () => [],
querySelector: () => null,
addEventListener() {},
body: { appendChild() {} },
};
globalThis.AudioContext = undefined;
globalThis.Blob = class { constructor() {} };
globalThis.URL = { createObjectURL: () => 'blob:x', revokeObjectURL: () => {} };
async function load(label, path) {
try {
await import(path);
console.log(`${label}`);
} catch (e) {
failures++;
console.error(`${label}: ${e.message}`);
console.error(e.stack?.split('\n').slice(1, 3).join('\n'));
}
}
await load('core/util', '../js/core/util.js');
await load('core/config', '../js/core/config.js');
await load('core/audio', '../js/core/audio.js');
await load('world/map', '../js/world/map.js');
await load('world/path', '../js/world/path.js');
await load('game/economy', '../js/game/economy.js');
await load('game/state', '../js/game/state.js');
await load('game/guests', '../js/game/guests.js');
await load('game/staff', '../js/game/staff.js');
await load('game/rides', '../js/game/rides.js');
await load('game/coaster', '../js/game/coaster.js');
await load('game/heroes', '../js/game/heroes.js');
await load('game/magic', '../js/game/magic.js');
await load('game/research', '../js/game/research.js');
await load('game/save', '../js/game/save.js');
await load('render/renderer', '../js/render/renderer.js');
await load('ui/ui', '../js/ui/ui.js');
await load('ui/dialogs', '../js/ui/dialogs.js');
await load('ui/povui', '../js/ui/povui.js');
await load('main (full graph)', '../js/main.js');
console.log(failures ? `\n${failures} LINK FAILURES` : '\nALL MODULES LINK OK');
process.exit(failures ? 1 : 0);
+63
View File
@@ -0,0 +1,63 @@
// ============ rendercheck.mjs — regression test for terrain culling ============
let fails = 0;
const ok = (cond, msg) => { console.log((cond ? '✔ ' : '✘ ') + msg); if (!cond) fails++; };
globalThis.window = globalThis;
globalThis.requestAnimationFrame = () => 0;
globalThis.document = {
getElementById: () => null, createElement: () => ({ style: {}, classList: { add() {}, remove() {} }, addEventListener() {}, appendChild() {} }),
addEventListener() {}, body: { appendChild() {} },
};
globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} };
const R = await import('../js/render/renderer.js');
const { visibleBounds, worldToScreen, screenToWorld } = R;
ok(typeof visibleBounds === 'function', 'visibleBounds exported');
// --- camera translation regression: panning MUST shift screen coords ---
const camA = { x: 20, y: 20, zoom: 1 };
const camB = { x: 30, y: 26, zoom: 1 };
const [ax] = worldToScreen(camA, 1280, 800, 10, 10);
const [bx] = worldToScreen(camB, 1280, 800, 10, 10);
ok(Math.abs(bx - ax) > 50, `worldToScreen shifts with cam.x (${ax.toFixed(0)}${bx.toFixed(0)})`);
const [, ay2] = worldToScreen(camA, 1280, 800, 10, 10);
const camC = { x: 20, y: 30, zoom: 1 };
const [, cy2] = worldToScreen(camC, 1280, 800, 10, 10);
ok(Math.abs(cy2 - ay2) > 50, `worldToScreen shifts with cam.y (${ay2.toFixed(0)}${cy2.toFixed(0)})`);
// --- roundtrip screen→world→screen identity ---
for (const cz of [0.5, 1, 1.7]) {
const c = { x: 25.3, y: 41.8, zoom: cz };
const [sx, sy] = worldToScreen(c, 1280, 800, 12.4, 33.9, 0); // ground plane
const [wx, wy] = screenToWorld(c, 1280, 800, sx, sy);
ok(wx === 12 && wy === 33,
`roundtrip zoom ${cz}: (${sx.toFixed(0)},${sy.toFixed(0)}) → (${wx},${wy}) expect (12,33)`);
}
// --- visibleBounds must follow the camera ---
const bA = visibleBounds({ x: 5, y: 5, zoom: 1 }, 1280, 800, 80);
const bB = visibleBounds({ x: 40, y: 40, zoom: 1 }, 1280, 800, 80);
ok(bB.x0 > bA.x0 + 10 && bB.y0 > bA.y0 + 10, `bounds track camera (x0 ${bA.x0}${bB.x0}, y0 ${bA.y0}${bB.y0})`);
const cases = [
{ name: 'center of 52-map, zoom 1', cam: { x: 26, y: 26, zoom: 1 }, cw: 1280, ch: 800 },
{ name: 'entrance area, zoom 1', cam: { x: 28, y: 44, zoom: 1 }, cw: 1280, ch: 800 },
{ name: 'zoomed out 0.5', cam: { x: 26, y: 26, zoom: 0.5 }, cw: 1280, ch: 800 },
{ name: 'zoomed in 2', cam: { x: 26, y: 26, zoom: 2 }, cw: 1920, ch: 1080 },
];
for (const c of cases) {
const b = visibleBounds(c.cam, c.cw, c.ch, 80);
const spanX = b.x1 - b.x0, spanY = b.y1 - b.y0;
// exact iso-diamond coverage: ((cw+2p)/(TW2·z) + (ch+2p)/(TH2·z)) / 2 per axis
const p = 80;
const expectSpan = ((c.cw + 2 * p) / (32 * c.cam.zoom) + (c.ch + 2 * p) / (16 * c.cam.zoom)) / 2;
ok(spanX >= expectSpan - 2 && spanY >= expectSpan - 2,
`${c.name}: span ${spanX}×${spanY} tiles (expect ≈${Math.round(expectSpan)})`);
}
// the old bug produced a y-span of roughly 10 rows on a 1280×800 @zoom1 view
const b = visibleBounds({ x: 26, y: 26, zoom: 1 }, 1280, 800, 80);
ok(b.y1 - b.y0 > 40, `vertical coverage fixed (${b.y1 - b.y0} rows, was ~10 before)`);
console.log(fails ? `\n${fails} RENDER FAILURES` : '\nRENDER CULLING OK');
process.exit(fails ? 1 : 0);
+266
View File
@@ -0,0 +1,266 @@
// ============ 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);