feat: surface pursuit-ready attacks in battle

This commit is contained in:
2026-07-11 13:30:04 +09:00
parent 307040ef00
commit 5b6ffa28f2
2 changed files with 754 additions and 41 deletions

View File

@@ -159,6 +159,391 @@ try {
`Expected pursuit damage and defeat credit to belong to the supporter, cancel counters on defeat, and reset next turn: ${JSON.stringify(firstBattleBondChain.chain)}`
);
const firstBattleBondChainReadyUi = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const stateBefore = window.__HEROS_DEBUG__?.battle();
const mechanics = stateBefore?.combatMechanicsProbe;
const attacker = mechanics?.attackerId ? scene?.debugUnitById(mechanics.attackerId) : undefined;
const partner = mechanics?.partnerId ? scene?.debugUnitById(mechanics.partnerId) : undefined;
const target = mechanics?.enemyId ? scene?.debugUnitById(mechanics.enemyId) : undefined;
const mismatchTarget = stateBefore?.units?.find(
(unit) => unit.faction === 'enemy' && unit.hp > 0 && unit.id !== mechanics?.enemyId
);
if (
!scene ||
!attacker ||
!partner ||
!target ||
!mismatchTarget ||
stateBefore?.phase !== 'idle' ||
stateBefore.selectedUnitId !== null ||
stateBefore.commandMenuVisible !== false
) {
return {
ready: false,
reason: 'first battle was not in a clean idle state for the pursuit-ready UI fixture',
stateBefore,
mechanics
};
}
const touchedUnits = [attacker, partner, target];
const original = {
phase: scene.phase,
selectedUnit: scene.selectedUnit,
pendingMove: scene.pendingMove,
targetingAction: scene.targetingAction,
selectedUsable: scene.selectedUsable,
lockedTargetPreview: scene.lockedTargetPreview,
attackIntents: scene.attackIntents.map((intent) => ({ ...intent })),
actedUnitIds: new Set(scene.actedUnitIds),
cameraTileX: scene.cameraTileX,
cameraTileY: scene.cameraTileY,
miniMapVisible: scene.miniMapVisible,
rosterTab: scene.rosterTab,
units: touchedUnits.map((unit) => ({
id: unit.id,
x: unit.x,
y: unit.y,
hp: unit.hp,
maxHp: unit.maxHp
}))
};
const fixtureViewportBounds = { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height };
const fixturePanelBounds = {
x: scene.layout.panelX,
y: scene.layout.panelY,
width: scene.layout.panelWidth,
height: scene.layout.panelHeight
};
const fixtureTargetPosition = { x: 5, y: 15 };
const fixtureTargetTileBounds = () => ({
x: scene.tileTopLeftX(target.x),
y: scene.tileTopLeftY(target.y),
width: scene.layout.tileSize,
height: scene.layout.tileSize
});
const fixturePartnerTileBounds = () => ({
x: scene.tileTopLeftX(partner.x),
y: scene.tileTopLeftY(partner.y),
width: scene.layout.tileSize,
height: scene.layout.tileSize
});
const capture = () => {
const state = scene.getDebugState();
return {
phase: state.phase,
selectedUnitId: state.selectedUnitId,
selectedUsable: state.selectedUsable,
preview: state.bondChainReadyPreview ?? null,
targetTileBounds: fixtureTargetTileBounds(),
partnerTileBounds: fixturePartnerTileBounds(),
viewportBounds: fixtureViewportBounds,
panelBounds: fixturePanelBounds
};
};
const stageTargeting = ({ user, action, usable, intentAttackerId, intentTargetId, actedUnitId }) => {
scene.clearMarkers();
scene.hideCommandMenu();
scene.selectedUnit = user;
scene.pendingMove = undefined;
scene.targetingAction = undefined;
scene.selectedUsable = undefined;
scene.lockedTargetPreview = undefined;
scene.phase = 'command';
scene.attackIntents = [{ attackerId: intentAttackerId, targetId: intentTargetId }];
scene.actedUnitIds = new Set([actedUnitId]);
scene.beginDamageTargeting(user, action, usable);
};
let probes;
try {
attacker.x = 4;
attacker.y = 15;
partner.x = 3;
partner.y = 15;
target.x = fixtureTargetPosition.x;
target.y = fixtureTargetPosition.y;
target.maxHp = Math.max(target.maxHp, 5000);
target.hp = target.maxHp;
scene.centerCameraOnTile(target.x, target.y);
touchedUnits.forEach((unit) => scene.positionUnitView(unit));
stageTargeting({
user: attacker,
action: 'attack',
intentAttackerId: partner.id,
intentTargetId: target.id,
actedUnitId: partner.id
});
const eligibleCombatPreview = scene.combatPreview(attacker, target, 'attack');
scene.renderAttackPreview(eligibleCombatPreview, false);
const eligible = { ...capture(), baseDamage: eligibleCombatPreview.damage, targetHp: target.hp };
const eligibleCamera = { x: scene.cameraTileX, y: scene.cameraTileY };
scene.cameraTileX = scene.maxCameraTileX();
scene.cameraTileY = 0;
scene.updateCameraView();
const offscreen = capture();
scene.cameraTileX = eligibleCamera.x;
scene.cameraTileY = eligibleCamera.y;
scene.updateCameraView();
const returned = capture();
stageTargeting({
user: attacker,
action: 'attack',
intentAttackerId: partner.id,
intentTargetId: mismatchTarget.id,
actedUnitId: partner.id
});
const mismatch = capture();
const damageStrategy = scene.availableUsables(partner, 'strategy').find((usable) => usable.effect === 'damage');
if (!damageStrategy) {
throw new Error(`Expected ${partner.id} to have a damaging strategy for the pursuit-ready UI fixture.`);
}
stageTargeting({
user: partner,
action: damageStrategy.command,
usable: damageStrategy,
intentAttackerId: attacker.id,
intentTargetId: target.id,
actedUnitId: attacker.id
});
const strategy = capture();
partner.x = 3;
partner.y = 15;
scene.positionUnitView(partner);
const lethalBaseDamage = scene.combatPreview(attacker, target, 'attack').damage;
target.hp = Math.max(1, lethalBaseDamage);
stageTargeting({
user: attacker,
action: 'attack',
intentAttackerId: partner.id,
intentTargetId: target.id,
actedUnitId: partner.id
});
const lethal = {
...capture(),
baseDamage: scene.combatPreview(attacker, target, 'attack').damage,
targetHp: target.hp
};
target.hp = target.maxHp;
partner.x = 18;
partner.y = 17;
scene.positionUnitView(partner);
stageTargeting({
user: attacker,
action: 'attack',
intentAttackerId: partner.id,
intentTargetId: target.id,
actedUnitId: partner.id
});
const far = capture();
probes = {
ready: true,
attackerId: attacker.id,
partnerId: partner.id,
partnerName: partner.name,
targetId: target.id,
targetName: target.name,
expectedRate: mechanics.chain?.rate ?? null,
eligible,
offscreen,
returned,
mismatch,
strategy,
lethal,
far
};
} finally {
scene.clearMarkers();
scene.hideCommandMenu();
original.units.forEach((snapshot) => {
const unit = scene.debugUnitById(snapshot.id);
if (!unit) {
return;
}
unit.x = snapshot.x;
unit.y = snapshot.y;
unit.hp = snapshot.hp;
unit.maxHp = snapshot.maxHp;
});
scene.attackIntents = original.attackIntents.map((intent) => ({ ...intent }));
scene.actedUnitIds = new Set(original.actedUnitIds);
scene.selectedUnit = original.selectedUnit;
scene.pendingMove = original.pendingMove;
scene.targetingAction = original.targetingAction;
scene.selectedUsable = original.selectedUsable;
scene.lockedTargetPreview = original.lockedTargetPreview;
scene.phase = original.phase;
scene.cameraTileX = original.cameraTileX;
scene.cameraTileY = original.cameraTileY;
scene.updateCameraView();
scene.refreshUnitLegibilityStyles();
scene.renderRosterPanel(original.rosterTab, '행동할 장수를 선택하세요.');
scene.setMiniMapVisible(original.miniMapVisible);
}
const restored = scene.getDebugState();
return {
...probes,
restored: {
phase: restored.phase,
selectedUnitId: restored.selectedUnitId,
commandMenuVisible: restored.commandMenuVisible,
markerCount: restored.markerCount,
actedUnitIds: [...restored.actedUnitIds].sort(),
attackIntents: restored.attackIntents,
camera: restored.camera,
pursuitReady: restored.bondChainReadyPreview ?? null,
units: restored.units
.filter((unit) => original.units.some((snapshot) => snapshot.id === unit.id))
.map(({ id, x, y, hp, maxHp }) => ({ id, x, y, hp, maxHp }))
.sort((left, right) => left.id.localeCompare(right.id))
},
expectedRestored: {
phase: stateBefore.phase,
selectedUnitId: stateBefore.selectedUnitId,
commandMenuVisible: stateBefore.commandMenuVisible,
markerCount: stateBefore.markerCount,
actedUnitIds: [...stateBefore.actedUnitIds].sort(),
attackIntents: stateBefore.attackIntents,
camera: stateBefore.camera,
units: original.units
.map(({ id, x, y, hp, maxHp }) => ({ id, x, y, hp, maxHp }))
.sort((left, right) => left.id.localeCompare(right.id))
}
};
});
assert(
firstBattleBondChainReadyUi.ready === true && firstBattleBondChainReadyUi.expectedRate === 18,
`Expected a deterministic level-72 pursuit-ready UI fixture: ${JSON.stringify(firstBattleBondChainReadyUi)}`
);
const readyCandidate = firstBattleBondChainReadyUi.eligible?.preview?.candidates?.[0];
const readyHud = firstBattleBondChainReadyUi.eligible?.preview?.hud;
const readyContext = '명중 후 대상 생존 시 · 지원 거리 충족';
assert(
firstBattleBondChainReadyUi.eligible?.phase === 'targeting' &&
firstBattleBondChainReadyUi.eligible.selectedUnitId === firstBattleBondChainReadyUi.attackerId &&
firstBattleBondChainReadyUi.eligible.selectedUsable === null &&
firstBattleBondChainReadyUi.eligible.preview?.visible === true &&
firstBattleBondChainReadyUi.eligible.preview.action === 'attack' &&
firstBattleBondChainReadyUi.eligible.preview.attackerId === firstBattleBondChainReadyUi.attackerId &&
sameJsonValue(firstBattleBondChainReadyUi.eligible.preview.eligibleTargetIds, [firstBattleBondChainReadyUi.targetId]) &&
firstBattleBondChainReadyUi.eligible.preview.candidates?.length === 1 &&
firstBattleBondChainReadyUi.eligible.baseDamage < firstBattleBondChainReadyUi.eligible.targetHp &&
readyCandidate?.targetId === firstBattleBondChainReadyUi.targetId &&
readyCandidate.targetName === firstBattleBondChainReadyUi.targetName &&
readyCandidate.partnerId === firstBattleBondChainReadyUi.partnerId &&
readyCandidate.partnerName === firstBattleBondChainReadyUi.partnerName &&
readyCandidate.rate === 18 &&
readyCandidate.damage > 0 &&
readyCandidate.text === `추격 후보 ${firstBattleBondChainReadyUi.partnerName} 18% · 예상 +${readyCandidate.damage}` &&
readyCandidate.context === readyContext &&
readyCandidate.targetMarkerId === `bond-chain-ready-${firstBattleBondChainReadyUi.targetId}` &&
readyCandidate.partnerMarkerId === `bond-chain-partner-${firstBattleBondChainReadyUi.partnerId}` &&
readyCandidate.targetMarkerVisible === true &&
readyCandidate.labelVisible === true &&
readyCandidate.partnerMarkerVisible === true,
`Expected matching acted intent to expose exactly one 18% pursuit candidate with clear partner, damage, and survival context: ${JSON.stringify(firstBattleBondChainReadyUi.eligible)}`
);
const returnedCandidate = firstBattleBondChainReadyUi.returned?.preview?.candidates?.[0];
assert(
firstBattleBondChainReadyUi.offscreen?.preview?.visible === false &&
sameJsonValue(firstBattleBondChainReadyUi.offscreen.preview.eligibleTargetIds, []) &&
sameJsonValue(firstBattleBondChainReadyUi.offscreen.preview.candidates, []) &&
firstBattleBondChainReadyUi.offscreen.preview.hud === null &&
firstBattleBondChainReadyUi.offscreen.preview.focused === null &&
firstBattleBondChainReadyUi.returned?.preview?.visible === true &&
returnedCandidate?.targetId === readyCandidate.targetId &&
returnedCandidate.partnerId === readyCandidate.partnerId &&
returnedCandidate.damage === readyCandidate.damage &&
returnedCandidate.targetMarkerVisible === true &&
returnedCandidate.labelVisible === true &&
returnedCandidate.partnerMarkerVisible === true &&
firstBattleBondChainReadyUi.returned.preview.hud?.targetId === readyCandidate.targetId,
`Expected camera movement to hide offscreen pursuit markers and restore the same visible target, partner, and HUD on return: ${JSON.stringify(firstBattleBondChainReadyUi)}`
);
assert(
isFiniteBounds(readyCandidate?.targetMarkerBounds) &&
isFiniteBounds(readyCandidate?.partnerMarkerBounds) &&
isFiniteBounds(readyCandidate?.labelBounds) &&
isFiniteBounds(readyCandidate?.tileBounds) &&
isFiniteBounds(firstBattleBondChainReadyUi.eligible?.targetTileBounds) &&
isFiniteBounds(firstBattleBondChainReadyUi.eligible?.partnerTileBounds) &&
sameJsonValue(readyCandidate.tileBounds, firstBattleBondChainReadyUi.eligible.targetTileBounds) &&
sameJsonValue(readyCandidate.bounds, readyCandidate.targetMarkerBounds) &&
boundsInside(readyCandidate.targetMarkerBounds, readyCandidate.tileBounds, 2) &&
boundsInside(readyCandidate.labelBounds, readyCandidate.targetMarkerBounds, 3) &&
boundsInside(readyCandidate.partnerMarkerBounds, firstBattleBondChainReadyUi.eligible.partnerTileBounds, 2) &&
boundsInside(readyCandidate.tileBounds, firstBattleBondChainReadyUi.eligible.viewportBounds) &&
boundsInside(firstBattleBondChainReadyUi.eligible.partnerTileBounds, firstBattleBondChainReadyUi.eligible.viewportBounds),
`Expected pursuit target badge, label, and partner marker to remain inside their visible map tiles: ${JSON.stringify(firstBattleBondChainReadyUi.eligible)}`
);
assert(
readyHud?.targetId === readyCandidate.targetId &&
readyHud.partnerId === readyCandidate.partnerId &&
readyHud.partnerName === readyCandidate.partnerName &&
readyHud.rate === readyCandidate.rate &&
readyHud.damage === readyCandidate.damage &&
readyHud.locked === false &&
readyHud.text === readyCandidate.text &&
readyHud.context === readyContext &&
isFiniteBounds(readyHud.bounds) &&
isFiniteBounds(readyHud.textBounds) &&
isFiniteBounds(readyHud.contextBounds) &&
boundsInside(readyHud.bounds, firstBattleBondChainReadyUi.eligible.panelBounds) &&
boundsInside(readyHud.bounds, firstBattleBondChainReadyUi.eligible.viewportBounds) &&
boundsInside(readyHud.textBounds, readyHud.bounds, 2) &&
boundsInside(readyHud.contextBounds, readyHud.bounds, 2),
`Expected the hovered target forecast to show the same two-line pursuit HUD entirely inside the side panel: ${JSON.stringify(firstBattleBondChainReadyUi.eligible)}`
);
const hiddenReadyStates = [
['mismatched target intent', firstBattleBondChainReadyUi.mismatch, 'attack', firstBattleBondChainReadyUi.attackerId, null],
['strategy action', firstBattleBondChainReadyUi.strategy, 'strategy', firstBattleBondChainReadyUi.partnerId, 'fireTactic'],
['lethal base attack', firstBattleBondChainReadyUi.lethal, 'attack', firstBattleBondChainReadyUi.attackerId, null],
['out-of-support-range partner', firstBattleBondChainReadyUi.far, 'attack', firstBattleBondChainReadyUi.attackerId, null]
];
assert(
firstBattleBondChainReadyUi.lethal?.baseDamage >= firstBattleBondChainReadyUi.lethal?.targetHp,
`Expected the lethal fixture to prove the base attack would defeat the target before pursuit: ${JSON.stringify(firstBattleBondChainReadyUi.lethal)}`
);
hiddenReadyStates.forEach(([context, probe, action, attackerId, selectedUsable]) => {
assert(
probe?.phase === 'targeting' &&
probe.selectedUnitId === attackerId &&
probe.selectedUsable === selectedUsable &&
probe.preview?.visible === false &&
probe.preview.action === action &&
probe.preview.attackerId === attackerId &&
sameJsonValue(probe.preview.eligibleTargetIds, []) &&
sameJsonValue(probe.preview.candidates, []) &&
probe.preview.hud === null &&
sameJsonValue(probe.preview.markers, []) &&
probe.preview.focused === null,
`Expected pursuit-ready UI to remain hidden for ${context} without stale markers or HUD: ${JSON.stringify(probe)}`
);
});
const { pursuitReady: restoredPursuitReady, ...restoredFixtureState } = firstBattleBondChainReadyUi.restored;
assert(
sameJsonValue(restoredFixtureState, firstBattleBondChainReadyUi.expectedRestored) &&
restoredPursuitReady?.visible === false &&
restoredPursuitReady.action === null &&
restoredPursuitReady.attackerId === null &&
sameJsonValue(restoredPursuitReady.eligibleTargetIds, []) &&
sameJsonValue(restoredPursuitReady.candidates, []) &&
restoredPursuitReady.hud === null,
`Expected the pursuit-ready UI fixture to restore battle positions, intents, acted state, camera, selection, markers, and idle HUD before the existing RC flow: ${JSON.stringify(firstBattleBondChainReadyUi)}`
);
const firstBattleOrderHud = await assertLiveSortieOrderHud(page, {
battleId: 'first-battle-zhuo-commandery',
orderId: 'elite',