1
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock('../../services/rpg-runtime', () => ({
|
||||
}));
|
||||
|
||||
import { generateNextStep } from '../../services/aiService';
|
||||
import { getScenePresetsByWorld } from '../../data/scenePresets';
|
||||
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
|
||||
import { createStoryChoiceActions } from './choiceActions';
|
||||
|
||||
@@ -138,6 +139,60 @@ function createFallbackStory(text = 'fallback'): StoryMoment {
|
||||
};
|
||||
}
|
||||
|
||||
function createCustomWorldProfileForSceneAct(sceneId: string) {
|
||||
return {
|
||||
id: 'custom-world-test',
|
||||
name: '场景幕重置测试',
|
||||
summary: '用于验证战败后回到首幕。',
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: `${sceneId}-chapter`,
|
||||
sceneId,
|
||||
title: '测试章节',
|
||||
summary: '测试章节摘要',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [],
|
||||
acts: [
|
||||
{
|
||||
id: `${sceneId}-act-1`,
|
||||
sceneId,
|
||||
title: '第一幕',
|
||||
summary: '开场第一幕',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc: '/act-1.png',
|
||||
encounterNpcIds: [],
|
||||
primaryNpcId: null,
|
||||
oppositeNpcId: null,
|
||||
eventDescription: '第一幕事件',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '完成第一幕目标',
|
||||
transitionHook: '第一幕过渡',
|
||||
},
|
||||
{
|
||||
id: `${sceneId}-act-2`,
|
||||
sceneId,
|
||||
title: '第二幕',
|
||||
summary: '推进第二幕',
|
||||
stageCoverage: ['expansion'],
|
||||
backgroundImageSrc: '/act-2.png',
|
||||
encounterNpcIds: [],
|
||||
primaryNpcId: null,
|
||||
oppositeNpcId: null,
|
||||
eventDescription: '第二幕事件',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '完成第二幕目标',
|
||||
transitionHook: '第二幕过渡',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as NonNullable<GameState['customWorldProfile']>;
|
||||
}
|
||||
|
||||
const neverNpcEncounter = (
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter => false;
|
||||
@@ -634,6 +689,144 @@ describe('createStoryChoiceActions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps local npc defeat on the death revive chain and resets to the first scene act', async () => {
|
||||
vi.useFakeTimers();
|
||||
const firstScene = getScenePresetsByWorld(WorldType.WUXIA)[0]!;
|
||||
const customWorldProfile = createCustomWorldProfileForSceneAct(firstScene.id);
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
customWorldProfile,
|
||||
currentScenePreset: firstScene,
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: [],
|
||||
activeThreadIds: [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
currentSceneActState: {
|
||||
sceneId: firstScene.id,
|
||||
chapterId: `${firstScene.id}-chapter`,
|
||||
currentActId: `${firstScene.id}-act-2`,
|
||||
currentActIndex: 1,
|
||||
completedActIds: [`${firstScene.id}-act-1`],
|
||||
visitedActIds: [`${firstScene.id}-act-1`, `${firstScene.id}-act-2`],
|
||||
},
|
||||
},
|
||||
currentEncounter: {
|
||||
id: 'npc-opponent',
|
||||
kind: 'npc' as const,
|
||||
npcName: '山道客',
|
||||
npcDescription: '拦路旧敌',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道旧案',
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
};
|
||||
const option = createBattleOption();
|
||||
const afterSequence = {
|
||||
...state,
|
||||
playerHp: 0,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
currentNpcBattleOutcome: 'fight_defeat' as const,
|
||||
};
|
||||
const finalizeNpcBattleResult = vi.fn(() => ({
|
||||
nextState: afterSequence,
|
||||
resultText: '不应该进入胜利结算',
|
||||
}));
|
||||
const setCurrentStory = vi.fn();
|
||||
const setGameState = vi.fn();
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory: createFallbackStory(),
|
||||
isLoading: false,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(() => ({
|
||||
optionKind: 'battle' as const,
|
||||
battlePlan: null,
|
||||
afterSequence,
|
||||
})),
|
||||
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
|
||||
buildStoryContextFromState: vi.fn(() => ({
|
||||
playerHp: 0,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
})),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => null),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(() => false),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult,
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
const choicePromise = handleChoice(option);
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await choicePromise;
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(finalizeNpcBattleResult).not.toHaveBeenCalled();
|
||||
expect(setGameState).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
playerHp: 0,
|
||||
inBattle: false,
|
||||
currentNpcBattleOutcome: 'fight_defeat',
|
||||
animationState: AnimationState.DIE,
|
||||
}),
|
||||
);
|
||||
expect(setGameState).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
currentScenePreset: expect.objectContaining({
|
||||
id: firstScene.id,
|
||||
}),
|
||||
playerHp: 100,
|
||||
inBattle: false,
|
||||
currentNpcBattleOutcome: null,
|
||||
storyEngineMemory: expect.objectContaining({
|
||||
currentSceneActState: expect.objectContaining({
|
||||
sceneId: firstScene.id,
|
||||
currentActId: `${firstScene.id}-act-1`,
|
||||
currentActIndex: 0,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('重新醒来'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('settles escape locally without ai continuation', async () => {
|
||||
const mockedGenerateNextStep = vi.mocked(generateNextStep);
|
||||
|
||||
|
||||
@@ -683,6 +683,44 @@ describe('npcEncounterActions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not turn fight_defeat into a local npc victory settlement', () => {
|
||||
const actions = createNpcEncounterActions({
|
||||
gameState: createState({
|
||||
inBattle: false,
|
||||
playerHp: 0,
|
||||
currentBattleNpcId: 'npc-rival',
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: 'fight_defeat',
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc-rival',
|
||||
name: '断桥客',
|
||||
action: '逼近',
|
||||
description: '拦路旧敌',
|
||||
animation: 'idle',
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1.4,
|
||||
speed: 7,
|
||||
hp: 12,
|
||||
maxHp: 12,
|
||||
renderKind: 'npc',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = actions.finalizeNpcBattleResult(
|
||||
actions.gameState,
|
||||
actions.gameState.playerCharacter!,
|
||||
'fight',
|
||||
'fight_defeat',
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('streams a model-driven npc-initiated opening on first meaningful contact', async () => {
|
||||
const encounter = createEncounter();
|
||||
streamNpcChatTurnMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { createNpcBattleMonster } from '../../data/npcInteractions';
|
||||
import {
|
||||
buildNpcBattleFormationFromEncounter,
|
||||
RESOLVED_ENTITY_X_METERS,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { getForwardScenePreset } from '../../data/scenePresets';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
@@ -11,9 +16,93 @@ import {
|
||||
type RuntimeStoryResponse,
|
||||
type RuntimeStorySnapshotRequest,
|
||||
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import type { GameState, SceneHostileNpc, StoryMoment, StoryOption } from '../../types';
|
||||
import { buildMapTravelResolution } from './storyGenerationState';
|
||||
|
||||
function isNpcBattleAlignmentDebugEnabled() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
window.localStorage.getItem('rpg:npc-battle-alignment-debug') === '1' ||
|
||||
window.location.search.includes('npcBattleAlignmentDebug=1')
|
||||
);
|
||||
}
|
||||
|
||||
function logNpcBattleAlignment(label: string, monsters: GameState['sceneHostileNpcs']) {
|
||||
if (!isNpcBattleAlignmentDebugEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[npc-battle-alignment] ${label}`,
|
||||
monsters.map((monster) => ({
|
||||
id: monster.id,
|
||||
encounterId: monster.encounter?.id ?? null,
|
||||
encounterName: monster.encounter?.npcName ?? null,
|
||||
xMeters: monster.xMeters,
|
||||
yOffset: monster.yOffset,
|
||||
facing: monster.facing,
|
||||
animation: monster.animation,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function cloneBattleFormation(monsters: GameState['sceneHostileNpcs']) {
|
||||
return monsters.map(
|
||||
(monster) =>
|
||||
({
|
||||
...monster,
|
||||
encounter: monster.encounter
|
||||
? {
|
||||
...monster.encounter,
|
||||
}
|
||||
: monster.encounter,
|
||||
}) satisfies SceneHostileNpc,
|
||||
);
|
||||
}
|
||||
|
||||
function alignBattleFormationToVisibleFormation(params: {
|
||||
visibleFormation: GameState['sceneHostileNpcs'];
|
||||
battleFormation: GameState['sceneHostileNpcs'];
|
||||
}) {
|
||||
const { visibleFormation, battleFormation } = params;
|
||||
if (visibleFormation.length === 0 || battleFormation.length === 0) {
|
||||
return battleFormation;
|
||||
}
|
||||
|
||||
const visibleFormationByEncounterId = new Map(
|
||||
visibleFormation.map((monster) => [
|
||||
monster.encounter?.id ?? monster.encounter?.npcName ?? monster.id,
|
||||
monster,
|
||||
]),
|
||||
);
|
||||
|
||||
return battleFormation.map((monster) => {
|
||||
const encounterKey =
|
||||
monster.encounter?.id ?? monster.encounter?.npcName ?? monster.id;
|
||||
const visibleMonster = visibleFormationByEncounterId.get(encounterKey);
|
||||
if (!visibleMonster) {
|
||||
return monster;
|
||||
}
|
||||
|
||||
return {
|
||||
...monster,
|
||||
xMeters: visibleMonster.xMeters,
|
||||
yOffset: visibleMonster.yOffset,
|
||||
facing: visibleMonster.facing,
|
||||
encounter: monster.encounter
|
||||
? {
|
||||
...monster.encounter,
|
||||
xMeters:
|
||||
visibleMonster.encounter?.xMeters ?? visibleMonster.xMeters,
|
||||
}
|
||||
: monster.encounter,
|
||||
} satisfies SceneHostileNpc;
|
||||
});
|
||||
}
|
||||
|
||||
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
return response.viewModel.availableOptions.length > 0
|
||||
? response.viewModel.availableOptions
|
||||
@@ -120,6 +209,102 @@ function bridgeServerSceneTravelSnapshot(params: {
|
||||
} satisfies HydratedSavedGameSnapshot;
|
||||
}
|
||||
|
||||
function bridgeServerNpcBattleSnapshot(params: {
|
||||
previousState: GameState;
|
||||
hydratedSnapshot: HydratedSavedGameSnapshot;
|
||||
functionId: string;
|
||||
}) {
|
||||
const { previousState, hydratedSnapshot, functionId } = params;
|
||||
if (functionId !== 'npc_fight' && functionId !== 'npc_spar') {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
const snapshotState = hydratedSnapshot.gameState;
|
||||
const isNpcBattleActive =
|
||||
snapshotState.inBattle &&
|
||||
Boolean(snapshotState.currentBattleNpcId) &&
|
||||
Boolean(snapshotState.currentNpcBattleMode);
|
||||
const hasResolvedBattleMonster = snapshotState.sceneHostileNpcs.length > 0;
|
||||
const sourceEncounter =
|
||||
previousState.currentEncounter?.kind === 'npc'
|
||||
? previousState.currentEncounter
|
||||
: null;
|
||||
|
||||
// 中文注释:作品测试/幕预览里最容易出现的错位,是服务端已经把
|
||||
// currentBattleNpcId / currentNpcBattleMode 切进战斗,但快照里没有把
|
||||
// sceneHostileNpcs 一起带回。这样前端本地 battlePlan 会直接判定
|
||||
// “场上没有敌人”,点击 battle_* 后立刻把整场战斗收掉。
|
||||
// 这里统一在网关层补齐 NPC 战场快照,保证后续本地逐轮回合一定有敌方单位可结算。
|
||||
if (!isNpcBattleActive || !sourceEncounter) {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
const fallbackNpcState =
|
||||
snapshotState.npcStates[
|
||||
snapshotState.currentBattleNpcId ?? sourceEncounter.id ?? sourceEncounter.npcName
|
||||
] ??
|
||||
previousState.npcStates[
|
||||
previousState.currentBattleNpcId ?? sourceEncounter.id ?? sourceEncounter.npcName
|
||||
] ?? {
|
||||
affinity: sourceEncounter.initialAffinity ?? (sourceEncounter.hostile ? -10 : 0),
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
};
|
||||
|
||||
const battleMode =
|
||||
snapshotState.currentNpcBattleMode === 'spar' ? 'spar' : 'fight';
|
||||
const fallbackFormationFromSceneAct = buildNpcBattleFormationFromEncounter({
|
||||
state: previousState,
|
||||
encounter: {
|
||||
...sourceEncounter,
|
||||
xMeters: sourceEncounter.xMeters ?? RESOLVED_ENTITY_X_METERS,
|
||||
},
|
||||
mode: battleMode,
|
||||
});
|
||||
const fallbackFormation =
|
||||
previousState.sceneHostileNpcs.length > 0
|
||||
? cloneBattleFormation(previousState.sceneHostileNpcs)
|
||||
: fallbackFormationFromSceneAct.length > 0
|
||||
? fallbackFormationFromSceneAct
|
||||
: [
|
||||
createNpcBattleMonster(
|
||||
sourceEncounter,
|
||||
fallbackNpcState,
|
||||
battleMode,
|
||||
{
|
||||
worldType: snapshotState.worldType,
|
||||
customWorldProfile: snapshotState.customWorldProfile,
|
||||
},
|
||||
),
|
||||
];
|
||||
const resolvedBattleFormation = hasResolvedBattleMonster
|
||||
? alignBattleFormationToVisibleFormation({
|
||||
visibleFormation: previousState.sceneHostileNpcs,
|
||||
battleFormation: snapshotState.sceneHostileNpcs,
|
||||
})
|
||||
: fallbackFormation;
|
||||
|
||||
logNpcBattleAlignment('previous-visible-formation', previousState.sceneHostileNpcs);
|
||||
logNpcBattleAlignment('server-battle-formation', snapshotState.sceneHostileNpcs);
|
||||
logNpcBattleAlignment('resolved-battle-formation', resolvedBattleFormation);
|
||||
|
||||
return {
|
||||
...hydratedSnapshot,
|
||||
gameState: {
|
||||
...snapshotState,
|
||||
// 中文注释:优先沿用进入战斗前已经可见的阵容与站位;
|
||||
// 若上一帧还没有 battle combatants,则从幕预览/当前遭遇恢复完整 NPC 编队,
|
||||
// 避免只补出一个前排角色,造成后排消失和敌方位置突变。
|
||||
sceneHostileNpcs: resolvedBattleFormation,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
},
|
||||
} satisfies HydratedSavedGameSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端访问服务端 runtime story 的统一网关。
|
||||
* 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。
|
||||
@@ -204,7 +389,11 @@ export async function resolveServerRuntimeChoice(params: {
|
||||
});
|
||||
const hydratedSnapshot = bridgeServerSceneTravelSnapshot({
|
||||
previousState: params.gameState,
|
||||
hydratedSnapshot: rehydrateSavedSnapshot(response.snapshot),
|
||||
hydratedSnapshot: bridgeServerNpcBattleSnapshot({
|
||||
previousState: params.gameState,
|
||||
hydratedSnapshot: rehydrateSavedSnapshot(response.snapshot),
|
||||
functionId: params.option.functionId,
|
||||
}),
|
||||
functionId: params.option.functionId,
|
||||
});
|
||||
|
||||
|
||||
@@ -653,6 +653,515 @@ describe('runtimeStoryCoordinator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('backfills npc battle monsters when npc_fight snapshot marks battle active but omits sceneHostileNpcs', async () => {
|
||||
const gameState = {
|
||||
...createTravelGameState(),
|
||||
currentEncounter: {
|
||||
id: 'npc-bandit',
|
||||
kind: 'npc',
|
||||
npcName: '断桥匪首',
|
||||
npcDescription: '拦路的刀客',
|
||||
npcAvatar: '/npc-bandit.png',
|
||||
context: '断桥口',
|
||||
hostile: true,
|
||||
initialAffinity: -12,
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
} as GameState;
|
||||
const currentStory = createStory('当前故事');
|
||||
const option = {
|
||||
functionId: 'npc_fight',
|
||||
actionText: '直接开战',
|
||||
text: '直接开战',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-bandit',
|
||||
action: 'fight',
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 42,
|
||||
maxHp: 50,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: {
|
||||
id: 'npc-bandit',
|
||||
kind: 'npc',
|
||||
npcName: '断桥匪首',
|
||||
hostile: true,
|
||||
affinity: -12,
|
||||
recruited: false,
|
||||
interactionActive: false,
|
||||
battleMode: 'fight',
|
||||
},
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'battle_attack_basic',
|
||||
actionText: '普通攻击',
|
||||
scope: 'combat',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: true,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '直接开战',
|
||||
resultText: '当前冲突正式转入战斗结算。',
|
||||
storyText: '断桥匪首已经摆开架势。',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: createRuntimeNpcBattleSnapshot({
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc-bandit',
|
||||
npcName: '断桥匪首',
|
||||
npcDescription: '拦路的刀客',
|
||||
context: '断桥口',
|
||||
hostile: true,
|
||||
} as GameState['currentEncounter'],
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
inBattle: true,
|
||||
currentBattleNpcId: 'npc-bandit',
|
||||
currentNpcBattleMode: 'fight',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
});
|
||||
|
||||
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs).toHaveLength(1);
|
||||
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
encounter: expect.objectContaining({
|
||||
id: 'npc-bandit',
|
||||
npcName: '断桥匪首',
|
||||
}),
|
||||
renderKind: 'npc',
|
||||
}),
|
||||
);
|
||||
expect(result.hydratedSnapshot.gameState.currentEncounter).toBeNull();
|
||||
expect(result.hydratedSnapshot.gameState.npcInteractionActive).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves previous hostile formation when npc_fight snapshot omits battle members', async () => {
|
||||
const gameState = {
|
||||
...createTravelGameState(),
|
||||
currentEncounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
npcAvatar: '/npc-front.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
initialAffinity: -20,
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc-opponent-npc-front',
|
||||
name: '正面对手',
|
||||
action: '摆开架势,随时准备出手',
|
||||
description: '正面对手',
|
||||
animation: 'idle',
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1.8,
|
||||
speed: 8,
|
||||
hp: 88,
|
||||
maxHp: 88,
|
||||
renderKind: 'npc',
|
||||
encounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
npcAvatar: '/npc-front.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
xMeters: 3.2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'npc-opponent-npc-back-1',
|
||||
name: '后排甲',
|
||||
action: '摆开架势,随时准备出手',
|
||||
description: '后排甲',
|
||||
animation: 'idle',
|
||||
xMeters: 4.28,
|
||||
yOffset: 62,
|
||||
facing: 'left',
|
||||
attackRange: 1.8,
|
||||
speed: 7,
|
||||
hp: 76,
|
||||
maxHp: 76,
|
||||
renderKind: 'npc',
|
||||
encounter: {
|
||||
id: 'npc-back-1',
|
||||
kind: 'npc',
|
||||
npcName: '后排甲',
|
||||
npcDescription: '后排甲',
|
||||
npcAvatar: '/npc-back-1.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
xMeters: 4.28,
|
||||
},
|
||||
},
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
} as GameState;
|
||||
const currentStory = createStory('当前故事');
|
||||
const option = {
|
||||
functionId: 'npc_fight',
|
||||
actionText: '直接开战',
|
||||
text: '直接开战',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-front',
|
||||
action: 'fight',
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 42,
|
||||
maxHp: 50,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
hostile: true,
|
||||
affinity: -20,
|
||||
recruited: false,
|
||||
interactionActive: false,
|
||||
battleMode: 'fight',
|
||||
},
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'battle_attack_basic',
|
||||
actionText: '普通攻击',
|
||||
scope: 'combat',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: true,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '直接开战',
|
||||
resultText: '当前冲突正式转入战斗结算。',
|
||||
storyText: '正面对手带着同伴压了上来。',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: createRuntimeNpcBattleSnapshot({
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc-front',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
} as GameState['currentEncounter'],
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
inBattle: true,
|
||||
currentBattleNpcId: 'npc-front',
|
||||
currentNpcBattleMode: 'fight',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
});
|
||||
|
||||
expect(
|
||||
result.hydratedSnapshot.gameState.sceneHostileNpcs.map((monster) => ({
|
||||
encounterId: monster.encounter?.id,
|
||||
xMeters: monster.xMeters,
|
||||
yOffset: monster.yOffset,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
encounterId: 'npc-front',
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
},
|
||||
{
|
||||
encounterId: 'npc-back-1',
|
||||
xMeters: 4.28,
|
||||
yOffset: 62,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('realigns non-empty npc_fight battle snapshots back to the visible pre-battle formation', async () => {
|
||||
const gameState = {
|
||||
...createTravelGameState(),
|
||||
currentEncounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
npcAvatar: '/npc-front.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
initialAffinity: -20,
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc-opponent-npc-front',
|
||||
name: '正面对手',
|
||||
action: '摆开架势,随时准备出手',
|
||||
description: '正面对手',
|
||||
animation: 'idle',
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1.8,
|
||||
speed: 8,
|
||||
hp: 88,
|
||||
maxHp: 88,
|
||||
renderKind: 'npc',
|
||||
encounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
npcAvatar: '/npc-front.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
xMeters: 3.2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'npc-opponent-npc-back-1',
|
||||
name: '后排甲',
|
||||
action: '摆开架势,随时准备出手',
|
||||
description: '后排甲',
|
||||
animation: 'idle',
|
||||
xMeters: 4.28,
|
||||
yOffset: 62,
|
||||
facing: 'left',
|
||||
attackRange: 1.8,
|
||||
speed: 7,
|
||||
hp: 76,
|
||||
maxHp: 76,
|
||||
renderKind: 'npc',
|
||||
encounter: {
|
||||
id: 'npc-back-1',
|
||||
kind: 'npc',
|
||||
npcName: '后排甲',
|
||||
npcDescription: '后排甲',
|
||||
npcAvatar: '/npc-back-1.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
xMeters: 4.28,
|
||||
},
|
||||
},
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
} as GameState;
|
||||
const currentStory = createStory('当前故事');
|
||||
const option = {
|
||||
functionId: 'npc_fight',
|
||||
actionText: '直接开战',
|
||||
text: '直接开战',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-front',
|
||||
action: 'fight',
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 42,
|
||||
maxHp: 50,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
hostile: true,
|
||||
affinity: -20,
|
||||
recruited: false,
|
||||
interactionActive: false,
|
||||
battleMode: 'fight',
|
||||
},
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'battle_attack_basic',
|
||||
actionText: '普通攻击',
|
||||
scope: 'combat',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: true,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '直接开战',
|
||||
resultText: '当前冲突正式转入战斗结算。',
|
||||
storyText: '正面对手带着同伴压了上来。',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: createRuntimeNpcBattleSnapshot({
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc-front',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
} as GameState['currentEncounter'],
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc-opponent-npc-front',
|
||||
name: '正面对手',
|
||||
action: '摆开架势,随时准备出手',
|
||||
description: '正面对手',
|
||||
animation: 'idle',
|
||||
xMeters: 1.4,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1.8,
|
||||
speed: 8,
|
||||
hp: 88,
|
||||
maxHp: 88,
|
||||
renderKind: 'npc',
|
||||
encounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
npcAvatar: '/npc-front.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
xMeters: 1.4,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'npc-opponent-npc-back-1',
|
||||
name: '后排甲',
|
||||
action: '摆开架势,随时准备出手',
|
||||
description: '后排甲',
|
||||
animation: 'idle',
|
||||
xMeters: 2.1,
|
||||
yOffset: 16,
|
||||
facing: 'left',
|
||||
attackRange: 1.8,
|
||||
speed: 7,
|
||||
hp: 76,
|
||||
maxHp: 76,
|
||||
renderKind: 'npc',
|
||||
encounter: {
|
||||
id: 'npc-back-1',
|
||||
kind: 'npc',
|
||||
npcName: '后排甲',
|
||||
npcDescription: '后排甲',
|
||||
npcAvatar: '/npc-back-1.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
xMeters: 2.1,
|
||||
},
|
||||
},
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
inBattle: true,
|
||||
currentBattleNpcId: 'npc-front',
|
||||
currentNpcBattleMode: 'fight',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
});
|
||||
|
||||
expect(
|
||||
result.hydratedSnapshot.gameState.sceneHostileNpcs.map((monster) => ({
|
||||
encounterId: monster.encounter?.id,
|
||||
xMeters: monster.xMeters,
|
||||
yOffset: monster.yOffset,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
encounterId: 'npc-front',
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
},
|
||||
{
|
||||
encounterId: 'npc-back-1',
|
||||
xMeters: 4.28,
|
||||
yOffset: 62,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('bridges idle_travel_next_scene server snapshots into the next scene runtime state', async () => {
|
||||
const gameState = createTravelGameState();
|
||||
const currentStory = createStory('桥口这一段已经收束。');
|
||||
|
||||
@@ -151,6 +151,14 @@ function buildDeterministicStoryForState(params: {
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
function isLocalNpcBattleVictoryOutcome(
|
||||
battleOutcome: GameState['currentNpcBattleOutcome'],
|
||||
) {
|
||||
return (
|
||||
battleOutcome === 'fight_victory' || battleOutcome === 'spar_complete'
|
||||
);
|
||||
}
|
||||
|
||||
export async function runLocalStoryChoiceContinuation(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
@@ -239,9 +247,7 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
const shouldUseLocalNpcVictory = Boolean(
|
||||
baseChoiceState.currentBattleNpcId &&
|
||||
resolvedChoice.optionKind === 'battle' &&
|
||||
(projectedState.currentNpcBattleOutcome ||
|
||||
(baseChoiceState.currentNpcBattleMode === 'fight' &&
|
||||
!projectedState.inBattle)),
|
||||
isLocalNpcBattleVictoryOutcome(projectedState.currentNpcBattleOutcome),
|
||||
);
|
||||
const projectedBattleReward = shouldUseLocalNpcVictory
|
||||
? null
|
||||
@@ -447,7 +453,11 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
|
||||
if (
|
||||
resolvedChoice.optionKind === 'battle' &&
|
||||
(!nextState.inBattle || nextState.currentNpcBattleOutcome === 'spar_complete')
|
||||
(
|
||||
nextState.currentNpcBattleOutcome === 'fight_victory' ||
|
||||
nextState.currentNpcBattleOutcome === 'spar_complete' ||
|
||||
(!baseChoiceState.currentBattleNpcId && !nextState.inBattle)
|
||||
)
|
||||
) {
|
||||
const postBattleState = buildPostBattleVictoryState(nextState);
|
||||
const postBattle = buildPostBattleVictoryStory(
|
||||
|
||||
@@ -452,6 +452,104 @@ describe('storyChoiceRuntime', () => {
|
||||
expect(setGameState).toHaveBeenLastCalledWith(finalState);
|
||||
});
|
||||
|
||||
it('routes server defeat outcomes into death and revive flow instead of victory settlement', async () => {
|
||||
const gameState = createState({
|
||||
worldType: 'WUXIA',
|
||||
inBattle: true,
|
||||
playerHp: 6,
|
||||
playerMaxHp: 30,
|
||||
playerMana: 10,
|
||||
playerMaxMana: 10,
|
||||
currentScenePreset: {
|
||||
id: 'wuxia-bamboo-road',
|
||||
name: '竹林古道',
|
||||
description: '风穿竹影,路面狭长。',
|
||||
imageSrc: '/scene-a.png',
|
||||
connectedSceneIds: [],
|
||||
connections: [],
|
||||
forwardSceneId: null,
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'wolf',
|
||||
name: '山狼',
|
||||
action: '逼近',
|
||||
description: '山狼',
|
||||
animation: 'idle',
|
||||
xMeters: 3,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1,
|
||||
speed: 1,
|
||||
hp: 4,
|
||||
maxHp: 18,
|
||||
},
|
||||
],
|
||||
});
|
||||
const finalState = createState({
|
||||
...gameState,
|
||||
inBattle: false,
|
||||
playerHp: 0,
|
||||
currentEncounter: null,
|
||||
sceneHostileNpcs: [],
|
||||
currentNpcBattleOutcome: 'fight_defeat',
|
||||
});
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
response: {
|
||||
presentation: {
|
||||
battle: {
|
||||
targetId: 'wolf',
|
||||
damageDealt: 22,
|
||||
damageTaken: 8,
|
||||
outcome: 'defeat',
|
||||
},
|
||||
resultText: '你在山狼的反扑下倒地。',
|
||||
},
|
||||
},
|
||||
hydratedSnapshot: {
|
||||
gameState: finalState,
|
||||
},
|
||||
nextStory: createStory('不会进入胜利文本'),
|
||||
});
|
||||
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory: createStory('当前故事'),
|
||||
option: createOption('battle_all_in_crush'),
|
||||
character: createCharacter(),
|
||||
setBattleReward: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setGameState,
|
||||
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
||||
buildFallbackStoryForState: () => createStory('fallback'),
|
||||
turnVisualMs: 1,
|
||||
});
|
||||
|
||||
expect(setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
playerHp: 0,
|
||||
animationState: 'die',
|
||||
inBattle: false,
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('重新醒来'),
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '不会进入胜利文本',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('commits server-returned next-scene state after idle_travel_next_scene resolution', async () => {
|
||||
const gameState = createState({
|
||||
currentScenePreset: {
|
||||
|
||||
@@ -458,7 +458,7 @@ async function playServerBattlePresentation(params: {
|
||||
const targetDefeated =
|
||||
battle.outcome === 'victory' ||
|
||||
battle.outcome === 'spar_complete' ||
|
||||
(!finalTarget && (battle.damageDealt ?? 0) > 0);
|
||||
(battle.outcome !== 'defeat' && !finalTarget && (battle.damageDealt ?? 0) > 0);
|
||||
params.setGameState({
|
||||
...actingState,
|
||||
playerHp: params.finalState.playerHp,
|
||||
|
||||
@@ -424,6 +424,12 @@ export function createStoryNpcEncounterActions({
|
||||
if (!npcState) return null;
|
||||
const activeBattleHostiles = state.sceneHostileNpcs;
|
||||
|
||||
// 中文注释:只有正式胜利或切磋完成才允许进入 NPC 战后收束;
|
||||
// 若当前是 fight_defeat,则必须交回死亡复活链,不能继续发奖励或推进剧情幕。
|
||||
if (battleMode === 'fight' && battleOutcome !== 'fight_victory') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (battleMode === 'spar' && battleOutcome === 'spar_complete') {
|
||||
const nextAffinity = npcState.affinity + NPC_SPAR_AFFINITY_GAIN;
|
||||
const restoredEncounter = state.sparReturnEncounter;
|
||||
|
||||
@@ -91,7 +91,10 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||||
options?: CustomWorldRuntimeLaunchOptions,
|
||||
) => {
|
||||
storyFlow.resetStoryState();
|
||||
selectCustomWorld(customWorldProfile, { mode: options?.mode });
|
||||
selectCustomWorld(customWorldProfile, {
|
||||
mode: options?.mode,
|
||||
disablePersistence: options?.disablePersistence,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCharacterSelect = (
|
||||
|
||||
@@ -493,11 +493,12 @@ export function useRpgSessionBootstrap() {
|
||||
|
||||
const handleCustomWorldSelect = (
|
||||
customWorldProfile: CustomWorldProfile,
|
||||
options?: { mode?: GameRuntimeMode },
|
||||
options?: { mode?: GameRuntimeMode; disablePersistence?: boolean },
|
||||
) => {
|
||||
const resolvedWorldType = WorldType.CUSTOM;
|
||||
const runtimeMode: GameRuntimeMode =
|
||||
options?.mode === 'play' ? 'play' : 'test';
|
||||
const runtimeMode: GameRuntimeMode = 'play';
|
||||
const runtimePersistenceDisabled =
|
||||
options?.disablePersistence ?? false;
|
||||
setRuntimeCustomWorldProfile(customWorldProfile);
|
||||
setRuntimeCharacterOverrides(
|
||||
buildCustomWorldRuntimeCharacters(customWorldProfile),
|
||||
@@ -510,7 +511,7 @@ export function useRpgSessionBootstrap() {
|
||||
worldType: resolvedWorldType,
|
||||
customWorldProfile,
|
||||
runtimeMode,
|
||||
runtimePersistenceDisabled: runtimeMode !== 'play',
|
||||
runtimePersistenceDisabled,
|
||||
currentScenePreset: initialScenePreset,
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: null,
|
||||
@@ -600,13 +601,11 @@ export function useRpgSessionBootstrap() {
|
||||
playerCharacter: character,
|
||||
runtimeMode:
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? prev.runtimeMode === 'play'
|
||||
? 'play'
|
||||
: 'test'
|
||||
? (prev.runtimeMode ?? 'play')
|
||||
: (prev.runtimeMode ?? 'play'),
|
||||
runtimePersistenceDisabled:
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? prev.runtimeMode !== 'play'
|
||||
? prev.runtimePersistenceDisabled === true
|
||||
: prev.runtimePersistenceDisabled,
|
||||
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
|
||||
playerProgression: createInitialPlayerProgressionState(),
|
||||
|
||||
@@ -466,7 +466,7 @@ function GameFlowHarness({
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCustomWorldSelect(profile)}
|
||||
onClick={() => handleCustomWorldSelect(profile, { mode: 'play' })}
|
||||
>
|
||||
选择世界
|
||||
</button>
|
||||
@@ -528,8 +528,8 @@ test('saved custom world result settings flow into game state after entering the
|
||||
});
|
||||
|
||||
expect(readSnapshot().playerCharacterName).toBe('沈砺');
|
||||
expect(readSnapshot().runtimeMode).toBe('test');
|
||||
expect(readSnapshot().runtimePersistenceDisabled).toBe(true);
|
||||
expect(readSnapshot().runtimeMode).toBe('play');
|
||||
expect(readSnapshot().runtimePersistenceDisabled).toBe(false);
|
||||
expect(readSnapshot().playerInventoryNames).toContain('旧潮短刃');
|
||||
expect(readSnapshot().playerInventoryNames).toContain('旧潮图残页');
|
||||
expect(readSnapshot().playerEquipment.weapon).toBe('旧潮短刃');
|
||||
|
||||
Reference in New Issue
Block a user