feat: improve combat feedback and progression
This commit is contained in:
324
scripts/verify-enemy-intent-rollout.mjs
Normal file
324
scripts/verify-enemy-intent-rollout.mjs
Normal file
@@ -0,0 +1,324 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'vite';
|
||||
import { chromium } from 'playwright';
|
||||
import { desktopBrowserContextOptions, desktopBrowserViewport } from './desktop-browser-viewport.mjs';
|
||||
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
server: { host: '127.0.0.1', port: 0, hmr: false },
|
||||
appType: 'spa'
|
||||
});
|
||||
let browser;
|
||||
|
||||
try {
|
||||
await server.listen();
|
||||
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
|
||||
const {
|
||||
enemyIntentOpeningGuide,
|
||||
isEnemyIntentCounterplayAvailable,
|
||||
isEnemyIntentForecastAvailable
|
||||
} = await server.ssrLoadModule('/src/game/data/enemyIntent.ts');
|
||||
const scenarios = Object.values(battleScenarios);
|
||||
assert.equal(scenarios.length, 66, 'Enemy-intent rollout must cover the complete 66-battle campaign.');
|
||||
|
||||
const playablePhases = ['idle', 'moving', 'command', 'targeting', 'animating'];
|
||||
scenarios.forEach((scenario) => {
|
||||
assert.ok(scenario.sortie, `${scenario.id}: sortie roles are required for counterplay contribution reporting.`);
|
||||
assert.ok(scenario.units.some((unit) => unit.faction === 'ally'), `${scenario.id}: missing allied intent target.`);
|
||||
assert.ok(scenario.units.some((unit) => unit.faction === 'enemy'), `${scenario.id}: missing enemy intent source.`);
|
||||
|
||||
playablePhases.forEach((phase) => {
|
||||
assert.equal(
|
||||
isEnemyIntentForecastAvailable({ activeFaction: 'ally', phase, battleResolved: false }),
|
||||
true,
|
||||
`${scenario.id}: intent forecast unexpectedly disabled during ${phase}.`
|
||||
);
|
||||
assert.equal(
|
||||
isEnemyIntentCounterplayAvailable({ phase, battleResolved: false }),
|
||||
true,
|
||||
`${scenario.id}: counterplay unexpectedly disabled during ${phase}.`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
isEnemyIntentForecastAvailable({ activeFaction: 'ally', phase: 'deployment', battleResolved: false }),
|
||||
false,
|
||||
'Deployment must not show forecasts before positions are confirmed.'
|
||||
);
|
||||
assert.equal(
|
||||
isEnemyIntentForecastAvailable({ activeFaction: 'enemy', phase: 'animating', battleResolved: false }),
|
||||
false,
|
||||
'Enemy execution must hide stale forecast visuals.'
|
||||
);
|
||||
assert.equal(
|
||||
isEnemyIntentForecastAvailable({ activeFaction: 'ally', phase: 'resolved', battleResolved: true }),
|
||||
false,
|
||||
'Settled battles must not show intent forecasts.'
|
||||
);
|
||||
assert.equal(
|
||||
isEnemyIntentCounterplayAvailable({ phase: 'animating', battleResolved: false }),
|
||||
true,
|
||||
'The final allied action must remain eligible for a defeat counterplay before settlement.'
|
||||
);
|
||||
assert.equal(
|
||||
isEnemyIntentCounterplayAvailable({ phase: 'resolved', battleResolved: true }),
|
||||
false,
|
||||
'Resolved battles must not record late counterplay events.'
|
||||
);
|
||||
|
||||
const commonGuide = enemyIntentOpeningGuide();
|
||||
const initiativeGuide = enemyIntentOpeningGuide(3);
|
||||
assert.match(commonGuide, /이동·선제·전열 방호로 파훼/, 'Common intent guide must explain universal counterplay.');
|
||||
assert.doesNotMatch(commonGuide, /전술 명령/, 'Campaign-wide guide must not promise the second-battle-only command.');
|
||||
assert.match(initiativeGuide, /파훼 3회 → 전술 명령/, 'The legacy pursuit battle must retain its initiative instruction.');
|
||||
|
||||
const address = server.httpServer?.address();
|
||||
assert(address && typeof address !== 'string', 'Expected the enemy-intent verification server to listen on a local port.');
|
||||
const targetUrl = `http://127.0.0.1:${address.port}/`;
|
||||
browser = await chromium.launch({ headless: true });
|
||||
|
||||
const firstScenario = battleScenarios['first-battle-zhuo-commandery'];
|
||||
const genericScenario = scenarios.find((scenario, index) => index >= 32 && scenario.id !== firstScenario.id);
|
||||
assert(firstScenario && genericScenario, 'Expected first and mid-campaign runtime fixtures.');
|
||||
|
||||
const runtimeScope = process.env.HEROS_ENEMY_INTENT_SCOPE;
|
||||
if (runtimeScope !== 'generic') {
|
||||
await verifyRuntimeIntentState(browser, targetUrl, firstScenario, { expectTutorial: true });
|
||||
}
|
||||
if (runtimeScope !== 'first') {
|
||||
await verifyRuntimeIntentState(browser, targetUrl, genericScenario, { verifySaveResume: true });
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Verified campaign-wide enemy intent for ${scenarios.length} battles plus ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} deployment, first tutorial, save-resume, final-defeat, settlement, and generalized-guide checks.`
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await server.close();
|
||||
}
|
||||
|
||||
async function verifyRuntimeIntentState(browserInstance, targetUrl, scenario, options = {}) {
|
||||
const url = new URL(targetUrl);
|
||||
url.searchParams.set('debug', '1');
|
||||
url.searchParams.set('renderer', 'canvas');
|
||||
url.searchParams.set('debugBattle', scenario.id);
|
||||
const page = await browserInstance.newPage(desktopBrowserContextOptions);
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await page.addInitScript(() => window.localStorage.clear());
|
||||
await page.goto(url.toString(), { waitUntil: 'domcontentloaded' });
|
||||
await waitForBattleDebugReady(page, scenario.id);
|
||||
|
||||
const initialProbe = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
return {
|
||||
state: window.__HEROS_DEBUG__?.battle(),
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio },
|
||||
scene: scene ? { width: scene.scale.width, height: scene.scale.height } : null
|
||||
};
|
||||
});
|
||||
const initial = initialProbe.state;
|
||||
assert.deepEqual(
|
||||
initialProbe.viewport,
|
||||
{ width: desktopBrowserViewport.width, height: desktopBrowserViewport.height, dpr: 1 },
|
||||
`${scenario.id}: browser did not use the required FHD DPR1 viewport.`
|
||||
);
|
||||
assert.deepEqual(
|
||||
initialProbe.scene,
|
||||
{ width: desktopBrowserViewport.width, height: desktopBrowserViewport.height },
|
||||
`${scenario.id}: battle scene did not use FHD dimensions.`
|
||||
);
|
||||
if (initial?.phase === 'deployment') {
|
||||
assert.equal(initial.counterplayFeedback?.active, false, `${scenario.id}: counterplay active during deployment.`);
|
||||
assert.equal(initial.counterplayFeedback?.forecastVisible, false, `${scenario.id}: forecast visible during deployment.`);
|
||||
assert.equal(initial.enemyIntentPreviews?.length, 0, `${scenario.id}: deployment produced stale intent plans.`);
|
||||
assert.equal(initial.enemyIntentVisualCount, 0, `${scenario.id}: deployment produced intent visuals.`);
|
||||
await page.evaluate(() => window.__HEROS_GAME__?.scene.getScene('BattleScene')?.confirmPreBattleDeployment?.());
|
||||
}
|
||||
|
||||
await page.waitForFunction((battleId) => {
|
||||
try {
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
const assetsReady = state?.combatAssets?.status === 'ready' || state?.combatAssets?.status === 'degraded';
|
||||
return state?.battleId === battleId && state.phase === 'idle' && assetsReady;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, scenario.id, { timeout: 90000 });
|
||||
|
||||
if (options.expectTutorial) {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
try {
|
||||
return window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial?.active === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
}
|
||||
|
||||
const live = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
||||
const aliveEnemyCount = live?.units?.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length ?? 0;
|
||||
const deployedAllyCount = live?.units?.filter((unit) => unit.faction === 'ally').length ?? 0;
|
||||
const liveTileCount = new Set(
|
||||
live?.units?.filter((unit) => unit.hp > 0).map((unit) => `${unit.x}:${unit.y}`) ?? []
|
||||
).size;
|
||||
const liveUnitCount = live?.units?.filter((unit) => unit.hp > 0).length ?? 0;
|
||||
assert.ok(deployedAllyCount <= scenario.sortie.sortieLimit, `${scenario.id}: fallback sortie exceeded its limit.`);
|
||||
assert.equal(liveTileCount, liveUnitCount, `${scenario.id}: fallback sortie created overlapping live units.`);
|
||||
assert.equal(live?.counterplayFeedback?.active, true, `${scenario.id}: campaign counterplay inactive.`);
|
||||
assert.equal(live?.counterplayFeedback?.forecastVisible, true, `${scenario.id}: allied forecast inactive.`);
|
||||
assert.equal(live?.counterplayFeedback?.scope, 'campaign', `${scenario.id}: intent scope is not campaign-wide.`);
|
||||
assert.equal(live?.enemyIntentPreviews?.length, aliveEnemyCount, `${scenario.id}: not every enemy received a plan.`);
|
||||
assert.equal(live?.counterplayFeedback?.byUnit?.length, deployedAllyCount, `${scenario.id}: contribution roles omit allies.`);
|
||||
assert.ok(live?.enemyIntentVisualCount > 0, `${scenario.id}: no visible intent marker was created.`);
|
||||
assert.ok(live?.triggeredBattleEvents?.includes('opening'), `${scenario.id}: opening guide event did not run.`);
|
||||
|
||||
if (options.expectTutorial) {
|
||||
assert.equal(live?.firstBattleTutorial?.active, true, 'First-battle tutorial was displaced by intent rendering.');
|
||||
assert.ok(live?.firstBattleTutorial?.step, 'First-battle tutorial lost its active step.');
|
||||
}
|
||||
|
||||
if (options.verifySaveResume) {
|
||||
const beforeSave = normalizedIntentPlans(live.enemyIntentPreviews);
|
||||
const saved = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
scene?.saveBattleState?.(1);
|
||||
return Object.keys(window.localStorage).some((key) => key.includes(`battle:${window.__HEROS_DEBUG__?.battle()?.battleId}:slot-1`));
|
||||
});
|
||||
assert.equal(saved, true, `${scenario.id}: battle slot was not persisted.`);
|
||||
await page.waitForFunction(() => {
|
||||
try {
|
||||
const transition = window.__HEROS_DEBUG__?.battle()?.battleHud?.panelTransition;
|
||||
return !transition || (!transition.active && transition.completedRevision === transition.revision);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, undefined, { timeout: 30000 });
|
||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
const restoreProbe = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
scene?.clearEnemyIntentForecast?.();
|
||||
const afterClear = scene?.enemyIntentForecasts?.size ?? -1;
|
||||
const readable = Boolean(scene?.readBattleSaveState?.(1));
|
||||
scene?.loadBattleState?.(1);
|
||||
return {
|
||||
afterClear,
|
||||
readable,
|
||||
afterLoad: scene?.enemyIntentForecasts?.size ?? -1,
|
||||
phase: scene?.phase,
|
||||
activeFaction: scene?.activeFaction,
|
||||
currentPlanCount: scene?.currentEnemyActionPlans?.().length ?? -1
|
||||
};
|
||||
});
|
||||
assert.equal(restoreProbe.readable, true, `${scenario.id}: newly written battle save failed validation.`);
|
||||
try {
|
||||
await page.waitForFunction((expectedCount) => {
|
||||
try {
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
state?.phase === 'idle' &&
|
||||
state.counterplayFeedback?.forecastVisible === true &&
|
||||
state.enemyIntentPreviews?.length === expectedCount
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, aliveEnemyCount, { timeout: 30000 });
|
||||
} catch (error) {
|
||||
const diagnostic = await page.evaluate(() => {
|
||||
let state;
|
||||
let debugError;
|
||||
try {
|
||||
state = window.__HEROS_DEBUG__?.battle();
|
||||
} catch (caught) {
|
||||
debugError = String(caught);
|
||||
}
|
||||
return {
|
||||
state: state ? {
|
||||
battleId: state.battleId,
|
||||
phase: state.phase,
|
||||
activeFaction: state.activeFaction,
|
||||
previewCount: state.enemyIntentPreviews?.length ?? -1,
|
||||
aliveEnemyCount: state.units?.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length ?? -1,
|
||||
forecastVisible: state.counterplayFeedback?.forecastVisible ?? false,
|
||||
enemyIntentVisualCount: state.enemyIntentVisualCount ?? -1
|
||||
} : undefined,
|
||||
debugError,
|
||||
storage: Object.keys(window.localStorage)
|
||||
.filter((key) => key.includes('battle') || key.includes('campaign'))
|
||||
.map((key) => ({ key, size: window.localStorage.getItem(key)?.length ?? 0 }))
|
||||
};
|
||||
});
|
||||
throw new Error(
|
||||
`${scenario.id}: save-resume intent wait failed: ${JSON.stringify({ restoreProbe, ...diagnostic, consoleErrors })}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
const resumed = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
||||
assert.deepEqual(
|
||||
normalizedIntentPlans(resumed.enemyIntentPreviews),
|
||||
beforeSave,
|
||||
`${scenario.id}: save-resume changed deterministic intent targets or damage.`
|
||||
);
|
||||
assert.equal(resumed.counterplayFeedback?.recentEvents?.length, 0, `${scenario.id}: resume created phantom counterplay.`);
|
||||
}
|
||||
|
||||
assert.deepEqual(pageErrors, [], `${scenario.id}: browser errors: ${pageErrors.join(' | ')}`);
|
||||
assert.deepEqual(consoleErrors, [], `${scenario.id}: console errors: ${consoleErrors.join(' | ')}`);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForBattleDebugReady(page, battleId) {
|
||||
await page.waitForFunction(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
if (!scene?.scene?.isActive?.() || !scene.layout || !scene.mapBackground || scene.unitViews?.size === 0) {
|
||||
return false;
|
||||
}
|
||||
if (scene.phase === 'deployment') {
|
||||
return Boolean(scene.deploymentPanelLayout && scene.sidePanelObjects?.length > 0);
|
||||
}
|
||||
return Boolean(scene.currentSidePanelView && scene.sideQuickTabLayout && scene.sidePanelObjects?.length > 0);
|
||||
}, undefined, { timeout: 90000 });
|
||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await page.waitForFunction((expectedBattleId) => {
|
||||
try {
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
state?.battleId === expectedBattleId &&
|
||||
state.mapBackgroundReady === true &&
|
||||
(state.phase === 'deployment' || state.phase === 'idle')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, battleId, { timeout: 30000 });
|
||||
}
|
||||
|
||||
function normalizedIntentPlans(plans = []) {
|
||||
return plans
|
||||
.map((plan) => ({
|
||||
enemyId: plan.enemyId,
|
||||
kind: plan.kind,
|
||||
destination: plan.destination,
|
||||
targetId: plan.targetId,
|
||||
usableId: plan.usableId,
|
||||
damage: plan.damage,
|
||||
hitRate: plan.hitRate
|
||||
}))
|
||||
.sort((left, right) => left.enemyId.localeCompare(right.enemyId));
|
||||
}
|
||||
Reference in New Issue
Block a user