feat: deepen audiovisual story immersion

This commit is contained in:
2026-07-23 10:57:07 +09:00
parent a9e401aedf
commit 8cf0886d7d
38 changed files with 2037 additions and 144 deletions

View File

@@ -29,6 +29,8 @@ try {
await verifyCampModalAndNavigation(page);
await verifyBattlePointerFlow(page);
await verifyWolongNarrativeVictoryGate(page);
await verifyCampTimelineRowLayout(page);
if (pageErrors.length > 0) {
throw new Error(`Unexpected browser errors: ${JSON.stringify(pageErrors.slice(-5))}`);
@@ -42,7 +44,9 @@ try {
console.log(
`Verified pointer-based interaction UX at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}: ` +
'camp and battle save modals block click-through, slow camp navigation is single-flight and commits after loading, ' +
'battle event overlays block edge-scroll and hover feedback, ' +
'battle event overlays block edge-scroll and hover feedback, prioritized same-action notices collapse into one disclosed modal and battle log, ' +
'tactical reactions exclude undeployed or defeated officers, the Wolong narrative objectives gate victory, ' +
'long camp timeline titles and victory conditions stay in separate fixed-width columns, ' +
'movement commands stay anchored to the destination, the final ally prompt waits for the command, ' +
'the persistent turn-end action reopens it, and the right-click menu follows the pointer.'
);
@@ -290,6 +294,7 @@ async function verifyBattlePointerFlow(page) {
}, expectedBattleId, { timeout: 90000 });
await startDeploymentIfNeeded(page, expectedBattleId);
await verifyBattleEventOverlayInputBlock(page);
await verifyBattleEventQueueBehavior(page);
const lastAllySetup = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
@@ -614,6 +619,230 @@ async function verifyBattleEventOverlayInputBlock(page) {
await page.mouse.move(safeScreenPoint.x, safeScreenPoint.y);
}
async function verifyBattleEventQueueBehavior(page) {
const initial = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
if (!scene) {
return null;
}
scene.hideBattleEventBanner();
scene.clearBattleEvents();
['qa-event-a', 'qa-event-b', 'qa-event-c', 'qa-event-d', 'qa-event-interrupted'].forEach((key) => scene.triggeredBattleEvents.delete(key));
scene.deferBattleEventPresentation = true;
scene.triggerBattleEvent('qa-event-a', '먼저 감지된 사건', ['같은 전투 흐름에서 먼저 표시됩니다.'], { playCue: false });
scene.triggerBattleEvent('qa-event-b', '낮은 우선순위', ['마지막에 표시되어야 합니다.'], { playCue: false, priority: 'low' });
scene.triggerBattleEvent('qa-event-c', '긴급 사건 하나', ['가장 높은 우선순위로 표시되어야 합니다.'], { playCue: false, priority: 'critical' });
scene.triggerBattleEvent('qa-event-d', '긴급 사건 둘', ['같은 우선순위의 발생 순서를 지켜야 합니다.'], { playCue: false, priority: 'critical' });
scene.deferBattleEventPresentation = false;
scene.showNextBattleEvent();
return {
active: scene.activeBattleEvent?.key ?? null,
queued: scene.battleEventQueue.map((event) => event.key),
completed: ['qa-event-a', 'qa-event-b', 'qa-event-c', 'qa-event-d'].filter((key) => scene.triggeredBattleEvents.has(key)),
objectCount: scene.battleEventObjects.length,
bannerText: scene.battleEventObjects
.map((object) => typeof object.text === 'string' ? object.text : '')
.filter(Boolean)
};
});
assert(
initial?.active === 'qa-event-c' &&
JSON.stringify(initial.queued) === JSON.stringify(['qa-event-d', 'qa-event-a', 'qa-event-b']) &&
initial.completed.length === 0 &&
initial.objectCount > 0 &&
initial.bannerText.some((text) => text.includes('외 3건')),
`Battle event grouping did not preserve priority/FIFO or disclose grouped notices: ${JSON.stringify(initial)}`
);
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
scene?.hideBattleEventBanner();
});
await page.waitForFunction(() => (
window.__HEROS_GAME__?.scene.getScene('BattleScene')?.activeBattleEvent === undefined
));
const afterFirstDismissal = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
return scene
? {
active: scene.activeBattleEvent?.key ?? null,
queued: scene.battleEventQueue.map((event) => event.key),
completed: ['qa-event-a', 'qa-event-b', 'qa-event-c', 'qa-event-d'].filter((key) => scene.triggeredBattleEvents.has(key)),
objectCount: scene.battleEventObjects.length
}
: null;
});
assert(
afterFirstDismissal?.active === null &&
afterFirstDismissal.queued.length === 0 &&
afterFirstDismissal.completed.length === 4 &&
afterFirstDismissal.objectCount === 0,
`Battle event dismissal did not complete the grouped one-modal batch: ${JSON.stringify(afterFirstDismissal)}`
);
const cleanup = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
scene?.triggerBattleEvent('qa-event-interrupted', '중단될 사건', ['정리 시 완료 처리되면 안 됩니다.'], { playCue: false });
scene?.clearBattleEvents();
return scene
? {
active: scene.activeBattleEvent?.key ?? null,
queued: scene.battleEventQueue.map((event) => event.key),
objectCount: scene.battleEventObjects.length,
interruptedCompleted: scene.triggeredBattleEvents.has('qa-event-interrupted')
}
: null;
});
assert(
cleanup?.active === null &&
cleanup.queued.length === 0 &&
cleanup.objectCount === 0 &&
cleanup.interruptedCompleted === false,
`Battle event cleanup retained or falsely completed interrupted events: ${JSON.stringify(cleanup)}`
);
const reactionProbe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const units = scene?.debugBattleUnits?.() ?? [];
const liuBei = units.find((unit) => unit.id === 'liu-bei');
if (!scene || !liuBei) {
return null;
}
const livingReaction = scene.tacticalEventReaction('ally-danger');
const previousHp = liuBei.hp;
liuBei.hp = 0;
const defeatedReaction = scene.tacticalEventReaction('ally-danger');
liuBei.hp = previousHp;
return {
deployedIds: units.filter((unit) => unit.faction === 'ally').map((unit) => unit.id),
livingSpeaker: livingReaction?.unitId ?? null,
defeatedSpeaker: defeatedReaction?.unitId ?? null
};
});
assert(
reactionProbe?.livingSpeaker === 'liu-bei' &&
reactionProbe.defeatedSpeaker === null &&
!reactionProbe.deployedIds.includes('zhao-yun'),
`Tactical event reaction used an undeployed or defeated officer: ${JSON.stringify(reactionProbe)}`
);
}
async function verifyWolongNarrativeVictoryGate(page) {
const battleId = 'seventeenth-battle-wolong-visit-road';
await page.evaluate((nextBattleId) => window.__HEROS_DEBUG__?.goToBattle(nextBattleId), battleId);
await page.waitForFunction((expectedId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return battle?.battleId === expectedId && ['deployment', 'idle'].includes(battle.phase);
}, battleId, { timeout: 90000 });
await startDeploymentIfNeeded(page, battleId);
const probe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const units = scene?.debugBattleUnits?.() ?? [];
const allies = units.filter((unit) => unit.faction === 'ally' && unit.hp > 0);
const enemies = units.filter((unit) => unit.faction === 'enemy');
const visitor = allies.find((unit) => unit.id === 'liu-bei') ?? allies[0];
if (!scene || !visitor || enemies.length === 0) {
return null;
}
scene.clearBattleEvents();
enemies.forEach((enemy) => {
enemy.hp = 0;
});
const blockedOutcome = scene.pendingBattleOutcome() ?? null;
const before = scene.requiredVictoryObjectiveStates().map((objective) => ({
id: objective.id,
category: objective.category,
achieved: objective.achieved
}));
const gateEventKnown = scene.isBattleEventKnown('victory-gate-pending');
visitor.x = 29;
visitor.y = 16;
const after = scene.requiredVictoryObjectiveStates().map((objective) => ({
id: objective.id,
category: objective.category,
achieved: objective.achieved
}));
const completedOutcome = scene.pendingBattleOutcome() ?? null;
return { blockedOutcome, completedOutcome, before, after, gateEventKnown };
});
assert(
probe?.blockedOutcome === null &&
probe.completedOutcome === 'victory' &&
probe.gateEventKnown === true &&
JSON.stringify(probe.before.map((objective) => objective.id)) === JSON.stringify(['scholar-rendezvous', 'longzhong-cottage']) &&
probe.before.every((objective) => objective.category === 'required' && objective.achieved === false) &&
probe.after.every((objective) => objective.category === 'required' && objective.achieved === true),
`Wolong battle ignored or misreported its narrative victory gate: ${JSON.stringify(probe)}`
);
}
async function verifyCampTimelineRowLayout(page) {
await page.evaluate(() => window.__HEROS_DEBUG__?.goToCamp());
await page.waitForFunction(() => {
const scenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
return scenes.includes('CampScene') && window.__HEROS_GAME__?.scene.getScene('CampScene')?.scene?.isActive();
}, undefined, { timeout: 90000 });
const rows = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
if (!scene) {
return null;
}
scene.hideSortiePrep(false);
scene.clearContent();
scene.renderProgressChapterDetail(
{
id: 'qa-long-timeline-fields',
title: '긴 연표 필드 검증',
period: '1920×1080',
description: '긴 전투명과 승리 조건이 서로 침범하지 않는지 확인합니다.',
battleIds: [
'seventeenth-battle-wolong-visit-road',
'fifty-fifth-battle-northern-qishan-road',
'fifty-seventh-battle-jieting-crisis',
'fifty-eighth-battle-qishan-retreat'
],
nextHints: []
},
0,
804,
262,
390,
310,
new Set()
);
const fields = scene.children.list
.filter((object) => object.type === 'Text' && object.active && object.getData('timelineField'))
.map((object) => {
const bounds = object.getBounds();
return {
field: object.getData('timelineField'),
text: object.text,
x: bounds.x,
y: bounds.y,
right: bounds.right,
width: bounds.width
};
});
const titles = fields.filter((field) => field.field === 'battle-title').sort((left, right) => left.y - right.y);
const objectives = fields.filter((field) => field.field === 'victory-condition').sort((left, right) => left.y - right.y);
return titles.map((title, index) => ({ title, objective: objectives[index] ?? null }));
});
assert(
rows?.length === 4 &&
rows.every(({ title, objective }) => (
objective &&
title.right < objective.x &&
title.width > 0 &&
objective.width > 0 &&
objective.text.endsWith('…')
)),
`Camp timeline detail fields overlap or fail to truncate at 1920x1080: ${JSON.stringify(rows)}`
);
}
async function startDeploymentIfNeeded(page, battleId) {
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
if (state?.phase === 'idle') {