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:
@@ -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');
|
||||
@@ -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();
|
||||
@@ -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');
|
||||
@@ -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();
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user