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