This commit is contained in:
2026-04-28 02:05:12 +08:00
parent 271db02e4a
commit 1eb090e4a5
39 changed files with 2671 additions and 165 deletions

View File

@@ -423,4 +423,49 @@ describe('buildBattlePlan', () => {
}),
);
});
it('prefers fight_defeat over fight_victory when the round ends with player death after local battle settlement', () => {
const state = {
...createBaseState(),
currentBattleNpcId: 'npc-opponent',
currentNpcBattleMode: 'fight' as const,
playerHp: 6,
playerMaxHp: 30,
sceneHostileNpcs: [
{
id: 'npc-opponent',
name: '山道客',
action: '提刀逼近',
description: '测试敌人',
animation: 'idle' as const,
xMeters: 3,
yOffset: 0,
facing: 'left' as const,
attackRange: 1,
speed: 1,
hp: 8,
maxHp: 8,
},
],
};
const plan = buildBattlePlan({
state,
option: {
...createBattleOption(),
functionId: 'battle_all_in_crush',
},
character: createTestCharacter(),
totalSequenceMs: 900,
turnVisualMs: 820,
resetStageMs: 260,
minTurnCount: 1,
});
expect(plan.turns.map((turn) => turn.actor)).toEqual(['player', 'monster']);
expect(plan.finalState.playerHp).toBe(0);
expect(plan.finalState.inBattle).toBe(false);
expect(plan.finalState.currentNpcBattleOutcome).toBe('fight_defeat');
expect(plan.finalState.sceneHostileNpcs).toEqual([]);
});
});

View File

@@ -87,6 +87,22 @@ export type BattlePlan = {
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]));
}
@@ -543,11 +559,29 @@ export function buildBattlePlan({
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.inBattle || simulatedState.playerHp <= 0) {
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' &&
@@ -725,20 +759,14 @@ export function buildBattlePlan({
}
: monster,
);
const playerDefeated =
const targetDefeated =
!isNpcSpar &&
resolvedMonsters.some(
(monster) => monster.id === currentTarget.id && monster.hp <= 0,
);
const remainingMonsters = playerDefeated
? resolvedMonsters.filter(
(monster) =>
!(monster.id === currentTarget.id && monster.hp <= 0),
)
: resolvedMonsters;
const nextTarget = getClosestHostileNpc(
originalPlayerX,
remainingMonsters,
resolvedMonsters.filter((monster) => monster.hp > 0),
);
simulatedState = {
@@ -757,7 +785,7 @@ export function buildBattlePlan({
simulatedState.playerMana - selectedSkill.manaCost,
),
playerSkillCooldowns: appliedCooldowns,
sceneHostileNpcs: remainingMonsters.map((monster) => ({
sceneHostileNpcs: resolvedMonsters.map((monster) => ({
...monster,
characterAnimation: undefined,
combatMode: undefined,
@@ -766,14 +794,17 @@ export function buildBattlePlan({
inBattle:
isNpcSpar
? !wouldEndSpar
: remainingMonsters.length > 0 && simulatedState.playerHp > 0,
: (resolvedMonsters.some((monster) => monster.hp > 0) ||
pendingMonsterTurnIds.size > 0) &&
simulatedState.playerHp > 0,
currentNpcBattleOutcome: wouldEndSpar
? 'spar_complete'
: !isNpcSpar &&
remainingMonsters.length === 0 &&
simulatedState.currentBattleNpcId
? 'fight_victory'
: simulatedState.currentNpcBattleOutcome,
: pendingMonsterTurnIds.size > 0
? simulatedState.currentNpcBattleOutcome
: resolveFightBattleOutcome({
...simulatedState,
sceneHostileNpcs: resolvedMonsters,
}),
};
turns.push({
@@ -787,7 +818,7 @@ export function buildBattlePlan({
appliedCooldowns,
damage: playerDamage,
criticalHit: playerCriticalHit,
defeated: playerDefeated,
defeated: targetDefeated,
endsBattle: wouldEndSpar,
delivery: playerDelivery,
playerHpAfterAction: simulatedState.playerHp,
@@ -870,16 +901,9 @@ export function buildBattlePlan({
const defeated = resolvedMonsters.some(
(monster) => monster.id === currentTarget.id && monster.hp <= 0,
);
const remainingMonsters = defeated
? resolvedMonsters.filter(
(monster) =>
!(monster.id === currentTarget.id && monster.hp <= 0),
)
: resolvedMonsters;
simulatedState = {
...simulatedState,
sceneHostileNpcs: remainingMonsters.map((monster) => ({
sceneHostileNpcs: resolvedMonsters.map((monster) => ({
...monster,
characterAnimation: undefined,
combatMode: undefined,
@@ -894,11 +918,16 @@ export function buildBattlePlan({
}),
),
inBattle:
remainingMonsters.length > 0 && simulatedState.playerHp > 0,
(resolvedMonsters.some((monster) => monster.hp > 0) ||
pendingMonsterTurnIds.size > 0) &&
simulatedState.playerHp > 0,
currentNpcBattleOutcome:
remainingMonsters.length === 0 && simulatedState.currentBattleNpcId
? 'fight_victory'
: simulatedState.currentNpcBattleOutcome,
pendingMonsterTurnIds.size > 0
? simulatedState.currentNpcBattleOutcome
: resolveFightBattleOutcome({
...simulatedState,
sceneHostileNpcs: resolvedMonsters,
}),
};
turns.push({
@@ -923,7 +952,8 @@ export function buildBattlePlan({
}
const actingMonster = simulatedState.sceneHostileNpcs.find(
(monster) => monster.id === turnActor.monsterId && monster.hp > 0,
(monster) =>
monster.id === turnActor.monsterId,
);
if (!actingMonster) {
continue;
@@ -1035,10 +1065,18 @@ export function buildBattlePlan({
inBattle:
isNpcSpar
? !wouldEndSpar
: nextPlayerHp > 0 && simulatedState.sceneHostileNpcs.length > 0,
: nextPlayerHp > 0 &&
(simulatedState.sceneHostileNpcs.some((monster) => monster.hp > 0) ||
pendingMonsterTurnIds.size > 0),
currentNpcBattleOutcome: wouldEndSpar
? 'spar_complete'
: simulatedState.currentNpcBattleOutcome,
: pendingMonsterTurnIds.size > 0
? simulatedState.currentNpcBattleOutcome
: resolveFightBattleOutcome({
...simulatedState,
...damagedState,
playerHp: nextPlayerHp,
}),
};
turns.push({
@@ -1115,10 +1153,18 @@ export function buildBattlePlan({
inBattle:
isNpcSpar
? !wouldEndSpar
: nextPlayerHp > 0 && simulatedState.sceneHostileNpcs.length > 0,
: nextPlayerHp > 0 &&
(simulatedState.sceneHostileNpcs.some((monster) => monster.hp > 0) ||
pendingMonsterTurnIds.size > 0),
currentNpcBattleOutcome: wouldEndSpar
? 'spar_complete'
: simulatedState.currentNpcBattleOutcome,
: pendingMonsterTurnIds.size > 0
? simulatedState.currentNpcBattleOutcome
: resolveFightBattleOutcome({
...simulatedState,
...damagedState,
playerHp: nextPlayerHp,
}),
};
turns.push({
@@ -1144,8 +1190,8 @@ export function buildBattlePlan({
return {
preparedState,
turns,
finalState: {
...simulatedState,
finalState: {
...simulatedState,
companions: resetCompanionCombatPresentation(simulatedState.companions),
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
@@ -1155,11 +1201,15 @@ export function buildBattlePlan({
simulatedState.currentNpcBattleOutcome === 'spar_complete' ||
simulatedState.playerHp <= 0
? false
: simulatedState.sceneHostileNpcs.length > 0,
: simulatedState.sceneHostileNpcs.some((monster) => monster.hp > 0),
sceneHostileNpcs: resetCombatPresentation(
simulatedState.sceneHostileNpcs,
simulatedState.sceneHostileNpcs.filter((monster) => monster.hp > 0),
simulatedState.playerX,
),
currentNpcBattleOutcome: resolveFightBattleOutcome({
...simulatedState,
sceneHostileNpcs: simulatedState.sceneHostileNpcs.filter((monster) => monster.hp > 0),
}),
},
};
}