Files
Genarrative/src/hooks/combat/battlePlan.ts
2026-04-26 17:34:52 +08:00

824 lines
27 KiB
TypeScript

import { resolveRoleCombatStats } from '../../data/attributeCombat';
import { resolveCharacterAttributeProfile } from '../../data/attributeResolver';
import {
appendBuildBuffs,
resolveCompanionOutgoingDamageResult,
resolveMonsterOutgoingDamageResult,
resolvePlayerOutgoingDamageResult,
tickBuildBuffs,
} from '../../data/buildDamage';
import {
getSkillDelivery,
} from '../../data/characterCombat';
import {
getCharacterById,
getCharacterMaxMana,
} from '../../data/characterPresets';
import { getEquipmentBonuses } from '../../data/equipmentEffects';
import {
getClosestHostileNpc,
getFacingTowardPlayer,
settleHostileNpcAnimations,
} from '../../data/hostileNpcs';
import { getFunctionEffect } from '../../data/stateFunctions';
import type {
Character,
CharacterSkillDefinition,
CombatDelivery,
CompanionState,
GameState,
SceneHostileNpc,
StoryOption,
} from '../../types';
import {
AnimationState,
} from '../../types';
import {
chooseWeightedSkill,
chooseWeightedSkillForStyle,
inferCombatStyle,
normalizeSkillProbabilities,
} from '../combatStoryUtils';
type TurnActor = 'player' | 'companion' | 'monster';
export type BattlePlanStep =
| {
actor: 'player';
targetHostileNpcId: string;
originalPlayerX: number;
strikeX: number;
cooledDown: Record<string, number>;
selectedSkillId: string | null;
appliedCooldowns: Record<string, number>;
damage: number;
criticalHit?: boolean;
defeated: boolean;
endsBattle: boolean;
delivery: CombatDelivery;
}
| {
actor: 'companion';
companionNpcId: string;
targetHostileNpcId: string;
strikeOffsetX: number;
cooledDown: Record<string, number>;
selectedSkillId: string | null;
appliedCooldowns: Record<string, number>;
damage: number;
criticalHit?: boolean;
defeated: boolean;
endsBattle: boolean;
delivery: CombatDelivery;
}
| {
actor: 'monster';
monsterId: string;
originalMonsterX: number;
strikeX: number;
target: 'player' | 'companion';
targetCompanionNpcId?: string;
targetX: number;
damage: number;
criticalHit?: boolean;
endsBattle: boolean;
selectedSkillId: string | null;
npcCharacterId: string | null;
delivery: CombatDelivery;
};
export type BattlePlan = {
preparedState: GameState;
turns: BattlePlanStep[];
finalState: GameState;
};
function createEmptyCooldowns(character: Character) {
return Object.fromEntries(character.skills.map(skill => [skill.id, 0]));
}
function normalizeCooldowns(character: Character, cooldowns: Record<string, number>) {
return Object.fromEntries(character.skills.map(skill => [skill.id, Math.max(0, cooldowns[skill.id] ?? 0)]));
}
function isCompanionAlive(companion: CompanionState) {
return companion.hp > 0;
}
export function resetCompanionCombatPresentation(companions: CompanionState[]) {
return companions.map(companion => ({
...companion,
animationState: companion.hp > 0 ? AnimationState.IDLE : AnimationState.DIE,
actionMode: 'idle' as const,
offsetX: 0,
offsetY: 0,
transitionMs: 0,
}));
}
export function updateCompanionState(
companions: CompanionState[],
npcId: string,
updater: (companion: CompanionState) => CompanionState,
) {
return companions.map(companion => companion.npcId === npcId ? updater(companion) : companion);
}
function getCompanionSlotIndex(companions: CompanionState[], npcId: string) {
return Math.max(0, companions.findIndex(companion => companion.npcId === npcId));
}
export function getCompanionAnchorX(playerX: number, companions: CompanionState[], npcId: string) {
const slotIndex = getCompanionSlotIndex(companions, npcId);
return Number((playerX - (slotIndex % 2 === 0 ? 0.38 : 0.18)).toFixed(2));
}
function getCompanionStrikeOffset(companions: CompanionState[], npcId: string) {
const slotIndex = getCompanionSlotIndex(companions, npcId);
return slotIndex % 2 === 0 ? 54 : 44;
}
function getLivingPartyTargets(state: GameState) {
const targets: Array<{ kind: 'player' } | { kind: 'companion'; npcId: string }> = [];
if (state.playerHp > 0) {
targets.push({ kind: 'player' });
}
if (state.currentNpcBattleMode === 'spar') {
return targets;
}
state.companions.filter(isCompanionAlive).forEach(companion => {
targets.push({ kind: 'companion', npcId: companion.npcId });
});
return targets;
}
function chooseRandomPartyTarget(state: GameState) {
const targets = getLivingPartyTargets(state);
if (targets.length === 0) return null;
return targets[Math.floor(Math.random() * targets.length)] ?? null;
}
function getCombatActorKey(actor: TurnActor, id?: string) {
return id ? `${actor}:${id}` : actor;
}
function buildCombatTurnOrder(
state: GameState,
playerCharacter: Character,
sequenceMs: number,
turnVisualMs: number,
resetStageMs: number,
minTurnCount: number,
) {
const actorTimings = new Map<string, { actor: TurnActor; id?: string; nextAt: number; cadence: number }>();
actorTimings.set(getCombatActorKey('player'), {
actor: 'player',
nextAt: 0,
cadence: 1400 / Math.max(
resolveRoleCombatStats(
resolveCharacterAttributeProfile(
playerCharacter,
state.worldType,
state.customWorldProfile,
),
).turnSpeed,
1,
),
});
state.companions
.filter(companion => state.currentNpcBattleMode !== 'spar' && isCompanionAlive(companion))
.forEach(companion => {
const companionCharacter = getCharacterById(companion.characterId);
if (!companionCharacter) return;
actorTimings.set(getCombatActorKey('companion', companion.npcId), {
actor: 'companion',
id: companion.npcId,
nextAt: 0,
cadence: 1400 / Math.max(
resolveRoleCombatStats(
resolveCharacterAttributeProfile(
companionCharacter,
state.worldType,
state.customWorldProfile,
),
).turnSpeed,
1,
),
});
});
state.sceneHostileNpcs.forEach(monster => {
actorTimings.set(getCombatActorKey('monster', monster.id), {
actor: 'monster',
id: monster.id,
nextAt: 0,
cadence: 1400 / Math.max(monster.speed, 1),
});
});
const turnOrder: Array<{ actor: TurnActor; id?: string }> = [];
while (turnOrder.length < minTurnCount || turnOrder.length * (turnVisualMs + resetStageMs) < sequenceMs) {
const availableActors = [...actorTimings.values()].filter(item => {
if (item.actor === 'player') return state.playerHp > 0;
if (item.actor === 'companion') {
return state.companions.some(companion => companion.npcId === item.id && isCompanionAlive(companion));
}
return state.sceneHostileNpcs.some(monster => monster.id === item.id && monster.hp > 0);
});
if (availableActors.length === 0) break;
availableActors.sort((a, b) => a.nextAt - b.nextAt || a.cadence - b.cadence);
const nextActor = availableActors[0];
if (!nextActor) break;
turnOrder.push({ actor: nextActor.actor, id: nextActor.id });
nextActor.nextAt += nextActor.cadence;
}
return turnOrder;
}
export function applyDamageToPartyTarget(
state: GameState,
target: { kind: 'player' } | { kind: 'companion'; npcId: string },
damage: number,
) {
if (target.kind === 'player') {
const adjustedDamage = Math.max(
1,
Math.round(damage * getEquipmentBonuses(state.playerEquipment).incomingDamageMultiplier),
);
return {
...state,
playerHp: Math.max(0, state.playerHp - adjustedDamage),
};
}
return {
...state,
companions: updateCompanionState(
state.companions,
target.npcId,
companion => ({
...companion,
hp: Math.max(0, companion.hp - damage),
}),
),
};
}
function tickSkillCooldowns(character: Character, cooldowns: Record<string, number>) {
const normalized = normalizeCooldowns(character, cooldowns);
return Object.fromEntries(
Object.entries(normalized).map(([skillId, turns]) => [skillId, Math.max(0, turns - 1)]),
);
}
function getRequestedSkillId(option: StoryOption) {
return typeof option.runtimePayload?.skillId === 'string'
? option.runtimePayload.skillId
: null;
}
function choosePlayerSkillForOption(
character: Character,
mana: number,
cooldowns: Record<string, number>,
option: StoryOption,
) {
const requestedSkillId = getRequestedSkillId(option);
if (requestedSkillId) {
const requestedSkill = character.skills.find(skill => skill.id === requestedSkillId) ?? null;
if (!requestedSkill) return null;
if ((cooldowns[requestedSkill.id] ?? 0) > 0 || mana < requestedSkill.manaCost) return null;
return requestedSkill;
}
return chooseWeightedSkill(character, mana, cooldowns, option);
}
export function getFacingForPlayer(playerX: number, monster: SceneHostileNpc | null) {
if (!monster) return 'right' as const;
return monster.xMeters >= playerX ? 'right' : 'left';
}
export function getMeleeStrikeX(attackerX: number, defenderX: number) {
return defenderX > attackerX
? Number((defenderX - 0.1).toFixed(1))
: Number((defenderX + 0.1).toFixed(1));
}
export function getSkillStrikeX(skill: CharacterSkillDefinition, attackerX: number, defenderX: number) {
return getSkillDelivery(skill) === 'ranged'
? attackerX
: getMeleeStrikeX(attackerX, defenderX);
}
export function resetCombatPresentation(monsters: SceneHostileNpc[], playerX: number) {
return settleHostileNpcAnimations(monsters).map(monster => ({
...monster,
facing: getFacingTowardPlayer(monster.xMeters, playerX),
characterAnimation: undefined,
combatMode: undefined,
}));
}
export function applyRecoveryEffectToState(
state: GameState,
character: Character,
functionId: string,
) {
const effect = getFunctionEffect(functionId);
if (
(effect.healAmount ?? 0) <= 0 &&
(effect.manaRestore ?? 0) <= 0 &&
(effect.cooldownTickBonus ?? 0) <= 0
) {
return state;
}
let cooldowns = state.playerSkillCooldowns;
for (let index = 0; index < (effect.cooldownTickBonus ?? 0); index += 1) {
cooldowns = tickSkillCooldowns(character, cooldowns);
}
return {
...state,
playerHp: Math.min(state.playerMaxHp, state.playerHp + (effect.healAmount ?? 0)),
playerMana: Math.min(state.playerMaxMana, state.playerMana + (effect.manaRestore ?? 0)),
playerSkillCooldowns: cooldowns,
};
}
export function buildBattlePlan({
state,
option,
character,
totalSequenceMs,
turnVisualMs,
resetStageMs,
minTurnCount,
}: {
state: GameState;
option: StoryOption;
character: Character;
totalSequenceMs: number;
turnVisualMs: number;
resetStageMs: number;
minTurnCount: number;
}): BattlePlan {
const battleState: GameState = {
...state,
};
const targetMonster = getClosestHostileNpc(
battleState.playerX,
battleState.sceneHostileNpcs,
);
if (!targetMonster) {
return {
preparedState: battleState,
turns: [],
finalState: {
...battleState,
inBattle: false,
sceneHostileNpcs: [],
companions: resetCompanionCombatPresentation(state.companions),
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
},
};
}
const functionEffect = getFunctionEffect(option.functionId);
const isRecoveryAction = option.functionId === 'battle_recover_breath';
const isNpcSpar = battleState.currentNpcBattleMode === 'spar';
const sequenceMs = Math.round(totalSequenceMs * (functionEffect.turnTimeMultiplier ?? 1));
const turnOrder = buildCombatTurnOrder(
battleState,
character,
sequenceMs,
turnVisualMs,
resetStageMs,
minTurnCount,
);
const normalizedOption = normalizeSkillProbabilities(option, character);
const npcBattleResources = new Map<string, {
character: Character;
mana: number;
cooldowns: Record<string, number>;
}>();
battleState.sceneHostileNpcs.forEach(monster => {
const npcCharacterId = monster.encounter?.characterId ?? null;
const npcCharacter = npcCharacterId ? getCharacterById(npcCharacterId) : null;
if (!npcCharacter) return;
npcBattleResources.set(monster.id, {
character: npcCharacter,
mana: getCharacterMaxMana(npcCharacter),
cooldowns: createEmptyCooldowns(npcCharacter),
});
});
let simulatedState: GameState = {
...applyRecoveryEffectToState(battleState, character, option.functionId),
companions: resetCompanionCombatPresentation(battleState.companions),
sceneHostileNpcs: resetCombatPresentation(
battleState.sceneHostileNpcs,
battleState.playerX,
),
activeCombatEffects: [],
playerActionMode: 'idle' as const,
currentNpcBattleOutcome: null,
};
const preparedState = simulatedState;
const turns: BattlePlanStep[] = [];
for (const [turnIndex, turn] of turnOrder.entries()) {
const currentTarget = getClosestHostileNpc(simulatedState.playerX, simulatedState.sceneHostileNpcs);
if (!currentTarget) break;
if (turn.actor === 'player') {
if (simulatedState.playerHp <= 0) continue;
const cooledDown = tickSkillCooldowns(character, simulatedState.playerSkillCooldowns);
simulatedState = {
...simulatedState,
playerSkillCooldowns: cooledDown,
};
// 后端单技能按钮通过 runtimePayload.skillId 指定技能,本地兜底也必须保持同一语义。
const selectedSkill = isRecoveryAction
? null
: choosePlayerSkillForOption(character, simulatedState.playerMana, cooledDown, normalizedOption);
if (!selectedSkill) {
continue;
}
const originalPlayerX = simulatedState.playerX;
const delivery = getSkillDelivery(selectedSkill);
const strikeX = getSkillStrikeX(selectedSkill, originalPlayerX, currentTarget.xMeters);
const appliedCooldowns = {
...cooledDown,
[selectedSkill.id]: selectedSkill.cooldownTurns,
};
const damageResult = isNpcSpar
? null
: resolvePlayerOutgoingDamageResult(
simulatedState,
character,
selectedSkill.damage,
functionEffect.damageMultiplier ?? 1,
`${option.functionId}:player:${turnIndex}:${selectedSkill.id}:${currentTarget.id}`,
);
const damage = isNpcSpar ? 1 : damageResult!.damage;
const wouldEndSpar = isNpcSpar && currentTarget.hp - damage <= 1;
const resolvedMonsters = simulatedState.sceneHostileNpcs.map(monster =>
monster.id === currentTarget.id
? {
...monster,
hp: isNpcSpar
? Math.max(1, monster.hp - damage)
: Math.max(0, monster.hp - damage),
}
: monster,
);
const defeated = !isNpcSpar && resolvedMonsters.some(monster => monster.id === currentTarget.id && monster.hp <= 0);
const remainingMonsters = defeated
? resolvedMonsters.filter(monster => !(monster.id === currentTarget.id && monster.hp <= 0))
: resolvedMonsters;
const nextTarget = getClosestHostileNpc(originalPlayerX, remainingMonsters);
simulatedState = {
...simulatedState,
playerX: originalPlayerX,
playerFacing: getFacingForPlayer(originalPlayerX, nextTarget ?? null),
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
activeBuildBuffs: appendBuildBuffs(
tickBuildBuffs(simulatedState.activeBuildBuffs),
selectedSkill.buildBuffs,
),
activeCombatEffects: [],
playerMana: Math.max(0, simulatedState.playerMana - selectedSkill.manaCost),
playerSkillCooldowns: appliedCooldowns,
sceneHostileNpcs: remainingMonsters.map(monster => ({
...monster,
characterAnimation: undefined,
combatMode: undefined,
})),
companions: resetCompanionCombatPresentation(simulatedState.companions),
inBattle: isNpcSpar ? !wouldEndSpar : remainingMonsters.length > 0,
currentNpcBattleOutcome: wouldEndSpar
? 'spar_complete'
: (!isNpcSpar && remainingMonsters.length === 0 && simulatedState.currentBattleNpcId
? 'fight_victory'
: simulatedState.currentNpcBattleOutcome),
};
turns.push({
actor: 'player',
targetHostileNpcId: currentTarget.id,
originalPlayerX,
strikeX,
cooledDown,
selectedSkillId: selectedSkill.id,
appliedCooldowns,
damage,
criticalHit: damageResult?.isCritical ?? false,
defeated,
endsBattle: wouldEndSpar,
delivery,
});
if (!simulatedState.inBattle) {
break;
}
continue;
}
if (turn.actor === 'companion') {
const companion = simulatedState.companions.find(item => item.npcId === turn.id && isCompanionAlive(item));
if (!companion) continue;
const companionCharacter = getCharacterById(companion.characterId);
if (!companionCharacter) continue;
const companionX = getCompanionAnchorX(simulatedState.playerX, simulatedState.companions, companion.npcId);
const targetMonster = getClosestHostileNpc(companionX, simulatedState.sceneHostileNpcs);
if (!targetMonster) break;
const cooledDown = tickSkillCooldowns(companionCharacter, companion.skillCooldowns);
simulatedState = {
...simulatedState,
companions: updateCompanionState(
simulatedState.companions,
companion.npcId,
currentCompanion => ({
...currentCompanion,
skillCooldowns: cooledDown,
}),
),
};
const selectedSkill = chooseWeightedSkillForStyle(
companionCharacter,
companion.mana,
cooledDown,
inferCombatStyle(option),
);
if (!selectedSkill) {
continue;
}
const delivery = getSkillDelivery(selectedSkill);
const strikeOffsetX = delivery === 'melee'
? getCompanionStrikeOffset(simulatedState.companions, companion.npcId)
: 0;
const appliedCooldowns = {
...cooledDown,
[selectedSkill.id]: selectedSkill.cooldownTurns,
};
const damageResult = isNpcSpar
? null
: resolveCompanionOutgoingDamageResult(
companionCharacter,
selectedSkill.damage,
functionEffect.damageMultiplier ?? 1,
state.worldType,
state.customWorldProfile,
`${option.functionId}:companion:${turnIndex}:${companion.npcId}:${selectedSkill.id}:${targetMonster.id}`,
);
const damage = isNpcSpar ? 1 : damageResult!.damage;
const wouldEndSpar = isNpcSpar && targetMonster.hp - damage <= 1;
const resolvedMonsters = simulatedState.sceneHostileNpcs.map(monster =>
monster.id === targetMonster.id
? {
...monster,
hp: isNpcSpar
? Math.max(1, monster.hp - damage)
: Math.max(0, monster.hp - damage),
}
: monster,
);
const defeated = !isNpcSpar && resolvedMonsters.some(monster => monster.id === targetMonster.id && monster.hp <= 0);
const remainingMonsters = defeated
? resolvedMonsters.filter(monster => !(monster.id === targetMonster.id && monster.hp <= 0))
: resolvedMonsters;
simulatedState = {
...simulatedState,
companions: updateCompanionState(
resetCompanionCombatPresentation(simulatedState.companions),
companion.npcId,
currentCompanion => ({
...currentCompanion,
skillCooldowns: appliedCooldowns,
}),
),
sceneHostileNpcs: remainingMonsters.map(monster => ({
...monster,
characterAnimation: undefined,
combatMode: undefined,
})),
inBattle: isNpcSpar ? !wouldEndSpar : remainingMonsters.length > 0,
currentNpcBattleOutcome: wouldEndSpar
? 'spar_complete'
: (!isNpcSpar && remainingMonsters.length === 0 && simulatedState.currentBattleNpcId
? 'fight_victory'
: simulatedState.currentNpcBattleOutcome),
};
turns.push({
actor: 'companion',
companionNpcId: companion.npcId,
targetHostileNpcId: targetMonster.id,
strikeOffsetX,
cooledDown,
selectedSkillId: selectedSkill.id,
appliedCooldowns,
damage,
criticalHit: damageResult?.isCritical ?? false,
defeated,
endsBattle: wouldEndSpar,
delivery,
});
if (!simulatedState.inBattle) {
break;
}
continue;
}
const actingMonster = simulatedState.sceneHostileNpcs.find(monster => monster.id === turn.id && monster.hp > 0);
if (!actingMonster) continue;
const randomTarget = chooseRandomPartyTarget(simulatedState);
if (!randomTarget) break;
const originalMonsterX = actingMonster.xMeters;
const targetX = randomTarget.kind === 'player'
? simulatedState.playerX
: getCompanionAnchorX(simulatedState.playerX, simulatedState.companions, randomTarget.npcId);
const npcCombatant = npcBattleResources.get(actingMonster.id);
if (npcCombatant) {
const cooledDown = tickSkillCooldowns(npcCombatant.character, npcCombatant.cooldowns);
const selectedSkill = chooseWeightedSkillForStyle(
npcCombatant.character,
npcCombatant.mana,
cooledDown,
inferCombatStyle(option),
);
npcBattleResources.set(actingMonster.id, {
...npcCombatant,
cooldowns: cooledDown,
});
if (selectedSkill) {
const delivery = getSkillDelivery(selectedSkill);
const strikeX = getSkillStrikeX(selectedSkill, originalMonsterX, targetX);
const damageResult = isNpcSpar
? null
: resolveCompanionOutgoingDamageResult(
npcCombatant.character,
selectedSkill.damage,
functionEffect.incomingDamageMultiplier ?? 1,
state.worldType,
state.customWorldProfile,
`${option.functionId}:monster-skill:${turnIndex}:${actingMonster.id}:${selectedSkill.id}:${randomTarget.kind}:${randomTarget.kind === 'companion' ? randomTarget.npcId : 'player'}`,
);
const damage = isNpcSpar ? 1 : damageResult!.damage;
const wouldEndSpar = isNpcSpar && randomTarget.kind === 'player' && simulatedState.playerHp - damage <= 1;
npcBattleResources.set(actingMonster.id, {
character: npcCombatant.character,
mana: npcCombatant.mana,
cooldowns: {
...cooledDown,
[selectedSkill.id]: selectedSkill.cooldownTurns,
},
});
const damagedState = applyDamageToPartyTarget(simulatedState, randomTarget, damage);
simulatedState = {
...damagedState,
companions: resetCompanionCombatPresentation(damagedState.companions),
sceneHostileNpcs: simulatedState.sceneHostileNpcs.map(monster => ({
...monster,
xMeters: monster.id === actingMonster.id ? originalMonsterX : monster.xMeters,
animation: 'idle' as const,
facing: getFacingTowardPlayer(
monster.id === actingMonster.id ? originalMonsterX : monster.xMeters,
simulatedState.playerX,
),
characterAnimation: undefined,
combatMode: undefined,
})),
playerHp: isNpcSpar && randomTarget.kind === 'player'
? Math.max(1, damagedState.playerHp)
: damagedState.playerHp,
inBattle: isNpcSpar ? !wouldEndSpar : damagedState.inBattle,
currentNpcBattleOutcome: wouldEndSpar ? 'spar_complete' : simulatedState.currentNpcBattleOutcome,
};
turns.push({
actor: 'monster',
monsterId: actingMonster.id,
originalMonsterX,
strikeX,
target: randomTarget.kind,
targetCompanionNpcId: randomTarget.kind === 'companion' ? randomTarget.npcId : undefined,
targetX,
damage,
criticalHit: damageResult?.isCritical ?? false,
endsBattle: wouldEndSpar,
selectedSkillId: selectedSkill.id,
npcCharacterId: npcCombatant.character.id,
delivery,
});
continue;
}
}
const strikeX = getMeleeStrikeX(originalMonsterX, targetX);
const damageResult = isNpcSpar
? null
: resolveMonsterOutgoingDamageResult(
actingMonster,
9,
functionEffect.incomingDamageMultiplier ?? 1,
state.worldType,
state.customWorldProfile,
`${option.functionId}:monster:${turnIndex}:${actingMonster.id}:${randomTarget.kind}:${randomTarget.kind === 'companion' ? randomTarget.npcId : 'player'}`,
);
const damage = isNpcSpar ? 1 : damageResult!.damage;
const wouldEndSpar = isNpcSpar && randomTarget.kind === 'player' && simulatedState.playerHp - damage <= 1;
const damagedState = applyDamageToPartyTarget(simulatedState, randomTarget, damage);
simulatedState = {
...damagedState,
companions: resetCompanionCombatPresentation(damagedState.companions),
sceneHostileNpcs: simulatedState.sceneHostileNpcs.map(monster => ({
...monster,
xMeters: monster.id === actingMonster.id ? originalMonsterX : monster.xMeters,
animation: 'idle' as const,
facing: getFacingTowardPlayer(
monster.id === actingMonster.id ? originalMonsterX : monster.xMeters,
simulatedState.playerX,
),
characterAnimation: undefined,
combatMode: undefined,
})),
playerHp: isNpcSpar && randomTarget.kind === 'player'
? Math.max(1, damagedState.playerHp)
: damagedState.playerHp,
inBattle: isNpcSpar ? !wouldEndSpar : damagedState.inBattle,
currentNpcBattleOutcome: wouldEndSpar ? 'spar_complete' : simulatedState.currentNpcBattleOutcome,
};
turns.push({
actor: 'monster',
monsterId: actingMonster.id,
originalMonsterX,
strikeX,
target: randomTarget.kind,
targetCompanionNpcId: randomTarget.kind === 'companion' ? randomTarget.npcId : undefined,
targetX,
damage,
criticalHit: damageResult?.isCritical ?? false,
endsBattle: wouldEndSpar,
selectedSkillId: null,
npcCharacterId: null,
delivery: 'melee',
});
}
return {
preparedState,
turns,
finalState: {
...simulatedState,
companions: resetCompanionCombatPresentation(simulatedState.companions),
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
inBattle: simulatedState.currentNpcBattleOutcome === 'spar_complete'
? false
: simulatedState.sceneHostileNpcs.length > 0,
sceneHostileNpcs: resetCombatPresentation(simulatedState.sceneHostileNpcs, simulatedState.playerX),
},
};
}