- 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)
127 lines
4.8 KiB
JavaScript
127 lines
4.8 KiB
JavaScript
// 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);
|