Files
Genarrative/src/hooks/combat/battlePlan.ts
T
2026-04-28 02:05:12 +08:00

1216 lines
34 KiB
TypeScript

import {
appendBuildBuffs,
resolveCompanionOutgoingDamageResult,
resolveMonsterOutgoingDamageResult,
resolvePlayerOutgoingDamageResult,
tickBuildBuffs,
} from '../../data/buildDamage';
import { getSkillDelivery } from '../../data/characterCombat';
import {
getCharacterById,
getCharacterCombatStats,
getCharacterMaxMana,
} from '../../data/characterPresets';
import { getEquipmentBonuses } from '../../data/equipmentEffects';
import { getClosestHostileNpc, getFacingTowardPlayer, settleHostileNpcAnimations } from '../../data/hostileNpcs';
import { resolveInventoryItemUseEffect } from '../../data/inventoryEffects';
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';
export type BattlePlanStep =
| {
actor: 'player';
actionKind: 'attack' | 'recover' | 'inventory';
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;
playerHpAfterAction: number;
playerManaAfterAction: number;
playerInventoryAfterAction?: GameState['playerInventory'];
}
| {
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 resolveFightBattleOutcome(state: GameState): GameState['currentNpcBattleOutcome'] {
if (state.currentNpcBattleMode === 'spar') {
return state.currentNpcBattleOutcome;
}
if (state.playerHp <= 0) {
return state.currentBattleNpcId ? 'fight_defeat' : state.currentNpcBattleOutcome;
}
if (
state.currentBattleNpcId &&
state.sceneHostileNpcs.every((monster) => monster.hp <= 0)
) {
return 'fight_victory';
}
return state.currentNpcBattleOutcome;
}
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 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;
}
type BattleTurnActor =
| {
actor: 'player';
speed: number;
tieBreaker: number;
}
| {
actor: 'companion';
companionNpcId: string;
speed: number;
tieBreaker: number;
}
| {
actor: 'monster';
monsterId: string;
speed: number;
tieBreaker: number;
};
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 tickCooldownsByCount(
character: Character,
cooldowns: Record<string, number>,
count: number,
) {
let nextCooldowns = cooldowns;
for (let index = 0; index < count; index += 1) {
nextCooldowns = tickSkillCooldowns(character, nextCooldowns);
}
return nextCooldowns;
}
function getRequestedSkillId(option: StoryOption) {
return typeof option.runtimePayload?.skillId === 'string'
? option.runtimePayload.skillId
: null;
}
function getRequestedItemId(option: StoryOption) {
return typeof option.runtimePayload?.itemId === 'string'
? option.runtimePayload.itemId
: 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) return null;
if (mana < requestedSkill.manaCost) return null;
return requestedSkill;
}
return chooseWeightedSkill(character, mana, cooldowns, option);
}
function choosePlayerActionSkill(
character: Character,
mana: number,
cooldowns: Record<string, number>,
option: StoryOption,
) {
if (option.functionId === 'battle_attack_basic') {
return buildBasicAttackSkill(character);
}
return choosePlayerSkillForOption(character, mana, cooldowns, option);
}
function buildBasicAttackSkill(character: Character): CharacterSkillDefinition {
return {
id: 'battle-basic-attack',
name: '普通攻击',
animation: AnimationState.ATTACK,
damage: Math.max(
8,
Math.round(
character.attributes.strength * 0.85 +
character.attributes.agility * 0.45,
),
),
manaCost: 0,
cooldownTurns: 0,
range: 1,
style: 'steady',
delivery: 'melee',
};
}
function consumeInventoryItem(
inventory: GameState['playerInventory'],
itemId: string,
) {
return inventory
.map((item) =>
item.id === itemId
? {
...item,
quantity: Math.max(0, item.quantity - 1),
}
: item,
)
.filter((item) => item.quantity > 0);
}
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,
};
}
function getPlayerTurnSpeed(state: GameState, character: Character) {
return getCharacterCombatStats(
character,
state.worldType,
state.customWorldProfile,
).turnSpeed;
}
function getCompanionTurnSpeed(state: GameState, companion: CompanionState) {
const companionCharacter = getCharacterById(companion.characterId);
if (!companionCharacter) return 0;
return getCharacterCombatStats(
companionCharacter,
state.worldType,
state.customWorldProfile,
).turnSpeed;
}
function buildRoundTurnOrder(state: GameState, character: Character) {
const turnOrder: BattleTurnActor[] = [];
if (state.playerHp > 0) {
turnOrder.push({
actor: 'player',
speed: getPlayerTurnSpeed(state, character),
tieBreaker: 0,
});
}
if (state.currentNpcBattleMode !== 'spar') {
state.companions
.filter(isCompanionAlive)
.forEach((companion, index) => {
turnOrder.push({
actor: 'companion',
companionNpcId: companion.npcId,
speed: getCompanionTurnSpeed(state, companion),
tieBreaker: 100 + index,
});
});
}
state.sceneHostileNpcs
.filter((monster) => monster.hp > 0)
.forEach((monster, index) => {
turnOrder.push({
actor: 'monster',
monsterId: monster.id,
speed: monster.speed,
tieBreaker: 1000 + index,
});
});
return turnOrder.sort((left, right) => {
if (right.speed !== left.speed) {
return right.speed - left.speed;
}
return left.tieBreaker - right.tieBreaker;
});
}
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 {
void totalSequenceMs;
void turnVisualMs;
void resetStageMs;
void minTurnCount;
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 isInventoryAction = option.functionId === 'inventory_use';
const isNpcSpar = battleState.currentNpcBattleMode === 'spar';
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 = {
...battleState,
companions: resetCompanionCombatPresentation(battleState.companions),
sceneHostileNpcs: resetCombatPresentation(
battleState.sceneHostileNpcs,
battleState.playerX,
),
activeCombatEffects: [],
playerActionMode: 'idle' as const,
currentNpcBattleOutcome: null,
};
const preparedState = simulatedState;
const turns: BattlePlanStep[] = [];
const turnOrder = buildRoundTurnOrder(simulatedState, character);
const pendingMonsterTurnIds = new Set(
turnOrder
.filter(
(turnActor): turnActor is Extract<BattleTurnActor, {actor: 'monster'}> =>
turnActor.actor === 'monster',
)
.map((turnActor) => turnActor.monsterId),
);
for (const turnActor of turnOrder) {
if (
simulatedState.playerHp <= 0 ||
simulatedState.currentNpcBattleOutcome === 'spar_complete' ||
simulatedState.currentNpcBattleOutcome === 'fight_defeat'
) {
break;
}
if (!simulatedState.inBattle && pendingMonsterTurnIds.size === 0) {
break;
}
if (turnActor.actor === 'monster') {
pendingMonsterTurnIds.delete(turnActor.monsterId);
}
if (
turnActor.actor === 'player' &&
simulatedState.playerHp > 0
) {
const currentTarget = getClosestHostileNpc(
simulatedState.playerX,
simulatedState.sceneHostileNpcs,
);
if (!currentTarget) {
simulatedState = {
...simulatedState,
inBattle: false,
};
break;
}
const originalPlayerX = simulatedState.playerX;
const cooledDown = tickSkillCooldowns(
character,
simulatedState.playerSkillCooldowns,
);
if (isRecoveryAction) {
const recoveredState = applyRecoveryEffectToState(
{
...simulatedState,
playerSkillCooldowns: cooledDown,
},
character,
option.functionId,
);
simulatedState = {
...recoveredState,
companions: resetCompanionCombatPresentation(
recoveredState.companions,
),
activeCombatEffects: [],
};
turns.push({
actor: 'player',
actionKind: 'recover',
targetHostileNpcId: currentTarget.id,
originalPlayerX,
strikeX: originalPlayerX,
cooledDown,
selectedSkillId: null,
appliedCooldowns: simulatedState.playerSkillCooldowns,
damage: 0,
defeated: false,
endsBattle: false,
delivery: 'melee',
playerHpAfterAction: simulatedState.playerHp,
playerManaAfterAction: simulatedState.playerMana,
});
continue;
}
if (isInventoryAction) {
const itemId = getRequestedItemId(option);
const item = itemId
? simulatedState.playerInventory.find(
(candidate) => candidate.id === itemId,
) ?? null
: null;
const itemEffect = item
? resolveInventoryItemUseEffect(item, character)
: null;
let appliedCooldowns = cooledDown;
if (item && itemEffect) {
appliedCooldowns = tickCooldownsByCount(
character,
cooledDown,
itemEffect.cooldownReduction ?? 0,
);
simulatedState = {
...simulatedState,
playerHp: Math.min(
simulatedState.playerMaxHp,
simulatedState.playerHp + itemEffect.hpRestore,
),
playerMana: Math.min(
simulatedState.playerMaxMana,
simulatedState.playerMana + itemEffect.manaRestore,
),
playerSkillCooldowns: appliedCooldowns,
playerInventory: consumeInventoryItem(
simulatedState.playerInventory,
item.id,
),
activeBuildBuffs: appendBuildBuffs(
tickBuildBuffs(simulatedState.activeBuildBuffs),
itemEffect.buildBuffs,
),
};
} else {
simulatedState = {
...simulatedState,
playerSkillCooldowns: appliedCooldowns,
};
}
turns.push({
actor: 'player',
actionKind: 'inventory',
targetHostileNpcId: currentTarget.id,
originalPlayerX,
strikeX: originalPlayerX,
cooledDown,
selectedSkillId: null,
appliedCooldowns,
damage: 0,
defeated: false,
endsBattle: false,
delivery: 'melee',
playerHpAfterAction: simulatedState.playerHp,
playerManaAfterAction: simulatedState.playerMana,
playerInventoryAfterAction: simulatedState.playerInventory,
});
continue;
}
const selectedSkill = choosePlayerActionSkill(
character,
simulatedState.playerMana,
cooledDown,
normalizedOption,
);
if (!selectedSkill) {
simulatedState = {
...simulatedState,
playerSkillCooldowns: cooledDown,
companions: resetCompanionCombatPresentation(
simulatedState.companions,
),
activeCombatEffects: [],
};
continue;
}
const appliedCooldowns =
selectedSkill.id === 'battle-basic-attack'
? cooledDown
: {
...cooledDown,
[selectedSkill.id]: selectedSkill.cooldownTurns,
};
const damageResult = isNpcSpar
? null
: resolvePlayerOutgoingDamageResult(
simulatedState,
character,
selectedSkill.damage,
functionEffect.damageMultiplier ?? 1,
`${option.functionId}:player:${selectedSkill.id}:${currentTarget.id}`,
);
const playerDamage = isNpcSpar ? 1 : damageResult!.damage;
const playerCriticalHit = damageResult?.isCritical ?? false;
const playerDelivery = getSkillDelivery(selectedSkill);
const wouldEndSpar = isNpcSpar && currentTarget.hp - playerDamage <= 1;
const strikeX = getSkillStrikeX(
selectedSkill,
originalPlayerX,
currentTarget.xMeters,
);
const resolvedMonsters = simulatedState.sceneHostileNpcs.map((monster) =>
monster.id === currentTarget.id
? {
...monster,
hp: isNpcSpar
? Math.max(1, monster.hp - playerDamage)
: Math.max(0, monster.hp - playerDamage),
}
: monster,
);
const targetDefeated =
!isNpcSpar &&
resolvedMonsters.some(
(monster) => monster.id === currentTarget.id && monster.hp <= 0,
);
const nextTarget = getClosestHostileNpc(
originalPlayerX,
resolvedMonsters.filter((monster) => monster.hp > 0),
);
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: resolvedMonsters.map((monster) => ({
...monster,
characterAnimation: undefined,
combatMode: undefined,
})),
companions: resetCompanionCombatPresentation(simulatedState.companions),
inBattle:
isNpcSpar
? !wouldEndSpar
: (resolvedMonsters.some((monster) => monster.hp > 0) ||
pendingMonsterTurnIds.size > 0) &&
simulatedState.playerHp > 0,
currentNpcBattleOutcome: wouldEndSpar
? 'spar_complete'
: pendingMonsterTurnIds.size > 0
? simulatedState.currentNpcBattleOutcome
: resolveFightBattleOutcome({
...simulatedState,
sceneHostileNpcs: resolvedMonsters,
}),
};
turns.push({
actor: 'player',
actionKind: 'attack',
targetHostileNpcId: currentTarget.id,
originalPlayerX,
strikeX,
cooledDown,
selectedSkillId: selectedSkill.id,
appliedCooldowns,
damage: playerDamage,
criticalHit: playerCriticalHit,
defeated: targetDefeated,
endsBattle: wouldEndSpar,
delivery: playerDelivery,
playerHpAfterAction: simulatedState.playerHp,
playerManaAfterAction: simulatedState.playerMana,
});
continue;
}
if (turnActor.actor === 'companion') {
const companion = simulatedState.companions.find(
(item) => item.npcId === turnActor.companionNpcId,
);
if (!companion || companion.hp <= 0) {
continue;
}
const companionCharacter = getCharacterById(companion.characterId);
if (!companionCharacter) {
continue;
}
const companionAnchorX = getCompanionAnchorX(
simulatedState.playerX,
simulatedState.companions,
companion.npcId,
);
const currentTarget = getClosestHostileNpc(
companionAnchorX,
simulatedState.sceneHostileNpcs,
);
if (!currentTarget) {
simulatedState = {
...simulatedState,
inBattle: false,
};
break;
}
const cooledDown = tickSkillCooldowns(
companionCharacter,
companion.skillCooldowns,
);
const selectedSkill =
chooseWeightedSkillForStyle(
companionCharacter,
companion.mana,
cooledDown,
inferCombatStyle(option),
) ?? buildBasicAttackSkill(companionCharacter);
const appliedCooldowns =
selectedSkill.id === 'battle-basic-attack'
? cooledDown
: {
...cooledDown,
[selectedSkill.id]: selectedSkill.cooldownTurns,
};
const damageResult = resolveCompanionOutgoingDamageResult(
companionCharacter,
selectedSkill.damage,
functionEffect.damageMultiplier ?? 1,
state.worldType,
state.customWorldProfile,
`${option.functionId}:companion:${companion.npcId}:${selectedSkill.id}:${currentTarget.id}`,
);
const damage = damageResult.damage;
const strikeX = getSkillStrikeX(
selectedSkill,
companionAnchorX,
currentTarget.xMeters,
);
const strikeOffsetX = Number((strikeX - companionAnchorX).toFixed(2));
const resolvedMonsters = simulatedState.sceneHostileNpcs.map((monster) =>
monster.id === currentTarget.id
? {
...monster,
hp: Math.max(0, monster.hp - damage),
}
: monster,
);
const defeated = resolvedMonsters.some(
(monster) => monster.id === currentTarget.id && monster.hp <= 0,
);
simulatedState = {
...simulatedState,
sceneHostileNpcs: resolvedMonsters.map((monster) => ({
...monster,
characterAnimation: undefined,
combatMode: undefined,
})),
companions: updateCompanionState(
resetCompanionCombatPresentation(simulatedState.companions),
companion.npcId,
(currentCompanion) => ({
...currentCompanion,
mana: Math.max(0, currentCompanion.mana - selectedSkill.manaCost),
skillCooldowns: appliedCooldowns,
}),
),
inBattle:
(resolvedMonsters.some((monster) => monster.hp > 0) ||
pendingMonsterTurnIds.size > 0) &&
simulatedState.playerHp > 0,
currentNpcBattleOutcome:
pendingMonsterTurnIds.size > 0
? simulatedState.currentNpcBattleOutcome
: resolveFightBattleOutcome({
...simulatedState,
sceneHostileNpcs: resolvedMonsters,
}),
};
turns.push({
actor: 'companion',
companionNpcId: companion.npcId,
targetHostileNpcId: currentTarget.id,
strikeOffsetX,
cooledDown,
selectedSkillId: selectedSkill.id,
appliedCooldowns,
damage,
criticalHit: damageResult.isCritical,
defeated,
endsBattle: false,
delivery: getSkillDelivery(selectedSkill),
});
continue;
}
if (turnActor.actor !== 'monster') {
continue;
}
const actingMonster = simulatedState.sceneHostileNpcs.find(
(monster) =>
monster.id === turnActor.monsterId,
);
if (!actingMonster) {
continue;
}
const randomTarget = chooseRandomPartyTarget(simulatedState);
if (!randomTarget) {
simulatedState = {
...simulatedState,
inBattle: false,
};
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:${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: Math.max(0, npcCombatant.mana - selectedSkill.manaCost),
cooldowns: {
...cooledDown,
[selectedSkill.id]: selectedSkill.cooldownTurns,
},
});
const damagedState = applyDamageToPartyTarget(
simulatedState,
randomTarget,
damage,
);
const nextPlayerHp =
isNpcSpar && randomTarget.kind === 'player'
? Math.max(1, damagedState.playerHp)
: damagedState.playerHp;
const endsBattle = isNpcSpar
? wouldEndSpar
: nextPlayerHp <= 0;
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: nextPlayerHp,
inBattle:
isNpcSpar
? !wouldEndSpar
: nextPlayerHp > 0 &&
(simulatedState.sceneHostileNpcs.some((monster) => monster.hp > 0) ||
pendingMonsterTurnIds.size > 0),
currentNpcBattleOutcome: wouldEndSpar
? 'spar_complete'
: pendingMonsterTurnIds.size > 0
? simulatedState.currentNpcBattleOutcome
: resolveFightBattleOutcome({
...simulatedState,
...damagedState,
playerHp: nextPlayerHp,
}),
};
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,
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:${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,
);
const nextPlayerHp =
isNpcSpar && randomTarget.kind === 'player'
? Math.max(1, damagedState.playerHp)
: damagedState.playerHp;
const endsBattle = isNpcSpar ? wouldEndSpar : nextPlayerHp <= 0;
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: nextPlayerHp,
inBattle:
isNpcSpar
? !wouldEndSpar
: nextPlayerHp > 0 &&
(simulatedState.sceneHostileNpcs.some((monster) => monster.hp > 0) ||
pendingMonsterTurnIds.size > 0),
currentNpcBattleOutcome: wouldEndSpar
? 'spar_complete'
: pendingMonsterTurnIds.size > 0
? simulatedState.currentNpcBattleOutcome
: resolveFightBattleOutcome({
...simulatedState,
...damagedState,
playerHp: nextPlayerHp,
}),
};
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,
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' ||
simulatedState.playerHp <= 0
? false
: simulatedState.sceneHostileNpcs.some((monster) => monster.hp > 0),
sceneHostileNpcs: resetCombatPresentation(
simulatedState.sceneHostileNpcs.filter((monster) => monster.hp > 0),
simulatedState.playerX,
),
currentNpcBattleOutcome: resolveFightBattleOutcome({
...simulatedState,
sceneHostileNpcs: simulatedState.sceneHostileNpcs.filter((monster) => monster.hp > 0),
}),
},
};
}