feat: improve combat feedback and progression

This commit is contained in:
2026-07-19 00:34:11 +09:00
parent f29956b90e
commit 87e3ddb047
16 changed files with 1284 additions and 80 deletions

View File

@@ -0,0 +1,58 @@
import { createServer } from 'vite';
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true, hmr: false },
appType: 'custom'
});
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
try {
const presentation = await server.ssrLoadModule('/src/game/settings/combatPresentation.ts');
const values = new Map();
const storage = {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value)
};
assert(presentation.loadCombatPresentationMode(storage) === 'important', 'The default mode must show only important cut-ins.');
assert(
presentation.nextCombatPresentationMode('always') === 'important' &&
presentation.nextCombatPresentationMode('important') === 'map' &&
presentation.nextCombatPresentationMode('map') === 'always',
'The three presentation modes must cycle in the displayed order.'
);
for (const mode of presentation.combatPresentationModes) {
presentation.saveCombatPresentationMode(mode, storage);
assert(presentation.loadCombatPresentationMode(storage) === mode, `Mode ${mode} did not persist.`);
}
values.set(presentation.combatPresentationStorageKey, 'invalid');
assert(presentation.loadCombatPresentationMode(storage) === 'important', 'Invalid stored values must normalize to the default.');
const normal = {};
const critical = { critical: true };
const defeated = { defeated: true };
const signature = { signature: true };
const resonance = { resonance: true };
const growth = { growth: true };
assert(presentation.shouldUseFullCombatCutIn('always', normal), 'Always mode must promote normal attacks.');
assert(!presentation.shouldUseFullCombatCutIn('important', normal), 'Important mode must keep normal attacks on the map.');
assert(presentation.shouldUseFullCombatCutIn('important', critical), 'Critical hits must be promoted.');
assert(presentation.shouldUseFullCombatCutIn('important', defeated), 'Defeats must be promoted.');
assert(presentation.shouldUseFullCombatCutIn('important', signature), 'Signature tactics must be promoted.');
assert(presentation.shouldUseFullCombatCutIn('important', resonance), 'Resonance actions must be promoted.');
assert(presentation.shouldUseFullCombatCutIn('important', growth), 'Level-up actions must be promoted.');
assert(!presentation.shouldUseFullCombatCutIn('map', signature), 'Map mode must suppress even signature cut-ins.');
console.info('Verified combat presentation settings, persistence, cycling, and importance promotion.');
} finally {
await server.close();
}

View 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));
}

View File

@@ -0,0 +1,164 @@
import { readFile } from 'node:fs/promises';
import { createServer } from 'vite';
const storage = new Map();
globalThis.window = {
localStorage: {
getItem(key) {
return storage.has(key) ? storage.get(key) : null;
},
setItem(key, value) {
storage.set(key, String(value));
},
removeItem(key) {
storage.delete(key);
}
}
};
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true, hmr: false },
appType: 'custom'
});
try {
const { calculateEquipmentBonuses } = await server.ssrLoadModule('/src/game/data/battleItems.ts');
const { applyCampaignUnitProgress } = await server.ssrLoadModule('/src/game/data/unitProgress.ts');
const {
campaignStorageKey,
createInitialCampaignState,
loadCampaignState
} = await server.ssrLoadModule('/src/game/state/campaignState.ts');
const equipment = {
weapon: { itemId: 'green-dragon-glaive', level: 4, exp: 12 },
armor: { itemId: 'oath-robe', level: 3, exp: 8 },
accessory: { itemId: 'bravery-token', level: 5, exp: 4 }
};
const equipmentBonuses = calculateEquipmentBonuses(equipment);
assert(
sameJson(equipmentBonuses, { attack: 9, defense: 4, strategy: 5 }),
`Expected item and slot-level equipment bonuses to share one calculation: ${JSON.stringify(equipmentBonuses)}`
);
const scenarioUnit = createUnit({ equipment });
const savedProgress = createUnit({
level: 5,
exp: 42,
hp: 27,
maxHp: 38,
attack: 14,
stats: { might: 81, intelligence: 85, leadership: 90, agility: 73, luck: 91 },
equipment
});
const merged = applyCampaignUnitProgress(scenarioUnit, savedProgress);
assert(
merged.level === 5 &&
merged.exp === 42 &&
merged.hp === 27 &&
merged.maxHp === 38 &&
merged.attack === 14 &&
sameJson(merged.stats, savedProgress.stats) &&
sameJson(merged.equipment, savedProgress.equipment) &&
merged.x === scenarioUnit.x &&
merged.y === scenarioUnit.y,
`Expected the next battle to preserve all saved growth while keeping authored deployment: ${JSON.stringify(merged)}`
);
merged.stats.might += 20;
merged.equipment.weapon.level += 1;
assert(
savedProgress.stats.might === 81 && savedProgress.equipment.weapon.level === 4,
'Expected merged campaign progress to be deeply cloned.'
);
const legacyProgress = createUnit({ level: 5, exp: 17, hp: 28, equipment });
const recovered = applyCampaignUnitProgress(scenarioUnit, legacyProgress);
assert(
recovered.level === 5 &&
recovered.attack === 11 &&
recovered.maxHp === 36 &&
sameJson(recovered.stats, { might: 78, intelligence: 80, leadership: 86, agility: 70, luck: 88 }),
`Expected older saves that lost level-up fields to recover authored growth floors: ${JSON.stringify(recovered)}`
);
const rawSave = createInitialCampaignState();
rawSave.step = 'first-camp';
rawSave.roster = [{
...savedProgress,
attack: '14',
stats: Object.fromEntries(Object.entries(savedProgress.stats).map(([key, value]) => [key, String(value)])),
equipment: Object.fromEntries(
Object.entries(savedProgress.equipment).map(([slot, state]) => [slot, { ...state, level: String(state.level), exp: String(state.exp) }])
)
}];
storage.set(campaignStorageKey, JSON.stringify(rawSave));
const loaded = loadCampaignState();
const loadedUnit = loaded.roster[0];
const loadedMerge = applyCampaignUnitProgress(scenarioUnit, loadedUnit);
assert(
loadedUnit.attack === 14 &&
sameJson(loadedUnit.stats, savedProgress.stats) &&
sameJson(loadedUnit.equipment, savedProgress.equipment) &&
loadedMerge.attack === 14 &&
sameJson(loadedMerge.stats, savedProgress.stats),
`Expected legacy-compatible normalization and reload to retain every growth field: ${JSON.stringify({ loadedUnit, loadedMerge })}`
);
const [battleSource, campSource] = await Promise.all([
readFile('src/game/scenes/BattleScene.ts', 'utf8'),
readFile('src/game/scenes/CampScene.ts', 'utf8')
]);
assert(
battleSource.includes('return mergeCampaignUnitProgress(unit, progress);'),
'Expected BattleScene campaign hydration to use the shared full-progress merger.'
);
assert(
countOccurrences(battleSource, 'calculateEquipmentBonuses(unit.equipment).') === 3 &&
countOccurrences(campSource, 'calculateEquipmentBonuses(unit.equipment).') === 3,
'Expected CampScene display and BattleScene combat to use the same three equipment bonus projections.'
);
console.log('Verified full character-growth carryover, legacy growth-floor recovery, save normalization, and shared camp/battle equipment level bonuses.');
} finally {
await server.close();
}
function createUnit(overrides = {}) {
return {
id: 'liu-bei',
name: '유비',
faction: 'ally',
className: '군주',
classKey: 'lord',
level: 3,
exp: 0,
hp: 32,
maxHp: 32,
attack: 9,
move: 4,
stats: { might: 76, intelligence: 78, leadership: 84, agility: 70, luck: 88 },
equipment: {
weapon: { itemId: 'training-sword', level: 1, exp: 0 },
armor: { itemId: 'cloth-armor', level: 1, exp: 0 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 3,
y: 14,
...overrides
};
}
function countOccurrences(source, needle) {
return source.split(needle).length - 1;
}
function sameJson(actual, expected) {
return JSON.stringify(actual) === JSON.stringify(expected);
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}

View File

@@ -1255,6 +1255,7 @@ try {
const originalPlayCombatCutIn = scene.playCombatCutIn;
const originalApplyDefeatedStyle = scene.applyDefeatedStyle;
const originalResolveCounterAttack = scene.resolveCounterAttack;
const originalCombatPresentationMode = scene.combatPresentationMode;
const methodOwnership = {
resolveCombatAction: Object.prototype.hasOwnProperty.call(scene, 'resolveCombatAction'),
playCombatCutIn: Object.prototype.hasOwnProperty.call(scene, 'playCombatCutIn'),
@@ -1283,6 +1284,9 @@ try {
setDebugForceHit(true);
setDebugForceBondChain('1');
// This fixture verifies full-screen pursuit/counter ordering and defeated-style timing.
// Force the opt-in "always" mode now that ordinary counters use the compact map tier by default.
scene.combatPresentationMode = 'always';
scene.rollPercent = () => false;
scene.resolveCombatAction = function (...args) {
const result = originalResolveCombatAction.apply(this, args);
@@ -1352,6 +1356,7 @@ try {
restoreMethod('playCombatCutIn', originalPlayCombatCutIn);
restoreMethod('applyDefeatedStyle', originalApplyDefeatedStyle);
restoreMethod('resolveCounterAttack', originalResolveCounterAttack);
scene.combatPresentationMode = originalCombatPresentationMode;
if (hadOwnRollPercent) {
scene.rollPercent = originalRollPercent;
} else {

View File

@@ -4,6 +4,8 @@ const checks = [
'scripts/verify-desktop-browser-viewport.mjs',
'scripts/verify-campaign-flow-data.mjs',
'scripts/verify-campaign-save-normalization.mjs',
'scripts/verify-growth-consistency.mjs',
'scripts/verify-combat-presentation-settings.mjs',
'scripts/verify-battle-save-normalization.mjs',
'scripts/verify-battle-usables.mjs',
'scripts/verify-sortie-synergy.mjs',