100 Days After — complete playable survival strategy game

- Seeded procedural runs (survivors, locations, weather, events)
- 100-day campaign with 5 phases, scripted story beats, Day-100 finale
- 8 endings incl. hidden 'First Light Again'
- Data-driven events (people/world/story packs) with callback chains
- Relationships, memories, grief, karma/hope systems
- Tactical encounters (fight/hide/run/negotiate) with honest odds
- 8 upgradeable camp buildings, 13 location templates
- Canvas parallax scenes, procedural SVG portraits, weather FX
- Synthesized adaptive audio (WebAudio, no assets)
- Save slots + autosave (localStorage), shareable seeds
- Tests: headless multi-seed simulator + browser E2E (playwright)
This commit is contained in:
2026-08-23 06:58:47 +00:00
commit 486201655b
36 changed files with 9989 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
import { chromium } from 'playwright-core';
// build a Day-100-ready save with node + tsx first
const { execSync } = await import('node:child_process');
const stateJson = execSync(`npx tsx -e "
import { createNewGame } from './src/engine/state';
const s = createNewGame(2024);
s.day = 99; s.ap = 3;
s.flags.evacPrep = 1; s.flags.heardTruth = 1; s.flags.militaryRep = 3;
s.res.food = 40; s.res.water = 40; s.res.medicine = 6;
console.log(JSON.stringify({ v: s.version, ts: Date.now(), state: s }));
"`, { encoding: 'utf8', cwd: '/root/100days' }).trim();
const browser = await chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell', args:['--no-sandbox','--disable-gpu','--disable-dev-shm-usage'] });
const ctx = await browser.newContext({ viewport:{width:1440,height:900} });
await ctx.addInitScript((save) => {
localStorage.setItem('d100_save_auto', save);
}, stateJson);
const page = await ctx.newPage();
page.setDefaultTimeout(9000);
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('http://127.0.0.1:5199', { waitUntil:'domcontentloaded' });
await page.waitForSelector('.title-screen');
await page.click('text=CONTINUE');
await page.waitForSelector('.game-layout');
console.log('loaded day:', await page.evaluate(()=>document.querySelector('#hud-day')?.textContent));
// end days 99 -> 100 -> finale
for (let i = 0; i < 10; i++) {
// drain modals
for (let k = 0; k < 12; k++) {
const r = await page.evaluate(() => {
const ov = document.querySelectorAll('.modal-root .overlay');
const last = ov[ov.length - 1];
if (!last) return { none: true };
const btns = [...last.querySelectorAll('button')].filter(b => !b.disabled && b.offsetParent !== null);
if (/DAY 100/i.test(last.querySelector('.modal-head h2')?.textContent ?? '')) {
const muster = btns.find(b => /convoy|muster/i.test(b.textContent));
const any = muster ?? btns[0];
any.click();
return { finale: true, clicked: any.textContent.slice(0, 30) };
}
const t = btns.find(b => /CONTINUE|STEEL/i.test(b.textContent))
|| btns.find(b => b.classList.contains('ev-choice') && !b.classList.contains('locked'))
|| btns.find(b => b.classList.contains('choice'));
if (!t) return { stuck: true };
t.click();
return { clicked: true };
});
if (r.none) break;
if (r.finale) { console.log('FINALE choice:', r.clicked); continue; }
if (r.stuck) { console.log('STUCK on modal'); break; }
await page.waitForTimeout(300);
}
const ended = await page.evaluate(() => !!document.querySelector('.ending-modal'));
if (ended) break;
const btn = page.locator('#btn-endday');
if (!(await btn.isEnabled())) break;
await btn.click();
await page.waitForTimeout(500);
}
await page.waitForTimeout(800);
const ending = await page.evaluate(() => {
const el = document.querySelector('.ending-title');
return el ? el.textContent : null;
});
await page.screenshot({ path: 'shots/ending.png' });
console.log('ENDING SHOWN:', ending);
console.log(errors.length ? 'PAGE ERRORS: ' + errors.join(' | ') : 'no page errors');
await browser.close();
if (!ending || errors.length) process.exit(1);
console.log('FINALE TEST PASSED');
+249
View File
@@ -0,0 +1,249 @@
/* Headless auto-play: runs full 100-day campaigns across many seeds to
* catch crashes, verify determinism, and sanity-check balance.
*
* npm run sim -> 12 seeds
* npm run sim -- --seeds=40
*/
import { createNewGame } from '../src/engine/state';
import { createRng } from '../src/engine/rng';
import {
apMaxOf, collectDueEvents, doBuild, doExplore, doHunt, doRepair, doRest,
doScavenge, doTalk, doTravel, endDay, estimateOdds, resolveEncounter,
runEventChoice, castFor, canBuild,
} from '../src/engine/sim';
import { finalizeEnding } from '../src/engine/ending';
import { aliveOf } from '../src/engine/sim-core';
import type { EventChoiceReq, GameState } from '../src/engine/types';
let failures = 0;
function fail(msg: string) { console.error('FAIL:', msg); failures++; }
function reqMet(s: GameState, req?: EventChoiceReq): boolean {
if (!req) return true;
for (const k of Object.keys(req.res ?? {}) as (keyof typeof s.res)[]) {
if (s.res[k] < (req.res![k] ?? 0)) return false;
}
if (req.flag && !s.flags[req.flag]) return false;
if (req.noFlag && s.flags[req.noFlag]) return false;
if (req.minFlag && (s.flags[req.minFlag.key] ?? 0) < req.minFlag.min) return false;
if (req.aliveMin && aliveOf(s).length < req.aliveMin) return false;
return true;
}
function pickChoice(s: GameState, ev: ReturnType<typeof collectDueEvents>[number], rng: ReturnType<typeof createRng>): number | null {
const viable = ev.choices.map((c, i) => ({ c, i })).filter(x => reqMet(s, x.c.req));
if (!viable.length) return null;
// heuristic: prefer later choices slightly (usually more interesting), random tiebreak
return rng.chance(0.6) ? viable[viable.length - 1].i : rng.pick(viable).i;
}
function playSeed(seed: number, verbose = false): GameState {
const s = createNewGame(seed);
const ui = createRng(seed ^ 0xA11CE); // separate rng for bot decisions
let dayGuard = 0;
while (s.phase !== 'gameover' && dayGuard++ < 130) {
// --- morning events ---
const events = collectDueEvents(s);
for (const ev of events) {
const cast = castFor(s, ev, ui);
const idx = pickChoice(s, ev, ui);
if (idx === null) continue;
try {
runEventChoice(s, ev, idx, cast);
} catch (e) {
fail(`seed ${seed} day ${s.day} event ${ev.id} choice ${idx}: ${e}`);
}
// resolve any encounters triggered inside events conservatively
while (s.pending.length) {
const enc = s.pending[0];
const odds = estimateOdds(s, enc);
const approach =
odds.fight >= 55 ? 'fight' :
(odds.hide ?? 0) >= 50 ? 'hide' :
(odds.negotiate ?? 0) >= 45 ? 'negotiate' : 'fight';
try { resolveEncounter(s, approach); } catch (e) { fail(`encounter ${enc.enemyName}: ${e}`); }
}
}
if (s.day >= 100 && s.flags.finale_path) {
finalizeEnding(s);
break;
}
// --- plan actions ---
const alive = aliveOf(s);
if (!alive.length) break;
const buildOrder = ['shelter', 'water_collector', 'storage', 'garden', 'medical', 'workshop', 'watchtower', 'generator'];
const actionsLeft = () => Math.min(s.ap, apMaxOf(s));
while (actionsLeft() > 0 && aliveOf(s).length > 0) {
const acted = (() => {
try {
// finish building priorities first
for (const b of buildOrder.slice(0, 4)) {
const { affordable } = canBuild(s, b);
if (affordable && s.ap > 0) { doBuild(s, b); return true; }
}
const gm = aliveOf(s);
const avgMorale = gm.reduce((t, x) => t + x.morale, 0) / Math.max(1, gm.length);
const known = s.locations.filter(l => l.discovered && !l.cleared);
if (known.length < 3 || (ui.f() < 0.25 && s.day < 60)) {
const ex = gm[0];
doExplore(s, ex.id);
return true;
}
if (avgMorale < 32 && ui.f() < 0.5) { doRest(s); return true; }
if (s.camp.integrity < 45 && s.res.materials >= 2 && ui.f() < 0.5) { doRepair(s); return true; }
// scavenge: prefer special-bearing sites sometimes, else richest known
const specialSite = known.filter(l => l.specialId && !l.specialFound)
.sort((a, b) => b.tier - a.tier)[0];
const site = (specialSite && ui.f() < 0.5) ? specialSite
: known.sort((a, b) => b.lootPool - a.lootPool)[0];
if (site && !(s.weather === 'storm' && site.tier >= 3)) {
const party = gm.slice(0, Math.min(2, gm.length)).map(x => x.id);
const r = doScavenge(s, site.uid, party);
if (r.ok) return true;
}
if (ui.f() < 0.35) { doHunt(s, gm.slice(0, 2).map(x => x.id)); return true; }
if (gm.length >= 2 && ui.f() < 0.3) {
doTalk(s, gm[0].id, gm[1].id);
return true;
}
// late-game: try travel for tier 3 loot
if (s.day > 30 && ui.f() < 0.3) {
const targets = s.locations.filter(l => l.tier === 3 && !l.cleared && l.discovered);
if (targets.length) { doTravel(s, targets[0].uid, gm.slice(0, 2).map(x => x.id)); return true; }
}
// remaining builds
for (const b of buildOrder.slice(4)) {
const { affordable } = canBuild(s, b);
if (affordable && s.ap > 0) { doBuild(s, b); return true; }
}
doRest(s);
return true;
} catch (e) {
fail(`seed ${seed} day ${s.day} action: ${e}`);
return false;
}
})();
if (!acted) break;
// encounters mid-action
while (s.pending.length) {
const enc = s.pending[0];
const odds = estimateOdds(s, enc);
const approach = (odds.hide ?? 0) >= 50 ? 'hide' : odds.fight >= 55 ? 'fight' : (odds.run ?? 0) >= 45 ? 'run' : (odds.negotiate ?? 0) >= 40 ? 'negotiate' : 'run';
try { resolveEncounter(s, approach); } catch (e) { fail(`encounter ${enc.enemyName}: ${e}`); }
}
// events fired by scavenging
void verbose;
}
// resolve leftover pending
while (s.pending.length) {
const enc = s.pending[0];
try { resolveEncounter(s, 'fight'); } catch (e) { fail(`pending encounter: ${e}`); }
}
// --- night ---
try {
const rep = endDay(s);
if (rep.gameOver) { finalizeEnding(s); break; }
} catch (e) {
fail(`seed ${seed} endDay ${s.day}: ${e}`);
break;
}
// sanity checks
for (const resKey of Object.keys(s.res) as (keyof typeof s.res)[]) {
if (!Number.isFinite(s.res[resKey])) fail(`NaN resource ${resKey} seed ${seed} day ${s.day}`);
}
for (const x of s.survivors) {
if (!Number.isFinite(x.hp) || !Number.isFinite(x.morale) || !Number.isFinite(x.hunger)) {
fail(`NaN vitals for ${x.name} seed ${seed} day ${s.day}`);
}
if ((x.morale < -0.001) || x.morale > 100.001) fail(`morale out of range ${x.name} ${x.morale}`);
}
if (aliveOf(s).length && s.day <= 100 && !anyPathPossible(s) && s.day > 95) {
// soft warning only
}
}
finalizeEnding(s);
return s;
}
function anyPathPossible(_s: GameState): boolean { return true; }
/* ---------------- runner ---------------- */
function summarize(seed: number, s: GameState) {
const alive = aliveOf(s);
const dead = s.survivors.filter(x => !x.alive && !x.gone);
const left = s.survivors.filter(x => x.gone);
for (const d of dead) {
const key = (d.deathCause ?? '?').replace(/(fighting|by|the)\s+.*/i, m => m.slice(0, 28));
deathCauses[key] = (deathCauses[key] ?? 0) + 1;
}
console.log(
`seed ${String(seed).padStart(8)} | end D${String(Math.min(s.day, 100)).padStart(3)} ` +
`| ${String(alive.length).padStart(2)} alive, ${dead.length} dead, ${left.length} left ` +
`| food ${String(Math.round(s.res.food)).padStart(3)} water ${String(Math.round(s.res.water)).padStart(3)} med ${String(Math.round(s.res.medicine)).padStart(2)} ` +
`| shelter L${s.camp.buildings.shelter ?? 0} gen L${s.camp.buildings.generator ?? 0} tower L${s.camp.buildings.watchtower ?? 0} ` +
`| radio ${(s.flags.radio_parts ?? 0)}/3 veh:${s.flags.vehicle ? 'Y' : 'n'} vault:${s.flags.seed_vault ? 'Y' : 'n'} ` +
`| ENDING: ${s.endingId}`,
);
}
const deathCauses: Record<string, number> = {};
function hashState(s: GameState): string {
const { survivors, rel, res, flags, strFlags, camp, locations, rngCursor, seenEvents, stats, queuedEvents, endingId } = s;
return JSON.stringify({
survivors: survivors.map(x => ({ i: x.id, hp: x.hp, hu: x.hunger, m: Math.round(x.morale), sk: x.skills, inj: x.injuries.map(j => [j.kind, j.severity, j.daysLeft]), dead: x.alive ? 0 : 1, gone: x.gone ? 1 : 0 })),
rel, res, flags, strFlags, camp,
locations: locations.map(l => ({ u: l.uid, r: l.risk, lp: l.lootPool, d: l.discovered ? 1 : 0, sp: l.specialFound ? 1 : 0 })),
rngCursor, seenEvents, stats, q: queuedEvents.length, endingId,
});
}
async function main() {
const argSeeds = process.argv.find(a => a.startsWith('--seeds='));
const n = argSeeds ? parseInt(argSeeds.split('=')[1], 10) : 12;
console.log(`Running ${n} seeded campaigns…\n`);
const endings: Record<string, number> = {};
let totalDead = 0, totalAliveEnd = 0, finished100 = 0;
for (let i = 0; i < n; i++) {
const seed = (i * 2654435761) % 0xffffffff;
const s = playSeed(seed);
summarize(seed, s);
endings[s.endingId ?? 'none'] = (endings[s.endingId ?? 'none'] ?? 0) + 1;
totalDead += s.survivors.filter(x => !x.alive && !x.gone).length;
totalAliveEnd += aliveOf(s).length;
if (s.day >= 100 || s.endingId === 'silence') finished100++;
}
// determinism check
console.log('\nDeterminism check…');
const detSeed = 123456789;
const a = playSeed(detSeed);
const b = playSeed(detSeed);
if (hashState(a) !== hashState(b)) fail('determinism: same seed produced different states');
else console.log('determinism OK');
console.log('\n==== SUMMARY ====');
console.log('death causes:', Object.entries(deathCauses).sort((a, b) => b[1] - a[1]));
console.log('endings:', endings);
console.log(`avg dead/run: ${(totalDead / n).toFixed(2)}, avg survivors at end: ${(totalAliveEnd / n).toFixed(2)}, runs resolved: ${finished100}/${n}`);
if (failures === 0) console.log('\nALL CHECKS PASSED');
else { console.log(`\n${failures} FAILURES`); process.exitCode = 1; }
}
main();
+58
View File
@@ -0,0 +1,58 @@
import { chromium } from 'playwright-core';
const browser = await chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell', args:['--no-sandbox','--disable-gpu','--disable-dev-shm-usage'] });
const page = await browser.newPage({ viewport:{width:1440,height:900} });
page.setDefaultTimeout(9000);
await page.goto('http://127.0.0.1:5199', { waitUntil:'domcontentloaded' });
await page.waitForSelector('.title-screen');
await page.waitForTimeout(1200);
await page.screenshot({ path: 'shots/title.png' });
await page.click('text=NEW RUN');
await page.fill('.seed-input','31337');
await page.click('text=BEGIN DAY 1');
await page.waitForSelector('.game-layout');
await page.waitForTimeout(1500);
await page.screenshot({ path: 'shots/game.png' });
// survivor detail
await page.locator('.surv-card:not(.dead)').first().click();
await page.waitForTimeout(400);
await page.screenshot({ path: 'shots/survivor.png' });
await page.click('.modal-x');
// build menu
await page.click('.action:has-text("BUILD")');
await page.waitForTimeout(400);
await page.screenshot({ path: 'shots/build.png' });
await page.keyboard.press('Escape').catch(()=>{});
await page.evaluate(() => document.querySelector('.modal-x')?.click());
await page.waitForTimeout(300);
// force an encounter via hunt a few times for the shot; else map hover
await page.click('[data-tab="map"]');
await page.waitForTimeout(200);
await page.locator('.map-entry').nth(2).hover();
await page.waitForTimeout(800);
await page.screenshot({ path: 'shots/map-preview.png' });
// end day -> night report shot
await page.click('#btn-endday');
await page.waitForTimeout(600);
for (let i=0;i<6;i++){
const res = await page.evaluate(()=>{
const ov=document.querySelectorAll('.modal-root .overlay'); const last=ov[ov.length-1];
if(!last) return {done:true};
const btns=[...last.querySelectorAll('button')].filter(b=>!b.disabled&&b.offsetParent!==null);
const t=btns.find(b=>/CONTINUE|STEEL/i.test(b.textContent))||btns.find(b=>b.classList.contains('ev-choice')&&!b.classList.contains('locked'))||btns.find(b=>b.classList.contains('choice'));
if(!t) return {done:true};
const isEvent = !!t.closest('.event-modal, .ev-choice');
t.click();
return {done:false, isEvent};
});
if (res.done) break;
if (i===0) await page.waitForTimeout(250);
await page.screenshot({ path: 'shots/modal.png' }).catch(()=>{});
await page.waitForTimeout(380);
}
await browser.close();
console.log('shots done');
+136
View File
@@ -0,0 +1,136 @@
/* Browser smoke test: boots the game, starts a run, plays a few days
* through the real UI, and fails on any console error or page crash.
*
* node scripts/smoke.mjs [url]
*/
import { chromium } from 'playwright-core';
const url = process.argv[2] ?? 'http://127.0.0.1:5199';
const exe = '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell';
const browser = await chromium.launch({ executablePath: exe, args: ['--no-sandbox','--disable-gpu','--disable-dev-shm-usage'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page.setDefaultTimeout(9000);
const errors = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push('console: ' + msg.text());
});
page.on('pageerror', err => errors.push('pageerror: ' + err.message));
const step = async (name, fn) => {
try { await fn(); console.log('ok ', name); }
catch (e) { console.log('FAIL', name, '-', e.message.split('\n')[0]); errors.push(name + ': ' + e.message); }
};
await step('load page', async () => {
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.title-screen', { timeout: 10000 });
});
await step('new run dialog', async () => {
await page.click('text=NEW RUN');
await page.waitForSelector('.seed-input');
});
await step('start run', async () => {
await page.fill('.seed-input', '777001');
await page.click('text=BEGIN DAY 1');
await page.waitForSelector('.game-layout', { timeout: 10000 });
});
await step('HUD renders', async () => {
await page.waitForFunction(() => document.querySelector('#hud-day')?.textContent?.includes('DAY'));
const resCount = await page.locator('.res-chip').count();
if (resCount !== 7) throw new Error('expected 7 resource chips, got ' + resCount);
});
await step('survivor list renders', async () => {
const n = await page.locator('.surv-card:not(.dead)').count();
if (n < 3 || n > 4) throw new Error('unexpected survivor count ' + n);
await page.locator('.surv-card:not(.dead)').first().click();
await page.waitForSelector('.sd-body');
await page.click('.modal-x');
});
await step('map tab renders', async () => {
await page.click('[data-tab="map"]');
const entries = await page.locator('.map-entry').count();
if (entries < 2) throw new Error('map too empty');
// hover preview shouldn't crash
await page.locator('.map-entry').nth(1).hover();
await page.click('[data-tab="log"]');
});
await step('scavenge action flow', async () => {
await page.click('.action:has-text("SCAVENGE")');
await page.waitForSelector('.site-grid .site-card');
await page.locator('.site-card').first().click();
const rows = page.locator('.party-check .check-row');
await rows.first().locator('input').check();
if (await rows.count() > 1) await rows.nth(1).locator('input').check();
await page.click('text=HEAD OUT');
await page.waitForSelector('.report, .enc-modal', { timeout: 8000 });
// resolve possible encounter
for (let i = 0; i < 6; i++) {
const enc = page.locator('.enc-modal .btn.choice');
if (await enc.count()) { await enc.first().click(); await page.waitForTimeout(300); continue; }
const cont = page.locator('.modal-card .btn:has-text("CONTINUE")');
if (await cont.count()) { await cont.first().click(); break; }
break;
}
await page.waitForTimeout(400);
});
// play several days via END DAY until an event modal appears and handle choices
for (let d = 0; d < 8; d++) {
await step(`day cycle ${d + 1}`, async () => {
// close any open report/event by clicking CONTINUE / first choice repeatedly
for (let i = 0; i < 10; i++) {
const choice = page.locator('.ev-choice:not(.locked)');
const cont = page.locator('.modal-card .btn:has-text("CONTINUE")');
const encBtns = page.locator('.enc-modal .btn.choice');
if (await encBtns.count()) { await encBtns.first().click(); await page.waitForTimeout(250); continue; }
if (await choice.count()) { await choice.first().click(); await page.waitForTimeout(250); continue; }
if (await cont.count()) { await cont.first().click(); await page.waitForTimeout(250); continue; }
break;
}
const endBtn = page.locator('#btn-endday');
if (await endBtn.isEnabled()) await endBtn.click();
await page.waitForTimeout(500);
for (let i = 0; i < 12; i++) {
const cont = page.locator('.modal-card .btn:has-text("CONTINUE")');
const encBtns = page.locator('.enc-modal .btn.choice');
const evChoice = page.locator('.ev-choice:not(.locked)');
if (await encBtns.count()) { await encBtns.first().click(); await page.waitForTimeout(250); continue; }
if (await cont.count()) { await cont.first().click(); await page.waitForTimeout(350); continue; }
if (await evChoice.count()) { await evChoice.first().click(); await page.waitForTimeout(250); continue; }
break;
}
});
}
await step('save to slot', async () => {
await page.click('#btn-menu');
await page.click('text=SAVE SLOT 1');
await page.waitForTimeout(300);
});
await step('reload + continue via load modal', async () => {
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('.title-screen');
await page.click('text=LOAD RUN');
await page.waitForSelector('.save-row');
await page.locator('.save-row .btn:has-text("LOAD")').first().click();
await page.waitForSelector('.game-layout', { timeout: 8000 });
});
console.log('\n---- RESULT ----');
if (errors.length) {
console.log(errors.length + ' ERRORS:');
for (const e of errors.slice(0, 20)) console.log(' -', e);
process.exitCode = 1;
} else {
console.log('SMOKE TEST PASSED — no console/page errors across boot, play, save, load.');
}
await browser.close();
+54
View File
@@ -0,0 +1,54 @@
import { chromium } from 'playwright-core';
const browser = await chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell', args:['--no-sandbox','--disable-gpu','--disable-dev-shm-usage'] });
const page = await browser.newPage({ viewport:{width:1440,height:900} });
await page.goto('http://127.0.0.1:5199', { waitUntil:'domcontentloaded' });
await page.waitForSelector('.title-screen');
await page.waitForTimeout(1500);
function analyzeCanvas(sel){
return page.evaluate((sel)=>{
const c=document.querySelector(sel);
const g=c.getContext('2d');
const d=g.getImageData(0,0,c.width,c.height).data;
let sum=0,sum2=0,n=0; const buckets={};
for(let i=0;i<d.length;i+=4*97){
const v=(d[i]+d[i+1]+d[i+2])/3;
sum+=v;sum2+=v*v;n++;
const b=Math.floor(v/32); buckets[b]=(buckets[b]??0)+1;
}
const mean=sum/n, sd=Math.sqrt(Math.max(0,sum2/n-mean*mean));
return {w:c.width,h:c.height,mean:Math.round(mean),sd:Math.round(sd),buckets};
},sel);
}
console.log('TITLE canvas:', JSON.stringify(await analyzeCanvas('#title-canvas')));
// body font/bg sanity
console.log('body bg:', await page.evaluate(()=>getComputedStyle(document.body).backgroundColor));
console.log('logo font-size:', await page.evaluate(()=>getComputedStyle(document.querySelector('.logo')).fontSize));
await page.click('text=NEW RUN'); await page.fill('.seed-input','999'); await page.click('text=BEGIN DAY 1');
await page.waitForSelector('.game-layout');
await page.waitForTimeout(1500);
console.log('GAME canvas:', JSON.stringify(await analyzeCanvas('#scene')));
// layout geometry: no zero-size majors, actionbar buttons count
const geo = await page.evaluate(()=>{
const r=s=>{const e=document.querySelector(s); if(!e) return null; const b=e.getBoundingClientRect(); return [Math.round(b.width),Math.round(b.height)];};
return {
scene:r('#scene'), left:r('.panel.left'), right:r('.panel.right'),
actions:[...document.querySelectorAll('.action')].length,
survCards:document.querySelectorAll('.surv-card').length,
resChips:document.querySelectorAll('.res-chip').length,
apPips:document.querySelectorAll('.ap-pip').length,
svgPorts:document.querySelectorAll('.surv-card svg').length,
};
});
console.log('geometry:',JSON.stringify(geo));
// portrait svg content sanity
const svgInfo = await page.evaluate(()=>{
const s=document.querySelector('.surv-card svg');
return s?{paths:s.querySelectorAll('path,ellipse,circle,rect').length,len:s.innerHTML.length}:null;
});
console.log('portrait:',JSON.stringify(svgInfo));
await browser.close();
console.log('VISUAL CHECK DONE');