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:
@@ -0,0 +1,35 @@
|
||||
// Browser smoke test: load game, start sandbox, watch for console errors.
|
||||
import { chromium } from 'playwright-core';
|
||||
|
||||
const exe = '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell';
|
||||
const errors = [];
|
||||
const browser = await chromium.launch({ executablePath: exe, args: ['--no-sandbox', '--disable-gpu'] });
|
||||
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('http://127.0.0.1:5173/', { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(1200);
|
||||
await page.screenshot({ path: '/tmp/shot_menu.png' });
|
||||
|
||||
// start sandbox
|
||||
await page.click('#mm-sandbox');
|
||||
await page.waitForTimeout(2500);
|
||||
await page.screenshot({ path: '/tmp/shot_game.png' });
|
||||
|
||||
// try placing a road via clicks on canvas (center-ish drag)
|
||||
const cv = await page.$('#world');
|
||||
const box = await cv.boundingBox();
|
||||
console.log('canvas at', box);
|
||||
// select housing tab then road card
|
||||
const tabs = await page.$$('#cattabs .cattab');
|
||||
console.log('tabs:', tabs.length);
|
||||
|
||||
// speed up time & let it run
|
||||
for (let i = 0; i < 6; i++) { await page.mouse.click(box.x + box.width / 2, box.y + 60); await page.waitForTimeout(300); }
|
||||
await page.screenshot({ path: '/tmp/shot_later.png' });
|
||||
|
||||
console.log('CONSOLE ERRORS:', errors.length);
|
||||
errors.slice(0, 12).forEach(e => console.log(' -', e.slice(0, 220)));
|
||||
await browser.close();
|
||||
process.exit(errors.length ? 1 : 0);
|
||||
@@ -0,0 +1,8 @@
|
||||
import pkg from '../node_modules/.pnpm/esbuild@0.21.5/node_modules/esbuild/lib/main.js';
|
||||
const { build } = pkg;
|
||||
await build({
|
||||
entryPoints: ['scripts/simtest.ts'],
|
||||
bundle: true, format: 'esm', platform: 'node',
|
||||
outfile: '/tmp/simtest.mjs', logLevel: 'warning',
|
||||
});
|
||||
console.log('bundled ok');
|
||||
@@ -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);
|
||||
@@ -0,0 +1,42 @@
|
||||
import { CAMPAIGN } from '../src/data/scenarios';
|
||||
import { createCity, tickCity, placeBuilding } from '../src/sim/city';
|
||||
import { ensureSimHooks } from '../src/sim/hooks';
|
||||
|
||||
const c = createCity(CAMPAIGN[0]);
|
||||
ensureSimHooks();
|
||||
let ox=-1, oy=-1;
|
||||
outer: for (let y=8;y<c.H-12;y++) for (let x=8;x<c.W-14;x++){
|
||||
let ok=true;
|
||||
for (let dy=-2; dy<8 && ok; dy++) for (let dx=-2;dx<26;dx++){
|
||||
const t=c.tiles[(y+dy)*c.W+(x+dx)];
|
||||
if(!t||t.terrain==='water'||t.terrain==='tree'||t.terrain==='rock'){ok=false;break;}
|
||||
}
|
||||
if(ok){ox=x;oy=y;break outer;}
|
||||
}
|
||||
for(let i=0;i<22;i++) placeBuilding(c,'road',ox+i,oy);
|
||||
for(let i=1;i<21;i++) placeBuilding(c,'house',ox+i,oy-1);
|
||||
placeBuilding(c,'well',ox+2,oy+1);
|
||||
placeBuilding(c,'market',ox+4,oy+1);
|
||||
placeBuilding(c,'granary',ox+8,oy+1);
|
||||
placeBuilding(c,'prefecture',ox+12,oy+1);
|
||||
let farmPlaced=false;
|
||||
for (const [fdx,fdy] of [[15,1],[14,1],[16,4],[13,4]] as const){
|
||||
if(placeBuilding(c,'farmWheat',ox+fdx,oy+fdy).ok){farmPlaced=true;console.log('farm at',fdx,fdy);break;}
|
||||
}
|
||||
|
||||
const dump=(label:string)=>{
|
||||
const f=[...c.buildings.values()].find(b=>b.def==='farmWheat')!;
|
||||
const g=[...c.buildings.values()].find(b=>b.def==='granary')!;
|
||||
const m=[...c.buildings.values()].find(b=>b.def==='market')!;
|
||||
console.log(`--- ${label} (month ${c.month})`);
|
||||
console.log(`farm workers=${f.workersLive} progress=${f.progress.toFixed(2)} wheat=${f.inv['wheat']??0}`);
|
||||
console.log(`granary wheat=${g.inv['wheat']??0}`);
|
||||
console.log(`market workers=${m.workersLive} inv=`, JSON.stringify(m.inv));
|
||||
const wk=[...c.walkers.values()];
|
||||
console.log('walkers:', wk.map(w=>`${w.kind}${w.cargo?'('+JSON.stringify(w.cargo)+')':''}`).slice(0,12).join(', '));
|
||||
const hs=[...c.buildings.values()].filter(b=>b.def==='house');
|
||||
const fed=hs.filter(h=>((h.goodsStock?.['wheat']??0)+(h.goodsStock?.['fruit']??0))>0).length;
|
||||
console.log(`houses=${hs.length} residents=${hs.reduce((a,b)=>a+b.residents,0)} fed=${fed}`);
|
||||
};
|
||||
dump('start');
|
||||
for (let i=0;i<20*20;i++){ tickCity(c,CAMPAIGN[0],0.5); if(i===199||i===399) dump(`t+${Math.round(i*0.5)}s`); }
|
||||
@@ -0,0 +1,89 @@
|
||||
// Deep E2E: start sandbox, build a working town via __IA hook, fast-forward months, assert state.
|
||||
import { chromium } from 'playwright-core';
|
||||
|
||||
const exe = '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell';
|
||||
const errors = [];
|
||||
const browser = await chromium.launch({ executablePath: exe, args: ['--no-sandbox', '--disable-gpu'] });
|
||||
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));
|
||||
|
||||
const BASE = process.env.BASE || 'http://127.0.0.1:5173/';
|
||||
await page.goto(BASE, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(800);
|
||||
await page.click('#mm-sandbox');
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
// Build town programmatically
|
||||
const result = await page.evaluate(() => {
|
||||
const I = window.__IA;
|
||||
const c = I.app.city;
|
||||
// find flat grass area
|
||||
let ox = -1, oy = -1;
|
||||
outer:
|
||||
for (let y = 8; y < c.H - 12; y++) for (let x = 4; x < c.W - 24; x++) {
|
||||
let ok = true, fertileNear = false;
|
||||
for (let dy = -2; dy < 9 && ok; dy++) for (let dx = -2; dx < 21; dx++) {
|
||||
const t = c.tiles[(y + dy) * c.W + (x + dx)];
|
||||
if (!t || t.terrain === 'water') { ok = false; break; }
|
||||
if (t.terrain === 'fertile' && t.fert > 30) fertileNear = true;
|
||||
}
|
||||
if (ok && fertileNear) { ox = x; oy = y; break outer; }
|
||||
}
|
||||
if (ox < 0) return { fail: 'no spot' };
|
||||
for (let i = 0; i < 20; i++) I.place('road', ox + i, oy);
|
||||
for (let i = 1; i < 19; 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);
|
||||
I.place('prefecture', ox + 11, oy + 1);
|
||||
I.place('forum', ox + 12, oy - 2) || I.place('forum', ox + 13, oy - 3);
|
||||
// farm directly below the road row -> top edge touches road => connected
|
||||
const fr = I.place('farmWheat', ox + 15, oy + 1);
|
||||
const farmPlaced = !!fr.ok;
|
||||
if (!farmPlaced) {
|
||||
for (const [fdx, fdy] of [[13, 1], [17, 1], [15, -4], [12, 1], [18, 1]]) {
|
||||
if (I.place('farmWheat', ox + fdx, oy + fdy).ok) { farmPlaced = true; break; }
|
||||
}
|
||||
}
|
||||
if (!farmPlaced) return { fail: 'no road-connected farm spot' };
|
||||
return { ox, oy, funds: Math.round(c.funds), pop: c.population };
|
||||
});
|
||||
console.log('build result:', JSON.stringify(result));
|
||||
|
||||
if (result.fail) { console.log('BUILD FAILED'); process.exit(1); }
|
||||
|
||||
// Fast-forward 30 months synchronously
|
||||
const ff = await page.evaluate(() => {
|
||||
const I = window.__IA;
|
||||
for (let i = 0; i < 30 * 20; i++) I.step(0.5);
|
||||
const c = I.app.city;
|
||||
const houses = [...c.buildings.values()].filter(b => b.def === 'house');
|
||||
return {
|
||||
month: c.month, pop: c.population, funds: Math.round(c.funds),
|
||||
employed: c.employed, laborers: c.laborers,
|
||||
sentiment: c.sentiment, ratings: c.ratings,
|
||||
avgLevel: (houses.reduce((a, b) => a + b.houseLevel, 0) / Math.max(1, houses.length)).toFixed(2),
|
||||
walkers: c.walkers.size,
|
||||
granaryWheat: [...c.buildings.values()].filter(b => b.def === 'granary').reduce((a, b) => a + (b.inv.wheat ?? 0), 0),
|
||||
};
|
||||
});
|
||||
console.log('after 30mo:', JSON.stringify(ff));
|
||||
|
||||
// Now test spells & heroes UI presence
|
||||
const ui = await page.evaluate(() => ({
|
||||
spellCount: document.querySelectorAll('#spellbar .spell').length,
|
||||
catTabs: document.querySelectorAll('#cattabs .cattab').length,
|
||||
minimap: !!document.getElementById('minimap'),
|
||||
topFunds: document.getElementById('t-funds')?.textContent,
|
||||
}));
|
||||
console.log('ui:', JSON.stringify(ui));
|
||||
|
||||
await page.screenshot({ path: '/tmp/shot_town.png' });
|
||||
console.log('CONSOLE ERRORS:', errors.length);
|
||||
errors.slice(0, 10).forEach(e => console.log(' -', e.slice(0, 200)));
|
||||
|
||||
const pass = ff.pop > 40 && ff.month >= 28 && ui.spellCount === 7 && errors.length === 0;
|
||||
console.log(pass ? '✅ E2E PASS' : '❌ E2E FAIL');
|
||||
await browser.close();
|
||||
process.exit(pass ? 0 : 1);
|
||||
@@ -0,0 +1,98 @@
|
||||
// Reproduce: real mouse clicks to build a road + house
|
||||
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:4174/';
|
||||
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);
|
||||
|
||||
// helper: convert tile -> screen px using exposed camera
|
||||
async function tileToScreen(tx, ty) {
|
||||
return page.evaluate(([x, y]) => {
|
||||
const app = window.__IA.app;
|
||||
const [wx, wy] = app.cam.tileToWorld(x, y);
|
||||
const [sx, sy] = app.cam.worldToScreen(wx, wy);
|
||||
return [sx, sy];
|
||||
}, [tx, ty]);
|
||||
}
|
||||
async function clickTile(x, y) {
|
||||
const [sx, sy] = await tileToScreen(x, y);
|
||||
await page.mouse.move(sx, sy);
|
||||
await page.waitForTimeout(80);
|
||||
await page.mouse.click(sx, sy);
|
||||
await page.waitForTimeout(60);
|
||||
}
|
||||
|
||||
// 1) pick road tool via UI card
|
||||
await page.click('#cattabs .cattab[data-cat="housing"]');
|
||||
await page.waitForTimeout(200);
|
||||
const cards = await page.$$('#buildcards .bcard');
|
||||
console.log('cards:', cards.length);
|
||||
for (const c of cards) console.log(' card:', (await c.textContent()).trim().slice(0, 30));
|
||||
|
||||
// find road card by title text
|
||||
let roadCard = null;
|
||||
for (const c of cards) { if ((await c.textContent()).includes('Road')) { roadCard = c; break; } }
|
||||
await roadCard.click();
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
const toolState1 = await page.evaluate(() => JSON.stringify(window.__IA.app.tool));
|
||||
console.log('tool after road select:', toolState1);
|
||||
|
||||
// center camera on map middle
|
||||
await page.evaluate(() => {
|
||||
const app = window.__IA.app;
|
||||
const mid = app.cam.tileToWorld(app.city.W / 2, app.city.H / 2);
|
||||
app.cam.cx = mid[0]; app.cam.cy = mid[1];
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// place road at center tile
|
||||
const c0 = Math.floor(await page.evaluate(() => window.__IA.app.city.W / 2));
|
||||
await clickTile(c0, c0);
|
||||
const afterRoad = await page.evaluate(([x, y]) => {
|
||||
const t = window.__IA.app.city.tiles[y * window.__IA.app.city.W + x];
|
||||
return { road: t.road, funds: window.__IA.app.city.funds };
|
||||
}, [c0, c0]);
|
||||
console.log('after road click:', JSON.stringify(afterRoad));
|
||||
|
||||
// 2) select house card and click NEXT to the road
|
||||
let houseCard = null;
|
||||
for (const c of await page.$$('#buildcards .bcard')) { if ((await c.textContent()).includes('House')) { houseCard = c; break; } }
|
||||
await houseCard.click();
|
||||
await page.waitForTimeout(150);
|
||||
console.log('tool after house select:', await page.evaluate(() => JSON.stringify(window.__IA.app.tool)));
|
||||
|
||||
await clickTile(c0, c0 - 1); // directly above road
|
||||
const afterHouse = await page.evaluate(([x, y]) => {
|
||||
const c = window.__IA.app.city;
|
||||
const t = c.tiles[y * c.W + x];
|
||||
const b = t.bld ? c.buildings.get(t.bld) : null;
|
||||
return { bld: t.bld, def: b?.def ?? null, funds: Math.round(c.funds) };
|
||||
}, [c0, c0 - 1]);
|
||||
console.log('after house click:', JSON.stringify(afterHouse));
|
||||
|
||||
// ghost validity debug at that spot
|
||||
const ghostDbg = await page.evaluate(([x, y]) => {
|
||||
const app = window.__IA.app;
|
||||
// replicate canPlace pieces
|
||||
const c = app.city;
|
||||
const t = c.tiles[y * c.W + x];
|
||||
let roadAdj = false;
|
||||
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||
const n = c.tiles[(y + dy) * c.W + (x + dx)];
|
||||
if (n && (n.road || n.plaza)) roadAdj = true;
|
||||
}
|
||||
return { terrain: t.terrain, bldHere: t.bld, roadAdj, tool: app.tool, ghost: app.view.ghost };
|
||||
}, [c0, c0 - 1]);
|
||||
console.log('ghost debug:', JSON.stringify(ghostDbg));
|
||||
|
||||
await page.screenshot({ path: '/tmp/build_repro.png' });
|
||||
console.log('errors:', errors.length ? errors.slice(0, 5) : 0);
|
||||
await browser.close();
|
||||
@@ -0,0 +1,107 @@
|
||||
// Headless simulation smoke test (run via esbuild bundle + node)
|
||||
import { CAMPAIGN } from '../src/data/scenarios';
|
||||
import { createCity, tickCity, placeBuilding } from '../src/sim/city';
|
||||
import { canPlace } from '../src/sim/city';
|
||||
import { ensureSimHooks } from '../src/sim/hooks';
|
||||
|
||||
function fail(msg: string): never {
|
||||
console.error('❌ FAIL:', msg);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sc = CAMPAIGN[0];
|
||||
const c = createCity(sc);
|
||||
ensureSimHooks();
|
||||
|
||||
// find a decent flat spot near center: scan for grass area
|
||||
let ox = -1, oy = -1;
|
||||
outer:
|
||||
for (let y = 8; y < c.H - 12; y++) {
|
||||
for (let x = 8; x < c.W - 14; x++) {
|
||||
let ok = true;
|
||||
for (let dy = -2; dy < 8 && ok; dy++) for (let dx = -2; dx < 16; dx++) {
|
||||
const t = c.tiles[(y + dy) * c.W + (x + dx)];
|
||||
if (!t || t.terrain === 'water' || t.terrain === 'tree' || t.terrain === 'rock') { ok = false; break; }
|
||||
}
|
||||
if (ok) { ox = x; oy = y; break outer; }
|
||||
}
|
||||
}
|
||||
if (ox < 0) fail('no buildable spot found');
|
||||
console.log(`build spot at ${ox},${oy}`);
|
||||
|
||||
// lay a long main road east-west
|
||||
for (let i = 0; i < 22; i++) {
|
||||
if (!placeBuilding(c, 'road', ox + i, oy).ok) fail('road placement failed at +' + i);
|
||||
}
|
||||
// houses along north side
|
||||
for (let i = 1; i < 21; i++) {
|
||||
const r = placeBuilding(c, 'house', ox + i, oy - 1);
|
||||
if (!r.ok) console.warn('house skip', ox + i, oy - 1);
|
||||
}
|
||||
// south side: services
|
||||
const mk = (k: string, dx: number, dy: number) => {
|
||||
const r = placeBuilding(c, k, ox + dx, oy + dy);
|
||||
if (!r.ok && !['farmWheat', 'fishingWharf', 'clayPit', 'timberCamp', 'ironMine', 'crystalMine', 'leyPylon'].includes(k)) fail(k + ' placement failed: ' + r.reason);
|
||||
};
|
||||
mk('well', 2, 1);
|
||||
mk('market', 4, 1);
|
||||
mk('granary', 8, 1);
|
||||
mk('prefecture', 12, 1);
|
||||
// farms near the road row (must touch a road to get workers)
|
||||
let farmOk = false;
|
||||
const farmSpots: Array<readonly [number, number]> = [
|
||||
[15, 1], [16, 1], [14, 1], [17, 1], [13, 1],
|
||||
[15, 4], [17, 4], [13, 4], [18, 1], [12, 1],
|
||||
];
|
||||
for (const [fdx, fdy] of farmSpots) {
|
||||
if (farmOk) break;
|
||||
if (canPlace(c, 'farmWheat', ox + fdx, oy + fdy) === true) {
|
||||
const r = placeBuilding(c, 'farmWheat', ox + fdx, oy + fdy);
|
||||
if (r.ok) { console.log(`farm at ${ox + fdx},${oy + fdy}`); farmOk = true; }
|
||||
}
|
||||
}
|
||||
if (!farmOk) {
|
||||
// widen: anywhere that touches the road row
|
||||
for (let x = 2; x < c.W - 5 && !farmOk; x++) {
|
||||
for (const dy of [1, -4] as const) {
|
||||
if (canPlace(c, 'farmWheat', x, oy + dy) === true) {
|
||||
const r = placeBuilding(c, 'farmWheat', x, oy + dy);
|
||||
if (r.ok) { console.log('wide-scan farm at', x, oy + dy); farmOk = true; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!farmOk) fail('no road-connected farm spot found');
|
||||
|
||||
console.log('initial funds', Math.round(c.funds));
|
||||
|
||||
// simulate 30 months (~300s of game time at speed idx 2)
|
||||
let monthsSeen = c.month;
|
||||
try {
|
||||
for (let step = 0; step < 34 * 20 + 60; step++) {
|
||||
tickCity(c, sc, 0.5); // dt seconds per iteration
|
||||
if (step % 200 === 0 && step > 0) console.log('step', step, 'month', c.month, 'walkers', c.walkers.size);
|
||||
if (c.month > monthsSeen) {
|
||||
monthsSeen = c.month;
|
||||
if (monthsSeen % 6 === 0) {
|
||||
console.log(`month ${monthsSeen}: pop=${c.population} funds=${Math.round(c.funds)} sentiment=${c.sentiment} ratings=C${c.ratings.culture}/P${c.ratings.prosperity}/Pe${c.ratings.peace}/F${c.ratings.favor}`);
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(c.funds)) fail('funds diverged');
|
||||
}
|
||||
} catch (e) { console.error('EXCEPTION IN LOOP:', e); fail(String(e)); }
|
||||
|
||||
console.log('LOOP DONE, months=' + c.month);
|
||||
console.log('--- after ~30 months ---');
|
||||
console.log(`pop=${c.population} funds=${Math.round(c.funds)} employed=${c.employed}/${c.laborers}`);
|
||||
console.log('walkers alive:', c.walkers.size);
|
||||
console.log('messages:', c.messages.slice(0, 8).map(m => m.text));
|
||||
const houses = [...c.buildings.values()].filter(b => b.def === 'house');
|
||||
const levels = houses.map(h => h.houseLevel);
|
||||
console.log('houses:', houses.length, 'avg level:', (levels.reduce((a, b) => a + b, 0) / Math.max(1, levels.length)).toFixed(2), 'max:', Math.max(0, ...levels));
|
||||
console.log('residents total:', houses.reduce((a, b) => a + b.residents, 0));
|
||||
|
||||
if (c.month < 25) fail(`only ${c.month} months advanced`);
|
||||
if (c.population <= 0 && monthsSeen > 12) fail('population never grew');
|
||||
if (monthsSeen >= 25 && c.population < 25) fail(`city collapsing: pop ${c.population} at month ${monthsSeen}`);
|
||||
console.log('✅ SIM SMOKE TEST PASSED');
|
||||
Reference in New Issue
Block a user