1
This commit is contained in:
@@ -6,19 +6,36 @@ vi.mock('../../services/aiService', () => ({
|
||||
|
||||
const {
|
||||
isRpgRuntimeServerFunctionIdMock,
|
||||
runServerRuntimeChoiceActionMock,
|
||||
} = vi.hoisted(() => ({
|
||||
isRpgRuntimeServerFunctionIdMock: vi.fn(() => false),
|
||||
runServerRuntimeChoiceActionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/rpg-runtime', () => ({
|
||||
isRpgRuntimeServerFunctionId: isRpgRuntimeServerFunctionIdMock,
|
||||
}));
|
||||
|
||||
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';
|
||||
|
||||
vi.mock('./storyChoiceRuntime', async () => {
|
||||
return {
|
||||
runCampTravelHomeChoice: vi.fn(),
|
||||
runServerRuntimeChoiceAction: runServerRuntimeChoiceActionMock,
|
||||
shouldOpenLocalRuntimeNpcModal: (option: StoryOption) =>
|
||||
(
|
||||
option.interaction?.kind === 'npc' ||
|
||||
!option.interaction
|
||||
) &&
|
||||
(
|
||||
option.functionId === 'npc_chat' ||
|
||||
option.functionId === 'npc_trade' ||
|
||||
option.functionId === 'npc_gift'
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
function createTestCharacter(): Character {
|
||||
return {
|
||||
id: 'test-hero',
|
||||
@@ -150,6 +167,7 @@ describe('createStoryChoiceActions', () => {
|
||||
beforeEach(() => {
|
||||
isRpgRuntimeServerFunctionIdMock.mockReset();
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(false);
|
||||
runServerRuntimeChoiceActionMock.mockReset();
|
||||
});
|
||||
|
||||
it('reveals deferred adventure options when story_continue_adventure is selected', async () => {
|
||||
@@ -290,19 +308,13 @@ describe('createStoryChoiceActions', () => {
|
||||
options: [continueOption],
|
||||
deferredOptions,
|
||||
deferredRuntimeState: {
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: [],
|
||||
activeThreadIds: [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
currentSceneActState: {
|
||||
sceneId: 'scene-bridge',
|
||||
chapterId: 'scene-bridge-chapter',
|
||||
currentActId: 'scene-bridge-act-2',
|
||||
currentActIndex: 1,
|
||||
completedActIds: ['scene-bridge-act-1'],
|
||||
visitedActIds: ['scene-bridge-act-1', 'scene-bridge-act-2'],
|
||||
},
|
||||
currentScenePreset: {
|
||||
id: 'scene-bridge',
|
||||
name: '断桥',
|
||||
description: '桥上雾气很重。',
|
||||
imageSrc: '/scene-bridge.png',
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -355,13 +367,14 @@ describe('createStoryChoiceActions', () => {
|
||||
|
||||
expect(setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
storyEngineMemory: expect.objectContaining({
|
||||
currentSceneActState: expect.objectContaining({
|
||||
currentActId: 'scene-bridge-act-2',
|
||||
}),
|
||||
currentScenePreset: expect.objectContaining({
|
||||
id: 'scene-bridge',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(setGameState.mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
'storyEngineMemory',
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith({
|
||||
...currentStory,
|
||||
options: deferredOptions,
|
||||
@@ -527,360 +540,7 @@ describe('createStoryChoiceActions', () => {
|
||||
expect(handleNpcInteraction).toHaveBeenCalledWith(option);
|
||||
});
|
||||
|
||||
it('uses deterministic continue option after local npc victory', async () => {
|
||||
const encounter: Encounter = {
|
||||
id: 'npc-opponent',
|
||||
kind: 'npc',
|
||||
npcName: '山道客',
|
||||
npcDescription: '拦路旧敌',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道旧案',
|
||||
};
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
currentEncounter: encounter,
|
||||
npcInteractionActive: true,
|
||||
};
|
||||
const option = createBattleOption();
|
||||
const afterSequence = {
|
||||
...state,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
currentNpcBattleOutcome: 'fight_victory' as const,
|
||||
};
|
||||
const generateStoryForState = vi.fn().mockResolvedValue(createFallbackStory('战后续写'));
|
||||
const setCurrentStory = vi.fn();
|
||||
const setGameState = vi.fn();
|
||||
const handleNpcBattleConversationContinuation = vi.fn(() => true);
|
||||
|
||||
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: 100,
|
||||
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,
|
||||
getAvailableOptionsForState: vi.fn(() => null),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation,
|
||||
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: vi.fn(() => ({
|
||||
nextState: {
|
||||
...afterSequence,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
inBattle: false,
|
||||
},
|
||||
resultText: '山道客已经败下阵来。胜利奖励:无战利品。',
|
||||
})),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(handleNpcBattleConversationContinuation).not.toHaveBeenCalled();
|
||||
expect(setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
inBattle: false,
|
||||
}),
|
||||
);
|
||||
expect(generateStoryForState).not.toHaveBeenCalled();
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '山道客已经败下阵来。胜利奖励:无战利品。',
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
functionId: 'story_continue_adventure',
|
||||
actionText: '继续前进',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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 state = {
|
||||
...createBaseState(),
|
||||
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('fallback')),
|
||||
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,
|
||||
playerMana: 20,
|
||||
inBattle: false,
|
||||
currentNpcBattleOutcome: null,
|
||||
}),
|
||||
);
|
||||
const revivedState = setGameState.mock.calls[1]?.[0] as GameState;
|
||||
expect(revivedState.currentBattleNpcId).toBeNull();
|
||||
expect(revivedState.currentNpcBattleMode).toBeNull();
|
||||
expect(revivedState.currentNpcBattleOutcome).toBeNull();
|
||||
expect(
|
||||
revivedState.currentEncounter !== null || revivedState.sceneHostileNpcs.length > 0,
|
||||
).toBe(true);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('重新醒来'),
|
||||
}),
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('settles escape locally without ai continuation', async () => {
|
||||
const mockedGenerateNextStep = vi.mocked(generateNextStep);
|
||||
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'wolf-1',
|
||||
name: '山狼',
|
||||
action: '低伏逼近',
|
||||
description: '一头山狼',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1.4,
|
||||
speed: 7,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
renderKind: 'npc' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
const option = createBattleOption('battle_escape_breakout');
|
||||
const afterSequence = {
|
||||
...state,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: -1.2,
|
||||
};
|
||||
const setBattleReward = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
const incrementRuntimeStats = vi.fn((inputState: GameState) => inputState);
|
||||
const buildStoryContextFromState = vi.fn(() => ({
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: -1.2,
|
||||
playerFacing: 'right' as const,
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
}));
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory: createFallbackStory(),
|
||||
isLoading: false,
|
||||
setGameState: vi.fn(),
|
||||
setCurrentStory,
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward,
|
||||
buildResolvedChoiceState: vi.fn(() => ({
|
||||
optionKind: 'escape' as const,
|
||||
battlePlan: null,
|
||||
afterSequence,
|
||||
})),
|
||||
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
|
||||
buildStoryContextFromState,
|
||||
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,
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(() => false),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(mockedGenerateNextStep).not.toHaveBeenCalled();
|
||||
expect(buildStoryContextFromState).not.toHaveBeenCalled();
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
|
||||
}),
|
||||
);
|
||||
expect(setBattleReward).toHaveBeenCalledTimes(1);
|
||||
expect(setBattleReward).toHaveBeenCalledWith(null);
|
||||
expect(incrementRuntimeStats).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ hostileNpcsDefeated: 0 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps battle attack and skill choices on the local combat path even if runtime server supports them', async () => {
|
||||
it('routes battle attack and skill choices to the backend resolver even while in battle', async () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
sceneHostileNpcs: [
|
||||
@@ -969,17 +629,20 @@ describe('createStoryChoiceActions', () => {
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(buildResolvedChoiceState).toHaveBeenCalledWith(
|
||||
state,
|
||||
option,
|
||||
state.playerCharacter!,
|
||||
expect(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gameState: state,
|
||||
option,
|
||||
character: state.playerCharacter,
|
||||
}),
|
||||
);
|
||||
expect(playResolvedChoice).toHaveBeenCalled();
|
||||
expect(setGameState).toHaveBeenCalled();
|
||||
expect(setCurrentStory).toHaveBeenCalled();
|
||||
expect(buildResolvedChoiceState).not.toHaveBeenCalled();
|
||||
expect(playResolvedChoice).not.toHaveBeenCalled();
|
||||
expect(setGameState).not.toHaveBeenCalled();
|
||||
expect(setCurrentStory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps stale battle panel choices on the local combat path when combat presentation is still visible', async () => {
|
||||
it('routes stale battle panel choices to the backend resolver when combat presentation is still visible', async () => {
|
||||
const battleOption = createBattleOption('battle_attack_basic');
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
@@ -1072,11 +735,80 @@ describe('createStoryChoiceActions', () => {
|
||||
|
||||
await handleChoice(battleOption);
|
||||
|
||||
expect(buildResolvedChoiceState).toHaveBeenCalledWith(
|
||||
state,
|
||||
battleOption,
|
||||
state.playerCharacter!,
|
||||
expect(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gameState: state,
|
||||
currentStory,
|
||||
option: battleOption,
|
||||
character: state.playerCharacter,
|
||||
}),
|
||||
);
|
||||
expect(playResolvedChoice).toHaveBeenCalled();
|
||||
expect(buildResolvedChoiceState).not.toHaveBeenCalled();
|
||||
expect(playResolvedChoice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes inventory_use combat choices to the backend resolver', async () => {
|
||||
const state = createBaseState();
|
||||
const option: StoryOption = {
|
||||
...createBattleOption('inventory_use'),
|
||||
runtimePayload: {
|
||||
itemId: 'focus-tonic',
|
||||
},
|
||||
};
|
||||
const buildResolvedChoiceState = vi.fn();
|
||||
const playResolvedChoice = vi.fn();
|
||||
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory: createFallbackStory(),
|
||||
isLoading: false,
|
||||
setGameState: vi.fn(),
|
||||
setCurrentStory: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => [option]),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => state.sceneHostileNpcs),
|
||||
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: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gameState: state,
|
||||
option,
|
||||
character: state.playerCharacter,
|
||||
}),
|
||||
);
|
||||
expect(buildResolvedChoiceState).not.toHaveBeenCalled();
|
||||
expect(playResolvedChoice).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,39 +77,6 @@ type IncrementRuntimeStats = (
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
function isImmediateCombatChoice(option: StoryOption) {
|
||||
return (
|
||||
option.functionId.startsWith('battle_') ||
|
||||
option.functionId === 'inventory_use'
|
||||
);
|
||||
}
|
||||
|
||||
function shouldResolveCombatChoiceLocally(
|
||||
gameState: GameState,
|
||||
currentStory: StoryMoment | null,
|
||||
option: StoryOption,
|
||||
) {
|
||||
if (!isImmediateCombatChoice(option)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (gameState.inBattle) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasBattleMarkers =
|
||||
Boolean(gameState.currentBattleNpcId || gameState.currentNpcBattleMode) ||
|
||||
gameState.sceneHostileNpcs.some((hostileNpc) => hostileNpc.hp > 0);
|
||||
const storyStillShowsBattleChoices = Boolean(
|
||||
currentStory?.options.some(isImmediateCombatChoice),
|
||||
);
|
||||
|
||||
// 中文注释:真实运行态里可能短暂出现“可见层仍在战斗,但逻辑态 inBattle
|
||||
// 已经被提前切回 false”的窗口。如果这时玩家点击了还在面板上的 battle_* /
|
||||
// inventory_use 选项,必须继续走本地逐帧战斗链,不能误分流到服务端直结算。
|
||||
return hasBattleMarkers || storyStillShowsBattleChoices;
|
||||
}
|
||||
|
||||
export function createStoryChoiceActions({
|
||||
gameState,
|
||||
currentStory,
|
||||
@@ -213,9 +180,6 @@ export function createStoryChoiceActions({
|
||||
currentScenePreset:
|
||||
currentStory.deferredRuntimeState.currentScenePreset ??
|
||||
gameState.currentScenePreset,
|
||||
storyEngineMemory:
|
||||
currentStory.deferredRuntimeState.storyEngineMemory ??
|
||||
gameState.storyEngineMemory,
|
||||
});
|
||||
}
|
||||
setCurrentStory({
|
||||
@@ -252,10 +216,7 @@ export function createStoryChoiceActions({
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isRpgRuntimeServerFunctionId(option.functionId) &&
|
||||
!shouldResolveCombatChoiceLocally(gameState, currentStory, option)
|
||||
) {
|
||||
if (isRpgRuntimeServerFunctionId(option.functionId)) {
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory,
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { useMemo, type Dispatch, type SetStateAction } from 'react';
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
|
||||
import type { RuntimeStoryInventoryActionView } from '../../../packages/shared/src/contracts/rpgRuntimeStoryState';
|
||||
import {
|
||||
EQUIPMENT_EQUIP_FUNCTION,
|
||||
EQUIPMENT_UNEQUIP_FUNCTION,
|
||||
FORGE_CRAFT_FUNCTION,
|
||||
FORGE_DISMANTLE_FUNCTION,
|
||||
FORGE_REFORGE_FUNCTION,
|
||||
INVENTORY_USE_FUNCTION,
|
||||
} from '../../data/functionCatalog';
|
||||
import { getForgeRecipeViews } from '../../data/forgeSystem';
|
||||
loadRpgRuntimeInventoryView,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryInventoryView,
|
||||
} from '../../services/rpg-runtime';
|
||||
import type { Character, GameState, StoryMoment } from '../../types';
|
||||
import { resolveRpgRuntimeChoice } from '.';
|
||||
import type { InventoryFlowUi } from './uiTypes';
|
||||
@@ -41,20 +38,71 @@ export function useStoryInventoryActions({
|
||||
setIsLoading,
|
||||
buildFallbackStoryForState,
|
||||
} = runtime;
|
||||
const forgeRecipes = useMemo(
|
||||
() =>
|
||||
getForgeRecipeViews(
|
||||
gameState.playerInventory,
|
||||
gameState.playerCurrency,
|
||||
gameState.worldType,
|
||||
),
|
||||
[gameState.playerCurrency, gameState.playerInventory, gameState.worldType],
|
||||
);
|
||||
const [serverInventoryView, setServerInventoryView] =
|
||||
useState<RuntimeStoryInventoryView | null>(null);
|
||||
const runtimeSessionId = gameState.runtimeSessionId;
|
||||
const runtimeActionVersion = gameState.runtimeActionVersion;
|
||||
const currentScene = gameState.currentScene;
|
||||
const hasPlayerCharacter = Boolean(gameState.playerCharacter);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasPlayerCharacter || currentScene !== 'Story') {
|
||||
setServerInventoryView(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
void loadRpgRuntimeInventoryView(
|
||||
{
|
||||
gameState: {
|
||||
runtimeSessionId,
|
||||
runtimeActionVersion,
|
||||
},
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((view) => {
|
||||
setServerInventoryView(view);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
console.error('Failed to load inventory runtime view:', error);
|
||||
setAiError(error instanceof Error ? error.message : '背包视图同步失败');
|
||||
});
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [
|
||||
currentScene,
|
||||
hasPlayerCharacter,
|
||||
runtimeActionVersion,
|
||||
runtimeSessionId,
|
||||
setAiError,
|
||||
]);
|
||||
|
||||
const rejectInventoryAction = (message: string) => {
|
||||
setAiError(message);
|
||||
return false;
|
||||
};
|
||||
|
||||
const findBackpackItemView = (itemId: string) =>
|
||||
serverInventoryView?.backpackItems.find(
|
||||
(candidate) => candidate.item.id === itemId,
|
||||
) ?? null;
|
||||
|
||||
const findEquipmentSlotView = (slot: 'weapon' | 'armor' | 'relic') =>
|
||||
serverInventoryView?.equipmentSlots.find(
|
||||
(candidate) => candidate.slotId === slot,
|
||||
) ?? null;
|
||||
|
||||
const resolveServerInventoryAction = async (params: {
|
||||
functionId: string;
|
||||
actionText: string;
|
||||
payload: Record<string, unknown>;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
}) => {
|
||||
const character = gameState.playerCharacter;
|
||||
if (
|
||||
@@ -69,7 +117,7 @@ export function useStoryInventoryActions({
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||||
const { response, hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option: {
|
||||
@@ -81,6 +129,7 @@ export function useStoryInventoryActions({
|
||||
|
||||
setGameState(hydratedSnapshot.gameState);
|
||||
setCurrentStory(nextStory);
|
||||
setServerInventoryView(response.viewModel.inventory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve inventory runtime action on the server:', error);
|
||||
@@ -94,100 +143,80 @@ export function useStoryInventoryActions({
|
||||
}
|
||||
};
|
||||
|
||||
const useInventoryItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
const submitInventoryAction = async (
|
||||
action: RuntimeStoryInventoryActionView | undefined,
|
||||
fallbackReason: string,
|
||||
) => {
|
||||
if (!action) {
|
||||
return rejectInventoryAction(fallbackReason);
|
||||
}
|
||||
if (!action.enabled) {
|
||||
return rejectInventoryAction(action.reason ?? fallbackReason);
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: action.functionId,
|
||||
actionText: action.actionText,
|
||||
payload: action.payload as RuntimeStoryChoicePayload | undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const useInventoryItem = async (itemId: string) =>
|
||||
submitInventoryAction(
|
||||
findBackpackItemView(itemId)?.actions.use,
|
||||
'后端背包视图尚未提供该物品的使用动作。',
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: INVENTORY_USE_FUNCTION.id,
|
||||
actionText: `使用${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
const equipInventoryItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
const equipInventoryItem = async (itemId: string) =>
|
||||
submitInventoryAction(
|
||||
findBackpackItemView(itemId)?.actions.equip,
|
||||
'后端背包视图尚未提供该物品的装备动作。',
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: EQUIPMENT_EQUIP_FUNCTION.id,
|
||||
actionText: `装备${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
const unequipItem = async (slot: 'weapon' | 'armor' | 'relic') => {
|
||||
const equippedItem = gameState.playerEquipment[slot];
|
||||
if (!equippedItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: EQUIPMENT_UNEQUIP_FUNCTION.id,
|
||||
actionText: `卸下${equippedItem.name}`,
|
||||
payload: { slotId: slot },
|
||||
});
|
||||
};
|
||||
const unequipItem = async (slot: 'weapon' | 'armor' | 'relic') =>
|
||||
submitInventoryAction(
|
||||
findEquipmentSlotView(slot)?.unequip,
|
||||
'后端装备视图尚未提供该槽位的卸装动作。',
|
||||
);
|
||||
|
||||
const craftRecipe = async (recipeId: string) => {
|
||||
const recipe = forgeRecipes.find(
|
||||
const recipe = serverInventoryView?.forgeRecipes.find(
|
||||
(candidate) => candidate.id === recipeId,
|
||||
);
|
||||
if (!recipe) {
|
||||
return false;
|
||||
return rejectInventoryAction('后端锻造视图尚未提供该配方。');
|
||||
}
|
||||
if (!recipe.canCraft) {
|
||||
return rejectInventoryAction(
|
||||
recipe.disabledReason ?? recipe.action.reason ?? '当前配方不可制作。',
|
||||
);
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: FORGE_CRAFT_FUNCTION.id,
|
||||
actionText: `制作${recipe.resultLabel}`,
|
||||
payload: { recipeId },
|
||||
});
|
||||
return submitInventoryAction(recipe.action, '当前配方不可制作。');
|
||||
};
|
||||
|
||||
const dismantleItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
const dismantleItem = async (itemId: string) =>
|
||||
submitInventoryAction(
|
||||
findBackpackItemView(itemId)?.actions.dismantle,
|
||||
'后端背包视图尚未提供该物品的拆解动作。',
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: FORGE_DISMANTLE_FUNCTION.id,
|
||||
actionText: `拆解${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
const reforgeItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
const reforgeItem = async (itemId: string) =>
|
||||
submitInventoryAction(
|
||||
findBackpackItemView(itemId)?.actions.reforge,
|
||||
'后端背包视图尚未提供该物品的重铸动作。',
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: FORGE_REFORGE_FUNCTION.id,
|
||||
actionText: `重铸${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
inventoryUi: {
|
||||
useInventoryItem,
|
||||
equipInventoryItem,
|
||||
unequipItem,
|
||||
forgeRecipes,
|
||||
playerCurrency: serverInventoryView?.playerCurrency ?? null,
|
||||
currencyText: serverInventoryView?.currencyText ?? null,
|
||||
backpackItems: serverInventoryView?.backpackItems ?? [],
|
||||
equipmentSlots: serverInventoryView?.equipmentSlots ?? [],
|
||||
forgeRecipes: serverInventoryView?.forgeRecipes ?? [],
|
||||
craftRecipe,
|
||||
dismantleItem,
|
||||
reforgeItem,
|
||||
|
||||
323
src/hooks/rpg-runtime-story/npcInteraction.test.tsx
Normal file
323
src/hooks/rpg-runtime-story/npcInteraction.test.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { resolveRpgRuntimeChoiceMock } = vi.hoisted(() => ({
|
||||
resolveRpgRuntimeChoiceMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('.', () => ({
|
||||
resolveRpgRuntimeChoice: resolveRpgRuntimeChoiceMock,
|
||||
}));
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type { Character, Encounter, GameState, StoryMoment } from '../../types';
|
||||
import { AnimationState, WorldType } from '../../types';
|
||||
import { useStoryNpcInteractionFlow } from './npcInteraction';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试角色',
|
||||
personality: '谨慎',
|
||||
skills: [],
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createEncounter(): Encounter {
|
||||
return {
|
||||
id: 'npc-merchant',
|
||||
kind: 'npc',
|
||||
npcName: '梁伯',
|
||||
npcDescription: '守着小摊的老人',
|
||||
npcAvatar: '',
|
||||
context: '行商',
|
||||
};
|
||||
}
|
||||
|
||||
function createGameState(overrides: Partial<GameState> = {}): GameState {
|
||||
const encounter = createEncounter();
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 0,
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: encounter,
|
||||
npcInteractionActive: true,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 40,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 16,
|
||||
playerMaxMana: 16,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
runtimeNpcInteraction: {
|
||||
npcId: 'npc-merchant',
|
||||
npcName: '梁伯',
|
||||
playerCurrency: 0,
|
||||
currencyName: '铜钱',
|
||||
trade: {
|
||||
buyItems: [
|
||||
{
|
||||
itemId: 'merchant-tonic',
|
||||
item: {
|
||||
id: 'merchant-tonic',
|
||||
category: '消耗品',
|
||||
name: '回气散',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
tags: ['mana'],
|
||||
},
|
||||
mode: 'buy',
|
||||
unitPrice: 29,
|
||||
maxQuantity: 2,
|
||||
canSubmit: false,
|
||||
reason: '当前钱币不足。',
|
||||
},
|
||||
],
|
||||
sellItems: [
|
||||
{
|
||||
itemId: 'player-ingot',
|
||||
item: {
|
||||
id: 'player-ingot',
|
||||
category: '材料',
|
||||
name: '精炼锭材',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: ['material'],
|
||||
},
|
||||
mode: 'sell',
|
||||
unitPrice: 23,
|
||||
maxQuantity: 1,
|
||||
canSubmit: true,
|
||||
reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
gift: {
|
||||
items: [
|
||||
{
|
||||
itemId: 'gift-herb',
|
||||
item: {
|
||||
id: 'gift-herb',
|
||||
category: '材料',
|
||||
name: '暖息草',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: ['material', 'mana'],
|
||||
},
|
||||
affinityGain: 16,
|
||||
canSubmit: true,
|
||||
reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
npcStates: {
|
||||
'npc-merchant': {
|
||||
affinity: 0,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createRuntime(gameState: GameState) {
|
||||
return {
|
||||
currentStory: null,
|
||||
setCurrentStory: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
buildStoryContextFromState: vi.fn(
|
||||
() => ({}) as unknown as StoryGenerationContext,
|
||||
),
|
||||
buildFallbackStoryForState: vi.fn(
|
||||
() =>
|
||||
({
|
||||
text: 'fallback',
|
||||
options: [],
|
||||
}) satisfies StoryMoment,
|
||||
),
|
||||
buildDialogueStoryMoment: vi.fn(
|
||||
(npcName: string, text: string) =>
|
||||
({
|
||||
text,
|
||||
options: [],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: text
|
||||
? [{ speaker: 'npc' as const, speakerName: npcName, text }]
|
||||
: [],
|
||||
}) satisfies StoryMoment,
|
||||
),
|
||||
generateStoryForState: vi.fn(
|
||||
async () =>
|
||||
({
|
||||
text: 'next',
|
||||
options: [],
|
||||
}) satisfies StoryMoment,
|
||||
),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => gameState.sceneHostileNpcs),
|
||||
getTypewriterDelay: vi.fn(() => 0),
|
||||
};
|
||||
}
|
||||
|
||||
function Harness({
|
||||
action,
|
||||
initialState,
|
||||
}: {
|
||||
action: 'buy' | 'gift';
|
||||
initialState: GameState;
|
||||
}) {
|
||||
const [gameState, setGameState] = useState(initialState);
|
||||
const openedRef = useRef(false);
|
||||
const confirmedRef = useRef(false);
|
||||
const runtime = createRuntime(gameState);
|
||||
const flow = useStoryNpcInteractionFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
getNpcEncounterKey: encounter => encounter.id ?? encounter.npcName,
|
||||
getResolvedNpcState: (state, encounter) =>
|
||||
state.npcStates[encounter.id ?? encounter.npcName]!,
|
||||
updateNpcState: (state) => state,
|
||||
cloneInventoryItemForOwner: (item) => item,
|
||||
runtime,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (openedRef.current) {
|
||||
return;
|
||||
}
|
||||
openedRef.current = true;
|
||||
const encounter = initialState.currentEncounter as Encounter;
|
||||
if (action === 'buy') {
|
||||
flow.openTradeModal(encounter, '交易');
|
||||
return;
|
||||
}
|
||||
flow.openGiftModal(encounter, '赠送礼物');
|
||||
}, [action, flow, initialState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (confirmedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (action === 'buy' && flow.npcUi.tradeModal) {
|
||||
confirmedRef.current = true;
|
||||
flow.npcUi.confirmTrade();
|
||||
return;
|
||||
}
|
||||
if (action === 'gift' && flow.npcUi.giftModal) {
|
||||
confirmedRef.current = true;
|
||||
flow.npcUi.confirmGift();
|
||||
}
|
||||
}, [action, flow.npcUi]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('useStoryNpcInteractionFlow', () => {
|
||||
beforeEach(() => {
|
||||
resolveRpgRuntimeChoiceMock.mockReset();
|
||||
resolveRpgRuntimeChoiceMock.mockResolvedValue({
|
||||
hydratedSnapshot: {
|
||||
gameState: createGameState({
|
||||
playerCurrency: 12,
|
||||
}),
|
||||
},
|
||||
nextStory: {
|
||||
text: 'server resolved',
|
||||
options: [],
|
||||
} satisfies StoryMoment,
|
||||
});
|
||||
});
|
||||
|
||||
it('submits npc trade to the server even when the server view marks local currency insufficient', async () => {
|
||||
render(<Harness action="buy" initialState={createGameState()} />);
|
||||
|
||||
await waitFor(() => expect(resolveRpgRuntimeChoiceMock).toHaveBeenCalled());
|
||||
expect(resolveRpgRuntimeChoiceMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
option: expect.objectContaining({
|
||||
functionId: 'npc_trade',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-merchant',
|
||||
action: 'trade',
|
||||
},
|
||||
}),
|
||||
payload: {
|
||||
mode: 'buy',
|
||||
itemId: 'merchant-tonic',
|
||||
quantity: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('submits npc gift from the server gift view without checking local inventory first', async () => {
|
||||
render(<Harness action="gift" initialState={createGameState()} />);
|
||||
|
||||
await waitFor(() => expect(resolveRpgRuntimeChoiceMock).toHaveBeenCalled());
|
||||
expect(resolveRpgRuntimeChoiceMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
option: expect.objectContaining({
|
||||
functionId: 'npc_gift',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-merchant',
|
||||
action: 'gift',
|
||||
},
|
||||
}),
|
||||
payload: {
|
||||
itemId: 'gift-herb',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,34 +7,22 @@ import { useState } from 'react';
|
||||
import {
|
||||
getCharacterById,
|
||||
} from '../../data/characterPresets';
|
||||
import {
|
||||
getNpcBuybackPrice,
|
||||
getNpcPurchasePrice,
|
||||
} from '../../data/economy';
|
||||
import {
|
||||
buildNpcGiftModalState,
|
||||
buildNpcRecruitModalState,
|
||||
buildNpcTradeModalState,
|
||||
buildNpcTradeModalIntroText,
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
addInventoryItems,
|
||||
buildNpcGiftCommitActionText,
|
||||
buildNpcGiftResultText,
|
||||
buildNpcTradeTransactionActionText,
|
||||
buildNpcTradeTransactionResultText,
|
||||
getGiftCandidates,
|
||||
getPreferredGiftItemId,
|
||||
removeInventoryItem,
|
||||
syncNpcTradeInventory,
|
||||
} from '../../data/npcInteractions';
|
||||
import { streamNpcChatDialogue, streamNpcRecruitDialogue } from '../../services/aiService';
|
||||
import { streamNpcRecruitDialogue } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
RuntimeNpcTradeItemView,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
@@ -154,13 +142,17 @@ function normalizeRecruitDialogue(
|
||||
return compactLines.slice(0, 6).join('\n');
|
||||
}
|
||||
|
||||
function normalizeTradeQuantity(quantity: number) {
|
||||
return Math.max(1, Math.floor(Number.isFinite(quantity) ? quantity : 1));
|
||||
}
|
||||
|
||||
export function useStoryNpcInteractionFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
getNpcEncounterKey,
|
||||
getResolvedNpcState,
|
||||
updateNpcState,
|
||||
cloneInventoryItemForOwner,
|
||||
getResolvedNpcState: _getResolvedNpcState,
|
||||
updateNpcState: _updateNpcState,
|
||||
cloneInventoryItemForOwner: _cloneInventoryItemForOwner,
|
||||
runtime,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
@@ -183,184 +175,6 @@ export function useStoryNpcInteractionFlow({
|
||||
const [giftModal, setGiftModal] = useState<GiftModalState | null>(null);
|
||||
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(null);
|
||||
|
||||
const getTradeNpcItem = (state: GameState, modal: TradeModalState) => {
|
||||
const npcState = getResolvedNpcState(state, modal.encounter);
|
||||
return npcState.inventory.find(item => item.id === modal.selectedNpcItemId) ?? null;
|
||||
};
|
||||
|
||||
const getTradePlayerItem = (state: GameState, modal: TradeModalState) =>
|
||||
state.playerInventory.find(item => item.id === modal.selectedPlayerItemId) ?? null;
|
||||
|
||||
const getTradeUnitPrice = (state: GameState, modal: TradeModalState) => {
|
||||
if (modal.mode === 'buy') {
|
||||
const npcItem = getTradeNpcItem(state, modal);
|
||||
const npcState = getResolvedNpcState(state, modal.encounter);
|
||||
return npcItem ? getNpcPurchasePrice(npcItem, npcState.affinity) : 0;
|
||||
}
|
||||
|
||||
const playerItem = getTradePlayerItem(state, modal);
|
||||
const npcState = getResolvedNpcState(state, modal.encounter);
|
||||
return playerItem ? getNpcBuybackPrice(playerItem, npcState.affinity) : 0;
|
||||
};
|
||||
|
||||
const getTradeMaxQuantity = (state: GameState, modal: TradeModalState) => {
|
||||
if (modal.mode === 'buy') {
|
||||
return getTradeNpcItem(state, modal)?.quantity ?? 0;
|
||||
}
|
||||
|
||||
return getTradePlayerItem(state, modal)?.quantity ?? 0;
|
||||
};
|
||||
|
||||
const clampTradeQuantity = (state: GameState, modal: TradeModalState, quantity: number) => {
|
||||
const maxQuantity = getTradeMaxQuantity(state, modal);
|
||||
if (maxQuantity <= 0) return 1;
|
||||
return Math.max(1, Math.min(maxQuantity, Math.floor(quantity)));
|
||||
};
|
||||
|
||||
const commitNpcReactionAndGenerate = async ({
|
||||
nextState,
|
||||
encounter,
|
||||
actionText,
|
||||
resultText,
|
||||
lastFunctionId,
|
||||
contextNpcStateOverride,
|
||||
}: {
|
||||
nextState: GameState;
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
lastFunctionId: string;
|
||||
contextNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
}) => {
|
||||
if (!gameState.playerCharacter || !gameState.worldType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const provisionalHistory = [
|
||||
...gameState.storyHistory,
|
||||
createHistoryMoment(actionText, 'action'),
|
||||
createHistoryMoment(resultText, 'result'),
|
||||
];
|
||||
const provisionalState = {
|
||||
...nextState,
|
||||
storyHistory: provisionalHistory,
|
||||
};
|
||||
|
||||
setGameState(provisionalState);
|
||||
runtime.setAiError(null);
|
||||
runtime.setIsLoading(true);
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true),
|
||||
);
|
||||
|
||||
let dialogueText = '';
|
||||
let streamedTargetText = '';
|
||||
let displayedText = '';
|
||||
let streamCompleted = false;
|
||||
|
||||
const typewriterPromise = (async () => {
|
||||
while (!streamCompleted || displayedText.length < streamedTargetText.length) {
|
||||
if (displayedText.length >= streamedTargetText.length) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 40));
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextChar = streamedTargetText[displayedText.length];
|
||||
if (!nextChar) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 40));
|
||||
continue;
|
||||
}
|
||||
|
||||
displayedText += nextChar;
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
displayedText,
|
||||
[],
|
||||
true,
|
||||
),
|
||||
);
|
||||
await new Promise(resolve =>
|
||||
window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar)),
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
dialogueText = await streamNpcChatDialogue(
|
||||
gameState.worldType,
|
||||
gameState.playerCharacter,
|
||||
encounter,
|
||||
runtime.getStoryGenerationHostileNpcs(provisionalState),
|
||||
provisionalHistory,
|
||||
runtime.buildStoryContextFromState(provisionalState, {
|
||||
lastFunctionId,
|
||||
encounterNpcStateOverride: contextNpcStateOverride,
|
||||
}),
|
||||
actionText,
|
||||
resultText,
|
||||
{
|
||||
onUpdate: text => {
|
||||
streamedTargetText = text;
|
||||
},
|
||||
},
|
||||
);
|
||||
streamedTargetText = dialogueText;
|
||||
streamCompleted = true;
|
||||
await typewriterPromise;
|
||||
|
||||
const finalDialogueText = dialogueText.trim() || displayedText.trim();
|
||||
const finalHistory = finalDialogueText
|
||||
? [...provisionalHistory, createHistoryMoment(finalDialogueText, 'result')]
|
||||
: provisionalHistory;
|
||||
const finalState = {
|
||||
...nextState,
|
||||
storyHistory: finalHistory,
|
||||
};
|
||||
|
||||
setGameState(finalState);
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
finalDialogueText || resultText,
|
||||
[],
|
||||
false,
|
||||
),
|
||||
);
|
||||
await new Promise(resolve => window.setTimeout(resolve, 260));
|
||||
|
||||
const nextStory = await runtime.generateStoryForState({
|
||||
state: finalState,
|
||||
character: gameState.playerCharacter,
|
||||
history: finalHistory,
|
||||
choice: actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
runtime.setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
streamCompleted = true;
|
||||
await typewriterPromise;
|
||||
console.error('Failed to continue npc interaction reaction:', error);
|
||||
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
|
||||
const fallbackHistory = provisionalHistory;
|
||||
const fallbackState = {
|
||||
...nextState,
|
||||
storyHistory: fallbackHistory,
|
||||
};
|
||||
setGameState(fallbackState);
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildFallbackStoryForState(
|
||||
fallbackState,
|
||||
gameState.playerCharacter,
|
||||
resultText,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
runtime.setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveRecruitmentOnServer = async (params: {
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
@@ -516,45 +330,68 @@ export function useStoryNpcInteractionFlow({
|
||||
});
|
||||
};
|
||||
|
||||
const openTradeModal = (encounter: Encounter, actionText: string) => {
|
||||
const currentNpcState = getResolvedNpcState(gameState, encounter);
|
||||
const npcState = syncNpcTradeInventory(
|
||||
gameState,
|
||||
encounter,
|
||||
currentNpcState,
|
||||
const getRuntimeTradeItems = (
|
||||
mode: 'buy' | 'sell',
|
||||
): RuntimeNpcTradeItemView[] =>
|
||||
mode === 'buy'
|
||||
? gameState.runtimeNpcInteraction?.trade.buyItems ?? []
|
||||
: gameState.runtimeNpcInteraction?.trade.sellItems ?? [];
|
||||
|
||||
const findRuntimeTradeItem = (modal: TradeModalState) => {
|
||||
const itemId =
|
||||
modal.mode === 'buy'
|
||||
? modal.selectedNpcItemId
|
||||
: modal.selectedPlayerItemId;
|
||||
if (!itemId) return null;
|
||||
|
||||
return (
|
||||
getRuntimeTradeItems(modal.mode).find((item) => item.itemId === itemId) ??
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
if (
|
||||
gameState.npcStates[getNpcEncounterKey(encounter)] !== npcState
|
||||
|| npcState !== currentNpcState
|
||||
) {
|
||||
setGameState(updateNpcState(gameState, encounter, () => npcState));
|
||||
}
|
||||
const findRuntimeGiftItem = (itemId: string | null) => {
|
||||
if (!itemId) return null;
|
||||
return (
|
||||
gameState.runtimeNpcInteraction?.gift.items.find(
|
||||
(item) => item.itemId === itemId,
|
||||
) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const openTradeModal = (encounter: Encounter, actionText: string) => {
|
||||
setTradeModal(
|
||||
buildNpcTradeModalState(
|
||||
gameState,
|
||||
{
|
||||
encounter,
|
||||
actionText,
|
||||
npcState.inventory,
|
||||
),
|
||||
introText: buildNpcTradeModalIntroText(encounter),
|
||||
mode: 'buy',
|
||||
selectedNpcItemId:
|
||||
gameState.runtimeNpcInteraction?.trade.buyItems.find(
|
||||
(item) => item.canSubmit,
|
||||
)?.itemId ??
|
||||
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
|
||||
null,
|
||||
selectedPlayerItemId:
|
||||
gameState.runtimeNpcInteraction?.trade.sellItems.find(
|
||||
(item) => item.canSubmit,
|
||||
)?.itemId ??
|
||||
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
|
||||
null,
|
||||
selectedQuantity: 1,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const openGiftModal = (encounter: Encounter, actionText: string) => {
|
||||
const selectedItemId = getPreferredGiftItemId(
|
||||
gameState.playerInventory,
|
||||
encounter,
|
||||
{
|
||||
worldType: gameState.worldType,
|
||||
customWorldProfile: gameState.customWorldProfile,
|
||||
},
|
||||
);
|
||||
if (!selectedItemId) return;
|
||||
const selectedItemId =
|
||||
gameState.runtimeNpcInteraction?.gift.items.find((item) => item.canSubmit)
|
||||
?.itemId ??
|
||||
gameState.runtimeNpcInteraction?.gift.items[0]?.itemId ??
|
||||
null;
|
||||
|
||||
setGiftModal(
|
||||
buildNpcGiftModalState(
|
||||
gameState,
|
||||
encounter,
|
||||
actionText,
|
||||
selectedItemId,
|
||||
@@ -630,53 +467,24 @@ export function useStoryNpcInteractionFlow({
|
||||
if (!tradeModal || !gameState.playerCharacter) return;
|
||||
|
||||
const encounter = tradeModal.encounter;
|
||||
const quantity = clampTradeQuantity(gameState, tradeModal, tradeModal.selectedQuantity);
|
||||
const unitPrice = getTradeUnitPrice(gameState, tradeModal);
|
||||
const totalPrice = unitPrice * quantity;
|
||||
|
||||
if (tradeModal.mode === 'buy') {
|
||||
const npcItem = getTradeNpcItem(gameState, tradeModal);
|
||||
if (!npcItem || quantity <= 0) return;
|
||||
if (npcItem.quantity < quantity || gameState.playerCurrency < totalPrice) return;
|
||||
|
||||
setTradeModal(null);
|
||||
void resolveServerNpcAction({
|
||||
encounter,
|
||||
actionText: buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
mode: 'buy',
|
||||
item: npcItem,
|
||||
quantity,
|
||||
}),
|
||||
functionId: 'npc_trade',
|
||||
action: 'trade',
|
||||
payload: {
|
||||
mode: 'buy',
|
||||
itemId: npcItem.id,
|
||||
quantity,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const playerItem = getTradePlayerItem(gameState, tradeModal);
|
||||
if (!playerItem || quantity <= 0) return;
|
||||
if (playerItem.quantity < quantity) return;
|
||||
const quantity = normalizeTradeQuantity(tradeModal.selectedQuantity);
|
||||
const tradeItem = findRuntimeTradeItem(tradeModal);
|
||||
if (!tradeItem) return;
|
||||
|
||||
setTradeModal(null);
|
||||
void resolveServerNpcAction({
|
||||
encounter,
|
||||
actionText: buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
mode: 'sell',
|
||||
item: playerItem,
|
||||
mode: tradeModal.mode,
|
||||
item: tradeItem.item,
|
||||
quantity,
|
||||
}),
|
||||
functionId: 'npc_trade',
|
||||
action: 'trade',
|
||||
payload: {
|
||||
mode: 'sell',
|
||||
itemId: playerItem.id,
|
||||
mode: tradeModal.mode,
|
||||
itemId: tradeItem.itemId,
|
||||
quantity,
|
||||
},
|
||||
});
|
||||
@@ -686,17 +494,17 @@ export function useStoryNpcInteractionFlow({
|
||||
if (!giftModal || !gameState.playerCharacter) return;
|
||||
|
||||
const encounter = giftModal.encounter;
|
||||
const giftItem = gameState.playerInventory.find(item => item.id === giftModal.selectedItemId);
|
||||
const giftItem = findRuntimeGiftItem(giftModal.selectedItemId);
|
||||
if (!giftItem) return;
|
||||
|
||||
setGiftModal(null);
|
||||
void resolveServerNpcAction({
|
||||
encounter,
|
||||
actionText: buildNpcGiftCommitActionText(encounter, giftItem),
|
||||
actionText: `把${giftItem.item.name}赠给${encounter.npcName}`,
|
||||
functionId: 'npc_gift',
|
||||
action: 'gift',
|
||||
payload: {
|
||||
itemId: giftItem.id,
|
||||
itemId: giftItem.itemId,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -708,44 +516,40 @@ export function useStoryNpcInteractionFlow({
|
||||
recruitModal,
|
||||
setTradeMode: (mode: 'buy' | 'sell') => setTradeModal(current => {
|
||||
if (!current) return current;
|
||||
const nextModal = {
|
||||
return {
|
||||
...current,
|
||||
mode,
|
||||
selectedNpcItemId: current.selectedNpcItemId ?? getResolvedNpcState(gameState, current.encounter).inventory[0]?.id ?? null,
|
||||
selectedPlayerItemId: current.selectedPlayerItemId ?? gameState.playerInventory[0]?.id ?? null,
|
||||
selectedNpcItemId:
|
||||
current.selectedNpcItemId ??
|
||||
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
|
||||
null,
|
||||
selectedPlayerItemId:
|
||||
current.selectedPlayerItemId ??
|
||||
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
|
||||
null,
|
||||
selectedQuantity: 1,
|
||||
};
|
||||
return {
|
||||
...nextModal,
|
||||
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
|
||||
};
|
||||
}),
|
||||
selectTradeNpcItem: (itemId: string) => setTradeModal(current => {
|
||||
if (!current) return current;
|
||||
const nextModal = {
|
||||
return {
|
||||
...current,
|
||||
selectedNpcItemId: itemId,
|
||||
};
|
||||
return {
|
||||
...nextModal,
|
||||
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
|
||||
selectedQuantity: 1,
|
||||
};
|
||||
}),
|
||||
selectTradePlayerItem: (itemId: string) => setTradeModal(current => {
|
||||
if (!current) return current;
|
||||
const nextModal = {
|
||||
return {
|
||||
...current,
|
||||
selectedPlayerItemId: itemId,
|
||||
};
|
||||
return {
|
||||
...nextModal,
|
||||
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
|
||||
selectedQuantity: 1,
|
||||
};
|
||||
}),
|
||||
setTradeQuantity: (quantity: number) => setTradeModal(current => current
|
||||
? {
|
||||
...current,
|
||||
selectedQuantity: clampTradeQuantity(gameState, current, quantity),
|
||||
selectedQuantity: normalizeTradeQuantity(quantity),
|
||||
}
|
||||
: current),
|
||||
closeTradeModal: () => setTradeModal(null),
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { ensureSceneEncounterPreviewMock } = vi.hoisted(() => ({
|
||||
ensureSceneEncounterPreviewMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../data/sceneEncounterPreviews', () => ({
|
||||
ensureSceneEncounterPreview: ensureSceneEncounterPreviewMock,
|
||||
}));
|
||||
|
||||
import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime';
|
||||
import { getScenePresetsByWorld } from '../../data/scenePresets';
|
||||
import { AnimationState, type GameState, WorldType } from '../../types';
|
||||
import { buildRevivedFirstSceneState } from './postBattleFlow';
|
||||
|
||||
function createBackstoryReveal(label: string) {
|
||||
return {
|
||||
publicSummary: `${label}的公开背景`,
|
||||
chapters: [
|
||||
{
|
||||
id: `${label}-surface`,
|
||||
title: '表层来意',
|
||||
affinityRequired: 15,
|
||||
teaser: `${label}先收着话。`,
|
||||
content: `${label}把真正目的藏在后面。`,
|
||||
contextSnippet: `${label}表面上仍在试探。`,
|
||||
},
|
||||
{
|
||||
id: `${label}-scar`,
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: 30,
|
||||
teaser: `${label}提到旧事会迟疑。`,
|
||||
content: `${label}背后压着旧伤。`,
|
||||
contextSnippet: `${label}仍被旧事牵制。`,
|
||||
},
|
||||
{
|
||||
id: `${label}-hidden`,
|
||||
title: '隐藏执念',
|
||||
affinityRequired: 60,
|
||||
teaser: `${label}真正执念并不在表面。`,
|
||||
content: `${label}真正想守住的是另一条暗线。`,
|
||||
contextSnippet: `${label}另有没说出口的理由。`,
|
||||
},
|
||||
{
|
||||
id: `${label}-final`,
|
||||
title: '最终底牌',
|
||||
affinityRequired: 90,
|
||||
teaser: `${label}手里还扣着底牌。`,
|
||||
content: `${label}掌握能改写局势的最后证据。`,
|
||||
contextSnippet: `${label}最后底牌还没翻出。`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createStoryRole(id: string, name: string, hostile = false) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
title: `${name}的头衔`,
|
||||
role: hostile ? '敌对角色' : '同幕角色',
|
||||
description: `${name}的测试描述`,
|
||||
backstory: `${name}的测试背景`,
|
||||
personality: '冷静克制',
|
||||
motivation: hostile ? '阻拦玩家继续向前' : '观察局势变化',
|
||||
combatStyle: hostile ? '正面压制' : '后排支援',
|
||||
initialAffinity: hostile ? -20 : 12,
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
backstoryReveal: createBackstoryReveal(name),
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createReviveState(): GameState {
|
||||
const customWorldProfile = {
|
||||
id: 'custom-revive-test',
|
||||
name: '复活回场测试世界',
|
||||
subtitle: '首幕站位恢复',
|
||||
summary: '用于验证复活后第一幕 NPC 会按既有 encounter preview 恢复。',
|
||||
settingText: '围绕开局营地与第一幕对峙角色展开的自定义世界。',
|
||||
tone: '紧张、克制',
|
||||
playerGoal: '复活后重新回到第一幕并面对主交互角色。',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
id: 'schema:test',
|
||||
worldId: 'CUSTOM',
|
||||
schemaVersion: 1,
|
||||
schemaName: '测试属性',
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '复活回场测试世界',
|
||||
settingSummary: '首幕站位恢复',
|
||||
tone: '紧张、克制',
|
||||
conflictCore: '复活后重新面对主交互角色',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [
|
||||
createStoryRole('npc-front', '正面对手', true),
|
||||
createStoryRole('npc-back-1', '后排甲'),
|
||||
createStoryRole('npc-back-2', '后排乙'),
|
||||
],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
camp: {
|
||||
id: 'custom-scene-camp',
|
||||
name: '开局营地',
|
||||
description: '用于复活回场测试。',
|
||||
visualDescription: '营地火光映着即将重开的第一幕。',
|
||||
imageSrc: '/camp.png',
|
||||
sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
connections: [],
|
||||
narrativeResidues: null,
|
||||
},
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'custom-scene-camp-chapter',
|
||||
sceneId: 'custom-scene-camp',
|
||||
title: '开局章节',
|
||||
summary: '复活后应回到这里的第一幕。',
|
||||
sceneTaskDescription: '',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [],
|
||||
acts: [
|
||||
{
|
||||
id: 'custom-scene-camp-act-1',
|
||||
sceneId: 'custom-scene-camp',
|
||||
title: '第一幕',
|
||||
summary: '主交互角色与后排角色一同出现。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc: '/act-1.png',
|
||||
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
primaryNpcId: 'npc-front',
|
||||
oppositeNpcId: 'npc-front',
|
||||
eventDescription: '第一幕事件',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '重新进入首幕',
|
||||
transitionHook: '首幕回场',
|
||||
},
|
||||
{
|
||||
id: 'custom-scene-camp-act-2',
|
||||
sceneId: 'custom-scene-camp',
|
||||
title: '第二幕',
|
||||
summary: '这是死亡前已经推进到的幕。',
|
||||
stageCoverage: ['expansion'],
|
||||
backgroundImageSrc: '/act-2.png',
|
||||
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
primaryNpcId: 'npc-front',
|
||||
oppositeNpcId: 'npc-front',
|
||||
eventDescription: '第二幕事件',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '推进第二幕',
|
||||
transitionHook: '第二幕推进',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as NonNullable<GameState['customWorldProfile']>;
|
||||
|
||||
setRuntimeCustomWorldProfile(customWorldProfile);
|
||||
const firstScene = getScenePresetsByWorld(WorldType.CUSTOM)[0]!;
|
||||
|
||||
return {
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile,
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
name: '测试主角',
|
||||
title: '旅人',
|
||||
description: '测试角色',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 10,
|
||||
spirit: 10,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
},
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.DIE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: firstScene,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 0,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 0,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {
|
||||
'npc-front': {
|
||||
affinity: -20,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
'npc-back-1': {
|
||||
affinity: 8,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
'npc-back-2': {
|
||||
affinity: 6,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: 'npc-front',
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: 'fight_defeat',
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: [],
|
||||
activeThreadIds: [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
currentSceneActState: {
|
||||
sceneId: 'custom-scene-camp',
|
||||
chapterId: 'custom-scene-camp-chapter',
|
||||
currentActId: 'custom-scene-camp-act-2',
|
||||
currentActIndex: 1,
|
||||
completedActIds: ['custom-scene-camp-act-1'],
|
||||
visitedActIds: ['custom-scene-camp-act-1', 'custom-scene-camp-act-2'],
|
||||
},
|
||||
},
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('postBattleFlow', () => {
|
||||
afterEach(() => {
|
||||
ensureSceneEncounterPreviewMock.mockReset();
|
||||
setRuntimeCustomWorldProfile(null);
|
||||
});
|
||||
|
||||
it('rebuilds revived first-scene state through encounter preview restoration', () => {
|
||||
const reviveState = createReviveState();
|
||||
const previewRestoredState = {
|
||||
...reviveState,
|
||||
currentEncounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc' as const,
|
||||
characterId: 'npc-front',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手的测试描述',
|
||||
npcAvatar: '正',
|
||||
context: '敌对角色',
|
||||
xMeters: 12,
|
||||
},
|
||||
};
|
||||
ensureSceneEncounterPreviewMock.mockReturnValue(previewRestoredState);
|
||||
|
||||
const revived = buildRevivedFirstSceneState(reviveState);
|
||||
|
||||
expect(ensureSceneEncounterPreviewMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
currentScenePreset: expect.objectContaining({
|
||||
id: 'custom-scene-camp',
|
||||
}),
|
||||
currentEncounter: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerHp: 100,
|
||||
playerMana: 20,
|
||||
inBattle: false,
|
||||
currentNpcBattleOutcome: null,
|
||||
storyEngineMemory: expect.objectContaining({
|
||||
currentSceneActState: expect.objectContaining({
|
||||
currentActId: 'custom-scene-camp-act-1',
|
||||
currentActIndex: 0,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(revived).toBe(previewRestoredState);
|
||||
});
|
||||
});
|
||||
@@ -1,229 +0,0 @@
|
||||
import { getScenePresetById, getScenePresetsByWorld } from '../../data/scenePresets';
|
||||
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
|
||||
import {
|
||||
advanceSceneActRuntimeState,
|
||||
buildInitialSceneActRuntimeState,
|
||||
getSceneConnectionDirectionText,
|
||||
resolveSceneActProgression,
|
||||
} from '../../services/customWorldSceneActRuntime';
|
||||
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||
import {
|
||||
AnimationState,
|
||||
type GameState,
|
||||
type ScenePresetInfo,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
} from '../../types';
|
||||
|
||||
const CONTINUE_ADVENTURE_FUNCTION_ID = 'story_continue_adventure';
|
||||
const TRAVEL_NEXT_SCENE_FUNCTION_ID = 'idle_travel_next_scene';
|
||||
|
||||
function buildBaseFlowVisuals(): StoryOption['visuals'] {
|
||||
return {
|
||||
playerAnimation: AnimationState.RUN,
|
||||
playerMoveMeters: 0.9,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildContinueOption(): StoryOption {
|
||||
return {
|
||||
functionId: CONTINUE_ADVENTURE_FUNCTION_ID,
|
||||
actionText: '继续前进',
|
||||
text: '继续前进',
|
||||
priority: 1,
|
||||
visuals: buildBaseFlowVisuals(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTravelOption(scene: ScenePresetInfo, actionText: string): StoryOption {
|
||||
return {
|
||||
functionId: TRAVEL_NEXT_SCENE_FUNCTION_ID,
|
||||
actionText,
|
||||
text: actionText,
|
||||
priority: 2,
|
||||
visuals: buildBaseFlowVisuals(),
|
||||
runtimePayload: {
|
||||
targetSceneId: scene.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSceneTravelOptions(state: GameState): StoryOption[] {
|
||||
if (!state.worldType) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const currentSceneId = state.currentScenePreset?.id ?? null;
|
||||
const currentScene = currentSceneId
|
||||
? getScenePresetById(state.worldType, currentSceneId)
|
||||
: null;
|
||||
const connectionOptions =
|
||||
currentScene?.connections
|
||||
?.map((connection) => {
|
||||
const scene = getScenePresetById(state.worldType!, connection.sceneId);
|
||||
if (!scene || scene.id === currentSceneId) {
|
||||
return null;
|
||||
}
|
||||
const directionText = getSceneConnectionDirectionText(connection.relativePosition);
|
||||
return buildTravelOption(scene, `${directionText},前往${scene.name}`);
|
||||
})
|
||||
.filter((option): option is StoryOption => Boolean(option)) ?? [];
|
||||
|
||||
if (connectionOptions.length > 0) {
|
||||
return connectionOptions;
|
||||
}
|
||||
|
||||
return getScenePresetsByWorld(state.worldType)
|
||||
.filter((scene) => scene.id !== currentSceneId)
|
||||
.slice(0, 4)
|
||||
.map((scene) => buildTravelOption(scene, `前往${scene.name}`));
|
||||
}
|
||||
|
||||
export function buildPostBattleVictoryState(state: GameState) {
|
||||
return {
|
||||
...state,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
inBattle: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
} satisfies GameState;
|
||||
}
|
||||
|
||||
export function buildPostBattleVictoryStory(
|
||||
state: GameState,
|
||||
resultText: string,
|
||||
fallbackOptions: StoryOption[] = [],
|
||||
): { state: GameState; story: StoryMoment } {
|
||||
const progress = resolveSceneActProgression({
|
||||
profile: state.customWorldProfile,
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: state.storyEngineMemory,
|
||||
});
|
||||
const nextActState = progress
|
||||
? advanceSceneActRuntimeState({ progress })
|
||||
: null;
|
||||
const nextState = nextActState
|
||||
? {
|
||||
...state,
|
||||
storyEngineMemory: {
|
||||
...(state.storyEngineMemory ?? createEmptyStoryEngineMemoryState()),
|
||||
currentSceneActState: nextActState,
|
||||
},
|
||||
}
|
||||
: state;
|
||||
if (progress?.isLastAct) {
|
||||
return {
|
||||
state: nextState,
|
||||
story: {
|
||||
text: resultText,
|
||||
options: buildSceneTravelOptions(nextState),
|
||||
streaming: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const deferredOptions =
|
||||
fallbackOptions.length > 0
|
||||
? fallbackOptions
|
||||
: buildSceneTravelOptions(nextState);
|
||||
|
||||
return {
|
||||
state: nextState,
|
||||
story: {
|
||||
text: resultText,
|
||||
options: [buildContinueOption()],
|
||||
deferredOptions,
|
||||
deferredRuntimeState: nextActState
|
||||
? {
|
||||
storyEngineMemory: nextState.storyEngineMemory,
|
||||
}
|
||||
: undefined,
|
||||
streaming: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRevivedFirstSceneState(state: GameState): GameState {
|
||||
const firstScene = state.worldType
|
||||
? getScenePresetsByWorld(state.worldType)[0] ?? state.currentScenePreset
|
||||
: state.currentScenePreset;
|
||||
const storyEngineMemory =
|
||||
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
const firstActState = buildInitialSceneActRuntimeState({
|
||||
profile: state.customWorldProfile,
|
||||
sceneId: firstScene?.id ?? null,
|
||||
storyEngineMemory: undefined,
|
||||
});
|
||||
|
||||
const revivedBaseState = {
|
||||
...state,
|
||||
currentScenePreset: firstScene,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerHp: state.playerMaxHp,
|
||||
playerMana: state.playerMaxMana,
|
||||
inBattle: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle',
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
currentSceneActState: firstActState,
|
||||
},
|
||||
} satisfies GameState;
|
||||
|
||||
// 中文注释:角色复活后要回到“开局进入世界”同一套首幕 encounter preview
|
||||
// 构建链,而不是只清空战斗态。这样第一幕主交互 NPC 与同幕陪衬 NPC
|
||||
// 会按既有槽位一起恢复,避免退化成所有人站成一排。
|
||||
return ensureSceneEncounterPreview(revivedBaseState);
|
||||
}
|
||||
|
||||
export function buildDeathStory(
|
||||
state: GameState,
|
||||
deferredOptions?: StoryOption[],
|
||||
): StoryMoment {
|
||||
const firstSceneName =
|
||||
state.worldType
|
||||
? getScenePresetsByWorld(state.worldType)[0]?.name
|
||||
: state.currentScenePreset?.name;
|
||||
|
||||
return {
|
||||
text: firstSceneName
|
||||
? `你在战斗中倒下,随后在${firstSceneName}重新醒来。`
|
||||
: '你在战斗中倒下,随后重新醒来。',
|
||||
options: [buildContinueOption()],
|
||||
// 中文注释:复活后的“继续前进”只负责揭示已经准备好的首场景入口,
|
||||
// 不能再次触发普通剧情推演,否则容易直接被推进到主 NPC 执行态。
|
||||
deferredOptions:
|
||||
deferredOptions && deferredOptions.length > 0
|
||||
? deferredOptions
|
||||
: undefined,
|
||||
streaming: false,
|
||||
};
|
||||
}
|
||||
@@ -1,86 +1,9 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
acceptQuest,
|
||||
buildChapterQuestForScene,
|
||||
getChapterQuestForScene,
|
||||
} from '../../data/questFlow';
|
||||
import { resolveSceneChapterBlueprint } from '../../services/customWorldSceneActRuntime';
|
||||
import {
|
||||
hasEncounterEntity,
|
||||
interpolateEncounterTransitionState,
|
||||
} from '../../data/encounterTransition';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
} from '../../services/storyEngine/actorNarrativeProfile';
|
||||
import { resolveCurrentActState } from '../../services/storyEngine/actPlanner';
|
||||
import { buildAuthorialConstraintPack } from '../../services/storyEngine/authorialConstraintPack';
|
||||
import { evaluateBranchBudget } from '../../services/storyEngine/branchBudgetPlanner';
|
||||
import {
|
||||
advanceCampaignState,
|
||||
resolveCampaignState,
|
||||
} from '../../services/storyEngine/campaignDirector';
|
||||
import { compileCampaignFromWorldProfile } from '../../services/storyEngine/campaignPackCompiler';
|
||||
import {
|
||||
buildCampEvent,
|
||||
evaluateCampEventOpportunity,
|
||||
} from '../../services/storyEngine/campEventDirector';
|
||||
import {
|
||||
advanceChapterState,
|
||||
resolveCurrentChapterState,
|
||||
} from '../../services/storyEngine/chapterDirector';
|
||||
import {
|
||||
advanceCompanionArc,
|
||||
buildCompanionArcStates,
|
||||
} from '../../services/storyEngine/companionArcDirector';
|
||||
import {
|
||||
applyCompanionReactionToStance,
|
||||
buildCompanionReactionBatch,
|
||||
} from '../../services/storyEngine/companionReactionDirector';
|
||||
import { resolveAllCompanionResolutions } from '../../services/storyEngine/companionResolutionDirector';
|
||||
import { appendConsequenceRecord } from '../../services/storyEngine/consequenceLedger';
|
||||
import { buildContentDiffReport } from '../../services/storyEngine/contentDiffReport';
|
||||
import { resolveEndingState } from '../../services/storyEngine/endingResolver';
|
||||
import { buildEpilogueSummary } from '../../services/storyEngine/epilogueComposer';
|
||||
import { buildFactionTensionState } from '../../services/storyEngine/factionTensionState';
|
||||
import { resolveCurrentJourneyBeat } from '../../services/storyEngine/journeyBeatPlanner';
|
||||
import { buildNarrativeCodex } from '../../services/storyEngine/narrativeCodex';
|
||||
import { runNarrativeConsistencyChecks } from '../../services/storyEngine/narrativeConsistencyChecks';
|
||||
import { buildNarrativeQaReport } from '../../services/storyEngine/narrativeQaReport';
|
||||
import {
|
||||
recordReplaySeed,
|
||||
replayNarrativeRun,
|
||||
} from '../../services/storyEngine/narrativeRegressionReplay';
|
||||
import { captureNarrativeTelemetry } from '../../services/storyEngine/narrativeTelemetry';
|
||||
import { updatePlayerStyleProfileFromAction } from '../../services/storyEngine/playerStyleProfiler';
|
||||
import { runPlaythroughMatrix } from '../../services/storyEngine/playthroughMatrixLab';
|
||||
import { buildContinueGameDigest } from '../../services/storyEngine/recapDigest';
|
||||
import { buildReleaseGateReport } from '../../services/storyEngine/releaseGateReport';
|
||||
import { buildSaveMigrationManifest } from '../../services/storyEngine/saveMigrationManifest';
|
||||
import { resolveScenarioPack } from '../../services/storyEngine/scenarioPackRegistry';
|
||||
import {
|
||||
buildSetpieceDirective,
|
||||
evaluateSetpieceOpportunity,
|
||||
} from '../../services/storyEngine/setpieceDirector';
|
||||
import { appendChronicleEntries } from '../../services/storyEngine/storyChronicle';
|
||||
import { buildThemePackFromWorldProfile } from '../../services/storyEngine/themePack';
|
||||
import { buildThreadContractsFromProfile } from '../../services/storyEngine/threadContract';
|
||||
import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime';
|
||||
import {
|
||||
collectStorySignals,
|
||||
resolveSignalsToThreadUpdates,
|
||||
} from '../../services/storyEngine/threadSignalRouter';
|
||||
import {
|
||||
buildEncounterVisibilitySlice,
|
||||
createEmptyStoryEngineMemoryState,
|
||||
} from '../../services/storyEngine/visibilityEngine';
|
||||
import {
|
||||
applyWorldMutationsToGameState,
|
||||
resolveWorldMutations,
|
||||
} from '../../services/storyEngine/worldMutationRouter';
|
||||
import { buildFallbackWorldStoryGraph } from '../../services/storyEngine/worldStoryGraph';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
Character,
|
||||
@@ -93,516 +16,6 @@ import type { CommitGeneratedState } from '../generatedState';
|
||||
const ENCOUNTER_ENTRY_DURATION_MS = 1800;
|
||||
const ENCOUNTER_ENTRY_TICK_MS = 180;
|
||||
|
||||
function dedupeStrings(values: Array<string | null | undefined>, limit = 10) {
|
||||
return [
|
||||
...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)),
|
||||
].slice(0, limit);
|
||||
}
|
||||
|
||||
function hydrateStoryEngineMemory(state: GameState): GameState {
|
||||
const storyEngineMemory =
|
||||
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
if (!state.customWorldProfile || state.currentEncounter?.kind !== 'npc') {
|
||||
return {
|
||||
...state,
|
||||
storyEngineMemory,
|
||||
};
|
||||
}
|
||||
|
||||
const role =
|
||||
state.customWorldProfile.storyNpcs.find(
|
||||
(npc) =>
|
||||
npc.id === state.currentEncounter?.id ||
|
||||
npc.name === state.currentEncounter?.npcName,
|
||||
) ??
|
||||
state.customWorldProfile.playableNpcs.find(
|
||||
(npc) =>
|
||||
npc.id === state.currentEncounter?.id ||
|
||||
npc.name === state.currentEncounter?.npcName,
|
||||
);
|
||||
if (!role) {
|
||||
return {
|
||||
...state,
|
||||
storyEngineMemory,
|
||||
};
|
||||
}
|
||||
|
||||
const themePack =
|
||||
state.customWorldProfile.themePack ??
|
||||
buildThemePackFromWorldProfile(state.customWorldProfile);
|
||||
const storyGraph =
|
||||
state.customWorldProfile.storyGraph ??
|
||||
buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
|
||||
const narrativeProfile = normalizeActorNarrativeProfile(
|
||||
role.narrativeProfile,
|
||||
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
|
||||
);
|
||||
const npcState =
|
||||
state.npcStates[
|
||||
state.currentEncounter.id ?? state.currentEncounter.npcName
|
||||
];
|
||||
const activeThreadIds =
|
||||
storyEngineMemory.activeThreadIds.length > 0
|
||||
? storyEngineMemory.activeThreadIds
|
||||
: narrativeProfile.relatedThreadIds.slice(0, 4);
|
||||
const visibilitySlice = buildEncounterVisibilitySlice({
|
||||
narrativeProfile,
|
||||
backstoryReveal: state.currentEncounter.backstoryReveal ?? null,
|
||||
disclosureStage:
|
||||
npcState?.affinity != null
|
||||
? npcState.affinity < 15
|
||||
? 'guarded'
|
||||
: npcState.affinity < 45
|
||||
? 'partial'
|
||||
: npcState.affinity < 75
|
||||
? 'honest'
|
||||
: 'deep'
|
||||
: 'guarded',
|
||||
isFirstMeaningfulContact: npcState?.firstMeaningfulContactResolved !== true,
|
||||
seenBackstoryChapterIds: npcState?.seenBackstoryChapterIds ?? [],
|
||||
storyEngineMemory,
|
||||
activeThreadIds,
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
discoveredFactIds: dedupeStrings(
|
||||
[
|
||||
...storyEngineMemory.discoveredFactIds,
|
||||
...visibilitySlice.sayableFactIds,
|
||||
],
|
||||
16,
|
||||
),
|
||||
activeThreadIds: dedupeStrings(
|
||||
[...storyEngineMemory.activeThreadIds, ...activeThreadIds],
|
||||
6,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function findNewInventoryItems(previousState: GameState, nextState: GameState) {
|
||||
const previousIds = new Set(
|
||||
previousState.playerInventory.map((item) => item.id),
|
||||
);
|
||||
return nextState.playerInventory.filter((item) => !previousIds.has(item.id));
|
||||
}
|
||||
|
||||
function ensureSceneChapterQuestState(params: {
|
||||
previousState: GameState;
|
||||
nextState: GameState;
|
||||
}) {
|
||||
const storyEngineMemory =
|
||||
params.nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
const scene = params.nextState.currentScenePreset;
|
||||
if (
|
||||
params.nextState.currentScene !== 'Story' ||
|
||||
!params.nextState.worldType ||
|
||||
!scene?.id
|
||||
) {
|
||||
return {
|
||||
...params.nextState,
|
||||
storyEngineMemory,
|
||||
};
|
||||
}
|
||||
|
||||
const openedSceneChapterIds = dedupeStrings(
|
||||
[...(storyEngineMemory.openedSceneChapterIds ?? [])],
|
||||
64,
|
||||
);
|
||||
if (openedSceneChapterIds.includes(scene.id)) {
|
||||
return {
|
||||
...params.nextState,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
openedSceneChapterIds,
|
||||
currentSceneActState:
|
||||
buildInitialSceneActRuntimeState({
|
||||
profile: params.nextState.customWorldProfile,
|
||||
sceneId: scene.id,
|
||||
storyEngineMemory,
|
||||
}) ?? storyEngineMemory.currentSceneActState ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const nextMemory = {
|
||||
...storyEngineMemory,
|
||||
openedSceneChapterIds: [...openedSceneChapterIds, scene.id],
|
||||
currentSceneActState:
|
||||
buildInitialSceneActRuntimeState({
|
||||
profile: params.nextState.customWorldProfile,
|
||||
sceneId: scene.id,
|
||||
storyEngineMemory,
|
||||
}) ?? storyEngineMemory.currentSceneActState ?? null,
|
||||
};
|
||||
const existingChapterQuest = getChapterQuestForScene(
|
||||
params.nextState.quests,
|
||||
scene.id,
|
||||
);
|
||||
if (existingChapterQuest) {
|
||||
return {
|
||||
...params.nextState,
|
||||
storyEngineMemory: nextMemory,
|
||||
};
|
||||
}
|
||||
|
||||
const sceneChapter = resolveSceneChapterBlueprint(
|
||||
params.nextState.customWorldProfile,
|
||||
scene.id,
|
||||
);
|
||||
const sceneChapterContext = sceneChapter
|
||||
? {
|
||||
sceneTaskDescription: sceneChapter.sceneTaskDescription,
|
||||
actEventDescriptions: sceneChapter.acts
|
||||
.map((act) => act.eventDescription)
|
||||
.filter(Boolean),
|
||||
primaryNpcName:
|
||||
params.nextState.customWorldProfile?.storyNpcs.find(
|
||||
(npc) => npc.id === sceneChapter.acts[0]?.primaryNpcId,
|
||||
)?.name ?? sceneChapter.acts[0]?.primaryNpcId ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
const chapterQuest = buildChapterQuestForScene({
|
||||
scene,
|
||||
worldType: params.nextState.worldType,
|
||||
sceneChapterContext,
|
||||
context: {
|
||||
worldType: params.nextState.worldType,
|
||||
actState: params.nextState.storyEngineMemory?.actState ?? null,
|
||||
recentStoryMoments: params.nextState.storyHistory.slice(-6),
|
||||
playerCharacter: params.nextState.playerCharacter,
|
||||
playerProgression: params.nextState.playerProgression ?? null,
|
||||
},
|
||||
});
|
||||
if (!chapterQuest) {
|
||||
return {
|
||||
...params.nextState,
|
||||
storyEngineMemory: nextMemory,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...params.nextState,
|
||||
storyEngineMemory: nextMemory,
|
||||
quests: acceptQuest(params.nextState.quests, chapterQuest),
|
||||
};
|
||||
}
|
||||
|
||||
function applyStoryEngineEchoes(params: {
|
||||
previousState: GameState;
|
||||
nextState: GameState;
|
||||
actionText: string;
|
||||
lastFunctionId?: string | null;
|
||||
}) {
|
||||
const hydratedState = hydrateStoryEngineMemory(params.nextState);
|
||||
const contracts = hydratedState.customWorldProfile
|
||||
? (hydratedState.customWorldProfile.threadContracts ??
|
||||
buildThreadContractsFromProfile(hydratedState.customWorldProfile))
|
||||
: [];
|
||||
const newItems = findNewInventoryItems(params.previousState, hydratedState);
|
||||
const signals = collectStorySignals({
|
||||
prevState: params.previousState,
|
||||
nextState: hydratedState,
|
||||
actionText: params.actionText,
|
||||
lastFunctionId: params.lastFunctionId,
|
||||
rewardItems: newItems,
|
||||
});
|
||||
const stateWithSignals = resolveSignalsToThreadUpdates({
|
||||
state: hydratedState,
|
||||
signals,
|
||||
contracts,
|
||||
});
|
||||
const stateWithSceneChapter = ensureSceneChapterQuestState({
|
||||
previousState: params.previousState,
|
||||
nextState: stateWithSignals,
|
||||
});
|
||||
const reactions = buildCompanionReactionBatch({
|
||||
state: stateWithSceneChapter,
|
||||
signals,
|
||||
actionText: params.actionText,
|
||||
});
|
||||
const stateWithReactions = applyCompanionReactionToStance({
|
||||
state: stateWithSceneChapter,
|
||||
reactions,
|
||||
});
|
||||
const storyEngineMemory =
|
||||
stateWithReactions.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
const chapterState = advanceChapterState({
|
||||
previousChapter:
|
||||
stateWithReactions.chapterState ??
|
||||
storyEngineMemory.currentChapter ??
|
||||
null,
|
||||
nextChapter: resolveCurrentChapterState({
|
||||
state: stateWithReactions,
|
||||
}),
|
||||
});
|
||||
const journeyBeat = resolveCurrentJourneyBeat({
|
||||
state: {
|
||||
...stateWithReactions,
|
||||
chapterState,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
currentChapter: chapterState,
|
||||
},
|
||||
},
|
||||
chapterState,
|
||||
});
|
||||
const companionArcStates = advanceCompanionArc({
|
||||
previous: storyEngineMemory.companionArcStates,
|
||||
next: buildCompanionArcStates({
|
||||
state: stateWithReactions,
|
||||
reactions,
|
||||
}),
|
||||
});
|
||||
const campEvent = evaluateCampEventOpportunity({
|
||||
state: stateWithReactions,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
companionArcStates,
|
||||
})
|
||||
? buildCampEvent({
|
||||
state: stateWithReactions,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
companionArcStates,
|
||||
})
|
||||
: null;
|
||||
const worldMutations = resolveWorldMutations({
|
||||
state: stateWithReactions,
|
||||
signals,
|
||||
chapterState,
|
||||
});
|
||||
const stateWithMutations = applyWorldMutationsToGameState({
|
||||
state: stateWithReactions,
|
||||
mutations: worldMutations,
|
||||
});
|
||||
const setpieceDirective = evaluateSetpieceOpportunity({
|
||||
state: stateWithMutations,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
})
|
||||
? buildSetpieceDirective({
|
||||
state: stateWithMutations,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
})
|
||||
: null;
|
||||
const chronicle = appendChronicleEntries({
|
||||
state: stateWithMutations,
|
||||
chapterState,
|
||||
worldMutations,
|
||||
reactions,
|
||||
signals,
|
||||
campEvent,
|
||||
setpieceDirective,
|
||||
});
|
||||
const factionTensionStates = buildFactionTensionState(
|
||||
stateWithMutations.customWorldProfile,
|
||||
storyEngineMemory,
|
||||
);
|
||||
const actState = resolveCurrentActState({
|
||||
state: stateWithMutations,
|
||||
chapterState,
|
||||
});
|
||||
const campaignState = advanceCampaignState({
|
||||
previous:
|
||||
storyEngineMemory.campaignState ??
|
||||
stateWithMutations.campaignState ??
|
||||
null,
|
||||
next: resolveCampaignState({
|
||||
state: stateWithMutations,
|
||||
actState,
|
||||
}),
|
||||
});
|
||||
const consequenceLedger = appendConsequenceRecord({
|
||||
existing: storyEngineMemory.consequenceLedger,
|
||||
signals,
|
||||
reactions,
|
||||
worldMutations,
|
||||
campEvent,
|
||||
});
|
||||
const authorialConstraintPack = buildAuthorialConstraintPack({
|
||||
profile: stateWithMutations.customWorldProfile,
|
||||
});
|
||||
const compiledPacks = stateWithMutations.customWorldProfile
|
||||
? compileCampaignFromWorldProfile({
|
||||
profile: stateWithMutations.customWorldProfile,
|
||||
})
|
||||
: null;
|
||||
const activeScenarioPack =
|
||||
resolveScenarioPack(stateWithMutations.activeScenarioPackId) ??
|
||||
compiledPacks?.scenarioPack ??
|
||||
null;
|
||||
const activeCampaignPack = compiledPacks?.campaignPack ?? null;
|
||||
const playerStyleProfile = updatePlayerStyleProfileFromAction({
|
||||
current: storyEngineMemory.playerStyleProfile,
|
||||
actionText: params.actionText,
|
||||
});
|
||||
const companionResolutions = resolveAllCompanionResolutions({
|
||||
state: stateWithMutations,
|
||||
arcStates: companionArcStates,
|
||||
ledger: consequenceLedger,
|
||||
reactions,
|
||||
});
|
||||
const endingState =
|
||||
actState?.status === 'finale' || actState?.status === 'resolved'
|
||||
? resolveEndingState({
|
||||
state: stateWithMutations,
|
||||
companionResolutions,
|
||||
factionTensionStates,
|
||||
})
|
||||
: (storyEngineMemory.endingState ?? null);
|
||||
const epilogueSummary = endingState
|
||||
? buildEpilogueSummary({
|
||||
endingState,
|
||||
companionResolutions,
|
||||
})
|
||||
: null;
|
||||
const currentJourneyBeatId =
|
||||
journeyBeat?.id ?? storyEngineMemory.currentJourneyBeatId ?? null;
|
||||
const branchBudgetStatus = evaluateBranchBudget({
|
||||
consequenceLedger,
|
||||
authorialConstraintPack,
|
||||
endingFamilyCount: endingState ? 1 : 0,
|
||||
});
|
||||
const baseMemoryForQa = {
|
||||
...storyEngineMemory,
|
||||
currentChapter: chapterState,
|
||||
currentJourneyBeatId,
|
||||
currentJourneyBeat: journeyBeat,
|
||||
companionArcStates,
|
||||
worldMutations,
|
||||
chronicle,
|
||||
factionTensionStates,
|
||||
currentCampEvent: campEvent,
|
||||
currentSetpieceDirective: setpieceDirective,
|
||||
campaignState,
|
||||
actState,
|
||||
consequenceLedger,
|
||||
companionResolutions,
|
||||
endingState,
|
||||
authorialConstraintPack,
|
||||
branchBudgetStatus,
|
||||
playerStyleProfile,
|
||||
};
|
||||
const consistencyIssues = runNarrativeConsistencyChecks({
|
||||
memory: baseMemoryForQa,
|
||||
threadContracts: contracts,
|
||||
branchBudgetStatus,
|
||||
});
|
||||
const narrativeQaReport = buildNarrativeQaReport({
|
||||
issues: consistencyIssues,
|
||||
});
|
||||
const simulationRunResults =
|
||||
activeScenarioPack && activeCampaignPack
|
||||
? runPlaythroughMatrix({
|
||||
scenarioPackId: activeScenarioPack.id,
|
||||
campaignPack: activeCampaignPack,
|
||||
memory: {
|
||||
...baseMemoryForQa,
|
||||
narrativeQaReport,
|
||||
},
|
||||
seeds: ['baseline', 'companion', 'explore'],
|
||||
})
|
||||
: [];
|
||||
const replaySummary = simulationRunResults[0]
|
||||
? replayNarrativeRun({
|
||||
recordedSeed: recordReplaySeed({
|
||||
seed: simulationRunResults[0].seed,
|
||||
label: `${activeCampaignPack?.title ?? 'campaign'} 基线回放`,
|
||||
}),
|
||||
result: simulationRunResults[0],
|
||||
}).summary
|
||||
: null;
|
||||
const releaseGateReport = buildReleaseGateReport({
|
||||
qaReport: narrativeQaReport,
|
||||
simulationResults: simulationRunResults,
|
||||
unresolvedThreadCount:
|
||||
stateWithMutations.storyEngineMemory?.activeThreadIds.length ?? 0,
|
||||
});
|
||||
const saveMigrationManifest = buildSaveMigrationManifest({
|
||||
version: 'story-engine-v5',
|
||||
});
|
||||
const telemetrySnapshot = captureNarrativeTelemetry({
|
||||
memory: {
|
||||
...baseMemoryForQa,
|
||||
narrativeQaReport,
|
||||
},
|
||||
qaReport: narrativeQaReport,
|
||||
});
|
||||
const contentDiffReport = buildContentDiffReport({
|
||||
previousProfile: params.previousState.customWorldProfile,
|
||||
nextProfile: stateWithMutations.customWorldProfile,
|
||||
previousCampaignPack: null,
|
||||
nextCampaignPack: activeCampaignPack,
|
||||
});
|
||||
const narrativeCodex = buildNarrativeCodex({
|
||||
...stateWithMutations,
|
||||
chapterState,
|
||||
campaignState,
|
||||
storyEngineMemory: {
|
||||
...baseMemoryForQa,
|
||||
narrativeQaReport,
|
||||
releaseGateReport,
|
||||
simulationRunResults,
|
||||
},
|
||||
});
|
||||
const continueDigest =
|
||||
buildContinueGameDigest({
|
||||
state: {
|
||||
...stateWithMutations,
|
||||
chapterState,
|
||||
campaignState,
|
||||
storyEngineMemory: {
|
||||
...baseMemoryForQa,
|
||||
currentJourneyBeatId,
|
||||
narrativeQaReport,
|
||||
releaseGateReport,
|
||||
simulationRunResults,
|
||||
narrativeCodex,
|
||||
saveMigrationManifest,
|
||||
},
|
||||
},
|
||||
}) +
|
||||
[
|
||||
epilogueSummary,
|
||||
replaySummary,
|
||||
telemetrySnapshot.summary,
|
||||
contentDiffReport.summary,
|
||||
`发布门禁:${releaseGateReport.status} / ${releaseGateReport.summary}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
...stateWithMutations,
|
||||
chapterState,
|
||||
campaignState,
|
||||
activeScenarioPackId:
|
||||
activeScenarioPack?.id ?? stateWithMutations.activeScenarioPackId ?? null,
|
||||
activeCampaignPackId:
|
||||
activeCampaignPack?.id ?? stateWithMutations.activeCampaignPackId ?? null,
|
||||
storyEngineMemory: {
|
||||
...baseMemoryForQa,
|
||||
currentJourneyBeatId,
|
||||
continueGameDigest: continueDigest,
|
||||
narrativeQaReport,
|
||||
narrativeCodex,
|
||||
releaseGateReport,
|
||||
simulationRunResults,
|
||||
saveMigrationManifest,
|
||||
recentCompanionReactions: [
|
||||
...(storyEngineMemory.recentCompanionReactions ?? []),
|
||||
...reactions,
|
||||
].slice(-6),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type GenerateStoryForState = (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
@@ -664,15 +77,10 @@ export function createStoryProgressionActions({
|
||||
lastFunctionId,
|
||||
) => {
|
||||
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
|
||||
const stateWithHistory = applyStoryEngineEchoes({
|
||||
previousState: gameState,
|
||||
nextState: {
|
||||
...nextState,
|
||||
storyHistory: nextHistory,
|
||||
} as GameState,
|
||||
actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
const stateWithHistory = {
|
||||
...nextState,
|
||||
storyHistory: nextHistory,
|
||||
} as GameState;
|
||||
|
||||
setGameState(stateWithHistory);
|
||||
setAiError(null);
|
||||
@@ -686,13 +94,7 @@ export function createStoryProgressionActions({
|
||||
choice: actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
const recoveredState = applyStoryEngineEchoes({
|
||||
previousState: gameState,
|
||||
nextState: applyStoryReasoningRecovery(stateWithHistory),
|
||||
actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
setGameState(recoveredState);
|
||||
setGameState(stateWithHistory);
|
||||
setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to continue scripted story:', error);
|
||||
@@ -744,15 +146,10 @@ export function createStoryProgressionActions({
|
||||
}
|
||||
|
||||
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
|
||||
const stateWithHistory = applyStoryEngineEchoes({
|
||||
previousState: gameState,
|
||||
nextState: {
|
||||
...resolvedState,
|
||||
storyHistory: nextHistory,
|
||||
} as GameState,
|
||||
actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
const stateWithHistory = {
|
||||
...resolvedState,
|
||||
storyHistory: nextHistory,
|
||||
} as GameState;
|
||||
|
||||
setGameState(stateWithHistory);
|
||||
|
||||
@@ -764,13 +161,7 @@ export function createStoryProgressionActions({
|
||||
choice: actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
const recoveredState = applyStoryEngineEchoes({
|
||||
previousState: gameState,
|
||||
nextState: applyStoryReasoningRecovery(stateWithHistory),
|
||||
actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
setGameState(recoveredState);
|
||||
setGameState(stateWithHistory);
|
||||
setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to continue encounter-entry story:', error);
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
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';
|
||||
import {
|
||||
@@ -14,94 +8,8 @@ import {
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryResponse,
|
||||
type RuntimeStorySnapshotRequest,
|
||||
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
|
||||
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;
|
||||
});
|
||||
}
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
|
||||
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
return response.viewModel.availableOptions.length > 0
|
||||
@@ -109,209 +17,6 @@ function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
: response.presentation.options;
|
||||
}
|
||||
|
||||
function buildRuntimeSnapshotRequest(
|
||||
gameState: GameState,
|
||||
currentStory: StoryMoment | null,
|
||||
): RuntimeStorySnapshotRequest {
|
||||
return {
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveServerTravelTargetSceneId(params: {
|
||||
previousState: GameState;
|
||||
snapshotState: GameState;
|
||||
}) {
|
||||
const { previousState, snapshotState } = params;
|
||||
const snapshotSceneId = snapshotState.currentScenePreset?.id ?? null;
|
||||
if (
|
||||
snapshotSceneId &&
|
||||
snapshotSceneId !== previousState.currentScenePreset?.id
|
||||
) {
|
||||
return snapshotSceneId;
|
||||
}
|
||||
|
||||
if (!previousState.worldType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
getForwardScenePreset(
|
||||
previousState.worldType,
|
||||
previousState.currentScenePreset?.id,
|
||||
)?.id ??
|
||||
previousState.currentScenePreset?.forwardSceneId ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function bridgeServerSceneTravelSnapshot(params: {
|
||||
previousState: GameState;
|
||||
hydratedSnapshot: HydratedSavedGameSnapshot;
|
||||
functionId: string;
|
||||
}) {
|
||||
const { previousState, hydratedSnapshot, functionId } = params;
|
||||
if (functionId !== 'idle_travel_next_scene' || !previousState.worldType) {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
const targetSceneId = resolveServerTravelTargetSceneId({
|
||||
previousState,
|
||||
snapshotState: hydratedSnapshot.gameState,
|
||||
});
|
||||
if (!targetSceneId) {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
const travelResolution = buildMapTravelResolution(previousState, targetSceneId);
|
||||
if (!travelResolution) {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
return {
|
||||
...hydratedSnapshot,
|
||||
gameState: {
|
||||
...hydratedSnapshot.gameState,
|
||||
// 中文注释:服务端 compat 当前只保证“本轮旅行动作已经结算完成”,
|
||||
// 前端这里复用既有地图旅行真相,补齐下一幕场景 preset、遭遇预览和任务推进结果。
|
||||
currentScenePreset: travelResolution.nextState.currentScenePreset,
|
||||
currentEncounter: travelResolution.nextState.currentEncounter,
|
||||
npcInteractionActive: travelResolution.nextState.npcInteractionActive,
|
||||
sceneHostileNpcs: travelResolution.nextState.sceneHostileNpcs,
|
||||
playerX: travelResolution.nextState.playerX,
|
||||
playerFacing: travelResolution.nextState.playerFacing,
|
||||
animationState: travelResolution.nextState.animationState,
|
||||
playerActionMode: travelResolution.nextState.playerActionMode,
|
||||
activeCombatEffects: travelResolution.nextState.activeCombatEffects,
|
||||
scrollWorld: travelResolution.nextState.scrollWorld,
|
||||
inBattle: travelResolution.nextState.inBattle,
|
||||
lastObserveSignsSceneId: travelResolution.nextState.lastObserveSignsSceneId,
|
||||
lastObserveSignsReport: travelResolution.nextState.lastObserveSignsReport,
|
||||
currentBattleNpcId: travelResolution.nextState.currentBattleNpcId,
|
||||
currentNpcBattleMode: travelResolution.nextState.currentNpcBattleMode,
|
||||
currentNpcBattleOutcome: travelResolution.nextState.currentNpcBattleOutcome,
|
||||
sparReturnEncounter: travelResolution.nextState.sparReturnEncounter,
|
||||
sparPlayerHpBefore: travelResolution.nextState.sparPlayerHpBefore,
|
||||
sparPlayerMaxHpBefore: travelResolution.nextState.sparPlayerMaxHpBefore,
|
||||
sparStoryHistoryBefore: travelResolution.nextState.sparStoryHistoryBefore,
|
||||
runtimeStats: {
|
||||
...hydratedSnapshot.gameState.runtimeStats,
|
||||
scenesTraveled:
|
||||
travelResolution.nextState.runtimeStats.scenesTraveled,
|
||||
},
|
||||
quests:
|
||||
hydratedSnapshot.gameState.quests.length > 0
|
||||
? hydratedSnapshot.gameState.quests
|
||||
: travelResolution.nextState.quests,
|
||||
},
|
||||
} 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,
|
||||
// 中文注释:服务端兼容链路若未带回战前遭遇,则沿用进入战斗前的原始 encounter,
|
||||
// 让后续 fight_victory / spar_complete 都能恢复到正确站位,而不是战斗中的临时坐标。
|
||||
sparReturnEncounter:
|
||||
snapshotState.sparReturnEncounter ??
|
||||
(previousState.currentEncounter?.kind === 'npc'
|
||||
? previousState.currentEncounter
|
||||
: null),
|
||||
},
|
||||
} satisfies HydratedSavedGameSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端访问服务端 runtime story 的统一网关。
|
||||
* 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。
|
||||
@@ -320,10 +25,11 @@ export async function loadServerRuntimeOptionCatalog(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
}) {
|
||||
// 中文注释:状态目录只从服务端持久化 session 读取,
|
||||
// 前端不再上传本地 GameState 快照参与动作合法性解析。
|
||||
const response = await getRpgRuntimeStoryState({
|
||||
sessionId: getRpgRuntimeSessionId(params.gameState),
|
||||
clientVersion: getRpgRuntimeClientVersion(params.gameState),
|
||||
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
|
||||
});
|
||||
const options = resolveRpgRuntimeStoryMoment({
|
||||
response,
|
||||
@@ -351,6 +57,8 @@ export async function resumeServerRuntimeStory(
|
||||
};
|
||||
}
|
||||
|
||||
// 中文注释:继续游戏后向服务端刷新一次状态,
|
||||
// 让长期离线的本地快照重新对齐服务端当前 runtime view model。
|
||||
const response = await getRpgRuntimeStoryState({
|
||||
sessionId: getRpgRuntimeSessionId(hydratedSnapshot.gameState),
|
||||
});
|
||||
@@ -383,6 +91,8 @@ export async function resolveServerRuntimeChoice(params: {
|
||||
Partial<Pick<StoryOption, 'interaction'>>;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
}) {
|
||||
// 中文注释:正式动作结算统一先走服务端;
|
||||
// 前端这里只提交 action/payload,并消费后端已经补齐的快照与表现数据。
|
||||
const response = await resolveRpgRuntimeStoryAction({
|
||||
sessionId: getRpgRuntimeSessionId(params.gameState),
|
||||
clientVersion: getRpgRuntimeClientVersion(params.gameState),
|
||||
@@ -392,17 +102,8 @@ export async function resolveServerRuntimeChoice(params: {
|
||||
? params.option.interaction.npcId
|
||||
: undefined,
|
||||
payload: params.payload,
|
||||
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
|
||||
});
|
||||
const hydratedSnapshot = bridgeServerSceneTravelSnapshot({
|
||||
previousState: params.gameState,
|
||||
hydratedSnapshot: bridgeServerNpcBattleSnapshot({
|
||||
previousState: params.gameState,
|
||||
hydratedSnapshot: rehydrateSavedSnapshot(response.snapshot),
|
||||
functionId: params.option.functionId,
|
||||
}),
|
||||
functionId: params.option.functionId,
|
||||
});
|
||||
const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot);
|
||||
|
||||
return {
|
||||
response,
|
||||
|
||||
@@ -257,7 +257,7 @@ describe('runtimeStoryCoordinator', () => {
|
||||
getRuntimeClientVersionMock.mockReturnValue(7);
|
||||
});
|
||||
|
||||
it('loads runtime option catalogs through the persisted server snapshot flow', async () => {
|
||||
it('loads runtime option catalogs through the persisted server state flow', async () => {
|
||||
const gameState = createGameState();
|
||||
const currentStory = createStory('当前故事');
|
||||
|
||||
@@ -311,11 +311,6 @@ describe('runtimeStoryCoordinator', () => {
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 7,
|
||||
snapshot: {
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
},
|
||||
});
|
||||
expect(options).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -416,11 +411,6 @@ describe('runtimeStoryCoordinator', () => {
|
||||
payload: {
|
||||
note: 'server-runtime-test',
|
||||
},
|
||||
snapshot: {
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
},
|
||||
});
|
||||
expect(result.hydratedSnapshot).toBe(hydratedSnapshot);
|
||||
expect(result.nextStory).toEqual(
|
||||
@@ -653,7 +643,7 @@ describe('runtimeStoryCoordinator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('backfills npc battle monsters when npc_fight snapshot marks battle active but omits sceneHostileNpcs', async () => {
|
||||
it('does not patch incomplete npc_fight snapshots in the frontend gateway', async () => {
|
||||
const gameState = {
|
||||
...createTravelGameState(),
|
||||
currentEncounter: {
|
||||
@@ -753,419 +743,17 @@ describe('runtimeStoryCoordinator', () => {
|
||||
option,
|
||||
});
|
||||
|
||||
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs).toHaveLength(1);
|
||||
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
|
||||
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs).toEqual([]);
|
||||
expect(result.hydratedSnapshot.gameState.currentEncounter).toEqual(
|
||||
expect.objectContaining({
|
||||
encounter: expect.objectContaining({
|
||||
id: 'npc-bandit',
|
||||
npcName: '断桥匪首',
|
||||
}),
|
||||
renderKind: 'npc',
|
||||
id: 'npc-bandit',
|
||||
npcName: '断桥匪首',
|
||||
}),
|
||||
);
|
||||
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,
|
||||
},
|
||||
]);
|
||||
expect(result.hydratedSnapshot.gameState.sparReturnEncounter).toEqual(
|
||||
gameState.currentEncounter,
|
||||
);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
it('uses idle_travel_next_scene snapshots as returned by the backend resolver', async () => {
|
||||
const gameState = createTravelGameState();
|
||||
const currentStory = createStory('桥口这一段已经收束。');
|
||||
const option = {
|
||||
@@ -1247,13 +835,13 @@ describe('runtimeStoryCoordinator', () => {
|
||||
);
|
||||
expect(
|
||||
result.hydratedSnapshot.gameState.runtimeStats.scenesTraveled,
|
||||
).toBe(1);
|
||||
).toBe(0);
|
||||
expect(
|
||||
Boolean(
|
||||
result.hydratedSnapshot.gameState.currentEncounter ||
|
||||
result.hydratedSnapshot.gameState.sceneHostileNpcs.length > 0,
|
||||
),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rehydrates mid-battle snapshots when resuming a saved runtime story', async () => {
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('sessionActions', () => {
|
||||
expect(rewardClaim).toHaveProperty('handoff');
|
||||
});
|
||||
|
||||
it('refreshes chapter state after a chapter quest is turned in', () => {
|
||||
it('does not rewrite backend-owned chapter state after a chapter quest is turned in', () => {
|
||||
const baseState = {
|
||||
...createBaseState(),
|
||||
currentScenePreset: {
|
||||
@@ -243,7 +243,7 @@ describe('sessionActions', () => {
|
||||
throw new Error('Expected reward claim result');
|
||||
}
|
||||
|
||||
expect(rewardClaim.nextState.chapterState?.stage).toBe('aftermath');
|
||||
expect(rewardClaim.nextState.storyEngineMemory?.currentChapter?.stage).toBe('aftermath');
|
||||
expect(rewardClaim.nextState.chapterState?.stage).toBe('climax');
|
||||
expect(rewardClaim.nextState.storyEngineMemory?.currentChapter?.stage).toBe('climax');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,13 +9,7 @@ import {
|
||||
markQuestCompletionNotified,
|
||||
markQuestTurnedIn,
|
||||
} from '../../data/questFlow';
|
||||
import {
|
||||
advanceChapterState,
|
||||
resolveCurrentChapterState,
|
||||
} from '../../services/storyEngine/chapterDirector';
|
||||
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
|
||||
import { buildGoalHandoffFromState } from '../../services/storyEngine/goalDirector';
|
||||
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||
import type {
|
||||
GameState,
|
||||
StoryMoment,
|
||||
@@ -53,7 +47,7 @@ export function applyQuestRewardClaim(
|
||||
|
||||
const issuerNpcState = state.npcStates[quest.issuerNpcId];
|
||||
|
||||
const nextState = appendStoryEngineCarrierMemory({
|
||||
const nextState: GameState = {
|
||||
...state,
|
||||
quests: markQuestTurnedIn(state.quests, questId),
|
||||
playerCurrency: state.playerCurrency + quest.reward.currency,
|
||||
@@ -67,30 +61,11 @@ export function applyQuestRewardClaim(
|
||||
},
|
||||
}
|
||||
: state.npcStates,
|
||||
}, quest.reward.items);
|
||||
const chapterState = advanceChapterState({
|
||||
previousChapter:
|
||||
nextState.chapterState
|
||||
?? nextState.storyEngineMemory?.currentChapter
|
||||
?? null,
|
||||
nextChapter: resolveCurrentChapterState({
|
||||
state: nextState,
|
||||
}),
|
||||
});
|
||||
const storyEngineMemory =
|
||||
nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
const synchronizedNextState: GameState = {
|
||||
...nextState,
|
||||
chapterState,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
currentChapter: chapterState,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
nextState: synchronizedNextState,
|
||||
handoff: buildGoalHandoffFromState(synchronizedNextState),
|
||||
nextState,
|
||||
handoff: buildGoalHandoffFromState(nextState),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import { applyQuestProgressFromHostileNpcDefeat } from '../../data/questFlow';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import { generateNextStep } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import { AnimationState } from '../../types';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
@@ -13,19 +9,7 @@ import type {
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { EscapePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { BattlePlan } from '../combat/battlePlan';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import {
|
||||
buildDeathStory,
|
||||
buildPostBattleVictoryState,
|
||||
buildPostBattleVictoryStory,
|
||||
buildRevivedFirstSceneState,
|
||||
} from './postBattleFlow';
|
||||
import {
|
||||
buildCombatResolutionContextText,
|
||||
buildHostileNpcBattleReward,
|
||||
} from './storyChoiceRuntime';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
|
||||
type RuntimeStatsIncrements = Partial<
|
||||
@@ -84,78 +68,10 @@ type IncrementRuntimeStats = (
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
const PLAYER_REVIVE_DELAY_MS = 3000;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function buildLocalCombatResultText(params: {
|
||||
option: StoryOption;
|
||||
battlePlan: BattlePlan | null;
|
||||
afterSequence: GameState;
|
||||
combatResolutionContextText: string | null;
|
||||
}) {
|
||||
if (params.combatResolutionContextText) {
|
||||
return params.combatResolutionContextText;
|
||||
}
|
||||
|
||||
const turns = params.battlePlan?.turns ?? [];
|
||||
const dealtDamage = turns
|
||||
.filter((turn) => turn.actor === 'player' || turn.actor === 'companion')
|
||||
.reduce((sum, turn) => sum + turn.damage, 0);
|
||||
const takenDamage = turns
|
||||
.filter((turn) => turn.actor === 'monster' && turn.target === 'player')
|
||||
.reduce((sum, turn) => sum + turn.damage, 0);
|
||||
|
||||
if (params.afterSequence.playerHp <= 0) {
|
||||
return takenDamage > 0
|
||||
? `你承受了${takenDamage}点伤害,气血归零。`
|
||||
: '你在战斗中倒下,气血归零。';
|
||||
}
|
||||
|
||||
const details = [
|
||||
dealtDamage > 0 ? `造成${dealtDamage}点伤害` : null,
|
||||
takenDamage > 0 ? `承受${takenDamage}点伤害` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return details.length > 0
|
||||
? `${params.option.actionText}完成,${details.join(',')}。`
|
||||
: `${params.option.actionText}完成,双方仍在对峙。`;
|
||||
}
|
||||
|
||||
function buildDeterministicStoryForState(params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
resultText: string;
|
||||
availableOptions: StoryOption[] | null;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
}) {
|
||||
if (params.availableOptions?.length) {
|
||||
return {
|
||||
text: params.resultText,
|
||||
options: params.availableOptions,
|
||||
streaming: false,
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
const fallbackStory = params.buildFallbackStoryForState(
|
||||
params.state,
|
||||
params.character,
|
||||
params.resultText,
|
||||
);
|
||||
return {
|
||||
...fallbackStory,
|
||||
text: params.resultText,
|
||||
streaming: false,
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
function isLocalNpcBattleVictoryOutcome(
|
||||
battleOutcome: GameState['currentNpcBattleOutcome'],
|
||||
) {
|
||||
function isBackendOwnedCombatChoice(option: StoryOption) {
|
||||
return (
|
||||
battleOutcome === 'fight_victory' || battleOutcome === 'spar_complete'
|
||||
option.functionId.startsWith('battle_') ||
|
||||
option.functionId === 'inventory_use'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,7 +95,6 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: EscapePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
buildStoryFromResponse: BuildStoryFromResponse;
|
||||
@@ -234,53 +149,32 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
let fallbackState = baseChoiceState;
|
||||
|
||||
try {
|
||||
if (isBackendOwnedCombatChoice(params.option)) {
|
||||
throw new Error(
|
||||
`战斗与物品动作必须由后端结算,禁止进入本地 continuation:${params.option.functionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const history = baseChoiceState.storyHistory;
|
||||
const resolvedChoice = params.buildResolvedChoiceState(
|
||||
baseChoiceState,
|
||||
params.option,
|
||||
params.character,
|
||||
);
|
||||
if (resolvedChoice.optionKind === 'battle' || resolvedChoice.optionKind === 'escape') {
|
||||
throw new Error(
|
||||
`战斗与逃脱动作必须由后端结算,禁止进入本地 continuation:${params.option.functionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const projectedState = resolvedChoice.afterSequence;
|
||||
const shouldUseDeterministicCombatFlow =
|
||||
resolvedChoice.optionKind === 'battle' ||
|
||||
resolvedChoice.optionKind === 'escape';
|
||||
const shouldUseLocalNpcVictory = Boolean(
|
||||
baseChoiceState.currentBattleNpcId &&
|
||||
resolvedChoice.optionKind === 'battle' &&
|
||||
isLocalNpcBattleVictoryOutcome(projectedState.currentNpcBattleOutcome),
|
||||
);
|
||||
const projectedBattleReward = shouldUseLocalNpcVictory
|
||||
? null
|
||||
: await buildHostileNpcBattleReward(
|
||||
baseChoiceState,
|
||||
projectedState,
|
||||
resolvedChoice.optionKind,
|
||||
params.getResolvedSceneHostileNpcs,
|
||||
);
|
||||
const projectedStateWithBattleReward = projectedBattleReward
|
||||
? appendStoryEngineCarrierMemory(
|
||||
{
|
||||
...projectedState,
|
||||
playerInventory: addInventoryItems(
|
||||
projectedState.playerInventory,
|
||||
projectedBattleReward.items,
|
||||
),
|
||||
} as GameState,
|
||||
projectedBattleReward.items,
|
||||
)
|
||||
: projectedState;
|
||||
const projectedStateWithBattleReward = projectedState;
|
||||
fallbackState = projectedStateWithBattleReward;
|
||||
const projectedAvailableOptions = params.getAvailableOptionsForState(
|
||||
projectedStateWithBattleReward,
|
||||
params.character,
|
||||
);
|
||||
const combatResolutionContextText = buildCombatResolutionContextText({
|
||||
baseState: baseChoiceState,
|
||||
afterSequence: projectedStateWithBattleReward,
|
||||
optionKind: resolvedChoice.optionKind,
|
||||
projectedBattleReward,
|
||||
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
|
||||
});
|
||||
const combatResolutionContextText = null;
|
||||
const historyForStoryGeneration = combatResolutionContextText
|
||||
? [
|
||||
...history,
|
||||
@@ -289,38 +183,27 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
]
|
||||
: history;
|
||||
|
||||
const responsePromise = shouldUseLocalNpcVictory || shouldUseDeterministicCombatFlow
|
||||
? Promise.resolve(null)
|
||||
: generateNextStep(
|
||||
params.gameState.worldType!,
|
||||
params.character,
|
||||
params.getStoryGenerationHostileNpcs(projectedStateWithBattleReward),
|
||||
historyForStoryGeneration,
|
||||
params.option.actionText,
|
||||
params.buildStoryContextFromState(projectedStateWithBattleReward, {
|
||||
lastFunctionId: params.option.functionId,
|
||||
observeSignsRequested:
|
||||
params.option.functionId === 'idle_observe_signs',
|
||||
recentActionResult: combatResolutionContextText,
|
||||
}),
|
||||
projectedAvailableOptions
|
||||
? { availableOptions: projectedAvailableOptions }
|
||||
: undefined,
|
||||
);
|
||||
const responseSettledPromise = responsePromise.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
const responsePromise = generateNextStep(
|
||||
params.gameState.worldType!,
|
||||
params.character,
|
||||
params.getStoryGenerationHostileNpcs(projectedStateWithBattleReward),
|
||||
historyForStoryGeneration,
|
||||
params.option.actionText,
|
||||
params.buildStoryContextFromState(projectedStateWithBattleReward, {
|
||||
lastFunctionId: params.option.functionId,
|
||||
observeSignsRequested:
|
||||
params.option.functionId === 'idle_observe_signs',
|
||||
recentActionResult: combatResolutionContextText,
|
||||
}),
|
||||
projectedAvailableOptions
|
||||
? { availableOptions: projectedAvailableOptions }
|
||||
: undefined,
|
||||
);
|
||||
const playbackSync: EscapePlaybackSync | undefined =
|
||||
resolvedChoice.optionKind === 'escape' && !shouldUseDeterministicCombatFlow
|
||||
? { waitForStoryResponse: responseSettledPromise }
|
||||
: undefined;
|
||||
const actionPromise = params.playResolvedChoice(
|
||||
baseChoiceState,
|
||||
params.option,
|
||||
params.character,
|
||||
resolvedChoice,
|
||||
playbackSync,
|
||||
);
|
||||
const [actionResult, responseResult] = await Promise.allSettled([
|
||||
actionPromise,
|
||||
@@ -331,186 +214,14 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
throw actionResult.reason;
|
||||
}
|
||||
|
||||
let afterSequence = shouldUseLocalNpcVictory
|
||||
? resolvedChoice.afterSequence
|
||||
: actionResult.value;
|
||||
if (projectedBattleReward) {
|
||||
afterSequence = appendStoryEngineCarrierMemory(
|
||||
{
|
||||
...afterSequence,
|
||||
playerInventory: addInventoryItems(
|
||||
afterSequence.playerInventory,
|
||||
projectedBattleReward.items,
|
||||
),
|
||||
} as GameState,
|
||||
projectedBattleReward.items,
|
||||
);
|
||||
}
|
||||
const afterSequence = actionResult.value;
|
||||
fallbackState = afterSequence;
|
||||
|
||||
if (shouldUseLocalNpcVictory) {
|
||||
const victory = params.finalizeNpcBattleResult(
|
||||
afterSequence,
|
||||
params.character,
|
||||
baseChoiceState.currentNpcBattleMode!,
|
||||
afterSequence.currentNpcBattleOutcome,
|
||||
);
|
||||
if (victory) {
|
||||
const historyBase =
|
||||
baseChoiceState.currentNpcBattleMode === 'spar'
|
||||
? (afterSequence.sparStoryHistoryBefore ?? [])
|
||||
: baseChoiceState.storyHistory;
|
||||
const nextHistory = [
|
||||
...historyBase,
|
||||
createHistoryMoment(params.option.actionText, 'action'),
|
||||
createHistoryMoment(victory.resultText, 'result'),
|
||||
];
|
||||
const nextState = {
|
||||
...victory.nextState,
|
||||
storyHistory: nextHistory,
|
||||
};
|
||||
const postBattleState = buildPostBattleVictoryState(nextState);
|
||||
const postBattle = buildPostBattleVictoryStory(
|
||||
postBattleState,
|
||||
victory.resultText,
|
||||
params.getAvailableOptionsForState(postBattleState, params.character) ?? [],
|
||||
);
|
||||
fallbackState = postBattle.state;
|
||||
params.setGameState(postBattle.state);
|
||||
params.setCurrentStory(postBattle.story);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldUseDeterministicCombatFlow) {
|
||||
const defeatedHostileNpcIds =
|
||||
resolvedChoice.optionKind === 'escape' || baseChoiceState.currentBattleNpcId
|
||||
? []
|
||||
: params
|
||||
.getResolvedSceneHostileNpcs(baseChoiceState)
|
||||
.map((hostileNpc) => hostileNpc.id)
|
||||
.filter(
|
||||
(hostileNpcId) =>
|
||||
!params
|
||||
.getResolvedSceneHostileNpcs(afterSequence)
|
||||
.some((hostileNpc) => hostileNpc.id === hostileNpcId),
|
||||
);
|
||||
const resultText = buildLocalCombatResultText({
|
||||
option: params.option,
|
||||
battlePlan: resolvedChoice.battlePlan,
|
||||
afterSequence,
|
||||
combatResolutionContextText,
|
||||
});
|
||||
const nextHistory = [
|
||||
...baseChoiceState.storyHistory,
|
||||
createHistoryMoment(params.option.actionText, 'action'),
|
||||
createHistoryMoment(resultText, 'result'),
|
||||
];
|
||||
const nextState = params.incrementRuntimeStats(
|
||||
{
|
||||
...params.updateQuestLog(afterSequence, (quests) =>
|
||||
applyQuestProgressFromHostileNpcDefeat(
|
||||
quests,
|
||||
baseChoiceState.currentScenePreset?.id ?? null,
|
||||
defeatedHostileNpcIds,
|
||||
),
|
||||
),
|
||||
storyHistory: nextHistory,
|
||||
},
|
||||
{
|
||||
hostileNpcsDefeated: defeatedHostileNpcIds.length,
|
||||
},
|
||||
);
|
||||
|
||||
if (projectedBattleReward) {
|
||||
params.setBattleReward(projectedBattleReward);
|
||||
}
|
||||
|
||||
if (nextState.playerHp <= 0) {
|
||||
const deathState = {
|
||||
...nextState,
|
||||
animationState: AnimationState.DIE,
|
||||
playerActionMode: 'idle' as const,
|
||||
inBattle: false,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
};
|
||||
fallbackState = deathState;
|
||||
params.setGameState(deathState);
|
||||
await sleep(PLAYER_REVIVE_DELAY_MS);
|
||||
const revivedState = {
|
||||
...buildRevivedFirstSceneState(deathState),
|
||||
storyHistory: [
|
||||
...nextHistory,
|
||||
createHistoryMoment('你在第一个场景第一幕重新醒来。', 'result'),
|
||||
],
|
||||
};
|
||||
fallbackState = revivedState;
|
||||
const revivedDeferredOptions =
|
||||
params.buildFallbackStoryForState(revivedState, params.character).options;
|
||||
params.setGameState(revivedState);
|
||||
params.setCurrentStory(
|
||||
buildDeathStory(revivedState, revivedDeferredOptions),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
resolvedChoice.optionKind === 'battle' &&
|
||||
(
|
||||
nextState.currentNpcBattleOutcome === 'fight_victory' ||
|
||||
nextState.currentNpcBattleOutcome === 'spar_complete' ||
|
||||
(!baseChoiceState.currentBattleNpcId && !nextState.inBattle)
|
||||
)
|
||||
) {
|
||||
const postBattleState = buildPostBattleVictoryState(nextState);
|
||||
const postBattle = buildPostBattleVictoryStory(
|
||||
postBattleState,
|
||||
resultText,
|
||||
params.getAvailableOptionsForState(postBattleState, params.character) ?? [],
|
||||
);
|
||||
fallbackState = postBattle.state;
|
||||
params.setGameState(postBattle.state);
|
||||
params.setCurrentStory(postBattle.story);
|
||||
return;
|
||||
}
|
||||
|
||||
const availableOptions = params.getAvailableOptionsForState(
|
||||
nextState,
|
||||
params.character,
|
||||
);
|
||||
fallbackState = nextState;
|
||||
params.setGameState(nextState);
|
||||
params.setCurrentStory(
|
||||
buildDeterministicStoryForState({
|
||||
state: nextState,
|
||||
character: params.character,
|
||||
resultText,
|
||||
availableOptions,
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (responseResult.status === 'rejected') {
|
||||
throw responseResult.reason;
|
||||
}
|
||||
|
||||
const response = responseResult.value!;
|
||||
const defeatedHostileNpcIds =
|
||||
baseChoiceState.currentBattleNpcId ||
|
||||
resolvedChoice.optionKind === 'escape'
|
||||
? []
|
||||
: params
|
||||
.getResolvedSceneHostileNpcs(baseChoiceState)
|
||||
.map((hostileNpc) => hostileNpc.id)
|
||||
.filter(
|
||||
(hostileNpcId) =>
|
||||
!params
|
||||
.getResolvedSceneHostileNpcs(afterSequence)
|
||||
.some((hostileNpc) => hostileNpc.id === hostileNpcId),
|
||||
);
|
||||
const nextHistory = combatResolutionContextText
|
||||
? [
|
||||
...historyForStoryGeneration,
|
||||
@@ -524,13 +235,7 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
|
||||
const nextState = params.incrementRuntimeStats(
|
||||
{
|
||||
...params.updateQuestLog(afterSequence, (quests) =>
|
||||
applyQuestProgressFromHostileNpcDefeat(
|
||||
quests,
|
||||
baseChoiceState.currentScenePreset?.id ?? null,
|
||||
defeatedHostileNpcIds,
|
||||
),
|
||||
),
|
||||
...afterSequence,
|
||||
lastObserveSignsSceneId:
|
||||
params.option.functionId === 'idle_observe_signs'
|
||||
? (afterSequence.currentScenePreset?.id ?? null)
|
||||
@@ -541,16 +246,11 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
: afterSequence.lastObserveSignsReport ?? null,
|
||||
storyHistory: nextHistory,
|
||||
},
|
||||
{
|
||||
hostileNpcsDefeated: defeatedHostileNpcIds.length,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const recoveredState = applyStoryReasoningRecovery(nextState);
|
||||
params.setGameState(recoveredState);
|
||||
if (projectedBattleReward) {
|
||||
params.setBattleReward(projectedBattleReward);
|
||||
}
|
||||
|
||||
params.setCurrentStory(
|
||||
params.buildStoryFromResponse(
|
||||
|
||||
@@ -1,34 +1,16 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
rollHostileNpcLootMock,
|
||||
resolveServerRuntimeChoiceMock,
|
||||
} = vi.hoisted(() => ({
|
||||
rollHostileNpcLootMock: vi.fn(),
|
||||
const { resolveServerRuntimeChoiceMock } = vi.hoisted(() => ({
|
||||
resolveServerRuntimeChoiceMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../data/hostileNpcPresets', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../../data/hostileNpcPresets')>(
|
||||
'../../data/hostileNpcPresets',
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
rollHostileNpcLoot: rollHostileNpcLootMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('.', () => ({
|
||||
resolveRpgRuntimeChoice: resolveServerRuntimeChoiceMock,
|
||||
}));
|
||||
|
||||
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import { WorldType } from '../../types/core';
|
||||
import {
|
||||
buildCombatResolutionContextText,
|
||||
buildHostileNpcBattleReward,
|
||||
buildReasonedOptionCatalog,
|
||||
runServerRuntimeChoiceAction,
|
||||
shouldOpenLocalRuntimeNpcModal,
|
||||
} from './storyChoiceRuntime';
|
||||
@@ -56,10 +38,10 @@ function createCharacter(): Character {
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
function createStory(text: string, options: StoryOption[] = []): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -140,23 +122,9 @@ function createState(overrides: Partial<GameState> = {}): GameState {
|
||||
|
||||
describe('storyChoiceRuntime', () => {
|
||||
beforeEach(() => {
|
||||
rollHostileNpcLootMock.mockReset();
|
||||
resolveServerRuntimeChoiceMock.mockReset();
|
||||
});
|
||||
|
||||
it('deduplicates option catalogs by function id for post-battle recovery', () => {
|
||||
const options = buildReasonedOptionCatalog([
|
||||
createOption('npc_chat'),
|
||||
createOption('npc_chat'),
|
||||
createOption('npc_help'),
|
||||
]);
|
||||
|
||||
expect(options.map((option) => option.functionId)).toEqual([
|
||||
'npc_chat',
|
||||
'npc_help',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps npc chat, trade and gift on the local runtime npc interaction path', () => {
|
||||
expect(
|
||||
shouldOpenLocalRuntimeNpcModal(
|
||||
@@ -190,117 +158,6 @@ describe('storyChoiceRuntime', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('builds escape and victory context text for local battle resolution', () => {
|
||||
const baseState = createState({
|
||||
inBattle: true,
|
||||
sceneHostileNpcs: [
|
||||
{ id: 'wolf', name: '山狼' },
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
});
|
||||
|
||||
expect(
|
||||
buildCombatResolutionContextText({
|
||||
baseState,
|
||||
afterSequence: {
|
||||
...baseState,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
},
|
||||
optionKind: 'escape',
|
||||
projectedBattleReward: null,
|
||||
getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs,
|
||||
}),
|
||||
).toContain('你已成功逃脱');
|
||||
|
||||
expect(
|
||||
buildCombatResolutionContextText({
|
||||
baseState: {
|
||||
...baseState,
|
||||
currentBattleNpcId: null,
|
||||
},
|
||||
afterSequence: {
|
||||
...baseState,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
},
|
||||
optionKind: 'battle',
|
||||
projectedBattleReward: {
|
||||
id: 'reward-1',
|
||||
defeatedHostileNpcs: [{ id: 'wolf', name: '山狼' }],
|
||||
items: [
|
||||
{ id: 'loot-1', category: '材料', name: '狼牙', quantity: 1, rarity: 'common', tags: [] },
|
||||
],
|
||||
},
|
||||
getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs,
|
||||
}),
|
||||
).toContain('战利品:狼牙。');
|
||||
});
|
||||
|
||||
it('builds defeated hostile rewards from locally resolved battle states', async () => {
|
||||
rollHostileNpcLootMock.mockResolvedValue([
|
||||
{
|
||||
id: 'loot-1',
|
||||
category: '材料',
|
||||
name: '狼牙',
|
||||
quantity: 1,
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const reward = await buildHostileNpcBattleReward(
|
||||
createState({
|
||||
inBattle: true,
|
||||
sceneHostileNpcs: [
|
||||
{ id: 'wolf', name: '山狼' },
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
currentBattleNpcId: null,
|
||||
}),
|
||||
createState({
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
}),
|
||||
'battle',
|
||||
(state) => state.sceneHostileNpcs,
|
||||
);
|
||||
|
||||
expect(rollHostileNpcLootMock).toHaveBeenCalledTimes(1);
|
||||
expect(reward?.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
name: '狼牙',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps defeated hostile reward render keys unique for duplicate monster ids', async () => {
|
||||
rollHostileNpcLootMock.mockResolvedValue([]);
|
||||
|
||||
const reward = await buildHostileNpcBattleReward(
|
||||
createState({
|
||||
inBattle: true,
|
||||
sceneHostileNpcs: [
|
||||
{ id: 'monster-16', name: '雷翼甲' },
|
||||
{ id: 'monster-16', name: '雷翼乙', xMeters: 4.1, yOffset: 32 },
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
currentBattleNpcId: null,
|
||||
}),
|
||||
createState({
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
}),
|
||||
'battle',
|
||||
(state) => state.sceneHostileNpcs,
|
||||
);
|
||||
|
||||
expect(reward?.defeatedHostileNpcs).toHaveLength(2);
|
||||
expect(reward?.defeatedHostileNpcs.map((npc) => npc.id)).toEqual([
|
||||
'monster-16',
|
||||
'monster-16',
|
||||
]);
|
||||
expect(new Set(reward?.defeatedHostileNpcs.map((npc) => npc.renderKey)).size)
|
||||
.toBe(2);
|
||||
});
|
||||
|
||||
it('applies server runtime responses and falls back locally when the request fails', async () => {
|
||||
const gameState = createState();
|
||||
const currentStory = createStory('当前故事');
|
||||
@@ -452,9 +309,9 @@ describe('storyChoiceRuntime', () => {
|
||||
expect(setGameState).toHaveBeenLastCalledWith(finalState);
|
||||
});
|
||||
|
||||
it('routes server defeat outcomes into death and revive flow instead of victory settlement', async () => {
|
||||
it('uses the server-returned defeat revive snapshot without local death reconstruction', async () => {
|
||||
const gameState = createState({
|
||||
worldType: 'WUXIA',
|
||||
worldType: WorldType.WUXIA,
|
||||
inBattle: true,
|
||||
playerHp: 6,
|
||||
playerMaxHp: 30,
|
||||
@@ -467,7 +324,7 @@ describe('storyChoiceRuntime', () => {
|
||||
imageSrc: '/scene-a.png',
|
||||
connectedSceneIds: [],
|
||||
connections: [],
|
||||
forwardSceneId: null,
|
||||
forwardSceneId: undefined,
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
@@ -488,16 +345,45 @@ describe('storyChoiceRuntime', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
const finalState = createState({
|
||||
const serverRevivedState = createState({
|
||||
...gameState,
|
||||
inBattle: false,
|
||||
playerHp: 0,
|
||||
currentEncounter: null,
|
||||
playerHp: 30,
|
||||
playerMana: 10,
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'wolf',
|
||||
npcName: '山狼',
|
||||
npcDescription: '林间伏击的野兽',
|
||||
npcAvatar: '狼',
|
||||
context: '复活后的首场景威胁',
|
||||
hostile: true,
|
||||
},
|
||||
sceneHostileNpcs: [],
|
||||
currentNpcBattleOutcome: 'fight_defeat',
|
||||
currentNpcBattleOutcome: null,
|
||||
currentScenePreset: {
|
||||
id: 'wuxia-bamboo-road',
|
||||
name: '竹林古道',
|
||||
description: '风穿竹影,路面狭长。',
|
||||
imageSrc: '/scene-a.png',
|
||||
connectedSceneIds: ['wuxia-mountain-gate'],
|
||||
connections: [
|
||||
{
|
||||
sceneId: 'wuxia-mountain-gate',
|
||||
relativePosition: 'forward',
|
||||
summary: '沿主路继续深入前方区域',
|
||||
},
|
||||
],
|
||||
forwardSceneId: 'wuxia-mountain-gate',
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
});
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
const serverDeathStory = createStory('你在战斗中倒下,随后在竹林古道重新醒来。', [
|
||||
createOption('story_continue_adventure'),
|
||||
]);
|
||||
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
response: {
|
||||
@@ -512,9 +398,9 @@ describe('storyChoiceRuntime', () => {
|
||||
},
|
||||
},
|
||||
hydratedSnapshot: {
|
||||
gameState: finalState,
|
||||
gameState: serverRevivedState,
|
||||
},
|
||||
nextStory: createStory('不会进入胜利文本'),
|
||||
nextStory: serverDeathStory,
|
||||
});
|
||||
|
||||
await runServerRuntimeChoiceAction({
|
||||
@@ -527,10 +413,7 @@ describe('storyChoiceRuntime', () => {
|
||||
setIsLoading: vi.fn(),
|
||||
setGameState,
|
||||
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
||||
buildFallbackStoryForState: () =>
|
||||
createStory('fallback', [
|
||||
createOption('idle_explore_forward'),
|
||||
]),
|
||||
buildFallbackStoryForState: () => createStory('fallback'),
|
||||
turnVisualMs: 1,
|
||||
});
|
||||
|
||||
@@ -541,21 +424,8 @@ describe('storyChoiceRuntime', () => {
|
||||
inBattle: false,
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('重新醒来'),
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
functionId: 'story_continue_adventure',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '不会进入胜利文本',
|
||||
}),
|
||||
);
|
||||
expect(setGameState).toHaveBeenLastCalledWith(serverRevivedState);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(serverDeathStory);
|
||||
});
|
||||
|
||||
it('commits server-returned next-scene state after idle_travel_next_scene resolution', async () => {
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
buildEncounterEntryState,
|
||||
hasEncounterEntity,
|
||||
} from '../../data/encounterTransition';
|
||||
import { rollHostileNpcLoot } from '../../data/hostileNpcPresets';
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import {
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
createSceneEncounterPreview,
|
||||
@@ -18,12 +16,6 @@ import {
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { resolveRpgRuntimeChoice } from '.';
|
||||
import {
|
||||
buildDeathStory,
|
||||
buildPostBattleVictoryState,
|
||||
buildPostBattleVictoryStory,
|
||||
buildRevivedFirstSceneState,
|
||||
} from './postBattleFlow';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
|
||||
type RuntimeStatsIncrements = Partial<
|
||||
@@ -48,68 +40,6 @@ function sleep(ms: number) {
|
||||
return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const PLAYER_REVIVE_DELAY_MS = 3000;
|
||||
|
||||
export function buildReasonedOptionCatalog(options: StoryOption[]) {
|
||||
const seenFunctionIds = new Set<string>();
|
||||
|
||||
return options.filter((option) => {
|
||||
if (seenFunctionIds.has(option.functionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenFunctionIds.add(option.functionId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCombatResolutionContextText(params: {
|
||||
baseState: GameState;
|
||||
afterSequence: GameState;
|
||||
optionKind: 'battle' | 'escape' | 'idle';
|
||||
projectedBattleReward: BattleRewardSummary | null;
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
}) {
|
||||
const {
|
||||
baseState,
|
||||
afterSequence,
|
||||
optionKind,
|
||||
projectedBattleReward,
|
||||
getResolvedSceneHostileNpcs,
|
||||
} = params;
|
||||
|
||||
if (optionKind === 'escape') {
|
||||
const hostileNames = getResolvedSceneHostileNpcs(baseState)
|
||||
.map((hostileNpc) => hostileNpc.name)
|
||||
.join('、');
|
||||
return hostileNames
|
||||
? `你已成功逃脱,与${hostileNames}的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。`
|
||||
: '你已成功逃脱刚才的交战,当前不再处于战斗状态。';
|
||||
}
|
||||
|
||||
if (
|
||||
!baseState.inBattle ||
|
||||
afterSequence.inBattle ||
|
||||
Boolean(baseState.currentBattleNpcId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostileNames = getResolvedSceneHostileNpcs(baseState)
|
||||
.map((hostileNpc) => hostileNpc.name)
|
||||
.join('、');
|
||||
const lootText =
|
||||
projectedBattleReward?.items.length
|
||||
? `战利品:${projectedBattleReward.items
|
||||
.map((item) => item.name)
|
||||
.join('、')}。`
|
||||
: '';
|
||||
|
||||
return hostileNames
|
||||
? `你已经击败${hostileNames},眼前这一轮交战已经结束。${lootText}`
|
||||
: `眼前这一轮交战已经结束,当前不再处于战斗状态。${lootText}`;
|
||||
}
|
||||
|
||||
export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) {
|
||||
return (
|
||||
(
|
||||
@@ -124,63 +54,6 @@ export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildHostileNpcBattleReward(
|
||||
state: GameState,
|
||||
afterSequence: GameState,
|
||||
optionKind: 'battle' | 'escape' | 'idle',
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'],
|
||||
): Promise<BattleRewardSummary | null> {
|
||||
if (
|
||||
optionKind === 'escape' ||
|
||||
!state.worldType ||
|
||||
state.currentBattleNpcId ||
|
||||
!state.inBattle ||
|
||||
afterSequence.inBattle
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeHostileNpcs = getResolvedSceneHostileNpcs(state);
|
||||
const nextHostileNpcs = getResolvedSceneHostileNpcs(afterSequence);
|
||||
const defeatedHostileNpcs = activeHostileNpcs.filter(
|
||||
(hostileNpc) =>
|
||||
!nextHostileNpcs.some(
|
||||
(nextHostileNpc) => nextHostileNpc.id === hostileNpc.id,
|
||||
),
|
||||
);
|
||||
|
||||
if (defeatedHostileNpcs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rolledItems = await rollHostileNpcLoot(
|
||||
state,
|
||||
defeatedHostileNpcs.map((hostileNpc) => ({
|
||||
id: hostileNpc.id,
|
||||
name: hostileNpc.name,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
id: `battle-reward-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`,
|
||||
defeatedHostileNpcs: defeatedHostileNpcs.map((hostileNpc, index) => ({
|
||||
id: hostileNpc.id,
|
||||
name: hostileNpc.name,
|
||||
// 中文注释:同一场战斗可能击败多个同 preset 怪物,奖励弹层 key 不能只用怪物 id。
|
||||
renderKey: [
|
||||
hostileNpc.id,
|
||||
hostileNpc.name,
|
||||
hostileNpc.xMeters,
|
||||
hostileNpc.yOffset ?? 0,
|
||||
index,
|
||||
].join(':'),
|
||||
})),
|
||||
items: addInventoryItems([], rolledItems),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCampTravelHomeChoice(params: {
|
||||
gameState: GameState;
|
||||
option: StoryOption;
|
||||
@@ -337,47 +210,6 @@ export async function runServerRuntimeChoiceAction(params: {
|
||||
});
|
||||
}
|
||||
|
||||
const battle = response?.presentation.battle;
|
||||
if (battle && hydratedSnapshot.gameState.playerHp <= 0) {
|
||||
const deathState = {
|
||||
...hydratedSnapshot.gameState,
|
||||
animationState: AnimationState.DIE,
|
||||
playerActionMode: 'idle' as const,
|
||||
inBattle: false,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
};
|
||||
params.setGameState(deathState);
|
||||
await sleep(PLAYER_REVIVE_DELAY_MS);
|
||||
const revivedState = buildRevivedFirstSceneState(deathState);
|
||||
const revivedDeferredOptions =
|
||||
params.buildFallbackStoryForState(revivedState, params.character).options;
|
||||
params.setGameState(revivedState);
|
||||
params.setCurrentStory(
|
||||
buildDeathStory(revivedState, revivedDeferredOptions),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
battle?.outcome === 'victory' ||
|
||||
battle?.outcome === 'spar_complete'
|
||||
) {
|
||||
const resultText =
|
||||
response?.presentation.resultText || nextStory.text || params.option.actionText;
|
||||
const postBattleState = buildPostBattleVictoryState(
|
||||
hydratedSnapshot.gameState,
|
||||
);
|
||||
const postBattle = buildPostBattleVictoryStory(
|
||||
postBattleState,
|
||||
resultText,
|
||||
nextStory.options,
|
||||
);
|
||||
params.setGameState(postBattle.state);
|
||||
params.setCurrentStory(postBattle.story);
|
||||
return;
|
||||
}
|
||||
|
||||
params.setGameState(hydratedSnapshot.gameState);
|
||||
params.setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
@@ -459,14 +291,15 @@ async function playServerBattlePresentation(params: {
|
||||
const finalTarget = params.finalState.sceneHostileNpcs.find(
|
||||
(hostileNpc) => hostileNpc.id === targetId,
|
||||
);
|
||||
const playerDefeated = battle.outcome === 'defeat';
|
||||
const targetDefeated =
|
||||
battle.outcome === 'victory' ||
|
||||
battle.outcome === 'spar_complete' ||
|
||||
(battle.outcome !== 'defeat' && !finalTarget && (battle.damageDealt ?? 0) > 0);
|
||||
params.setGameState({
|
||||
...actingState,
|
||||
playerHp: params.finalState.playerHp,
|
||||
playerMana: params.finalState.playerMana,
|
||||
playerHp: playerDefeated ? 0 : params.finalState.playerHp,
|
||||
playerMana: playerDefeated ? params.baseState.playerMana : params.finalState.playerMana,
|
||||
playerSkillCooldowns: params.finalState.playerSkillCooldowns,
|
||||
activeBuildBuffs: params.finalState.activeBuildBuffs,
|
||||
sceneHostileNpcs: actingState.sceneHostileNpcs.map((hostileNpc) => {
|
||||
@@ -483,9 +316,12 @@ async function playServerBattlePresentation(params: {
|
||||
});
|
||||
await sleep(Math.max(180, Math.round(params.turnVisualMs * 0.45)));
|
||||
|
||||
if (params.finalState.playerHp <= 0) {
|
||||
if (playerDefeated || params.finalState.playerHp <= 0) {
|
||||
// 中文注释:这里只是 presentation 的临时倒地视觉,
|
||||
// 正式复活位置、血蓝和故事仍以随后提交的服务端 snapshot 为准。
|
||||
params.setGameState({
|
||||
...params.finalState,
|
||||
...actingState,
|
||||
playerHp: 0,
|
||||
animationState: AnimationState.DIE,
|
||||
playerActionMode: 'idle',
|
||||
inBattle: false,
|
||||
|
||||
@@ -1,60 +1,5 @@
|
||||
import { getCharacterById } from '../../data/characterPresets';
|
||||
import {
|
||||
NPC_CHAT_FUNCTION,
|
||||
STORY_OPENING_CAMP_DIALOGUE_FUNCTION,
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
describeNpcAffinityInWords,
|
||||
getNpcConversationDirective,
|
||||
isNpcFirstMeaningfulContact,
|
||||
} from '../../data/npcInteractions';
|
||||
import { buildSceneEntityCatalogText } from '../../data/scenePresets';
|
||||
import { hasMixedNarrativeLanguage } from '../../services/narrativeLanguage';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
} from '../../services/storyEngine/actorNarrativeProfile';
|
||||
import { applyAdaptiveTuningToPromptContext } from '../../services/storyEngine/adaptiveNarrativeTuner';
|
||||
import { compileCampaignFromWorldProfile } from '../../services/storyEngine/campaignPackCompiler';
|
||||
import {
|
||||
buildCampEvent,
|
||||
evaluateCampEventOpportunity,
|
||||
} from '../../services/storyEngine/campEventDirector';
|
||||
import {
|
||||
advanceChapterState,
|
||||
resolveCurrentChapterState,
|
||||
} from '../../services/storyEngine/chapterDirector';
|
||||
import {
|
||||
advanceCompanionArc,
|
||||
buildCompanionArcStates,
|
||||
} from '../../services/storyEngine/companionArcDirector';
|
||||
import { buildGoalStackState } from '../../services/storyEngine/goalDirector';
|
||||
import { resolveCurrentJourneyBeat } from '../../services/storyEngine/journeyBeatPlanner';
|
||||
import { buildVisibilitySliceFromFacts } from '../../services/storyEngine/knowledgeContract';
|
||||
import { buildKnowledgeGraph } from '../../services/storyEngine/knowledgeGraph';
|
||||
import { buildRecentCarrierEchoes } from '../../services/storyEngine/narrativeCarrierCatalog';
|
||||
import { buildChapterRecap } from '../../services/storyEngine/recapDigest';
|
||||
import { resolveScenarioPack } from '../../services/storyEngine/scenarioPackRegistry';
|
||||
import { buildSceneNarrativeDirective } from '../../services/storyEngine/sceneNarrativeDirector';
|
||||
import {
|
||||
buildSetpieceDirective,
|
||||
evaluateSetpieceOpportunity,
|
||||
} from '../../services/storyEngine/setpieceDirector';
|
||||
import { buildChronicleSummary } from '../../services/storyEngine/storyChronicle';
|
||||
import { buildThemePackFromWorldProfile } from '../../services/storyEngine/themePack';
|
||||
import {
|
||||
buildEncounterVisibilitySlice,
|
||||
createEmptyStoryEngineMemoryState,
|
||||
} from '../../services/storyEngine/visibilityEngine';
|
||||
import { buildFallbackWorldStoryGraph } from '../../services/storyEngine/worldStoryGraph';
|
||||
import type { GameState } from '../../types';
|
||||
import { getCharacterChatRecord } from './characterChat';
|
||||
import { getNpcEncounterKey } from './storyGenerationState';
|
||||
|
||||
const OPENING_CAMP_DIALOGUE_FUNCTION_ID =
|
||||
STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
|
||||
|
||||
export type StoryContextBuilderExtras = {
|
||||
pendingSceneEncounter?: boolean;
|
||||
@@ -66,560 +11,35 @@ export type StoryContextBuilderExtras = {
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
};
|
||||
|
||||
function buildPartyRelationshipNotes(state: GameState) {
|
||||
const lines: string[] = [];
|
||||
const seenCharacterIds = new Set<string>();
|
||||
|
||||
const appendNote = (characterId: string, roleLabel: string) => {
|
||||
if (seenCharacterIds.has(characterId)) return;
|
||||
const character = getCharacterById(characterId);
|
||||
const summary = getCharacterChatRecord(state, characterId).summary.trim();
|
||||
if (hasMixedNarrativeLanguage(summary)) return;
|
||||
if (!character || !summary) return;
|
||||
|
||||
seenCharacterIds.add(characterId);
|
||||
lines.push(
|
||||
`- ${character.name} (${character.title} / ${roleLabel}): ${summary}`,
|
||||
);
|
||||
};
|
||||
|
||||
state.companions.forEach((companion) =>
|
||||
appendNote(companion.characterId, '当前同行'),
|
||||
);
|
||||
state.roster.forEach((companion) =>
|
||||
appendNote(companion.characterId, '营地待命'),
|
||||
);
|
||||
|
||||
return lines.length > 0 ? lines.join('\n') : null;
|
||||
}
|
||||
|
||||
function describeScenePressureLevel(
|
||||
pressureLevel: 'low' | 'medium' | 'high' | 'extreme' | null | undefined,
|
||||
) {
|
||||
switch (pressureLevel) {
|
||||
case 'low':
|
||||
return '低';
|
||||
case 'medium':
|
||||
return '中';
|
||||
case 'high':
|
||||
return '高';
|
||||
case 'extreme':
|
||||
return '极高';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRecentConversationEventText(state: GameState) {
|
||||
const recentText = state.storyHistory
|
||||
.slice(-6)
|
||||
.map((item) => item.text)
|
||||
.join('\n');
|
||||
if (
|
||||
/击败|怪物|战斗|切磋|交手|脱身/u.test(recentText)
|
||||
) {
|
||||
return '你们刚经历过一场交锋或切磋,空气里的紧张感还没有完全散去。';
|
||||
}
|
||||
if (/携手|相助|帮你|并肩/u.test(recentText)) {
|
||||
return '你们刚并肩配合过一次,彼此之间的距离感稍微淡了一些。';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferConversationSituation(
|
||||
state: GameState,
|
||||
extras: Pick<
|
||||
StoryContextBuilderExtras,
|
||||
'lastFunctionId' | 'openingCampDialogue'
|
||||
>,
|
||||
) {
|
||||
if (state.inBattle) return 'shared_danger_coordination' as const;
|
||||
if (extras.lastFunctionId === OPENING_CAMP_DIALOGUE_FUNCTION_ID)
|
||||
return 'camp_first_contact' as const;
|
||||
if (
|
||||
state.currentEncounter?.specialBehavior === 'camp_companion' &&
|
||||
extras.openingCampDialogue?.trim()
|
||||
) {
|
||||
return 'camp_followup' as const;
|
||||
}
|
||||
const recentText = state.storyHistory
|
||||
.slice(-6)
|
||||
.map((item) => item.text)
|
||||
.join('\n');
|
||||
if (
|
||||
/击败|怪物|战斗|切磋|交手|脱身/u.test(recentText)
|
||||
) {
|
||||
return 'post_battle_breath' as const;
|
||||
}
|
||||
if (extras.lastFunctionId === NPC_CHAT_FUNCTION.id)
|
||||
return 'private_followup' as const;
|
||||
return 'first_contact_cautious' as const;
|
||||
}
|
||||
|
||||
function inferConversationPressure(
|
||||
state: GameState,
|
||||
situation: ReturnType<typeof inferConversationSituation>,
|
||||
) {
|
||||
const hpRatio = state.playerHp / Math.max(state.playerMaxHp, 1);
|
||||
if (state.inBattle || hpRatio < 0.35) return 'high' as const;
|
||||
if (
|
||||
situation === 'post_battle_breath' ||
|
||||
situation === 'shared_danger_coordination'
|
||||
)
|
||||
return 'medium' as const;
|
||||
if (situation === 'camp_first_contact' || situation === 'camp_followup')
|
||||
return 'low' as const;
|
||||
return 'medium' as const;
|
||||
}
|
||||
|
||||
function describeConversationSituation(
|
||||
situation: ReturnType<typeof inferConversationSituation>,
|
||||
) {
|
||||
switch (situation) {
|
||||
case 'camp_first_contact':
|
||||
return '这是营地里第一次真正静下来对话的时刻,语气要保持谨慎、观察和轻微试探。';
|
||||
case 'camp_followup':
|
||||
return '营地里的第一轮试探已经发生过了,这一轮应当顺着刚才的话头稍微往深处接。';
|
||||
case 'post_battle_breath':
|
||||
return '一场交锋刚结束,眼前危险稍缓,但双方都还带着余悸和紧绷。';
|
||||
case 'shared_danger_coordination':
|
||||
return '危险还没过去,对话应当短、准、直接,优先服务眼前判断。';
|
||||
case 'private_followup':
|
||||
return '这已经不是严格意义上的初见,更适合作为刚才未说完那句话的延续。';
|
||||
default:
|
||||
return '双方才刚真正对上话,此刻仍在判断彼此能信到什么程度。';
|
||||
}
|
||||
}
|
||||
|
||||
function describeConversationTalkPriority(
|
||||
situation: ReturnType<typeof inferConversationSituation>,
|
||||
) {
|
||||
switch (situation) {
|
||||
case 'camp_first_contact':
|
||||
return '优先写眼前印象、彼此态度和营地气氛,不要一上来就把动机讲透。';
|
||||
case 'camp_followup':
|
||||
return '先接住上一轮还没说透的话头,再决定要不要继续往下追问。';
|
||||
case 'post_battle_breath':
|
||||
return '先谈刚刚那次交锋以及彼此的判断,再视情况往更深处推进。';
|
||||
case 'shared_danger_coordination':
|
||||
return '先说最有用的判断、危险和下一步,不要扩成大段背景说明。';
|
||||
case 'private_followup':
|
||||
return '承接当前话头和关系变化,不要把对话又写回刚见面时的节奏。';
|
||||
default:
|
||||
return '先试探态度和现场判断,不要急着把来意和秘密一次摊开。';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEncounterNarrativeProfile(state: GameState) {
|
||||
const encounter = state.currentEncounter;
|
||||
if (!encounter || encounter.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
if (encounter.narrativeProfile) {
|
||||
return encounter.narrativeProfile;
|
||||
}
|
||||
if (!state.customWorldProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const role =
|
||||
state.customWorldProfile.storyNpcs.find((npc) =>
|
||||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||||
)
|
||||
?? state.customWorldProfile.playableNpcs.find((npc) =>
|
||||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||||
);
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const themePack =
|
||||
state.customWorldProfile.themePack
|
||||
?? buildThemePackFromWorldProfile(state.customWorldProfile);
|
||||
const storyGraph =
|
||||
state.customWorldProfile.storyGraph
|
||||
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
|
||||
|
||||
return normalizeActorNarrativeProfile(
|
||||
role.narrativeProfile,
|
||||
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveActiveThreadIds(
|
||||
state: GameState,
|
||||
encounterNarrativeProfile: ReturnType<typeof resolveEncounterNarrativeProfile>,
|
||||
) {
|
||||
if (state.storyEngineMemory?.activeThreadIds?.length) {
|
||||
return state.storyEngineMemory.activeThreadIds.slice(0, 4);
|
||||
}
|
||||
if (encounterNarrativeProfile?.relatedThreadIds.length) {
|
||||
return encounterNarrativeProfile.relatedThreadIds.slice(0, 4);
|
||||
}
|
||||
if (!state.customWorldProfile) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const themePack =
|
||||
state.customWorldProfile.themePack
|
||||
?? buildThemePackFromWorldProfile(state.customWorldProfile);
|
||||
const storyGraph =
|
||||
state.customWorldProfile.storyGraph
|
||||
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
|
||||
|
||||
return storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时 story prompt context 的正式投影已经迁到 server-rs。
|
||||
* 前端只保留 session 与少量请求元信息,方便旧调用面继续复用同一个函数签名。
|
||||
*/
|
||||
export function buildStoryContextFromState(
|
||||
state: GameState,
|
||||
extras: StoryContextBuilderExtras = {},
|
||||
): StoryGenerationContext {
|
||||
const conversationSituation = inferConversationSituation(state, extras);
|
||||
const conversationPressure = inferConversationPressure(
|
||||
state,
|
||||
conversationSituation,
|
||||
);
|
||||
const recentSharedEvent = buildRecentConversationEventText(state);
|
||||
const encounterNpcState =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return extras.encounterNpcStateOverride
|
||||
?? state.npcStates[getNpcEncounterKey(encounter)]
|
||||
?? buildInitialNpcState(encounter, state.worldType, state);
|
||||
})()
|
||||
: null;
|
||||
const encounterDirective =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return encounterNpcState
|
||||
? getNpcConversationDirective(encounter, encounterNpcState)
|
||||
: null;
|
||||
})()
|
||||
: null;
|
||||
const isFirstMeaningfulContact =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return encounterNpcState
|
||||
? isNpcFirstMeaningfulContact(encounter, encounterNpcState)
|
||||
: false;
|
||||
})()
|
||||
: false;
|
||||
const firstContactRelationStance = (() => {
|
||||
if (
|
||||
!isFirstMeaningfulContact ||
|
||||
!state.currentEncounter ||
|
||||
state.currentEncounter.kind !== 'npc'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stance = encounterNpcState?.relationState?.stance ?? null;
|
||||
if (
|
||||
stance === 'guarded' ||
|
||||
stance === 'neutral' ||
|
||||
stance === 'cooperative' ||
|
||||
stance === 'bonded'
|
||||
) {
|
||||
return stance;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const encounterAffinityText =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return encounterNpcState
|
||||
? describeNpcAffinityInWords(encounter, encounterNpcState.affinity, {
|
||||
recruited: encounterNpcState.recruited,
|
||||
})
|
||||
: null;
|
||||
})()
|
||||
: null;
|
||||
const baseSceneDescription = state.currentScenePreset?.description ?? null;
|
||||
const sceneMutationDescription = [
|
||||
state.currentScenePreset?.mutationStateText
|
||||
? `最新世界变化:${state.currentScenePreset.mutationStateText}`
|
||||
: null,
|
||||
describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)
|
||||
? `当前区域压力等级:${describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
const observeSignsSceneDescription =
|
||||
extras.observeSignsRequested && state.worldType
|
||||
? [
|
||||
baseSceneDescription,
|
||||
sceneMutationDescription,
|
||||
'当前可观察实体池:',
|
||||
buildSceneEntityCatalogText(
|
||||
state.worldType,
|
||||
state.currentScenePreset?.id ?? null,
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: [baseSceneDescription, sceneMutationDescription].filter(Boolean).join('\n');
|
||||
const storyEngineMemory =
|
||||
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
const knowledgeFacts =
|
||||
state.customWorldProfile?.knowledgeFacts
|
||||
?? (state.customWorldProfile ? buildKnowledgeGraph(state.customWorldProfile) : []);
|
||||
const encounterNarrativeProfile = resolveEncounterNarrativeProfile(state);
|
||||
const activeThreadIds = resolveActiveThreadIds(
|
||||
{
|
||||
...state,
|
||||
storyEngineMemory,
|
||||
} as GameState,
|
||||
encounterNarrativeProfile,
|
||||
);
|
||||
const visibilitySlice =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const relevantFacts = knowledgeFacts.filter((fact) =>
|
||||
fact.ownerActorIds.includes(state.currentEncounter?.id ?? '')
|
||||
|| fact.ownerActorIds.includes(state.currentEncounter?.npcName ?? '')
|
||||
|| fact.relatedThreadIds.some((threadId) => activeThreadIds.includes(threadId)),
|
||||
);
|
||||
return relevantFacts.length > 0
|
||||
? buildVisibilitySliceFromFacts({
|
||||
facts: relevantFacts,
|
||||
discoveredFactIds: [
|
||||
...storyEngineMemory.discoveredFactIds,
|
||||
...(encounterNpcState?.revealedFacts ?? []),
|
||||
...(encounterNpcState?.seenBackstoryChapterIds ?? []).map(
|
||||
(chapterId) =>
|
||||
relevantFacts.find((fact) =>
|
||||
fact.aliases?.includes(chapterId) || fact.id.includes(chapterId),
|
||||
)?.id ?? '',
|
||||
),
|
||||
],
|
||||
activeThreadIds,
|
||||
disclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
})
|
||||
: buildEncounterVisibilitySlice({
|
||||
narrativeProfile: encounterNarrativeProfile,
|
||||
backstoryReveal: state.currentEncounter.backstoryReveal ?? null,
|
||||
disclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
seenBackstoryChapterIds: encounterNpcState?.seenBackstoryChapterIds ?? [],
|
||||
storyEngineMemory,
|
||||
activeThreadIds,
|
||||
});
|
||||
})()
|
||||
: null;
|
||||
const sceneNarrativeDirective = buildSceneNarrativeDirective({
|
||||
return {
|
||||
runtimeSessionId: state.runtimeSessionId ?? null,
|
||||
runtimeActionVersion: state.runtimeActionVersion,
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
playerMaxMana: state.playerMaxMana,
|
||||
inBattle: state.inBattle,
|
||||
playerX: state.playerX,
|
||||
playerFacing: state.playerFacing,
|
||||
playerAnimation: state.animationState,
|
||||
skillCooldowns: state.playerSkillCooldowns,
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
sceneName: state.currentScenePreset?.name ?? null,
|
||||
encounterId: state.currentEncounter?.id ?? null,
|
||||
encounterName: state.currentEncounter?.npcName ?? null,
|
||||
recentActions: state.storyHistory.slice(-3).map((moment) => moment.text),
|
||||
activeThreadIds,
|
||||
visibilitySlice,
|
||||
encounterNarrativeProfile,
|
||||
disclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
affinity: encounterNpcState?.affinity ?? null,
|
||||
});
|
||||
const chapterState = advanceChapterState({
|
||||
previousChapter: state.chapterState ?? storyEngineMemory.currentChapter ?? null,
|
||||
nextChapter: resolveCurrentChapterState({
|
||||
state: {
|
||||
...state,
|
||||
storyEngineMemory,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const journeyBeat = resolveCurrentJourneyBeat({
|
||||
state: {
|
||||
...state,
|
||||
chapterState,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
currentChapter: chapterState,
|
||||
},
|
||||
} as GameState,
|
||||
chapterState,
|
||||
});
|
||||
const companionArcStates = advanceCompanionArc({
|
||||
previous: storyEngineMemory.companionArcStates,
|
||||
next: buildCompanionArcStates({
|
||||
state,
|
||||
reactions: storyEngineMemory.recentCompanionReactions,
|
||||
}),
|
||||
});
|
||||
const currentCampEvent = evaluateCampEventOpportunity({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
companionArcStates,
|
||||
})
|
||||
? buildCampEvent({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
companionArcStates,
|
||||
})
|
||||
: null;
|
||||
const setpieceDirective = evaluateSetpieceOpportunity({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
})
|
||||
? buildSetpieceDirective({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
})
|
||||
: null;
|
||||
const recentWorldMutations = storyEngineMemory.worldMutations ?? [];
|
||||
const recentChronicleSummary = buildChronicleSummary({
|
||||
...state,
|
||||
chapterState,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
currentChapter: chapterState,
|
||||
companionArcStates,
|
||||
},
|
||||
} as GameState);
|
||||
const compiledPacks = state.customWorldProfile
|
||||
? compileCampaignFromWorldProfile({ profile: state.customWorldProfile })
|
||||
: null;
|
||||
const goalStack = buildGoalStackState({
|
||||
quests: state.quests,
|
||||
worldType: state.worldType,
|
||||
currentSceneId: state.currentScenePreset?.id ?? null,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
setpieceDirective,
|
||||
currentCampEvent,
|
||||
currentSceneName: state.currentScenePreset?.name ?? null,
|
||||
});
|
||||
const activeScenarioPack =
|
||||
resolveScenarioPack(state.activeScenarioPackId)
|
||||
?? compiledPacks?.scenarioPack
|
||||
?? null;
|
||||
const activeCampaignPack = compiledPacks?.campaignPack ?? null;
|
||||
|
||||
const fallbackChapterRecap = buildChapterRecap({
|
||||
state: { ...state, chapterState } as GameState,
|
||||
});
|
||||
const safeEncounterRelationshipSummary =
|
||||
state.currentEncounter?.characterId
|
||||
? getCharacterChatRecord(state, state.currentEncounter.characterId)
|
||||
.summary
|
||||
.trim()
|
||||
: '';
|
||||
|
||||
return applyAdaptiveTuningToPromptContext({
|
||||
context: {
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
playerMaxMana: state.playerMaxMana,
|
||||
inBattle: state.inBattle,
|
||||
playerX: state.playerX,
|
||||
playerFacing: state.playerFacing,
|
||||
playerAnimation: state.animationState,
|
||||
skillCooldowns: state.playerSkillCooldowns,
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
sceneName: state.currentScenePreset?.name ?? null,
|
||||
sceneDescription: observeSignsSceneDescription,
|
||||
pendingSceneEncounter: extras.pendingSceneEncounter ?? false,
|
||||
lastFunctionId: extras.lastFunctionId ?? null,
|
||||
observeSignsRequested: extras.observeSignsRequested ?? false,
|
||||
recentActionResult: extras.recentActionResult ?? null,
|
||||
lastObserveSignsReport:
|
||||
state.lastObserveSignsSceneId === (state.currentScenePreset?.id ?? null)
|
||||
? (state.lastObserveSignsReport ?? null)
|
||||
: null,
|
||||
encounterKind: state.currentEncounter?.kind ?? null,
|
||||
encounterName: state.currentEncounter?.npcName ?? null,
|
||||
encounterDescription: state.currentEncounter?.npcDescription ?? null,
|
||||
encounterContext: state.currentEncounter?.context ?? null,
|
||||
encounterId: state.currentEncounter?.id ?? null,
|
||||
encounterCharacterId: state.currentEncounter?.characterId ?? null,
|
||||
encounterGender: state.currentEncounter?.gender ?? null,
|
||||
encounterCustomProfile: state.currentEncounter
|
||||
? {
|
||||
title: state.currentEncounter.title ?? '',
|
||||
description: state.currentEncounter.npcDescription ?? '',
|
||||
backstory: state.currentEncounter.backstory ?? '',
|
||||
personality: state.currentEncounter.personality ?? '',
|
||||
motivation: state.currentEncounter.motivation ?? '',
|
||||
combatStyle: state.currentEncounter.combatStyle ?? '',
|
||||
relationshipHooks: [...(state.currentEncounter.relationshipHooks ?? [])],
|
||||
tags: [...(state.currentEncounter.tags ?? [])],
|
||||
backstoryReveal: state.currentEncounter.backstoryReveal,
|
||||
skills: [...(state.currentEncounter.skills ?? [])],
|
||||
initialItems: [...(state.currentEncounter.initialItems ?? [])],
|
||||
imageSrc: state.currentEncounter.imageSrc,
|
||||
visual: state.currentEncounter.visual,
|
||||
narrativeProfile: state.currentEncounter.narrativeProfile,
|
||||
}
|
||||
: null,
|
||||
encounterAffinity: encounterDirective?.affinity ?? null,
|
||||
encounterAffinityText,
|
||||
encounterStanceProfile: encounterNpcState?.stanceProfile ?? null,
|
||||
encounterConversationStyle: encounterDirective?.style ?? null,
|
||||
encounterDisclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
encounterWarmthStage: encounterDirective?.warmthStage ?? null,
|
||||
encounterAnswerMode: encounterDirective?.answerMode ?? null,
|
||||
encounterAllowedTopics: encounterDirective?.allowTopics ?? null,
|
||||
encounterBlockedTopics: encounterDirective?.blockedTopics ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
firstContactRelationStance,
|
||||
conversationSituation,
|
||||
conversationPressure,
|
||||
recentSharedEvent:
|
||||
recentSharedEvent ?? describeConversationSituation(conversationSituation),
|
||||
talkPriority: describeConversationTalkPriority(conversationSituation),
|
||||
visibilitySlice,
|
||||
sceneNarrativeDirective,
|
||||
campaignState: state.campaignState ?? storyEngineMemory.campaignState ?? null,
|
||||
actState: storyEngineMemory.actState ?? null,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
goalStack,
|
||||
currentCampEvent,
|
||||
setpieceDirective,
|
||||
activeScenarioPack,
|
||||
activeCampaignPack,
|
||||
encounterNarrativeProfile,
|
||||
knowledgeFacts,
|
||||
activeThreadIds,
|
||||
companionArcStates,
|
||||
companionResolutions: storyEngineMemory.companionResolutions ?? [],
|
||||
consequenceLedger: storyEngineMemory.consequenceLedger ?? [],
|
||||
authorialConstraintPack: storyEngineMemory.authorialConstraintPack ?? null,
|
||||
playerStyleProfile: storyEngineMemory.playerStyleProfile ?? null,
|
||||
recentCompanionReactions: storyEngineMemory.recentCompanionReactions ?? [],
|
||||
recentCarrierEchoes: buildRecentCarrierEchoes(state),
|
||||
recentWorldMutations,
|
||||
recentFactionTensionStates: storyEngineMemory.factionTensionStates ?? [],
|
||||
recentChronicleSummary:
|
||||
recentChronicleSummary.trim() &&
|
||||
!hasMixedNarrativeLanguage(recentChronicleSummary)
|
||||
? recentChronicleSummary
|
||||
: fallbackChapterRecap,
|
||||
narrativeQaReport: storyEngineMemory.narrativeQaReport ?? null,
|
||||
releaseGateReport: storyEngineMemory.releaseGateReport ?? null,
|
||||
simulationRunResults: storyEngineMemory.simulationRunResults ?? [],
|
||||
branchBudgetPressure: storyEngineMemory.branchBudgetStatus?.pressure ?? null,
|
||||
encounterRelationshipSummary: state.currentEncounter?.characterId
|
||||
? !hasMixedNarrativeLanguage(safeEncounterRelationshipSummary)
|
||||
? safeEncounterRelationshipSummary || null
|
||||
: null
|
||||
: null,
|
||||
partyRelationshipNotes: buildPartyRelationshipNotes(state),
|
||||
customWorldProfile: state.customWorldProfile ?? null,
|
||||
openingCampBackground: extras.openingCampBackground ?? null,
|
||||
openingCampDialogue: extras.openingCampDialogue ?? null,
|
||||
},
|
||||
profile: storyEngineMemory.playerStyleProfile ?? null,
|
||||
});
|
||||
sceneDescription: state.currentScenePreset?.description ?? null,
|
||||
pendingSceneEncounter: extras.pendingSceneEncounter ?? false,
|
||||
lastFunctionId: extras.lastFunctionId ?? null,
|
||||
observeSignsRequested: extras.observeSignsRequested ?? false,
|
||||
recentActionResult: extras.recentActionResult ?? null,
|
||||
customWorldProfile: null,
|
||||
openingCampBackground: extras.openingCampBackground ?? null,
|
||||
openingCampDialogue: extras.openingCampDialogue ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -156,6 +156,51 @@ function createBaseState(): GameState {
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 10,
|
||||
playerInventory: [createInventoryItem('player-potion', 'Potion')],
|
||||
runtimeNpcInteraction: {
|
||||
npcId: 'npc-trader',
|
||||
npcName: 'Trader Lin',
|
||||
playerCurrency: 10,
|
||||
currencyName: '铜钱',
|
||||
trade: {
|
||||
buyItems: [
|
||||
{
|
||||
itemId: 'npc-herb',
|
||||
item: createInventoryItem('npc-herb', 'Herb'),
|
||||
mode: 'buy',
|
||||
unitPrice: 3,
|
||||
maxQuantity: 1,
|
||||
canSubmit: true,
|
||||
reason: null,
|
||||
},
|
||||
],
|
||||
sellItems: [
|
||||
{
|
||||
itemId: 'player-potion',
|
||||
item: createInventoryItem('player-potion', 'Potion'),
|
||||
mode: 'sell',
|
||||
unitPrice: 1,
|
||||
maxQuantity: 1,
|
||||
canSubmit: true,
|
||||
reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
gift: {
|
||||
items: [
|
||||
{
|
||||
itemId: 'jade-token',
|
||||
item: createInventoryItem('jade-token', 'Jade Token', {
|
||||
rarity: 'rare',
|
||||
category: '专属',
|
||||
tags: ['merchant'],
|
||||
}),
|
||||
affinityGain: 16,
|
||||
canSubmit: true,
|
||||
reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
@@ -202,7 +247,7 @@ function createInteractionOption(action: Extract<NonNullable<StoryOption['intera
|
||||
}
|
||||
|
||||
describe('storyGenerationState', () => {
|
||||
it('opens the trade modal with the first npc and player inventory items selected', () => {
|
||||
it('opens the trade modal with server-selected npc and player items', () => {
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
createBaseState(),
|
||||
createInteractionOption('trade'),
|
||||
@@ -218,14 +263,39 @@ describe('storyGenerationState', () => {
|
||||
expect(decision.modal.selectedQuantity).toBe(1);
|
||||
});
|
||||
|
||||
it('skips zero-quantity player items when opening the trade modal', () => {
|
||||
it('prefers the first server-submittable sell item when opening the trade modal', () => {
|
||||
const baseState = createBaseState();
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
{
|
||||
...createBaseState(),
|
||||
playerInventory: [
|
||||
createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }),
|
||||
createInventoryItem('player-herb', 'Herb'),
|
||||
],
|
||||
...baseState,
|
||||
runtimeNpcInteraction: {
|
||||
...baseState.runtimeNpcInteraction!,
|
||||
trade: {
|
||||
buyItems: baseState.runtimeNpcInteraction!.trade.buyItems,
|
||||
sellItems: [
|
||||
{
|
||||
itemId: 'empty-slot',
|
||||
item: createInventoryItem('empty-slot', 'Empty Slot', {
|
||||
quantity: 0,
|
||||
}),
|
||||
mode: 'sell',
|
||||
unitPrice: 1,
|
||||
maxQuantity: 0,
|
||||
canSubmit: false,
|
||||
reason: '背包数量不足。',
|
||||
},
|
||||
{
|
||||
itemId: 'player-herb',
|
||||
item: createInventoryItem('player-herb', 'Herb'),
|
||||
mode: 'sell',
|
||||
unitPrice: 2,
|
||||
maxQuantity: 1,
|
||||
canSubmit: true,
|
||||
reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
createInteractionOption('trade'),
|
||||
);
|
||||
@@ -257,21 +327,9 @@ describe('storyGenerationState', () => {
|
||||
expect(decision.modal.selectedReleaseNpcId).toBe('npc-1');
|
||||
});
|
||||
|
||||
it('opens the gift modal with the preferred gift candidate selected', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
playerInventory: [
|
||||
createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }),
|
||||
createInventoryItem('jade-token', 'Jade Token', {
|
||||
rarity: 'rare',
|
||||
category: '专属',
|
||||
tags: ['merchant'],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
it('opens the gift modal with the server-selected gift candidate', () => {
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
state,
|
||||
createBaseState(),
|
||||
createInteractionOption('gift'),
|
||||
);
|
||||
|
||||
@@ -284,9 +342,13 @@ describe('storyGenerationState', () => {
|
||||
});
|
||||
|
||||
it('does not open the gift modal when there are no gift candidates', () => {
|
||||
const baseState = createBaseState();
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
playerInventory: [],
|
||||
...baseState,
|
||||
runtimeNpcInteraction: {
|
||||
...baseState.runtimeNpcInteraction!,
|
||||
gift: { items: [] },
|
||||
},
|
||||
};
|
||||
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
|
||||
@@ -10,11 +10,7 @@
|
||||
import {
|
||||
applyQuestProgressFromSceneReached,
|
||||
} from '../../data/questFlow';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
getPreferredGiftItemId,
|
||||
MAX_COMPANIONS,
|
||||
} from '../../data/npcInteractions';
|
||||
import { MAX_COMPANIONS } from '../../data/npcInteractions';
|
||||
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
|
||||
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
|
||||
import { getScenePresetById } from '../../data/scenePresets';
|
||||
@@ -53,11 +49,10 @@ export function getNpcEncounterKey(encounter: Encounter) {
|
||||
return encounter.id ?? encounter.npcName;
|
||||
}
|
||||
|
||||
function getResolvedNpcState(state: GameState, encounter: Encounter) {
|
||||
return (
|
||||
state.npcStates[getNpcEncounterKey(encounter)] ??
|
||||
buildInitialNpcState(encounter, state.worldType)
|
||||
);
|
||||
function findPreferredTradeItemId(
|
||||
items: Array<{ itemId: string; canSubmit: boolean }>,
|
||||
) {
|
||||
return items.find(item => item.canSubmit)?.itemId ?? items[0]?.itemId ?? null;
|
||||
}
|
||||
|
||||
export function resolveNpcInteractionDecision(
|
||||
@@ -73,29 +68,29 @@ export function resolveNpcInteractionDecision(
|
||||
}
|
||||
|
||||
const encounter = state.currentEncounter;
|
||||
const npcState = getResolvedNpcState(state, encounter);
|
||||
|
||||
switch (option.functionId) {
|
||||
case NPC_TRADE_FUNCTION.id:
|
||||
return {
|
||||
kind: 'trade_modal',
|
||||
modal: buildNpcTradeModalState(
|
||||
state,
|
||||
encounter,
|
||||
option.actionText,
|
||||
npcState.inventory,
|
||||
findPreferredTradeItemId(
|
||||
state.runtimeNpcInteraction?.trade.buyItems ?? [],
|
||||
),
|
||||
findPreferredTradeItemId(
|
||||
state.runtimeNpcInteraction?.trade.sellItems ?? [],
|
||||
),
|
||||
),
|
||||
};
|
||||
case NPC_GIFT_FUNCTION.id:
|
||||
{
|
||||
const selectedGiftItemId = getPreferredGiftItemId(
|
||||
state.playerInventory,
|
||||
encounter,
|
||||
{
|
||||
worldType: state.worldType,
|
||||
customWorldProfile: state.customWorldProfile,
|
||||
},
|
||||
);
|
||||
const selectedGiftItemId =
|
||||
state.runtimeNpcInteraction?.gift.items.find(item => item.canSubmit)
|
||||
?.itemId ??
|
||||
state.runtimeNpcInteraction?.gift.items[0]?.itemId ??
|
||||
null;
|
||||
if (!selectedGiftItemId) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
@@ -103,7 +98,6 @@ export function resolveNpcInteractionDecision(
|
||||
return {
|
||||
kind: 'gift_modal',
|
||||
modal: buildNpcGiftModalState(
|
||||
state,
|
||||
encounter,
|
||||
option.actionText,
|
||||
selectedGiftItemId,
|
||||
|
||||
@@ -60,6 +60,8 @@ type StoryInteractionCoordinatorParams = {
|
||||
export function createStoryInteractionCoordinatorConfig(
|
||||
params: StoryInteractionCoordinatorParams,
|
||||
) {
|
||||
// 中文注释:sharedRuntime 是宝箱流和背包流共享的最小运行时上下文,
|
||||
// 这两类动作不需要拿到完整 NPC / 对话链配置,因此先抽一层轻量公共配置。
|
||||
const sharedRuntime = {
|
||||
currentStory: params.currentStory,
|
||||
setGameState: params.setGameState,
|
||||
@@ -87,6 +89,8 @@ export function createStoryInteractionCoordinatorConfig(
|
||||
cloneInventoryItemForOwner:
|
||||
params.runtimeSupport.cloneInventoryItemForOwner,
|
||||
runtime: {
|
||||
// 中文注释:NPC 交互流需要最完整的故事上下文,
|
||||
// 包括对话故事构建、继续生成、打字机延迟和敌对 NPC 推断。
|
||||
currentStory: params.currentStory,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
@@ -100,6 +104,8 @@ export function createStoryInteractionCoordinatorConfig(
|
||||
},
|
||||
},
|
||||
npcEncounterActions: {
|
||||
// 中文注释:npcEncounterActions 是最重的一组配置,
|
||||
// 它同时服务“进入 NPC 遭遇”“提交 NPC 动作”“战斗后恢复对话”等整条分支。
|
||||
gameState: params.gameState,
|
||||
currentStory: params.currentStory,
|
||||
setGameState: params.setGameState,
|
||||
|
||||
@@ -118,6 +118,8 @@ describe('storyRequestCoordinator', () => {
|
||||
const buildStoryContextFromState = vi.fn(
|
||||
(_state, extras) =>
|
||||
({
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 3,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 30,
|
||||
@@ -177,7 +179,8 @@ describe('storyRequestCoordinator', () => {
|
||||
history,
|
||||
'继续交谈',
|
||||
expect.objectContaining({
|
||||
sceneId: 'inn_room',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 3,
|
||||
lastFunctionId: 'npc_chat',
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type {
|
||||
RuntimeStoryEquipmentSlotView,
|
||||
RuntimeStoryForgeRecipeView,
|
||||
RuntimeStoryInventoryItemView,
|
||||
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryState';
|
||||
import type {
|
||||
Encounter,
|
||||
GoalHandoff,
|
||||
GoalPulseEvent,
|
||||
@@ -52,22 +57,11 @@ export interface InventoryFlowUi {
|
||||
useInventoryItem: (itemId: string) => Promise<boolean>;
|
||||
equipInventoryItem: (itemId: string) => Promise<boolean>;
|
||||
unequipItem: (slot: 'weapon' | 'armor' | 'relic') => Promise<boolean>;
|
||||
forgeRecipes: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'synthesis' | 'forge';
|
||||
description: string;
|
||||
resultLabel: string;
|
||||
currencyCost: number;
|
||||
currencyText: string;
|
||||
requirements: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
quantity: number;
|
||||
owned: number;
|
||||
}>;
|
||||
canCraft: boolean;
|
||||
}>;
|
||||
playerCurrency: number | null;
|
||||
currencyText: string | null;
|
||||
backpackItems: RuntimeStoryInventoryItemView[];
|
||||
equipmentSlots: RuntimeStoryEquipmentSlotView[];
|
||||
forgeRecipes: RuntimeStoryForgeRecipeView[];
|
||||
craftRecipe: (recipeId: string) => Promise<boolean>;
|
||||
dismantleItem: (itemId: string) => Promise<boolean>;
|
||||
reforgeItem: (itemId: string) => Promise<boolean>;
|
||||
|
||||
@@ -62,6 +62,8 @@ export function createClearStoryInteractionUi(params: {
|
||||
clearNpcInteractionUi: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
// 中文注释:story 选择面板和 NPC 交互面板是两套独立 UI;
|
||||
// 清理运行时交互态时必须同时重置,避免战斗/对话切换后残留旧弹层。
|
||||
params.clearStoryChoiceUi();
|
||||
params.clearNpcInteractionUi();
|
||||
};
|
||||
@@ -120,6 +122,8 @@ export function useRpgRuntimeInteractionFlow({
|
||||
}
|
||||
|
||||
if (isNpcEncounter(gameState.currentEncounter)) {
|
||||
// 中文注释:当场景里已经解析出 NPC 遭遇,且当前不在战斗/加载中时,
|
||||
// 自动进入 NPC 交互态,让开场相遇和旅行后遭遇都能无缝落到对话/互动面板。
|
||||
enterNpcInteraction(
|
||||
gameState.currentEncounter,
|
||||
`与${gameState.currentEncounter.npcName}搭话`,
|
||||
@@ -180,6 +184,8 @@ export function useRpgRuntimeInteractionFlow({
|
||||
);
|
||||
},
|
||||
};
|
||||
// 中文注释:choice coordinator 只关心“点下某个 story option 后怎么结算”,
|
||||
// NPC 战斗结束后要不要回到对话态,则通过 runtimeSupport 在这里桥接进去。
|
||||
const choiceRuntimeSupport: ChoiceRuntimeSupport = {
|
||||
...runtimeSupport,
|
||||
handleNpcBattleConversationContinuation: ({
|
||||
@@ -248,6 +254,8 @@ export function useRpgRuntimeInteractionFlow({
|
||||
return false;
|
||||
}
|
||||
|
||||
// 中文注释:聊天提交是 fire-and-forget,
|
||||
// 调用方只需要知道“当前能不能发给 NPC”,不需要阻塞等待整轮对话结束。
|
||||
void handleNpcChatTurn(encounter, input);
|
||||
return true;
|
||||
},
|
||||
@@ -263,6 +271,8 @@ export function useRpgRuntimeInteractionFlow({
|
||||
return false;
|
||||
}
|
||||
|
||||
// 中文注释:NPC 聊天的“换一组回应建议”当前通过轮转 options 实现,
|
||||
// 不额外发请求,优先复用本轮已经拿到的候选动作。
|
||||
interactionConfig.npcEncounterActions.setCurrentStory({
|
||||
...story,
|
||||
options: [...restOptions, firstOption],
|
||||
|
||||
@@ -22,13 +22,9 @@ import { resolveFunctionOption } from '../../data/stateFunctions';
|
||||
import { streamNpcChatTurn } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import {
|
||||
advanceSceneActRuntimeState,
|
||||
getSceneConnectionDirectionText,
|
||||
resolveLimitedPrimaryNpcChatState,
|
||||
resolveSceneActProgression,
|
||||
} from '../../services/customWorldSceneActRuntime';
|
||||
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
|
||||
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
@@ -513,44 +509,41 @@ export function createStoryNpcEncounterActions({
|
||||
nextNpcInventory = removeInventoryItem(nextNpcInventory, item.id, 1);
|
||||
}
|
||||
|
||||
const nextState: GameState = appendStoryEngineCarrierMemory(
|
||||
incrementRuntimeStats(
|
||||
{
|
||||
...state,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
currentEncounter: restoredEncounter,
|
||||
npcInteractionActive: true,
|
||||
sceneHostileNpcs: [],
|
||||
playerInventory: addInventoryItems(state.playerInventory, lootItems),
|
||||
quests: progressedQuests,
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
[battleNpcId]: {
|
||||
...markNpcFirstMeaningfulContactResolved(npcState),
|
||||
affinity: npcState.affinity,
|
||||
relationState: buildRelationState(npcState.affinity),
|
||||
recruited: false,
|
||||
inventory: nextNpcInventory,
|
||||
},
|
||||
const nextState: GameState = incrementRuntimeStats(
|
||||
{
|
||||
...state,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
currentEncounter: restoredEncounter,
|
||||
npcInteractionActive: true,
|
||||
sceneHostileNpcs: [],
|
||||
playerInventory: addInventoryItems(state.playerInventory, lootItems),
|
||||
quests: progressedQuests,
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
[battleNpcId]: {
|
||||
...markNpcFirstMeaningfulContactResolved(npcState),
|
||||
affinity: npcState.affinity,
|
||||
relationState: buildRelationState(npcState.affinity),
|
||||
recruited: false,
|
||||
inventory: nextNpcInventory,
|
||||
},
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: state.animationState,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
},
|
||||
{
|
||||
hostileNpcsDefeated: defeatedHostileNpcIds.length,
|
||||
},
|
||||
),
|
||||
lootItems,
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: state.animationState,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
},
|
||||
{
|
||||
hostileNpcsDefeated: defeatedHostileNpcIds.length,
|
||||
},
|
||||
);
|
||||
|
||||
const lootText =
|
||||
@@ -985,57 +978,6 @@ export function createStoryNpcEncounterActions({
|
||||
encounter: Encounter,
|
||||
playerCharacter: Character,
|
||||
) => {
|
||||
const progression = resolveSceneActProgression({
|
||||
profile: gameState.customWorldProfile,
|
||||
sceneId: gameState.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: gameState.storyEngineMemory,
|
||||
});
|
||||
|
||||
if (!progression) {
|
||||
return {
|
||||
deferredRuntimeState: null,
|
||||
options: currentStory?.deferredOptions?.length
|
||||
? currentStory.deferredOptions
|
||||
: buildPostNpcChatOptionCatalog(encounter, playerCharacter),
|
||||
};
|
||||
}
|
||||
|
||||
if (!progression.isLastAct) {
|
||||
const nextActState = advanceSceneActRuntimeState({ progress: progression });
|
||||
const nextStoryEngineMemory = nextActState
|
||||
? {
|
||||
...(gameState.storyEngineMemory ??
|
||||
createEmptyStoryEngineMemoryState()),
|
||||
currentSceneActState: nextActState,
|
||||
}
|
||||
: gameState.storyEngineMemory;
|
||||
const nextState = {
|
||||
...gameState,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
inBattle: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
storyEngineMemory: nextStoryEngineMemory,
|
||||
};
|
||||
const nextOptions = collapseNpcChatOptions(
|
||||
getAvailableOptionsForState(nextState, playerCharacter) ?? [],
|
||||
);
|
||||
|
||||
return {
|
||||
deferredRuntimeState: {
|
||||
currentScenePreset: nextState.currentScenePreset,
|
||||
storyEngineMemory: nextState.storyEngineMemory,
|
||||
},
|
||||
options:
|
||||
nextOptions.length > 0
|
||||
? nextOptions
|
||||
: buildPostNpcChatOptionCatalog(encounter, playerCharacter),
|
||||
};
|
||||
}
|
||||
|
||||
const travelOptions = buildSceneConnectionTravelOptions(gameState);
|
||||
|
||||
return {
|
||||
@@ -1794,12 +1736,8 @@ export function createStoryNpcEncounterActions({
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
currentScenePreset:
|
||||
progressionResult.deferredRuntimeState?.currentScenePreset ??
|
||||
gameState.currentScenePreset,
|
||||
storyEngineMemory:
|
||||
progressionResult.deferredRuntimeState?.storyEngineMemory ??
|
||||
gameState.storyEngineMemory,
|
||||
currentScenePreset: gameState.currentScenePreset,
|
||||
storyEngineMemory: gameState.storyEngineMemory,
|
||||
};
|
||||
|
||||
setGameState(nextState);
|
||||
|
||||
@@ -80,6 +80,8 @@ export function useRpgRuntimeStory({
|
||||
buildStoryContextFromState,
|
||||
});
|
||||
|
||||
// 中文注释:controller 负责“当前故事是什么”,
|
||||
// flow 负责“用户点下去以后发生什么”,两者在这里被装成统一运行时 story 出口。
|
||||
const runtimeController = useRpgRuntimeStoryController({
|
||||
gameState,
|
||||
setGameState,
|
||||
@@ -125,6 +127,7 @@ export function useRpgRuntimeStory({
|
||||
turnVisualMs: TURN_VISUAL_MS,
|
||||
});
|
||||
|
||||
// 中文注释:这里返回的对象就是 runtime shell / adventure panel 直接消费的故事域 API。
|
||||
return {
|
||||
currentStory: runtimeController.currentStory,
|
||||
isLoading: runtimeController.isLoading,
|
||||
|
||||
@@ -43,6 +43,8 @@ function createGameState(params: {
|
||||
} = {}): GameState {
|
||||
return {
|
||||
worldType: WorldType.CUSTOM,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 4,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
currentScene: 'Story',
|
||||
@@ -89,6 +91,8 @@ function buildStoryContextFromState(
|
||||
_state: GameState,
|
||||
): StoryGenerationContext {
|
||||
return {
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 4,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
@@ -187,8 +191,8 @@ describe('useRpgRuntimeStoryController', () => {
|
||||
expect.objectContaining({ id: 'hero' }),
|
||||
[],
|
||||
expect.objectContaining({
|
||||
sceneId: 'scene-opening',
|
||||
sceneName: '证券交易所大厅',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 4,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
|
||||
@@ -57,6 +57,8 @@ export function useRpgRuntimeStoryController(params: {
|
||||
[],
|
||||
);
|
||||
|
||||
// 中文注释:presentation 层负责把服务端/AI 返回的原始故事数据
|
||||
// 编译成前端当前可直接展示的 StoryMoment。
|
||||
const buildStoryFromResponse = useCallback(
|
||||
(
|
||||
state: GameState,
|
||||
@@ -135,6 +137,8 @@ export function useRpgRuntimeStoryController(params: {
|
||||
gameState.currentScenePreset?.id ?? 'scene',
|
||||
gameState.storyHistory.length,
|
||||
].join(':');
|
||||
// 中文注释:开场剧情只允许同一份“玩家 + 场景 + 历史长度”请求飞一次,
|
||||
// 防止 React 严格模式、状态抖动或异步回填触发重复开局生成。
|
||||
if (openingStoryRequestKeyRef.current === requestKey) {
|
||||
return;
|
||||
}
|
||||
@@ -162,6 +166,8 @@ export function useRpgRuntimeStoryController(params: {
|
||||
}
|
||||
|
||||
console.error('Failed to start opening RPG story:', error);
|
||||
// 中文注释:即使 AI / 服务端首段故事失败,也要兜底出一个本地可玩的故事壳,
|
||||
// 否则冒险面板会直接卡死在无 story 的空白状态。
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
|
||||
})
|
||||
@@ -195,6 +201,8 @@ export function useRpgRuntimeStoryController(params: {
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
preparedOpeningAdventure: null,
|
||||
// 中文注释:这几个 opening adventure 相关字段先按空实现保留,
|
||||
// 目的是兼容旧调用面,同时避免新 runtime 链再把预制开场逻辑塞回 controller。
|
||||
startOpeningAdventure: async () => undefined,
|
||||
resetPreparedOpeningAdventure: () => undefined,
|
||||
buildStoryContextFromState,
|
||||
|
||||
@@ -97,6 +97,8 @@ export function useRpgRuntimeStoryFlow({
|
||||
buildOpeningCampChatContext,
|
||||
resetPreparedOpeningAdventure,
|
||||
} = runtimeController;
|
||||
// 中文注释:interactionConfig 是“剧情交互协调器”的配置快照;
|
||||
// 后续选项刷新、动作提交、fallback 叙事都会共用这套上下文。
|
||||
const interactionConfig = createStoryInteractionCoordinatorConfig({
|
||||
gameState,
|
||||
setGameState,
|
||||
@@ -131,6 +133,8 @@ export function useRpgRuntimeStoryFlow({
|
||||
gameState,
|
||||
currentStory,
|
||||
});
|
||||
// 中文注释:这一层把“战斗/NPC/背包/地图旅行”等具体交互入口分发到对应流程,
|
||||
// 保证冒险面板只调用统一的 handleChoice / handleNpcChatInput 等接口。
|
||||
const {
|
||||
handleChoice,
|
||||
battleRewardUi,
|
||||
@@ -175,6 +179,7 @@ export function useRpgRuntimeStoryFlow({
|
||||
clearCharacterChatModal,
|
||||
});
|
||||
|
||||
// 中文注释:最终返回的是已经过目标选项协调、交互分发和 story state 收束后的稳定输出。
|
||||
return {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
|
||||
@@ -19,6 +19,8 @@ export function createClearStoryRuntimeUi(params: {
|
||||
clearCharacterChatModal: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
// 中文注释:story runtime 的“清场”不只是清掉故事文本,
|
||||
// 还要把目标 UI、交互 UI、错误态、加载态和角色私聊弹层一起回收。
|
||||
params.clearStoryGoalOptionUi();
|
||||
params.clearStoryInteractionUi();
|
||||
params.setAiError(null);
|
||||
@@ -81,6 +83,8 @@ export function useRpgRuntimeStoryState(params: {
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
});
|
||||
|
||||
// 中文注释:quest 相关按钮属于运行时 story UI 的一部分,
|
||||
// 但真正的状态迁移统一交给 sessionActions,当前层只负责对外暴露稳定接口。
|
||||
return {
|
||||
questUi: {
|
||||
acknowledgeQuestCompletion,
|
||||
|
||||
@@ -4,7 +4,10 @@ import { DEFAULT_MUSIC_VOLUME } from '../../../packages/shared/src/contracts/run
|
||||
import { useAuthUi } from '../../components/auth/AuthUiContext';
|
||||
import type { CustomWorldRuntimeLaunchOptions } from '../../components/platform-entry/platformEntryTypes';
|
||||
import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell/types';
|
||||
import { activateRosterCompanion, benchActiveCompanion } from '../../data/companionRoster';
|
||||
import {
|
||||
activateRosterCompanion,
|
||||
benchActiveCompanion,
|
||||
} from '../../data/companionRoster';
|
||||
import { syncGameStatePlayTime } from '../../data/runtimeStats';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { useBackgroundMusic } from '../useBackgroundMusic';
|
||||
@@ -33,10 +36,14 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||||
handleCharacterSelect: selectCharacter,
|
||||
} = useRpgSessionBootstrap();
|
||||
|
||||
// 中文注释:战斗播放与结算仍然沿用独立 combat flow;
|
||||
// runtime session 只消费它暴露出来的“选项结算结果”和“动画播放入口”。
|
||||
const combatFlow = useCombatFlow({
|
||||
setGameState,
|
||||
});
|
||||
|
||||
// 中文注释:剧情流是运行时主链的另一半。
|
||||
// 这里把 GameState 交给 runtime story,由它负责剧情文本、选项、NPC 交互与任务 UI。
|
||||
const storyFlow = useRpgRuntimeStory({
|
||||
gameState,
|
||||
setGameState,
|
||||
@@ -46,6 +53,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||||
|
||||
const { companionRenderStates, buildCompanionRenderStates } =
|
||||
useNpcInteractionFlow(gameState);
|
||||
// 中文注释:持久化层统一负责继续游戏、自动存档与退出保存,
|
||||
// session 只把当前运行态快照和 story 水位传进去。
|
||||
const persistence = useRpgSessionPersistence({
|
||||
authenticatedUserId: authUi?.user?.id ?? null,
|
||||
gameState,
|
||||
@@ -70,6 +79,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||||
return;
|
||||
}
|
||||
|
||||
// 中文注释:游玩时长统计不跟每一帧绑定,而是固定 15 秒增量同步,
|
||||
// 这样既能累计活跃时长,也不会因为高频 setState 拉高运行态噪音。
|
||||
const intervalId = window.setInterval(() => {
|
||||
setGameState((currentState) => {
|
||||
if (
|
||||
@@ -90,6 +101,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||||
customWorldProfile: Parameters<typeof selectCustomWorld>[0],
|
||||
options?: CustomWorldRuntimeLaunchOptions,
|
||||
) => {
|
||||
// 中文注释:切换世界前先清空上一局 story 控制器,
|
||||
// 避免旧世界的 currentStory / 选项残留到新开局。
|
||||
storyFlow.resetStoryState();
|
||||
selectCustomWorld(customWorldProfile, {
|
||||
mode: options?.mode,
|
||||
@@ -100,6 +113,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||||
const handleCharacterSelect = (
|
||||
character: Parameters<typeof selectCharacter>[0],
|
||||
) => {
|
||||
// 中文注释:角色确认意味着正式进入新 run,
|
||||
// 这里同样先清理 story 层,保证开场剧情重新按当前角色生成。
|
||||
storyFlow.resetStoryState();
|
||||
selectCharacter(character);
|
||||
};
|
||||
@@ -110,6 +125,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||||
};
|
||||
|
||||
const handleContinueGame = (snapshot?: HydratedSavedGameSnapshot | null) => {
|
||||
// 中文注释:继续游戏是异步恢复链,内部会重新向服务端刷新 runtime story,
|
||||
// 所以这里显式丢给 persistence 异步执行。
|
||||
void persistence.continueSavedGame(snapshot);
|
||||
};
|
||||
|
||||
@@ -120,9 +137,9 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||||
};
|
||||
|
||||
const handleSaveAndExit = () => {
|
||||
const syncedGameState = syncGameStatePlayTime(gameState);
|
||||
// 中文注释:退出保存只请求服务端基于已存快照创建 checkpoint;
|
||||
// 游玩时长的最终刷新由后端 checkpoint 负责,不再上传本地同步后的 GameState。
|
||||
void persistence.saveCurrentGame({
|
||||
gameState: syncedGameState,
|
||||
bottomTab,
|
||||
currentStory: storyFlow.currentStory,
|
||||
});
|
||||
|
||||
@@ -1,176 +1,29 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildCustomWorldRuntimeCharacters,
|
||||
createCharacterSkillCooldowns,
|
||||
getCharacterMaxHp,
|
||||
getCharacterMaxMana,
|
||||
setRuntimeCharacterOverrides,
|
||||
} from '../../data/characterPresets';
|
||||
import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime';
|
||||
import { getInitialPlayerCurrency } from '../../data/economy';
|
||||
import {
|
||||
applyEquipmentLoadoutToState,
|
||||
buildInitialEquipmentLoadout,
|
||||
createEmptyEquipmentLoadout,
|
||||
} from '../../data/equipmentEffects';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
buildInitialPlayerInventory,
|
||||
} from '../../data/npcInteractions';
|
||||
import { createEmptyEquipmentLoadout } from '../../data/equipmentEffects';
|
||||
import { createInitialPlayerProgressionState } from '../../data/playerProgression';
|
||||
import { createInitialGameRuntimeStats } from '../../data/runtimeStats';
|
||||
import {
|
||||
ensureSceneEncounterPreview,
|
||||
RESOLVED_ENTITY_X_METERS,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import {
|
||||
buildEncounterFromSceneNpc,
|
||||
getScenePreset,
|
||||
getScenePresetById,
|
||||
getWorldCampScenePreset,
|
||||
} from '../../data/scenePresets';
|
||||
import {
|
||||
findCustomWorldRoleByReference,
|
||||
resolveCustomWorldRoleIdReference,
|
||||
resolveCustomWorldRoleIdReferences,
|
||||
} from '../../services/customWorldRoleReferences';
|
||||
import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime';
|
||||
import { getScenePreset } from '../../data/scenePresets';
|
||||
import { beginRpgRuntimeStorySession } from '../../services/rpg-runtime/rpgRuntimeStoryClient';
|
||||
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CustomWorldProfile,
|
||||
Encounter,
|
||||
EquipmentLoadout,
|
||||
GameState,
|
||||
GameRuntimeMode,
|
||||
InventoryItem,
|
||||
SceneActBlueprint,
|
||||
SceneChapterBlueprint,
|
||||
SceneNpc,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import type { BottomTab } from './rpgSessionTypes';
|
||||
|
||||
const PLAYER_BASE_MAX_HP = 180;
|
||||
|
||||
function mergeStarterInventoryItems<
|
||||
T extends { category: string; name: string },
|
||||
>(explicitItems: T[], fallbackItems: T[]) {
|
||||
const merged = new Map<string, T>();
|
||||
|
||||
[...explicitItems, ...fallbackItems].forEach((item) => {
|
||||
merged.set(`${item.category}:${item.name}`, item);
|
||||
});
|
||||
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
function normalizeExplicitStarterCategory(category: string) {
|
||||
const normalized = category.trim();
|
||||
return normalized === '专属物' ? '专属物品' : normalized;
|
||||
}
|
||||
|
||||
function inferExplicitStarterSlot(category: string) {
|
||||
const normalized = normalizeExplicitStarterCategory(category);
|
||||
if (normalized === '武器') return 'weapon' as const;
|
||||
if (normalized === '护甲') return 'armor' as const;
|
||||
if (
|
||||
normalized === '饰品' ||
|
||||
normalized === '稀有品' ||
|
||||
normalized === '专属物品'
|
||||
) {
|
||||
return 'relic' as const;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildExplicitCustomWorldRoleStarterState(
|
||||
profile: CustomWorldProfile,
|
||||
character: Character,
|
||||
) {
|
||||
const role =
|
||||
profile.playableNpcs.find((entry) => entry.id === character.id) ??
|
||||
profile.storyNpcs.find((entry) => entry.id === character.id) ??
|
||||
profile.playableNpcs.find((entry) => entry.name === character.name) ??
|
||||
profile.storyNpcs.find((entry) => entry.name === character.name) ??
|
||||
null;
|
||||
|
||||
const inventory = role
|
||||
? role.initialItems.map((item, index) => {
|
||||
const category = normalizeExplicitStarterCategory(item.category);
|
||||
return {
|
||||
id: `custom-role-item:${role.id}:${index + 1}`,
|
||||
category,
|
||||
name: item.name,
|
||||
quantity: Math.max(1, item.quantity),
|
||||
rarity: item.rarity,
|
||||
tags: [...item.tags],
|
||||
description: item.description,
|
||||
equipmentSlotId: inferExplicitStarterSlot(category),
|
||||
runtimeMetadata: {
|
||||
origin: 'ai_compiled' as const,
|
||||
generationChannel: 'discovery' as const,
|
||||
seedKey: `${role.id}:${index + 1}`,
|
||||
relationAnchor: {
|
||||
type: 'npc' as const,
|
||||
npcId: role.id,
|
||||
npcName: role.name,
|
||||
roleText: role.role,
|
||||
},
|
||||
sourceReason: `${role.name}在自定义世界开局时自带的初始物品。`,
|
||||
},
|
||||
} satisfies InventoryItem;
|
||||
})
|
||||
: [];
|
||||
|
||||
const equipment: EquipmentLoadout = createEmptyEquipmentLoadout();
|
||||
inventory.forEach((item) => {
|
||||
const slot = item.equipmentSlotId;
|
||||
if (!slot || equipment[slot]) {
|
||||
return;
|
||||
}
|
||||
equipment[slot] = item;
|
||||
});
|
||||
|
||||
return {
|
||||
inventory,
|
||||
equipment,
|
||||
};
|
||||
}
|
||||
|
||||
function createInitialCampEncounter(
|
||||
worldType: WorldType | null,
|
||||
playerCharacter: Character,
|
||||
): Encounter | null {
|
||||
if (!worldType) return null;
|
||||
|
||||
const campScenePreset =
|
||||
getWorldCampScenePreset(worldType) ?? getScenePreset(worldType, 0);
|
||||
const npcCandidates = (campScenePreset?.npcs ?? [])
|
||||
.filter((npc: SceneNpc) => Boolean(npc.characterId))
|
||||
.filter((npc: SceneNpc) => npc.characterId !== playerCharacter.id);
|
||||
if (npcCandidates.length === 0) return null;
|
||||
|
||||
const npc =
|
||||
npcCandidates[Math.floor(Math.random() * npcCandidates.length)] ?? null;
|
||||
if (!npc) return null;
|
||||
|
||||
return {
|
||||
id: npc.id,
|
||||
kind: 'npc',
|
||||
characterId: npc.characterId,
|
||||
npcName: npc.name,
|
||||
npcDescription: npc.description,
|
||||
npcAvatar: npc.avatar,
|
||||
context: npc.role,
|
||||
gender: npc.gender,
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
};
|
||||
}
|
||||
|
||||
function createInitialGameState(): GameState {
|
||||
function createSelectionGameState(): GameState {
|
||||
return {
|
||||
worldType: null,
|
||||
customWorldProfile: null,
|
||||
@@ -222,261 +75,20 @@ function createInitialGameState(): GameState {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOpeningActScenePreset(
|
||||
profile: CustomWorldProfile | null,
|
||||
): NonNullable<GameState['currentScenePreset']> | null {
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const openingChapter = profile.sceneChapterBlueprints?.[0] ?? null;
|
||||
const openingSceneIds = [
|
||||
openingChapter?.acts[0]?.sceneId,
|
||||
openingChapter?.sceneId,
|
||||
...(openingChapter?.linkedLandmarkIds ?? []),
|
||||
]
|
||||
.map((sceneId) => sceneId?.trim() ?? '')
|
||||
.filter(Boolean);
|
||||
|
||||
for (const sceneId of openingSceneIds) {
|
||||
const directScene = resolveCustomWorldScenePresetByConfiguredId(
|
||||
profile,
|
||||
sceneId,
|
||||
);
|
||||
if (directScene) {
|
||||
return directScene;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackLandmarkIndex = profile.landmarks.findIndex(
|
||||
(landmark) => landmark.sceneNpcIds.length > 0,
|
||||
);
|
||||
if (fallbackLandmarkIndex >= 0) {
|
||||
return getScenePresetById(
|
||||
WorldType.CUSTOM,
|
||||
`custom-scene-landmark-${fallbackLandmarkIndex + 1}`,
|
||||
);
|
||||
}
|
||||
|
||||
const firstLandmarkId = profile.landmarks[0]?.id?.trim() ?? '';
|
||||
if (firstLandmarkId) {
|
||||
const firstLandmarkScene = getScenePresetById(
|
||||
WorldType.CUSTOM,
|
||||
'custom-scene-landmark-1',
|
||||
);
|
||||
if (firstLandmarkScene) {
|
||||
return firstLandmarkScene;
|
||||
}
|
||||
}
|
||||
|
||||
return profile.landmarks.length > 0
|
||||
? getScenePresetById(WorldType.CUSTOM, 'custom-scene-landmark-1')
|
||||
: null;
|
||||
}
|
||||
|
||||
function resolveOpeningSceneActBlueprint(
|
||||
profile: CustomWorldProfile | null,
|
||||
): { chapter: SceneChapterBlueprint; act: SceneActBlueprint } | null {
|
||||
const openingChapter = profile?.sceneChapterBlueprints?.[0] ?? null;
|
||||
const openingAct = openingChapter?.acts[0] ?? null;
|
||||
return openingChapter && openingAct
|
||||
? { chapter: openingChapter, act: openingAct }
|
||||
: null;
|
||||
}
|
||||
|
||||
function resolveCustomWorldScenePresetByConfiguredId(
|
||||
profile: CustomWorldProfile,
|
||||
sceneId: string | null | undefined,
|
||||
): NonNullable<GameState['currentScenePreset']> | null {
|
||||
const normalizedSceneId = sceneId?.trim() ?? '';
|
||||
if (!normalizedSceneId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directScene = getScenePresetById(WorldType.CUSTOM, normalizedSceneId);
|
||||
if (directScene) {
|
||||
return directScene;
|
||||
}
|
||||
|
||||
const campId = profile.camp?.id?.trim() ?? '';
|
||||
if (
|
||||
normalizedSceneId === campId ||
|
||||
normalizedSceneId === 'custom-scene-camp'
|
||||
) {
|
||||
return getScenePresetById(WorldType.CUSTOM, 'custom-scene-camp');
|
||||
}
|
||||
|
||||
const landmarkIndex = profile.landmarks.findIndex(
|
||||
(landmark) => landmark.id === normalizedSceneId,
|
||||
);
|
||||
if (landmarkIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getScenePresetById(
|
||||
WorldType.CUSTOM,
|
||||
`custom-scene-landmark-${landmarkIndex + 1}`,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveOpeningActNpcIdPriority(
|
||||
profile: CustomWorldProfile,
|
||||
openingAct: SceneActBlueprint,
|
||||
) {
|
||||
return resolveCustomWorldRoleIdReferences(profile, [
|
||||
openingAct.oppositeNpcId,
|
||||
openingAct.primaryNpcId,
|
||||
...openingAct.encounterNpcIds,
|
||||
]);
|
||||
}
|
||||
|
||||
function doRoleReferencesMatch(
|
||||
profile: CustomWorldProfile | null,
|
||||
left: string | null | undefined,
|
||||
right: string | null | undefined,
|
||||
) {
|
||||
const normalizedLeft = resolveCustomWorldRoleIdReference(profile, left);
|
||||
const normalizedRight = resolveCustomWorldRoleIdReference(profile, right);
|
||||
return Boolean(normalizedLeft && normalizedLeft === normalizedRight);
|
||||
}
|
||||
|
||||
function findSceneNpcByRuntimeRoleId(
|
||||
scenePreset: GameState['currentScenePreset'],
|
||||
profile: CustomWorldProfile | null,
|
||||
roleId: string,
|
||||
) {
|
||||
return (
|
||||
scenePreset?.npcs?.find(
|
||||
(npc) =>
|
||||
doRoleReferencesMatch(profile, npc.id, roleId) ||
|
||||
doRoleReferencesMatch(profile, npc.characterId, roleId) ||
|
||||
doRoleReferencesMatch(profile, npc.name, roleId) ||
|
||||
doRoleReferencesMatch(profile, npc.title, roleId),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function buildOpeningEncounterFromCustomWorldRole(
|
||||
profile: CustomWorldProfile,
|
||||
roleId: string,
|
||||
): Encounter | null {
|
||||
const role =
|
||||
findCustomWorldRoleByReference(profile, roleId);
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isHostile = role.initialAffinity < 0;
|
||||
return {
|
||||
id: role.id,
|
||||
kind: 'npc',
|
||||
characterId: role.id,
|
||||
npcName: role.name,
|
||||
npcDescription: role.description,
|
||||
npcAvatar: role.imageSrc ?? role.name.slice(0, 1) ?? '?',
|
||||
context: role.role,
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
initialAffinity: role.initialAffinity,
|
||||
hostile: isHostile,
|
||||
title: role.title,
|
||||
backstory: role.backstory,
|
||||
personality: role.personality,
|
||||
motivation: role.motivation,
|
||||
combatStyle: role.combatStyle,
|
||||
relationshipHooks: [...role.relationshipHooks],
|
||||
tags: [...role.tags],
|
||||
backstoryReveal: role.backstoryReveal,
|
||||
skills: role.skills.map((skill) => ({ ...skill })),
|
||||
initialItems: role.initialItems.map((item) => ({
|
||||
...item,
|
||||
tags: [...item.tags],
|
||||
})),
|
||||
imageSrc: role.imageSrc,
|
||||
visual: (role as { visual?: Encounter['visual'] }).visual,
|
||||
narrativeProfile: role.narrativeProfile,
|
||||
attributeProfile: role.attributeProfile,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOpeningActEncounter(params: {
|
||||
profile: CustomWorldProfile | null;
|
||||
scenePreset: GameState['currentScenePreset'];
|
||||
playerCharacter: Character;
|
||||
}) {
|
||||
const opening = resolveOpeningSceneActBlueprint(params.profile);
|
||||
if (!opening || !params.profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const npcId of resolveOpeningActNpcIdPriority(params.profile, opening.act)) {
|
||||
if (
|
||||
doRoleReferencesMatch(
|
||||
params.profile,
|
||||
npcId,
|
||||
params.playerCharacter.id,
|
||||
) ||
|
||||
doRoleReferencesMatch(
|
||||
params.profile,
|
||||
npcId,
|
||||
params.playerCharacter.name,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sceneNpc = findSceneNpcByRuntimeRoleId(
|
||||
params.scenePreset,
|
||||
params.profile,
|
||||
npcId,
|
||||
);
|
||||
if (sceneNpc && sceneNpc.characterId !== params.playerCharacter.id) {
|
||||
return {
|
||||
...buildEncounterFromSceneNpc(sceneNpc, RESOLVED_ENTITY_X_METERS),
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
};
|
||||
}
|
||||
|
||||
const roleEncounter = buildOpeningEncounterFromCustomWorldRole(
|
||||
params.profile,
|
||||
npcId,
|
||||
);
|
||||
if (roleEncounter) {
|
||||
return roleEncounter;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildOpeningStoryEngineMemory(
|
||||
profile: CustomWorldProfile | null,
|
||||
sceneId: string | null | undefined,
|
||||
) {
|
||||
const storyEngineMemory = createEmptyStoryEngineMemoryState();
|
||||
|
||||
return {
|
||||
...storyEngineMemory,
|
||||
currentSceneActState:
|
||||
buildInitialSceneActRuntimeState({
|
||||
profile,
|
||||
sceneId,
|
||||
storyEngineMemory,
|
||||
}) ?? storyEngineMemory.currentSceneActState ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG session bootstrap 主实现。
|
||||
* 工作包 C 起由新域 hook 承载世界选择、选角确认与新开局初始化。
|
||||
*/
|
||||
export function useRpgSessionBootstrap() {
|
||||
const [gameState, setGameState] = useState<GameState>(() =>
|
||||
createInitialGameState(),
|
||||
createSelectionGameState(),
|
||||
);
|
||||
const [bottomTab, setBottomTab] = useState<BottomTab>('adventure');
|
||||
const [isMapOpen, setIsMapOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 中文注释:当前运行中的自定义世界 profile 需要同步给静态数据层,
|
||||
// 这样角色预设、场景预设、运行时引用解析才能读取到同一份世界真相。
|
||||
setRuntimeCustomWorldProfile(gameState.customWorldProfile);
|
||||
setRuntimeCharacterOverrides(
|
||||
gameState.customWorldProfile
|
||||
@@ -486,9 +98,11 @@ export function useRpgSessionBootstrap() {
|
||||
}, [gameState.customWorldProfile]);
|
||||
|
||||
const resetGame = () => {
|
||||
// 中文注释:reset 不只清 GameState,还要把底部 tab 和地图弹层一起还原,
|
||||
// 避免返回入口后 UI 仍停留在上一次冒险的局部状态。
|
||||
setBottomTab('adventure');
|
||||
setIsMapOpen(false);
|
||||
setGameState(createInitialGameState());
|
||||
setGameState(createSelectionGameState());
|
||||
};
|
||||
|
||||
const handleCustomWorldSelect = (
|
||||
@@ -505,11 +119,12 @@ export function useRpgSessionBootstrap() {
|
||||
);
|
||||
const initialScenePreset = getScenePreset(resolvedWorldType, 0) ?? null;
|
||||
setIsMapOpen(false);
|
||||
setGameState((prev) =>
|
||||
ensureSceneEncounterPreview({
|
||||
...prev,
|
||||
worldType: resolvedWorldType,
|
||||
customWorldProfile,
|
||||
setGameState((prev) => ({
|
||||
// 中文注释:世界刚选中时只进入“已装入世界,但尚未选角”的中间态;
|
||||
// 正式开局 GameState 必须等待角色确认后由 server-rs 统一生成。
|
||||
...prev,
|
||||
worldType: resolvedWorldType,
|
||||
customWorldProfile,
|
||||
runtimeMode,
|
||||
runtimePersistenceDisabled,
|
||||
currentScenePreset: initialScenePreset,
|
||||
@@ -532,153 +147,38 @@ export function useRpgSessionBootstrap() {
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
}),
|
||||
);
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBackToWorldSelect = () => {
|
||||
setBottomTab('adventure');
|
||||
setIsMapOpen(false);
|
||||
setGameState(createInitialGameState());
|
||||
setGameState(createSelectionGameState());
|
||||
};
|
||||
|
||||
const handleCharacterSelect = (character: Character) => {
|
||||
setBottomTab('adventure');
|
||||
setIsMapOpen(false);
|
||||
|
||||
setGameState((prev) => {
|
||||
const resolvedWorldType = prev.worldType;
|
||||
const resolvedCustomWorldProfile = prev.customWorldProfile;
|
||||
const initialScenePreset =
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? (resolveOpeningActScenePreset(resolvedCustomWorldProfile) ??
|
||||
getWorldCampScenePreset(resolvedWorldType) ??
|
||||
getScenePreset(resolvedWorldType, 0))
|
||||
: resolvedWorldType
|
||||
? (getWorldCampScenePreset(resolvedWorldType) ??
|
||||
getScenePreset(resolvedWorldType, 0))
|
||||
: null;
|
||||
const initialEncounter =
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? resolveOpeningActEncounter({
|
||||
profile: resolvedCustomWorldProfile,
|
||||
scenePreset: initialScenePreset,
|
||||
playerCharacter: character,
|
||||
})
|
||||
: createInitialCampEncounter(resolvedWorldType, character);
|
||||
const initialNpcState = initialEncounter
|
||||
? buildInitialNpcState(initialEncounter, resolvedWorldType, prev)
|
||||
: null;
|
||||
const initialEquipment = buildInitialEquipmentLoadout(
|
||||
character,
|
||||
resolvedCustomWorldProfile,
|
||||
);
|
||||
const explicitStarterItems =
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? buildExplicitCustomWorldRoleStarterState(
|
||||
resolvedCustomWorldProfile!,
|
||||
character,
|
||||
)
|
||||
: null;
|
||||
const mergedStarterEquipment = {
|
||||
weapon:
|
||||
explicitStarterItems?.equipment.weapon ?? initialEquipment.weapon,
|
||||
armor: explicitStarterItems?.equipment.armor ?? initialEquipment.armor,
|
||||
relic: explicitStarterItems?.equipment.relic ?? initialEquipment.relic,
|
||||
};
|
||||
const playerMaxHp = getCharacterMaxHp(
|
||||
character,
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
);
|
||||
const launchState = gameState;
|
||||
const resolvedWorldType = launchState.worldType;
|
||||
if (!resolvedWorldType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const openingState = applyEquipmentLoadoutToState(
|
||||
{
|
||||
...prev,
|
||||
playerCharacter: character,
|
||||
runtimeMode:
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? (prev.runtimeMode ?? 'play')
|
||||
: (prev.runtimeMode ?? 'play'),
|
||||
runtimePersistenceDisabled:
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? prev.runtimePersistenceDisabled === true
|
||||
: prev.runtimePersistenceDisabled,
|
||||
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
|
||||
playerProgression: createInitialPlayerProgressionState(),
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
storyEngineMemory:
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? buildOpeningStoryEngineMemory(
|
||||
resolvedCustomWorldProfile,
|
||||
initialScenePreset?.id,
|
||||
)
|
||||
: createEmptyStoryEngineMemoryState(),
|
||||
chapterState: null,
|
||||
campaignState: null,
|
||||
activeScenarioPackId: prev.customWorldProfile?.scenarioPackId ?? null,
|
||||
activeCampaignPackId: prev.customWorldProfile?.campaignPackId ?? null,
|
||||
characterChats: {},
|
||||
currentEncounter: initialEncounter,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: initialScenePreset,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
animationState: AnimationState.IDLE,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: playerMaxHp,
|
||||
playerMaxHp: playerMaxHp,
|
||||
playerMana: getCharacterMaxMana(character),
|
||||
playerMaxMana: getCharacterMaxMana(character),
|
||||
playerSkillCooldowns: createCharacterSkillCooldowns(character),
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: getInitialPlayerCurrency(
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
),
|
||||
playerInventory: mergeStarterInventoryItems<InventoryItem>(
|
||||
explicitStarterItems?.inventory ?? [],
|
||||
buildInitialPlayerInventory(
|
||||
character,
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
),
|
||||
),
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
npcStates:
|
||||
initialEncounter && initialNpcState
|
||||
? {
|
||||
[initialEncounter.id!]: initialNpcState,
|
||||
}
|
||||
: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
},
|
||||
mergedStarterEquipment,
|
||||
);
|
||||
|
||||
return resolvedWorldType === WorldType.CUSTOM
|
||||
? openingState
|
||||
: ensureSceneEncounterPreview(openingState);
|
||||
void beginRpgRuntimeStorySession({
|
||||
worldType: resolvedWorldType,
|
||||
customWorldProfile: launchState.customWorldProfile,
|
||||
character,
|
||||
runtimeMode: launchState.runtimeMode ?? 'play',
|
||||
disablePersistence: launchState.runtimePersistenceDisabled === true,
|
||||
}).then((response) => {
|
||||
// 中文注释:开局正式 GameState 由 server-rs 生成并持久化;
|
||||
// 前端只接收后端快照,避免浏览器继续承担初始背包、装备、遭遇和 NPC 状态裁决。
|
||||
setGameState(response.snapshot.gameState);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -699,3 +199,4 @@ export function useRpgSessionBootstrap() {
|
||||
export type RpgSessionBootstrapResult = ReturnType<
|
||||
typeof useRpgSessionBootstrap
|
||||
>;
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { isAbortError } from '../../services/apiClient';
|
||||
import { rpgSnapshotClient } from '../../services/rpg-runtime';
|
||||
import {
|
||||
getRpgRuntimeSessionId,
|
||||
rpgSnapshotClient,
|
||||
} from '../../services/rpg-runtime';
|
||||
import type { GameState, StoryMoment } from '../../types';
|
||||
import { resumeServerRuntimeStory } from '../rpg-runtime-story/runtimeStoryCoordinator';
|
||||
import type { BottomTab } from './rpgSessionTypes';
|
||||
@@ -10,6 +13,8 @@ import type { BottomTab } from './rpgSessionTypes';
|
||||
const AUTO_SAVE_DELAY_MS = 400;
|
||||
|
||||
function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) {
|
||||
// 中文注释:preview / test 模式、非 Story 场景、未选角、以及流式输出中的故事都不应入正式存档,
|
||||
// 否则容易把临时态或半成品叙事写进继续游戏链路。
|
||||
return (
|
||||
gameState.runtimePersistenceDisabled !== true &&
|
||||
gameState.runtimeMode !== 'preview' &&
|
||||
@@ -30,6 +35,8 @@ function normalizeBottomTab(bottomTab: string | null | undefined): BottomTab {
|
||||
}
|
||||
|
||||
function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) {
|
||||
// 中文注释:远端快照允许缺少局部 UI 状态;
|
||||
// 这里统一补底部 tab 的兜底值,避免恢复后落到非法面板名。
|
||||
return {
|
||||
gameState: snapshot.gameState,
|
||||
currentStory: snapshot.currentStory ?? null,
|
||||
@@ -75,6 +82,8 @@ export function useRpgSessionPersistence({
|
||||
const saveRequestIdRef = useRef(0);
|
||||
|
||||
const abortActiveSave = useCallback(() => {
|
||||
// 中文注释:自动存档是“后写覆盖前写”的串行语义;
|
||||
// 新一次保存开始前,主动打断旧请求,避免旧快照回写覆盖最新状态。
|
||||
saveControllerRef.current?.abort();
|
||||
saveControllerRef.current = null;
|
||||
setIsPersistingSnapshot(false);
|
||||
@@ -83,9 +92,8 @@ export function useRpgSessionPersistence({
|
||||
const persistSnapshot = useCallback(
|
||||
async (params: {
|
||||
payload: {
|
||||
gameState: GameState;
|
||||
sessionId: string;
|
||||
bottomTab: BottomTab;
|
||||
currentStory: StoryMoment | null;
|
||||
};
|
||||
logLabel: string;
|
||||
}) => {
|
||||
@@ -103,11 +111,12 @@ export function useRpgSessionPersistence({
|
||||
setPersistenceError(null);
|
||||
|
||||
try {
|
||||
// 中文注释:这里不再上传整份本地快照;
|
||||
// 前端只告诉后端“当前 session 需要 checkpoint”,真实 GameState 由服务端快照表读取。
|
||||
const snapshot = await rpgSnapshotClient.putSnapshot(
|
||||
{
|
||||
gameState: params.payload.gameState,
|
||||
sessionId: params.payload.sessionId,
|
||||
bottomTab: params.payload.bottomTab,
|
||||
currentStory: params.payload.currentStory,
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
@@ -158,6 +167,8 @@ export function useRpgSessionPersistence({
|
||||
hydrateControllerRef.current = controller;
|
||||
setIsHydratingSnapshot(true);
|
||||
|
||||
// 中文注释:登录后第一时间探测一次远端快照,
|
||||
// 让入口页能够准确判断“继续游戏”按钮是否可见。
|
||||
void rpgSnapshotClient
|
||||
.getSnapshot({ signal: controller.signal })
|
||||
.then((snapshot) => {
|
||||
@@ -207,12 +218,13 @@ export function useRpgSessionPersistence({
|
||||
|
||||
if (!canPersist) return;
|
||||
|
||||
// 中文注释:自动存档做一个很短的去抖,
|
||||
// 避免同一轮状态连锁更新时重复打多次快照请求。
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void persistSnapshot({
|
||||
payload: {
|
||||
gameState,
|
||||
sessionId: getRpgRuntimeSessionId(gameState),
|
||||
bottomTab,
|
||||
currentStory,
|
||||
},
|
||||
logLabel: 'failed to autosave remote snapshot',
|
||||
});
|
||||
@@ -235,11 +247,12 @@ export function useRpgSessionPersistence({
|
||||
return false;
|
||||
}
|
||||
|
||||
// 中文注释:手动存档和自动存档走同一套底层 persist 逻辑,
|
||||
// 差别只在于调用方可显式覆盖本次 checkpoint 的 session 与 UI tab。
|
||||
const snapshot = await persistSnapshot({
|
||||
payload: {
|
||||
gameState: nextGameState,
|
||||
sessionId: getRpgRuntimeSessionId(nextGameState),
|
||||
bottomTab: nextBottomTab,
|
||||
currentStory: nextStory,
|
||||
},
|
||||
logLabel: 'failed to save remote snapshot',
|
||||
});
|
||||
@@ -300,6 +313,8 @@ export function useRpgSessionPersistence({
|
||||
resetStoryState();
|
||||
const fallbackHydration = resolveRemoteSnapshotState(snapshot);
|
||||
|
||||
// 中文注释:继续游戏不是简单把旧 currentStory 塞回去,
|
||||
// 还要向服务端刷新一遍 runtime story,拿到当前服务端判定的可选动作与视图模型。
|
||||
const resumedState = await resumeServerRuntimeStory(snapshot).catch(
|
||||
(error) => {
|
||||
if (!isAbortError(error)) {
|
||||
|
||||
@@ -23,6 +23,8 @@ vi.mock('../services/rpg-entry', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../services/rpg-runtime', () => ({
|
||||
getRpgRuntimeSessionId: (gameState: Pick<GameState, 'runtimeSessionId'>) =>
|
||||
gameState.runtimeSessionId?.trim() || 'runtime-main',
|
||||
rpgSnapshotClient: {
|
||||
getSnapshot: storageMocks.getSaveSnapshot,
|
||||
putSnapshot: storageMocks.putSaveSnapshot,
|
||||
@@ -30,7 +32,11 @@ vi.mock('../services/rpg-runtime', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function SettingsHarness({ authenticatedUserId }: { authenticatedUserId: string | null }) {
|
||||
function SettingsHarness({
|
||||
authenticatedUserId,
|
||||
}: {
|
||||
authenticatedUserId: string | null;
|
||||
}) {
|
||||
const settings = useGameSettings(authenticatedUserId);
|
||||
|
||||
return (
|
||||
@@ -50,14 +56,18 @@ function SettingsHarness({ authenticatedUserId }: { authenticatedUserId: string
|
||||
|
||||
function PersistenceHarness({
|
||||
authenticatedUserId,
|
||||
gameState = {} as GameState,
|
||||
currentStory = null as StoryMoment | null,
|
||||
}: {
|
||||
authenticatedUserId: string | null;
|
||||
gameState?: GameState;
|
||||
currentStory?: StoryMoment | null;
|
||||
}) {
|
||||
const persistence = useRpgSessionPersistence({
|
||||
authenticatedUserId,
|
||||
gameState: {} as GameState,
|
||||
gameState,
|
||||
bottomTab: 'adventure' as BottomTab,
|
||||
currentStory: null as StoryMoment | null,
|
||||
currentStory,
|
||||
isLoading: false,
|
||||
setGameState: () => {},
|
||||
setBottomTab: () => {},
|
||||
@@ -67,7 +77,9 @@ function PersistenceHarness({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="saved-game">{persistence.hasSavedGame ? 'yes' : 'no'}</div>
|
||||
<div data-testid="saved-game">
|
||||
{persistence.hasSavedGame ? 'yes' : 'no'}
|
||||
</div>
|
||||
<div data-testid="hydrating">
|
||||
{persistence.isHydratingSnapshot ? 'yes' : 'no'}
|
||||
</div>
|
||||
@@ -161,3 +173,64 @@ test('unauthenticated runtime skips remote snapshot hydration', async () => {
|
||||
expect(screen.getByTestId('saved-game').textContent).toBe('no');
|
||||
expect(storageMocks.getSaveSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('runtime autosave requests backend checkpoint without uploading local state', async () => {
|
||||
vi.useFakeTimers();
|
||||
storageMocks.getSaveSnapshot.mockResolvedValue(null);
|
||||
storageMocks.putSaveSnapshot.mockResolvedValue({
|
||||
version: 2,
|
||||
savedAt: '2026-04-28T10:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
runtimeSessionId: 'runtime-main',
|
||||
currentScene: 'Story',
|
||||
},
|
||||
});
|
||||
const gameState = {
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimePersistenceDisabled: false,
|
||||
runtimeMode: 'play',
|
||||
currentScene: 'Story',
|
||||
worldType: 'CUSTOM',
|
||||
playerCharacter: { id: 'hero_001' },
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
} as unknown as GameState;
|
||||
const story = { text: '开场', options: [], streaming: false } as StoryMoment;
|
||||
|
||||
render(
|
||||
<PersistenceHarness
|
||||
authenticatedUserId="user-1"
|
||||
gameState={gameState}
|
||||
currentStory={story}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(storageMocks.getSaveSnapshot).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storageMocks.putSaveSnapshot).toHaveBeenCalledWith(
|
||||
{
|
||||
sessionId: 'runtime-main',
|
||||
bottomTab: 'adventure',
|
||||
},
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user