- 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)
108 lines
4.3 KiB
TypeScript
108 lines
4.3 KiB
TypeScript
// 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');
|