|
|
|
|
@@ -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) {
|
|
|
|
|
|