IMPERIUM ARCANUM — web-based Caesar 3-like city builder with heroes, magic & monsters

- Full classic layer: roads/walkers, 11-tier housing evolution, food chain,
  industry chains, trade routes, taxes/wages, ratings, fires & collapse
- Fantasy layer: heroes (XP/levels/auras), mana & 7 spells, monsters/invasions,
  walls/towers/legions/golems, campaign of 5 scenarios + endless sandbox
- Canvas2D isometric engine with procedural sprites, zero runtime deps
- Save/load slots + autosave, WebAudio synth music/SFX, HUD/menus/minimap
- Tests: headless sim smoke test + Playwright E2E (build/combat/save)
This commit is contained in:
deepseek
2026-08-23 07:00:05 +00:00
commit b642726428
38 changed files with 7288 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
// Combat / heroes / magic / save-load E2E
import { chromium } from 'playwright-core';
const exe = '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell';
const BASE = process.env.BASE || 'http://127.0.0.1:5173/';
const errors = [];
const browser = await chromium.launch({ executablePath: exe, args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
await page.goto(BASE, { waitUntil: 'networkidle' });
await page.waitForTimeout(600);
await page.click('#mm-sandbox');
await page.waitForTimeout(900);
// minimal town + defenses + hero hall
const setup = await page.evaluate(() => {
const I = window.__IA;
const c = I.app.city;
let ox = -1, oy = -1;
outer:
for (let y = 8; y < c.H - 14; y++) for (let x = 4; x < c.W - 24; x++) {
let ok = true;
for (let dy = -2; dy < 9 && ok; dy++) for (let dx = -2; dx < 20; dx++) {
const t = c.tiles[(y + dy) * c.W + (x + dx)];
if (!t || t.terrain === 'water') { ok = false; break; }
}
if (ok) { ox = x; oy = y; break outer; }
}
for (let i = 0; i < 14; i++) I.place('road', ox + i, oy);
for (let i = 1; i < 13; i++) I.place('house', ox + i, oy - 1);
I.place('well', ox + 2, oy + 1);
I.place('market', ox + 4, oy + 1);
I.place('granary', ox + 7, oy + 1);
const placeNear = (key, cx, cy, R = 7) => {
for (let dy = -R; dy <= R; dy++) for (let dx = -R; dx <= R; dx++) {
if (I.place(key, cx + dx, cy + dy).ok) return true;
}
return false;
};
for (let i = 0; i < 8; i++) I.place('wall', ox + 2 + i, oy + 4);
I.place('tower', ox + 4, oy + 5);
placeNear('fort', ox + 11, oy + 5);
placeNear('heroHall', ox + 13, oy - 3);
I.give('weapons', 60);
I.give('iron', 100);
return { ox, oy, hall: [...c.buildings.values()].some(b => b.def === 'heroHall'), fort: [...c.buildings.values()].some(b => b.def === 'fort') };
});
console.log('setup:', JSON.stringify(setup));
// run to month 8 so fort can recruit & pop exists
await page.evaluate(() => { const I = window.__IA; for (let i = 0; i < 8 * 20; i++) I.step(0.5); });
const mid = await page.evaluate(() => {
const c = window.__IA.app.city;
return { pop: c.population, legions: [...c.walkers.values()].filter(w => w.unit?.kind === 'soldiers').length };
});
console.log('mid:', JSON.stringify(mid));
// summon hero
const heroOk = await page.evaluate(() => {
const I = window.__IA;
I.app.city.funds += 5000; // ensure affordable for test
return I.summon('champion');
});
console.log('summoned:', heroOk);
// spawn a hydra in front of the walls and let combat resolve
await page.evaluate((pos) => {
const I = window.__IA;
I.spawnWave('hydra', 1, 1);
// teleport hydra near the town for a quick fight
const foe = [...I.app.city.walkers.values()].find(w => w.unit?.side === 'foe');
if (foe) { foe.x = pos.ox + 6; foe.y = pos.oy + 6; }
}, setup);
// cast firestorm at the foe location (grant spell first)
const castRes = await page.evaluate((pos) => {
const I = window.__IA;
I.grantSpell('firestorm');
I.app.city.mana = 500;
return I.cast('firestorm', pos.ox + 7, pos.oy + 7);
}, setup);
console.log('firestorm cast:', castRes);
// step until hydra dies (max ~60s game time)
const fight = await page.evaluate(() => {
const I = window.__IA;
let slain0 = I.app.city.stats.monstersSlain;
for (let i = 0; i < 120; i++) {
I.step(0.5);
const foes = [...I.app.city.walkers.values()].filter(w => w.unit?.side === 'foe');
if (foes.length === 0) break;
}
return {
slain: I.app.city.stats.monstersSlain - slain0,
foesLeft: [...I.app.city.walkers.values()].filter(w => w.unit?.side === 'foe').length,
heroAlive: [...I.app.city.walkers.values()].some(w => w.kind === 'hero'),
golems: [...c_walkers(I)].length,
};
function c_walkers(I) { return []; }
});
console.log('fight:', JSON.stringify(fight));
// SAVE / LOAD round-trip
const before = await page.evaluate(() => {
const I = window.__IA;
I.save();
return { month: I.app.city.month, pop: I.app.city.population };
});
await page.reload({ waitUntil: 'networkidle' });
await page.waitForTimeout(700);
const after = await page.evaluate(() => {
const I = window.__IA;
const ok = I.loadAutosave();
return { ok, month: I.app?.city.month ?? -1, pop: I.app?.city.population ?? -1, before: null };
});
console.log(`save/load: before=${JSON.stringify(before)} after=${JSON.stringify(after)}`);
console.log('CONSOLE ERRORS:', errors.length);
errors.slice(0, 8).forEach(e => console.log(' -', e.slice(0, 180)));
const pass = heroOk && castRes && fight.slain >= 1 && after.ok && Math.abs(after.month - before.month) <= 1 && errors.length === 0;
console.log(pass ? '✅ COMBAT/MAGIC/SAVE E2E PASS' : '❌ FAIL');
await browser.close();
process.exit(pass ? 0 : 1);