feat: carry sortie doctrine through campaign battles

This commit is contained in:
2026-07-10 14:14:54 +09:00
parent 3caf03fe2f
commit 4a5a73a220
8 changed files with 792 additions and 125 deletions

View File

@@ -1277,6 +1277,7 @@ async function setupBattle(page, battle) {
{ timeout: 90000 }
);
await startDeploymentIfNeeded(page, battle.id);
await verifySortieDoctrine(page, battle);
await normalizeRepresentativeBattleUnits(page, battle);
}
@@ -1414,6 +1415,7 @@ async function setupCumulativeBattle(page, battle, firstBattle) {
{ timeout: 90000 }
);
await startDeploymentIfNeeded(page, battle.id);
await verifySortieDoctrine(page, battle);
await normalizeRepresentativeBattleUnits(page, battle);
return preparation;
}
@@ -1520,6 +1522,48 @@ async function startDeploymentIfNeeded(page, battleId) {
);
}
async function verifySortieDoctrine(page, battle) {
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const doctrine = state?.sortieDoctrine;
if (!doctrine?.active || doctrine.battleId !== battle.id || doctrine.openingBannerShown !== true) {
throw new Error(`Sortie doctrine did not activate for ${battle.id}: ${JSON.stringify(doctrine)}`);
}
const expectedFirstPursuit = battle.id === 'second-battle-yellow-turban-pursuit';
if (
state.firstSortieRoleEffects?.active !== expectedFirstPursuit ||
state.tacticalInitiative?.active !== expectedFirstPursuit
) {
throw new Error(
`First-pursuit-only mechanics leaked for ${battle.id}: ${JSON.stringify({
firstSortieRoleEffects: state.firstSortieRoleEffects,
tacticalInitiative: state.tacticalInitiative
})}`
);
}
const roleGroups = new Map((doctrine.roles ?? []).map((group) => [group.role, new Set(group.unitIds ?? [])]));
const coreRoles = new Set(['front', 'flank', 'support']);
const sealByRole = { front: '전', flank: '돌', support: '후' };
const deployedAllies = (state.units ?? []).filter((unit) => unit.faction === 'ally');
for (const unit of deployedAllies) {
if (coreRoles.has(unit.formationRole)) {
if (
!unit.sortieRoleEffect ||
!unit.roleSealPresent ||
unit.roleSealText !== sealByRole[unit.formationRole] ||
!roleGroups.get(unit.formationRole)?.has(unit.id)
) {
throw new Error(`Missing doctrine effect or seal for ${battle.id}/${unit.id}: ${JSON.stringify(unit)}`);
}
continue;
}
if (unit.formationRole === 'reserve' && (unit.sortieRoleEffect || unit.roleSealPresent || unit.roleSealText !== null)) {
throw new Error(`Reserve unit received a doctrine effect for ${battle.id}/${unit.id}: ${JSON.stringify(unit)}`);
}
}
}
async function writeCampaignSnapshot(page, snapshotPath) {
const snapshot = await page.evaluate(
({ slotKey, storageKey }) => window.localStorage.getItem(slotKey) ?? window.localStorage.getItem(storageKey),

View File

@@ -303,6 +303,11 @@ function validateSortie(errors, scenario, classKeys, formationRoles, terrainRule
}
requiredUnitIds.add(unitId);
});
scenario.defeatConditions.forEach((condition) => {
if (allyUnitIds.has(condition.unitId) && !requiredUnitIds.has(condition.unitId)) {
errors.push(`${context}: allied defeat-condition unit "${condition.unitId}" must be required for sortie`);
}
});
excludedUnits.forEach((unitId) => {
if (excludedUnitIds.has(unitId)) {
errors.push(`${context}: duplicate excluded unit "${unitId}"`);

View File

@@ -75,9 +75,9 @@ try {
assert(firstBattleProbe.objectiveSubText?.includes('패배 조건:'), `Expected clear defeat condition label: ${JSON.stringify(firstBattleProbe)}`);
assert(firstBattleProbe.objectiveSubText?.includes('진군:'), `Expected tactical route in objective tracker: ${JSON.stringify(firstBattleProbe)}`);
assert(
firstBattleProbe.sideTexts.some((text) => text.includes('작전 목표')) &&
firstBattleProbe.sideTexts.some((text) => text.includes('출진 군세')) &&
!firstBattleProbe.sideTexts.some((text) => text.includes('...')),
`Expected opening tactical message without ASCII truncation: ${JSON.stringify(firstBattleProbe.sideTexts)}`
`Expected sortie doctrine opening message without ASCII truncation: ${JSON.stringify(firstBattleProbe.sideTexts)}`
);
await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory'));
@@ -188,7 +188,7 @@ try {
await page.screenshot({ path: `${screenshotDir}/rc-second-battle-from-first-camp.png`, fullPage: true });
await assertCanvasPainted(page, 'second battle from first camp');
const secondBattleProbe = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const secondBattleProbe = await readBattleDoctrineProbe(page);
assert(secondBattleProbe?.battleId === 'second-battle-yellow-turban-pursuit', `Expected first camp sortie to enter second battle: ${JSON.stringify(secondBattleProbe)}`);
assertSameMembers(
secondBattleProbe?.selectedSortieUnitIds,
@@ -205,6 +205,112 @@ try {
firstCampDeploymentPreview,
`Expected second battle ally positions to match first camp deployment preview: ${JSON.stringify(secondBattleProbe?.deployedAllyPositions)} / ${JSON.stringify(firstCampDeploymentPreview)}`
);
assertSortieDoctrine(secondBattleProbe, { requireFullCoverage: true, requireVisibleSeals: true });
assert(
secondBattleProbe.firstSortieRoleEffects?.active === true &&
secondBattleProbe.firstSortieRoleEffects?.fullCoverage === true &&
secondBattleProbe.firstSortieRoleEffects?.trinityActive === true &&
secondBattleProbe.firstSortieRoleEffects?.trinitySupportRange === 2,
`Expected the second battle to retain its three-role Peach Garden trinity special: ${JSON.stringify(secondBattleProbe.firstSortieRoleEffects)}`
);
assert(
secondBattleProbe.sortieDoctrine?.roles?.every((role) => role.unitIds.length === 1) &&
secondBattleProbe.sortieDoctrine?.activeBondCount === 3,
`Expected the second battle doctrine to expose one officer per role and all three active bonds: ${JSON.stringify(secondBattleProbe.sortieDoctrine)}`
);
assert(
secondBattleProbe.openingBannerTexts?.some((text) => text.includes('세 형제 생존 중 공명 지원 거리 2칸')) &&
secondBattleProbe.openingBannerTexts?.some((text) => text.includes('전술 명령')),
`Expected the second battle opening banner to retain trinity and tactical-command guidance: ${JSON.stringify(secondBattleProbe.openingBannerTexts)}`
);
const roleSealResyncProbe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
if (!scene) {
return undefined;
}
const officerIds = ['liu-bei', 'guan-yu', 'zhang-fei'];
const campaignCurrentKey = 'heros-web:campaign-state';
const campaignSlotKey = `${campaignCurrentKey}:slot-1`;
const battleKey = 'heros-web:battle:second-battle-yellow-turban-pursuit';
const storageKeys = [campaignCurrentKey, campaignSlotKey, battleKey, `${battleKey}:slot-1`, 'heros-web:first-battle-state'];
const storageSnapshot = new Map(storageKeys.map((key) => [key, window.localStorage.getItem(key)]));
const captureUnits = () => {
const byId = new Map((window.__HEROS_DEBUG__?.battle()?.units ?? []).map((unit) => [unit.id, unit]));
return officerIds.map((id) => {
const unit = byId.get(id);
return {
id,
role: unit?.formationRole ?? null,
effect: unit?.sortieRoleEffect?.label ?? null,
seal: unit?.roleSealText ?? null,
sealPresent: unit?.roleSealPresent ?? false,
sealVisible: unit?.roleSealVisible ?? false
};
});
};
const loadAssignments = (savedCampaignRaw, assignments) => {
const campaign = JSON.parse(savedCampaignRaw);
campaign.sortieFormationAssignments = assignments;
window.localStorage.setItem(campaignSlotKey, JSON.stringify(campaign));
scene.loadBattleState?.(1);
};
scene.saveBattleState?.(1);
const savedCampaignRaw = window.localStorage.getItem(campaignSlotKey);
if (!savedCampaignRaw) {
return { error: 'campaign slot save missing' };
}
loadAssignments(savedCampaignRaw, { 'liu-bei': 'front', 'guan-yu': 'support', 'zhang-fei': 'reserve' });
const changed = captureUnits();
loadAssignments(savedCampaignRaw, { 'liu-bei': 'reserve', 'guan-yu': 'reserve', 'zhang-fei': 'reserve' });
scene.hideBattleEventBanner?.();
scene.triggeredBattleEvents?.delete('opening');
scene.showOpeningBattleEvent?.();
const allReserve = {
doctrineActive: window.__HEROS_DEBUG__?.battle()?.sortieDoctrine?.active ?? false,
coveredRoleCount: window.__HEROS_DEBUG__?.battle()?.sortieDoctrine?.coveredRoleCount ?? -1,
bannerTexts: (scene.battleEventObjects ?? [])
.filter((object) => object?.type === 'Text')
.map((object) => object.text)
};
scene.hideBattleEventBanner?.();
window.localStorage.setItem(campaignSlotKey, savedCampaignRaw);
scene.loadBattleState?.(1);
const restored = captureUnits();
storageSnapshot.forEach((value, key) => {
if (value === null) {
window.localStorage.removeItem(key);
} else {
window.localStorage.setItem(key, value);
}
});
return { changed, allReserve, restored };
});
assert(
JSON.stringify(roleSealResyncProbe?.changed) === JSON.stringify([
{ id: 'liu-bei', role: 'front', effect: '전열 방호', seal: '전', sealPresent: true, sealVisible: true },
{ id: 'guan-yu', role: 'support', effect: '후원 지휘', seal: '후', sealPresent: true, sealVisible: true },
{ id: 'zhang-fei', role: 'reserve', effect: null, seal: null, sealPresent: false, sealVisible: false }
]),
`Expected role seals to resync when a loaded formation changes roles: ${JSON.stringify(roleSealResyncProbe)}`
);
assert(
roleSealResyncProbe?.allReserve?.doctrineActive === true &&
roleSealResyncProbe?.allReserve?.coveredRoleCount === 0 &&
roleSealResyncProbe?.allReserve?.bannerTexts?.some((text) => text.startsWith('출진 군세')),
`Expected an all-reserve formation to keep the sortie-doctrine opening banner: ${JSON.stringify(roleSealResyncProbe?.allReserve)}`
);
assert(
JSON.stringify(roleSealResyncProbe?.restored) === JSON.stringify([
{ id: 'liu-bei', role: 'support', effect: '후원 지휘', seal: '후', sealPresent: true, sealVisible: true },
{ id: 'guan-yu', role: 'front', effect: '전열 방호', seal: '전', sealPresent: true, sealVisible: true },
{ id: 'zhang-fei', role: 'flank', effect: '돌파 선봉', seal: '돌', sealPresent: true, sealVisible: true }
]),
`Expected loaded role seals to restore the original formation cleanly: ${JSON.stringify(roleSealResyncProbe?.restored)}`
);
await seedCampaignSave(page, {
battleId: 'fifty-eighth-battle-qishan-retreat',
@@ -303,8 +409,26 @@ try {
`Expected an under-capacity manual formation to survive scene recreation: ${JSON.stringify(restoredUnderCapacityState?.selectedSortieUnitIds)} / ${JSON.stringify(underCapacityProbe)}`
);
const midCampSelectedSortieUnitIds = restoredUnderCapacityState.selectedSortieUnitIds ?? [];
const midCampDeploymentPreview = restoredUnderCapacityState.sortieDeploymentPreview ?? [];
const reserveDoctrineSetup = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const state = window.__HEROS_DEBUG__?.camp();
const supportUnitIds = (state?.sortieRoster ?? [])
.filter((unit) => unit.selected && unit.formationRole === 'support')
.map((unit) => unit.id);
scene.sortieFormationAssignments = {
...scene.sortieFormationAssignments,
...Object.fromEntries(supportUnitIds.map((unitId) => [unitId, 'reserve']))
};
scene.persistSortieSelection();
return { supportUnitIds, state: window.__HEROS_DEBUG__?.camp() };
});
assert(
reserveDoctrineSetup.supportUnitIds.length > 0 && reserveDoctrineSetup.state?.sortiePlan?.formationCoverageComplete === false,
`Expected the mid-campaign doctrine setup to leave support uncovered with reserve officers: ${JSON.stringify(reserveDoctrineSetup)}`
);
const midCampSelectedSortieUnitIds = reserveDoctrineSetup.state?.selectedSortieUnitIds ?? [];
const midCampDeploymentPreview = reserveDoctrineSetup.state?.sortieDeploymentPreview ?? [];
await page.mouse.click(1160, 38);
await waitForSortiePrep(page);
await page.mouse.click(1116, 656);
@@ -312,7 +436,7 @@ try {
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-next-battle.png`, fullPage: true });
await assertCanvasPainted(page, 'mid camp next battle');
const midNextBattleProbe = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const midNextBattleProbe = await readBattleDoctrineProbe(page);
assert(midNextBattleProbe?.battleId === midCampNextBattleId, `Expected mid-campaign sortie to enter next battle: ${JSON.stringify(midNextBattleProbe)} / ${midCampNextBattleId}`);
assertSameMembers(
midNextBattleProbe?.selectedSortieUnitIds,
@@ -329,6 +453,25 @@ try {
midCampDeploymentPreview,
`Expected mid-campaign next battle ally positions to match deployment preview: ${JSON.stringify(midNextBattleProbe?.deployedAllyPositions)} / ${JSON.stringify(midCampDeploymentPreview)}`
);
assertSortieDoctrine(midNextBattleProbe, { requireReserve: true });
assert(
midNextBattleProbe.sortieDoctrine?.coveredRoleCount < 3,
`Expected the seeded mid-campaign formation to allow a missing doctrine role: ${JSON.stringify(midNextBattleProbe.sortieDoctrine)}`
);
assert(
midNextBattleProbe.firstSortieRoleEffects?.active === false &&
midNextBattleProbe.firstSortieRoleEffects?.trinityActive === false,
`Expected Peach Garden trinity to remain exclusive to the second battle: ${JSON.stringify(midNextBattleProbe.firstSortieRoleEffects)}`
);
assert(
midNextBattleProbe.units.some((unit) =>
unit.faction === 'ally' &&
!['liu-bei', 'guan-yu', 'zhang-fei'].includes(unit.id) &&
['front', 'flank', 'support'].includes(unit.formationRole) &&
unit.sortieRoleEffect
),
`Expected a non-Peach-Garden officer to receive a generic sortie role effect: ${JSON.stringify(midNextBattleProbe.units)}`
);
await seedCampaignSave(page, {
battleId: 'sixty-sixth-battle-wuzhang-final',
@@ -636,6 +779,27 @@ async function readCampaignSave(page) {
});
}
async function readBattleDoctrineProbe(page) {
return page.evaluate(() => {
const state = window.__HEROS_DEBUG__?.battle();
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const roleSeals = (state?.units ?? [])
.filter((unit) => unit.faction === 'ally')
.map((unit) => {
const seal = scene?.unitViews?.get(unit.id)?.roleSeal;
return {
unitId: unit.id,
text: seal?.text ?? null,
visible: seal?.visible ?? false
};
});
const openingBannerTexts = (scene?.battleEventObjects ?? [])
.filter((object) => object?.type === 'Text')
.map((object) => object.text);
return { ...state, roleSeals, openingBannerTexts };
});
}
async function seedCampaignSave(page, options) {
await page.evaluate((seed) => {
const storageKey = 'heros-web:campaign-state';
@@ -795,6 +959,70 @@ function assertDeploymentMatchesPreview(positions, preview, message) {
);
}
function assertSortieDoctrine(probe, options = {}) {
const doctrine = probe?.sortieDoctrine;
const expectedSealByRole = { front: '전', flank: '돌', support: '후' };
const expectedRoles = Object.keys(expectedSealByRole);
const roles = doctrine?.roles ?? [];
const roleById = new Map(roles.map((role) => [role.role, role]));
const allyUnits = (probe?.units ?? []).filter((unit) => unit.faction === 'ally');
const sealsByUnitId = new Map((probe?.roleSeals ?? []).map((seal) => [seal.unitId, seal]));
const effectUnits = allyUnits.filter((unit) => expectedRoles.includes(unit.formationRole));
const reserveUnits = allyUnits.filter((unit) => unit.formationRole === 'reserve');
const coveredRoleCount = roles.filter((role) => role.unitIds.length > 0).length;
assert(doctrine?.active === true, `Expected sortie doctrine to be active: ${JSON.stringify(probe)}`);
assert(doctrine?.openingBannerShown === true, `Expected sortie doctrine opening banner to be shown: ${JSON.stringify(doctrine)}`);
assert(
roles.length === expectedRoles.length && expectedRoles.every((role) => roleById.has(role)),
`Expected doctrine debug state to expose front, flank, and support roles: ${JSON.stringify(doctrine)}`
);
assert(
doctrine.coveredRoleCount === coveredRoleCount,
`Expected doctrine role coverage to match non-empty role groups: ${JSON.stringify(doctrine)}`
);
if (options.requireFullCoverage) {
assert(coveredRoleCount === 3, `Expected all three doctrine roles to be covered: ${JSON.stringify(doctrine)}`);
}
assert(effectUnits.length > 0, `Expected at least one deployed officer with a doctrine role effect: ${JSON.stringify(allyUnits)}`);
effectUnits.forEach((unit) => {
const role = roleById.get(unit.formationRole);
const seal = sealsByUnitId.get(unit.id);
assert(
role?.unitIds.includes(unit.id) &&
unit.sortieRoleEffect?.label === role.label &&
unit.sortieRoleEffect?.summary === role.summary,
`Expected ${unit.id} to expose the assigned ${unit.formationRole} doctrine effect: ${JSON.stringify({ unit, role })}`
);
assert(
seal?.text === expectedSealByRole[unit.formationRole] && unit.roleSealVisible === seal.visible,
`Expected ${unit.id} to show the ${expectedSealByRole[unit.formationRole]} role seal: ${JSON.stringify({ unit, seal })}`
);
if (options.requireVisibleSeals) {
assert(
unit.roleSealVisible === true && seal.visible === true,
`Expected ${unit.id} role seal to be visible in the opening formation: ${JSON.stringify({ unit, seal })}`
);
}
});
if (options.requireReserve) {
assert(reserveUnits.length > 0, `Expected the doctrine probe to include a reserve officer: ${JSON.stringify(allyUnits)}`);
}
reserveUnits.forEach((unit) => {
const seal = sealsByUnitId.get(unit.id);
assert(
unit.sortieRoleEffect === null &&
unit.roleSealVisible === false &&
seal?.visible === false &&
seal?.text === null &&
roles.every((role) => !role.unitIds.includes(unit.id)),
`Expected reserve officer ${unit.id} to have no doctrine effect or role seal: ${JSON.stringify({ unit, seal, doctrine })}`
);
});
}
function runCommand(command, args, env = {}) {
const result = spawnSync(command, args, { cwd: process.cwd(), env: { ...process.env, ...env }, stdio: 'inherit' });
if (result.status !== 0) {

View File

@@ -10,10 +10,26 @@ try {
const {
compareSortieSynergy,
evaluateSortieSynergy,
firstPursuitRolePreviewSummaries,
firstPursuitSynergyBattleId,
sortieBondBonusForLevel
sortieBondBonusForLevel,
sortieRoleEffectSummaries
} = await server.ssrLoadModule('/src/game/data/sortieSynergy.ts');
const expectedRoleEffectSummaries = {
front: '일반 공격 피해 -8%(최소 1) · 반격 +20%',
flank: '초반 2턴 이동 +1 · 공격 피해 +10%',
support: '공격 책략 +10% · 회복 +2'
};
assert(
JSON.stringify(sortieRoleEffectSummaries) === JSON.stringify(expectedRoleEffectSummaries),
`Expected the three generic sortie role effects to retain their combat values and copy: ${JSON.stringify(sortieRoleEffectSummaries)}`
);
assert(
firstPursuitRolePreviewSummaries === sortieRoleEffectSummaries,
'Expected the first-pursuit role preview export to remain a compatibility alias.'
);
const boundaryCases = [
[19, 0, 0],
[20, 3, 0],
@@ -98,7 +114,7 @@ try {
);
assert(JSON.stringify({ members, bonds }) === frozenInputs, 'Expected synergy evaluation not to mutate its inputs.');
console.log('Verified sortie synergy previews, bond thresholds, role coverage, and first-pursuit trinity.');
console.log('Verified generic sortie role effects, synergy previews, bond thresholds, role coverage, and first-pursuit trinity.');
} finally {
await server.close();
}