feat: surface pursuit-ready attacks in battle
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user