This commit is contained in:
376
src/hooks/rpg-runtime-story/characterChat.ts
Normal file
376
src/hooks/rpg-runtime-story/characterChat.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import {type Dispatch, type SetStateAction,useState} from 'react';
|
||||
|
||||
import {
|
||||
generateCharacterPanelChatSuggestions,
|
||||
generateCharacterPanelChatSummary,
|
||||
streamCharacterPanelChatReply,
|
||||
} from '../../services/aiService';
|
||||
import type {StoryGenerationContext} from '../../services/aiTypes';
|
||||
import type {
|
||||
Character,
|
||||
CharacterChatRecord,
|
||||
CharacterChatTurn,
|
||||
GameState,
|
||||
} from '../../types';
|
||||
|
||||
const MAX_CHARACTER_CHAT_TURNS = 24;
|
||||
|
||||
export type CharacterChatTarget = {
|
||||
character: Character;
|
||||
npcId: string | null;
|
||||
roleLabel: string;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
mana: number;
|
||||
maxMana: number;
|
||||
affinity?: number | null;
|
||||
};
|
||||
|
||||
export type CharacterChatModalState = {
|
||||
target: CharacterChatTarget;
|
||||
draft: string;
|
||||
messages: CharacterChatTurn[];
|
||||
suggestions: string[];
|
||||
summary: string;
|
||||
isSending: boolean;
|
||||
isLoadingSuggestions: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export interface CharacterChatUi {
|
||||
modal: CharacterChatModalState | null;
|
||||
openChat: (target: CharacterChatTarget) => void;
|
||||
closeChat: () => void;
|
||||
setDraft: (value: string) => void;
|
||||
useSuggestion: (value: string) => void;
|
||||
refreshSuggestions: () => void;
|
||||
sendDraft: () => void;
|
||||
}
|
||||
|
||||
export function getCharacterChatRecord(state: GameState, characterId: string): CharacterChatRecord {
|
||||
return state.characterChats[characterId] ?? {
|
||||
history: [],
|
||||
summary: '',
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function trimCharacterChatHistory(history: CharacterChatTurn[]) {
|
||||
return history.slice(-MAX_CHARACTER_CHAT_TURNS);
|
||||
}
|
||||
|
||||
export function buildLocalCharacterChatSummary(
|
||||
character: Character,
|
||||
history: CharacterChatTurn[],
|
||||
previousSummary: string,
|
||||
) {
|
||||
const latestTurns = history
|
||||
.slice(-4)
|
||||
.map(turn => `${turn.speaker === 'player' ? '玩家' : character.name}: ${turn.text}`)
|
||||
.join(' ');
|
||||
|
||||
const currentSummary = latestTurns
|
||||
? `${character.name} 在私下对话中变得更加开放。最近的交流:${latestTurns}`
|
||||
: `${character.name} 愿意继续私下对话,并逐渐更加信任玩家。`;
|
||||
|
||||
if (!previousSummary) {
|
||||
return currentSummary.slice(0, 118);
|
||||
}
|
||||
|
||||
return `${previousSummary} ${currentSummary}`.slice(0, 118);
|
||||
}
|
||||
|
||||
export function buildLocalCharacterChatSuggestions(character: Character) {
|
||||
return [
|
||||
'请更清楚地告诉我你的意思。',
|
||||
`${character.name},你真正担心的是什么?`,
|
||||
'暂时放下大局。我想更好地了解你。',
|
||||
];
|
||||
}
|
||||
|
||||
function buildCharacterChatRecordUpdate(
|
||||
state: GameState,
|
||||
characterId: string,
|
||||
record: CharacterChatRecord,
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
characterChats: {
|
||||
...state.characterChats,
|
||||
[characterId]: record,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type CharacterChatTargetStatus = {
|
||||
roleLabel: string;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
mana: number;
|
||||
maxMana: number;
|
||||
affinity?: number | null;
|
||||
};
|
||||
|
||||
function buildTargetStatus(target: CharacterChatTarget): CharacterChatTargetStatus {
|
||||
return {
|
||||
roleLabel: target.roleLabel,
|
||||
hp: target.hp,
|
||||
maxHp: target.maxHp,
|
||||
mana: target.mana,
|
||||
maxMana: target.maxMana,
|
||||
affinity: target.affinity ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCharacterChatFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildStoryContextFromState,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
buildStoryContextFromState: (state: GameState) => StoryGenerationContext;
|
||||
}) {
|
||||
const [characterChatModal, setCharacterChatModal] = useState<CharacterChatModalState | null>(null);
|
||||
|
||||
const loadCharacterChatSuggestions = async (
|
||||
target: CharacterChatTarget,
|
||||
messages: CharacterChatTurn[],
|
||||
summary: string,
|
||||
) => {
|
||||
if (!gameState.worldType || !gameState.playerCharacter) {
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
suggestions: buildLocalCharacterChatSuggestions(target.character),
|
||||
isLoadingSuggestions: false,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
isLoadingSuggestions: true,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
|
||||
try {
|
||||
const suggestions = await generateCharacterPanelChatSuggestions(
|
||||
gameState.worldType,
|
||||
gameState.playerCharacter,
|
||||
target.character,
|
||||
gameState.storyHistory,
|
||||
buildStoryContextFromState(gameState),
|
||||
messages,
|
||||
summary,
|
||||
buildTargetStatus(target),
|
||||
);
|
||||
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
suggestions,
|
||||
isLoadingSuggestions: false,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate character chat suggestions:', error);
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
suggestions: buildLocalCharacterChatSuggestions(target.character),
|
||||
isLoadingSuggestions: false,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const openCharacterChat = (target: CharacterChatTarget) => {
|
||||
const record = getCharacterChatRecord(gameState, target.character.id);
|
||||
|
||||
setCharacterChatModal({
|
||||
target,
|
||||
draft: '',
|
||||
messages: record.history,
|
||||
suggestions: buildLocalCharacterChatSuggestions(target.character),
|
||||
summary: record.summary,
|
||||
isSending: false,
|
||||
isLoadingSuggestions: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
void loadCharacterChatSuggestions(target, record.history, record.summary);
|
||||
};
|
||||
|
||||
const sendCharacterChatDraft = async () => {
|
||||
if (!characterChatModal || !gameState.worldType || !gameState.playerCharacter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const draft = characterChatModal.draft.trim();
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = characterChatModal.target;
|
||||
const existingRecord = getCharacterChatRecord(gameState, target.character.id);
|
||||
const baseMessages = trimCharacterChatHistory(characterChatModal.messages);
|
||||
const nextMessages = trimCharacterChatHistory([
|
||||
...baseMessages,
|
||||
{
|
||||
speaker: 'player' as const,
|
||||
text: draft,
|
||||
},
|
||||
]);
|
||||
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
draft: '',
|
||||
messages: [...nextMessages, {speaker: 'character', text: ''}],
|
||||
suggestions: [],
|
||||
isSending: true,
|
||||
isLoadingSuggestions: true,
|
||||
error: null,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
|
||||
let replyText = '';
|
||||
|
||||
try {
|
||||
replyText = await streamCharacterPanelChatReply(
|
||||
gameState.worldType,
|
||||
gameState.playerCharacter,
|
||||
target.character,
|
||||
gameState.storyHistory,
|
||||
buildStoryContextFromState(gameState),
|
||||
nextMessages,
|
||||
existingRecord.summary,
|
||||
draft,
|
||||
buildTargetStatus(target),
|
||||
{
|
||||
onUpdate: text => {
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
messages: [...nextMessages, {speaker: 'character', text}],
|
||||
}
|
||||
: current,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to stream character panel chat reply:', error);
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
draft,
|
||||
messages: baseMessages,
|
||||
isSending: false,
|
||||
isLoadingSuggestions: false,
|
||||
error: error instanceof Error ? error.message : '未知智能生成错误',
|
||||
suggestions: current.suggestions.length > 0
|
||||
? current.suggestions
|
||||
: buildLocalCharacterChatSuggestions(target.character),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const finalMessages = trimCharacterChatHistory([
|
||||
...nextMessages,
|
||||
{
|
||||
speaker: 'character' as const,
|
||||
text: replyText,
|
||||
},
|
||||
]);
|
||||
|
||||
let nextSummary = existingRecord.summary;
|
||||
try {
|
||||
nextSummary = await generateCharacterPanelChatSummary(
|
||||
gameState.worldType,
|
||||
gameState.playerCharacter,
|
||||
target.character,
|
||||
gameState.storyHistory,
|
||||
buildStoryContextFromState(gameState),
|
||||
finalMessages,
|
||||
existingRecord.summary,
|
||||
buildTargetStatus(target),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to summarize character chat:', error);
|
||||
nextSummary = buildLocalCharacterChatSummary(target.character, finalMessages, existingRecord.summary);
|
||||
}
|
||||
|
||||
const nextRecord: CharacterChatRecord = {
|
||||
history: finalMessages,
|
||||
summary: nextSummary,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setGameState(current =>
|
||||
buildCharacterChatRecordUpdate(current, target.character.id, nextRecord),
|
||||
);
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
messages: finalMessages,
|
||||
summary: nextSummary,
|
||||
isSending: false,
|
||||
error: null,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
|
||||
await loadCharacterChatSuggestions(target, finalMessages, nextSummary);
|
||||
};
|
||||
|
||||
const characterChatUi: CharacterChatUi = {
|
||||
modal: characterChatModal,
|
||||
openChat: openCharacterChat,
|
||||
closeChat: () => setCharacterChatModal(null),
|
||||
setDraft: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)),
|
||||
useSuggestion: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)),
|
||||
refreshSuggestions: () => {
|
||||
if (!characterChatModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadCharacterChatSuggestions(
|
||||
characterChatModal.target,
|
||||
characterChatModal.messages,
|
||||
characterChatModal.summary,
|
||||
);
|
||||
},
|
||||
sendDraft: () => {
|
||||
void sendCharacterChatDraft();
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
characterChatModal,
|
||||
setCharacterChatModal,
|
||||
characterChatUi,
|
||||
openCharacterChat,
|
||||
sendCharacterChatDraft,
|
||||
loadCharacterChatSuggestions,
|
||||
clearCharacterChatModal: () => setCharacterChatModal(null),
|
||||
};
|
||||
}
|
||||
749
src/hooks/rpg-runtime-story/choiceActions.test.ts
Normal file
749
src/hooks/rpg-runtime-story/choiceActions.test.ts
Normal file
@@ -0,0 +1,749 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../services/aiService', () => ({
|
||||
generateNextStep: vi.fn(),
|
||||
}));
|
||||
|
||||
const {
|
||||
isRpgRuntimeServerFunctionIdMock,
|
||||
} = vi.hoisted(() => ({
|
||||
isRpgRuntimeServerFunctionIdMock: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/rpg-runtime', () => ({
|
||||
isRpgRuntimeServerFunctionId: isRpgRuntimeServerFunctionIdMock,
|
||||
}));
|
||||
|
||||
import { generateNextStep } from '../../services/aiService';
|
||||
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
|
||||
import { createStoryChoiceActions } from './choiceActions';
|
||||
|
||||
function createTestCharacter(): Character {
|
||||
return {
|
||||
id: 'test-hero',
|
||||
name: '测试主角',
|
||||
title: '游侠',
|
||||
description: '一名测试用主角',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 10,
|
||||
spirit: 10,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-basic',
|
||||
name: '试探一击',
|
||||
animation: AnimationState.ATTACK,
|
||||
damage: 10,
|
||||
manaCost: 0,
|
||||
cooldownTurns: 1,
|
||||
range: 1,
|
||||
style: 'steady',
|
||||
},
|
||||
],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseState(): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createTestCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: true,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {
|
||||
'npc-opponent': {
|
||||
affinity: 0,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: 'npc-opponent',
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createBattleOption(functionId = 'battle_all_in_crush'): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText: '挥刀抢攻',
|
||||
text: '挥刀抢攻',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.ATTACK,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFallbackStory(text = 'fallback'): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
const neverNpcEncounter = (
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter => false;
|
||||
|
||||
describe('createStoryChoiceActions', () => {
|
||||
beforeEach(() => {
|
||||
isRpgRuntimeServerFunctionIdMock.mockReset();
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('reveals deferred adventure options when story_continue_adventure is selected', async () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
};
|
||||
const deferredOptions = [
|
||||
{
|
||||
functionId: 'idle_explore_forward',
|
||||
actionText: '继续向前探索',
|
||||
text: '继续向前探索',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right' as const,
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
] satisfies StoryOption[];
|
||||
const continueOption: StoryOption = {
|
||||
functionId: 'story_continue_adventure',
|
||||
actionText: '查看后续',
|
||||
text: '查看后续',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right' as const,
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
const currentStory: StoryMoment = {
|
||||
text: '对话已经完成',
|
||||
options: [continueOption],
|
||||
deferredOptions,
|
||||
};
|
||||
const setCurrentStory = vi.fn();
|
||||
const generateStoryForState = vi.fn();
|
||||
const handleNpcInteraction = vi.fn();
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory,
|
||||
isLoading: false,
|
||||
setGameState: vi.fn(),
|
||||
setCurrentStory,
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(),
|
||||
playResolvedChoice: vi.fn(),
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
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: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(
|
||||
(option: StoryOption) =>
|
||||
option.functionId === 'story_continue_adventure',
|
||||
),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(continueOption);
|
||||
|
||||
expect(setCurrentStory).toHaveBeenCalledWith({
|
||||
...currentStory,
|
||||
options: deferredOptions,
|
||||
deferredOptions: undefined,
|
||||
});
|
||||
expect(generateStoryForState).not.toHaveBeenCalled();
|
||||
expect(handleNpcInteraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies deferred runtime state when story_continue_adventure reveals the next act', async () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
inBattle: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
};
|
||||
const deferredOptions = [
|
||||
{
|
||||
functionId: 'idle_observe_signs',
|
||||
actionText: '观察下一幕的线索',
|
||||
text: '观察下一幕的线索',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right' as const,
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
] satisfies StoryOption[];
|
||||
const continueOption: StoryOption = {
|
||||
functionId: 'story_continue_adventure',
|
||||
actionText: '继续冒险',
|
||||
text: '继续冒险',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right' as const,
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
const currentStory: StoryMoment = {
|
||||
text: '对话已经完成',
|
||||
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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const setCurrentStory = vi.fn();
|
||||
const setGameState = vi.fn();
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory,
|
||||
isLoading: false,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(),
|
||||
playResolvedChoice: vi.fn(),
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => null),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn(
|
||||
(inputState: GameState) => inputState.sceneHostileNpcs,
|
||||
),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(
|
||||
(option: StoryOption) =>
|
||||
option.functionId === 'story_continue_adventure',
|
||||
),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(continueOption);
|
||||
|
||||
expect(setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
storyEngineMemory: expect.objectContaining({
|
||||
currentSceneActState: expect.objectContaining({
|
||||
currentActId: 'scene-bridge-act-2',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith({
|
||||
...currentStory,
|
||||
options: deferredOptions,
|
||||
deferredOptions: undefined,
|
||||
deferredRuntimeState: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps npc chat choices on the local UI path so chat mode can continue streaming locally', async () => {
|
||||
const state = createBaseState();
|
||||
const option = createBattleOption('npc_chat');
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
const handleNpcInteraction = vi.fn(() => true);
|
||||
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: {
|
||||
...state,
|
||||
currentEncounter: {
|
||||
id: 'npc-opponent',
|
||||
kind: 'npc',
|
||||
npcName: '山道客',
|
||||
npcDescription: '拦路的陌生人',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道相遇',
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
},
|
||||
currentStory: createFallbackStory('当前故事'),
|
||||
isLoading: false,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(),
|
||||
playResolvedChoice: vi.fn(),
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => null),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: (encounter): encounter is Encounter =>
|
||||
Boolean(encounter?.kind === 'npc'),
|
||||
isNpcEncounter: (encounter): encounter is Encounter =>
|
||||
Boolean(encounter?.kind === 'npc'),
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(handleNpcInteraction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
}),
|
||||
);
|
||||
expect(setGameState).not.toHaveBeenCalled();
|
||||
expect(setCurrentStory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps npc trade choices on the local UI path so the trade modal can collect payload', async () => {
|
||||
const state: GameState = {
|
||||
...createBaseState(),
|
||||
currentEncounter: {
|
||||
id: 'npc-merchant',
|
||||
kind: 'npc' as const,
|
||||
npcName: '梁伯',
|
||||
npcDescription: '沿街商贩',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '沿街商贩',
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
};
|
||||
const option: StoryOption = {
|
||||
functionId: 'npc_trade',
|
||||
actionText: '交易',
|
||||
text: '交易',
|
||||
interaction: {
|
||||
kind: 'npc' as const,
|
||||
npcId: 'npc-merchant',
|
||||
action: 'trade' as const,
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right' as const,
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
const handleNpcInteraction = vi.fn(() => true);
|
||||
|
||||
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: vi.fn(),
|
||||
playResolvedChoice: vi.fn(),
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => null),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: (encounter): encounter is Encounter =>
|
||||
Boolean(encounter?.kind === 'npc'),
|
||||
isNpcEncounter: (encounter): encounter is Encounter =>
|
||||
Boolean(encounter?.kind === 'npc'),
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(handleNpcInteraction).toHaveBeenCalledWith(option);
|
||||
});
|
||||
|
||||
it('reopens npc chat instead of running generic follow-up 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).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nextState: expect.objectContaining({
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
}),
|
||||
encounter,
|
||||
actionText: '挥刀抢攻',
|
||||
resultText: '山道客已经败下阵来。胜利奖励:无战利品。',
|
||||
battleMode: 'fight',
|
||||
}),
|
||||
);
|
||||
expect(generateStoryForState).not.toHaveBeenCalled();
|
||||
expect(setCurrentStory).not.toHaveBeenCalledWith(
|
||||
createFallbackStory('战后续写'),
|
||||
);
|
||||
});
|
||||
|
||||
it('injects an escape resolution into the immediate story context before ai continuation', async () => {
|
||||
const mockedGenerateNextStep = vi.mocked(generateNextStep);
|
||||
mockedGenerateNextStep.mockResolvedValue({
|
||||
storyText: '你落到山道外侧,呼吸总算稳了下来。',
|
||||
options: [],
|
||||
});
|
||||
|
||||
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 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: vi.fn(),
|
||||
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).toHaveBeenCalledTimes(1);
|
||||
const history = mockedGenerateNextStep.mock.calls[0]?.[3] as StoryMoment[];
|
||||
expect(history.map((entry) => `${entry.historyRole}:${entry.text}`)).toEqual([
|
||||
'action:挥刀抢攻',
|
||||
'result:你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
|
||||
]);
|
||||
expect(buildStoryContextFromState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
lastFunctionId: 'battle_escape_breakout',
|
||||
recentActionResult: '你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
|
||||
}),
|
||||
);
|
||||
expect(setBattleReward).toHaveBeenCalledTimes(1);
|
||||
expect(setBattleReward).toHaveBeenCalledWith(null);
|
||||
expect(incrementRuntimeStats).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ hostileNpcsDefeated: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
292
src/hooks/rpg-runtime-story/choiceActions.ts
Normal file
292
src/hooks/rpg-runtime-story/choiceActions.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import type {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { isRpgRuntimeServerFunctionId } from '../../services/rpg-runtime';
|
||||
import {
|
||||
type Character,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
} from '../../types';
|
||||
import type { EscapePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import type {
|
||||
CommitGeneratedStateWithEncounterEntry,
|
||||
GenerateStoryForState,
|
||||
} from './progressionActions';
|
||||
import { runLocalStoryChoiceContinuation } from './storyChoiceContinuation';
|
||||
import {
|
||||
runCampTravelHomeChoice,
|
||||
runServerRuntimeChoiceAction,
|
||||
shouldOpenLocalRuntimeNpcModal,
|
||||
} from './storyChoiceRuntime';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
|
||||
type RuntimeStatsIncrements = Partial<Pick<GameState['runtimeStats'], 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'>>;
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type BuildStoryFromResponse = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog?: StoryOption[] | null,
|
||||
) => StoryMoment;
|
||||
|
||||
type BuildNpcStory = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type HandleNpcBattleConversationContinuation = (params: {
|
||||
nextState: GameState;
|
||||
encounter: Encounter;
|
||||
character: Character;
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>;
|
||||
}) => boolean;
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
type UpdateQuestLog = (
|
||||
state: GameState,
|
||||
updater: (quests: GameState['quests']) => GameState['quests'],
|
||||
) => GameState;
|
||||
|
||||
type IncrementRuntimeStats = (
|
||||
state: GameState,
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
export function createStoryChoiceActions({
|
||||
gameState,
|
||||
currentStory,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setBattleReward,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
buildFallbackStoryForState,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
buildNpcStory,
|
||||
handleNpcBattleConversationContinuation,
|
||||
updateQuestLog,
|
||||
incrementRuntimeStats,
|
||||
getCampCompanionTravelScene,
|
||||
enterNpcInteraction,
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
finalizeNpcBattleResult,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
isLoading: boolean;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
setBattleReward: Dispatch<SetStateAction<BattleRewardSummary | null>>;
|
||||
buildResolvedChoiceState: (state: GameState, option: StoryOption, character: Character) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: EscapePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
buildStoryFromResponse: BuildStoryFromResponse;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
generateStoryForState: GenerateStoryForState;
|
||||
getAvailableOptionsForState: (state: GameState, character: Character) => StoryOption[] | null;
|
||||
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
buildNpcStory: BuildNpcStory;
|
||||
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
|
||||
updateQuestLog: UpdateQuestLog;
|
||||
incrementRuntimeStats: IncrementRuntimeStats;
|
||||
getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
|
||||
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
|
||||
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
|
||||
handleTreasureInteraction: (
|
||||
option: StoryOption,
|
||||
) => void | Promise<void> | boolean | Promise<boolean>;
|
||||
commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry;
|
||||
finalizeNpcBattleResult: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>,
|
||||
battleOutcome: GameState['currentNpcBattleOutcome'],
|
||||
) => { nextState: GameState; resultText: string } | null;
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
|
||||
isNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
|
||||
npcPreviewTalkFunctionId: string;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
}) {
|
||||
const handleChoice = async (option: StoryOption) => {
|
||||
const character = gameState.playerCharacter;
|
||||
if (!gameState.worldType || !character || isLoading) return;
|
||||
if (option.disabled) return;
|
||||
|
||||
if (currentStory?.deferredOptions?.length && isContinueAdventureOption(option)) {
|
||||
if (currentStory.deferredRuntimeState) {
|
||||
setGameState({
|
||||
...gameState,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
inBattle: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
currentScenePreset:
|
||||
currentStory.deferredRuntimeState.currentScenePreset ??
|
||||
gameState.currentScenePreset,
|
||||
storyEngineMemory:
|
||||
currentStory.deferredRuntimeState.storyEngineMemory ??
|
||||
gameState.storyEngineMemory,
|
||||
});
|
||||
}
|
||||
setCurrentStory({
|
||||
...currentStory,
|
||||
options: currentStory.deferredOptions,
|
||||
deferredOptions: undefined,
|
||||
deferredRuntimeState: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCampTravelHomeOption(option)) {
|
||||
await runCampTravelHomeChoice({
|
||||
gameState,
|
||||
option,
|
||||
character,
|
||||
setBattleReward,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setGameState,
|
||||
incrementRuntimeStats,
|
||||
getCampCompanionTravelScene,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
isNpcEncounter,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldOpenLocalRuntimeNpcModal(option)) {
|
||||
setAiError(null);
|
||||
await handleNpcInteraction(option);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRpgRuntimeServerFunctionId(option.functionId)) {
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
character,
|
||||
setBattleReward,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setGameState,
|
||||
setCurrentStory: (story) => setCurrentStory(story),
|
||||
buildFallbackStoryForState,
|
||||
turnVisualMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
option.functionId === npcPreviewTalkFunctionId
|
||||
&& isRegularNpcEncounter(gameState.currentEncounter)
|
||||
&& !gameState.npcInteractionActive
|
||||
) {
|
||||
setAiError(null);
|
||||
enterNpcInteraction(gameState.currentEncounter, option.actionText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'npc') {
|
||||
setAiError(null);
|
||||
await handleNpcInteraction(option);
|
||||
return;
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'treasure') {
|
||||
setAiError(null);
|
||||
await handleTreasureInteraction(option);
|
||||
return;
|
||||
}
|
||||
|
||||
await runLocalStoryChoiceContinuation({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
character,
|
||||
setGameState,
|
||||
setCurrentStory: (story) => setCurrentStory(story),
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setBattleReward,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
buildFallbackStoryForState,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
buildNpcStory,
|
||||
handleNpcBattleConversationContinuation,
|
||||
updateQuestLog,
|
||||
incrementRuntimeStats,
|
||||
finalizeNpcBattleResult,
|
||||
isRegularNpcEncounter,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
handleChoice,
|
||||
};
|
||||
}
|
||||
88
src/hooks/rpg-runtime-story/goalFlow.ts
Normal file
88
src/hooks/rpg-runtime-story/goalFlow.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildGoalStackState,
|
||||
createGoalPulseSnapshot,
|
||||
deriveGoalPulseEvent,
|
||||
} from '../../services/storyEngine/goalDirector';
|
||||
import type { GameState } from '../../types';
|
||||
import type { GoalFlowUi } from './uiTypes';
|
||||
|
||||
export function useStoryGoalFlow(gameState: GameState) {
|
||||
const [goalPulse, setGoalPulse] = useState<GoalFlowUi['pulse']>(null);
|
||||
const previousGoalPulseSnapshotRef =
|
||||
useRef<ReturnType<typeof createGoalPulseSnapshot> | null>(null);
|
||||
|
||||
const runtimeGoalStack = useMemo(
|
||||
() =>
|
||||
buildGoalStackState({
|
||||
quests: gameState.quests,
|
||||
worldType: gameState.worldType,
|
||||
currentSceneId: gameState.currentScenePreset?.id ?? null,
|
||||
chapterState:
|
||||
gameState.chapterState ??
|
||||
gameState.storyEngineMemory?.currentChapter ??
|
||||
null,
|
||||
journeyBeat: gameState.storyEngineMemory?.currentJourneyBeat ?? null,
|
||||
setpieceDirective:
|
||||
gameState.storyEngineMemory?.currentSetpieceDirective ?? null,
|
||||
currentCampEvent:
|
||||
gameState.storyEngineMemory?.currentCampEvent ?? null,
|
||||
currentSceneName: gameState.currentScenePreset?.name ?? null,
|
||||
}),
|
||||
[
|
||||
gameState.chapterState,
|
||||
gameState.currentScenePreset?.id,
|
||||
gameState.currentScenePreset?.name,
|
||||
gameState.quests,
|
||||
gameState.storyEngineMemory?.currentCampEvent,
|
||||
gameState.storyEngineMemory?.currentChapter,
|
||||
gameState.storyEngineMemory?.currentJourneyBeat,
|
||||
gameState.storyEngineMemory?.currentSetpieceDirective,
|
||||
gameState.worldType,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentSnapshot = createGoalPulseSnapshot(
|
||||
gameState.quests,
|
||||
runtimeGoalStack,
|
||||
);
|
||||
const previousSnapshot = previousGoalPulseSnapshotRef.current;
|
||||
|
||||
if (!previousSnapshot) {
|
||||
previousGoalPulseSnapshotRef.current = currentSnapshot;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPulse = deriveGoalPulseEvent({
|
||||
previous: previousSnapshot,
|
||||
quests: gameState.quests,
|
||||
goalStack: runtimeGoalStack,
|
||||
});
|
||||
if (nextPulse) {
|
||||
setGoalPulse(nextPulse);
|
||||
}
|
||||
|
||||
previousGoalPulseSnapshotRef.current = currentSnapshot;
|
||||
}, [gameState.quests, runtimeGoalStack]);
|
||||
|
||||
const dismissGoalPulse = useCallback(() => {
|
||||
setGoalPulse(null);
|
||||
}, []);
|
||||
|
||||
const resetGoalPulseTracking = useCallback(() => {
|
||||
previousGoalPulseSnapshotRef.current = null;
|
||||
setGoalPulse(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
runtimeGoalStack,
|
||||
goalUi: {
|
||||
goalStack: runtimeGoalStack,
|
||||
pulse: goalPulse,
|
||||
dismissPulse: dismissGoalPulse,
|
||||
} satisfies GoalFlowUi,
|
||||
resetGoalPulseTracking,
|
||||
};
|
||||
}
|
||||
52
src/hooks/rpg-runtime-story/index.ts
Normal file
52
src/hooks/rpg-runtime-story/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export {
|
||||
loadRpgRuntimeOptionCatalog,
|
||||
resolveRpgRuntimeChoice,
|
||||
resumeRpgRuntimeStory,
|
||||
type LoadRpgRuntimeOptionCatalogParams,
|
||||
type ResolveRpgRuntimeChoiceParams,
|
||||
} from './rpgRuntimeStoryGateway';
|
||||
export {
|
||||
useRpgRuntimeInteractionFlow,
|
||||
createRpgRuntimeInteractionUiResetter,
|
||||
type RpgRuntimeInteractionFlowResult,
|
||||
type UseRpgRuntimeInteractionFlowParams,
|
||||
} from './useRpgRuntimeInteractionFlow';
|
||||
export {
|
||||
useRpgRuntimeNpcInteraction,
|
||||
type RpgRuntimeNpcInteractionResult,
|
||||
type UseRpgRuntimeNpcInteractionParams,
|
||||
} from './useRpgRuntimeNpcInteraction';
|
||||
export {
|
||||
useRpgRuntimeStory,
|
||||
type BattleRewardSummary,
|
||||
type BattleRewardUi,
|
||||
type CharacterChatModalState,
|
||||
type CharacterChatTarget,
|
||||
type CharacterChatUi,
|
||||
type GiftModalState,
|
||||
type GoalFlowUi,
|
||||
type InventoryFlowUi,
|
||||
type NpcChatQuestOfferUi,
|
||||
type QuestFlowUi,
|
||||
type RecruitModalState,
|
||||
type RpgRuntimeStoryResult,
|
||||
type StoryGenerationNpcUi,
|
||||
type TradeModalState,
|
||||
type UseRpgRuntimeStoryParams,
|
||||
} from './useRpgRuntimeStory';
|
||||
export {
|
||||
useRpgRuntimeStoryController,
|
||||
type RpgRuntimeStoryControllerResult,
|
||||
type UseRpgRuntimeStoryControllerParams,
|
||||
} from './useRpgRuntimeStoryController';
|
||||
export {
|
||||
useRpgRuntimeStoryFlow,
|
||||
type RpgRuntimeStoryFlowResult,
|
||||
type UseRpgRuntimeStoryFlowParams,
|
||||
} from './useRpgRuntimeStoryFlow';
|
||||
export {
|
||||
createRpgRuntimeStoryUiResetter,
|
||||
useRpgRuntimeStoryState,
|
||||
type RpgRuntimeStoryStateResult,
|
||||
type UseRpgRuntimeStoryStateParams,
|
||||
} from './useRpgRuntimeStoryState';
|
||||
196
src/hooks/rpg-runtime-story/inventoryActions.ts
Normal file
196
src/hooks/rpg-runtime-story/inventoryActions.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useMemo, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
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';
|
||||
import type { Character, GameState, StoryMoment } from '../../types';
|
||||
import { resolveRpgRuntimeChoice } from '.';
|
||||
import type { InventoryFlowUi } from './uiTypes';
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
export function useStoryInventoryActions({
|
||||
gameState,
|
||||
runtime,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
runtime: {
|
||||
currentStory: StoryMoment | null;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
};
|
||||
}) {
|
||||
const {
|
||||
currentStory,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
buildFallbackStoryForState,
|
||||
} = runtime;
|
||||
const forgeRecipes = useMemo(
|
||||
() =>
|
||||
getForgeRecipeViews(
|
||||
gameState.playerInventory,
|
||||
gameState.playerCurrency,
|
||||
gameState.worldType,
|
||||
),
|
||||
[gameState.playerCurrency, gameState.playerInventory, gameState.worldType],
|
||||
);
|
||||
|
||||
const resolveServerInventoryAction = async (params: {
|
||||
functionId: string;
|
||||
actionText: string;
|
||||
payload: Record<string, unknown>;
|
||||
}) => {
|
||||
const character = gameState.playerCharacter;
|
||||
if (
|
||||
!character ||
|
||||
!gameState.worldType ||
|
||||
gameState.currentScene !== 'Story'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option: {
|
||||
functionId: params.functionId,
|
||||
actionText: params.actionText,
|
||||
},
|
||||
payload: params.payload,
|
||||
});
|
||||
|
||||
setGameState(hydratedSnapshot.gameState);
|
||||
setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve inventory runtime action on the server:', error);
|
||||
setAiError(error instanceof Error ? error.message : '背包动作执行失败');
|
||||
if (!currentStory) {
|
||||
setCurrentStory(buildFallbackStoryForState(gameState, character));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const useInventoryItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
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,
|
||||
);
|
||||
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 craftRecipe = async (recipeId: string) => {
|
||||
const recipe = forgeRecipes.find(
|
||||
(candidate) => candidate.id === recipeId,
|
||||
);
|
||||
if (!recipe) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: FORGE_CRAFT_FUNCTION.id,
|
||||
actionText: `制作${recipe.resultLabel}`,
|
||||
payload: { recipeId },
|
||||
});
|
||||
};
|
||||
|
||||
const dismantleItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
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,
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: FORGE_REFORGE_FUNCTION.id,
|
||||
actionText: `重铸${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
inventoryUi: {
|
||||
useInventoryItem,
|
||||
equipInventoryItem,
|
||||
unequipItem,
|
||||
forgeRecipes,
|
||||
craftRecipe,
|
||||
dismantleItem,
|
||||
reforgeItem,
|
||||
} satisfies InventoryFlowUi,
|
||||
};
|
||||
}
|
||||
1888
src/hooks/rpg-runtime-story/npcEncounterActions.test.ts
Normal file
1888
src/hooks/rpg-runtime-story/npcEncounterActions.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
773
src/hooks/rpg-runtime-story/npcInteraction.ts
Normal file
773
src/hooks/rpg-runtime-story/npcInteraction.ts
Normal file
@@ -0,0 +1,773 @@
|
||||
import type {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
getCharacterById,
|
||||
} from '../../data/characterPresets';
|
||||
import {
|
||||
getNpcBuybackPrice,
|
||||
getNpcPurchasePrice,
|
||||
} from '../../data/economy';
|
||||
import {
|
||||
buildNpcGiftModalState,
|
||||
buildNpcRecruitModalState,
|
||||
buildNpcTradeModalState,
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
addInventoryItems,
|
||||
buildNpcGiftCommitActionText,
|
||||
buildNpcGiftResultText,
|
||||
buildNpcTradeTransactionActionText,
|
||||
buildNpcTradeTransactionResultText,
|
||||
getGiftCandidates,
|
||||
getPreferredGiftItemId,
|
||||
removeInventoryItem,
|
||||
syncNpcTradeInventory,
|
||||
} from '../../data/npcInteractions';
|
||||
import { streamNpcChatDialogue, streamNpcRecruitDialogue } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { resolveRpgRuntimeChoice } from '.';
|
||||
import type {
|
||||
GiftModalState,
|
||||
RecruitModalState,
|
||||
StoryGenerationNpcUi,
|
||||
TradeModalState,
|
||||
} from './uiTypes';
|
||||
|
||||
type GenerateStoryForState = (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
|
||||
type StoryNpcInteractionRuntime = {
|
||||
currentStory: StoryMoment | null;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
buildFallbackStoryForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
buildDialogueStoryMoment: (
|
||||
npcName: string,
|
||||
text: string,
|
||||
options: StoryOption[],
|
||||
streaming?: boolean,
|
||||
) => StoryMoment;
|
||||
generateStoryForState: GenerateStoryForState;
|
||||
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
getTypewriterDelay: (char: string) => number;
|
||||
};
|
||||
|
||||
function buildOfflineRecruitDialogue(encounter: Encounter, releasedCompanionName?: string | null) {
|
||||
const releaseLine = releasedCompanionName
|
||||
? `你:如果你愿意加入,我会先让${releasedCompanionName}暂时离队,把位置腾给你。`
|
||||
: '你:如果你愿意加入,我希望接下来能和你并肩行动。';
|
||||
|
||||
return [
|
||||
'你:我不是一时起意,我想正式邀请你加入我的队伍。',
|
||||
`${encounter.npcName}:你这话说得够直接,不过我更在意,你是否真的清楚自己接下来要面对什么。`,
|
||||
releaseLine,
|
||||
`${encounter.npcName}:既然你已经想清楚了,那我就跟你走这一程,看看你能把这条路走到哪里。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function normalizeRecruitDialogue(
|
||||
encounter: Encounter,
|
||||
dialogueText: string,
|
||||
releasedCompanionName?: string | null,
|
||||
) {
|
||||
const rawLines = dialogueText
|
||||
.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
const refusalPattern = /拒绝|不加入|不答应|不能加入|不会加入|暂不加入|以后再说|改天再说|再考虑|观望|算了|不同路/u;
|
||||
const sanitizedLines = rawLines.filter(line => !refusalPattern.test(line));
|
||||
const npcPrefix = `${encounter.npcName}:`;
|
||||
const playerPrefix = '你:';
|
||||
const releaseLine = releasedCompanionName
|
||||
? `${playerPrefix}我会先让${releasedCompanionName}暂时离队,把位置腾给你。`
|
||||
: `${playerPrefix}我会把接下来的路安排好,和你并肩前行。`;
|
||||
const defaultLines = [
|
||||
`${playerPrefix}我是真心邀请你加入队伍,不是随口一提。`,
|
||||
`${npcPrefix}你的意思我已经听明白了,我更在意你是否真的准备好了。`,
|
||||
releaseLine,
|
||||
`${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`,
|
||||
];
|
||||
|
||||
const workingLines = sanitizedLines.length > 0 ? sanitizedLines.slice(0, 5) : defaultLines.slice(0, 3);
|
||||
if (!workingLines.some(line => line.startsWith(playerPrefix))) {
|
||||
const firstDefaultLine = defaultLines[0];
|
||||
if (firstDefaultLine) {
|
||||
workingLines.unshift(firstDefaultLine);
|
||||
}
|
||||
}
|
||||
if (!workingLines.some(line => line.startsWith(npcPrefix))) {
|
||||
const secondDefaultLine = defaultLines[1];
|
||||
if (secondDefaultLine) {
|
||||
workingLines.push(secondDefaultLine);
|
||||
}
|
||||
}
|
||||
|
||||
const acceptanceLine = `${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`;
|
||||
const lastWorkingLine = workingLines[workingLines.length - 1];
|
||||
if (
|
||||
workingLines.length === 0
|
||||
|| !lastWorkingLine?.startsWith(npcPrefix)
|
||||
|| refusalPattern.test(lastWorkingLine)
|
||||
) {
|
||||
workingLines.push(acceptanceLine);
|
||||
} else {
|
||||
workingLines[workingLines.length - 1] = acceptanceLine;
|
||||
}
|
||||
|
||||
const compactLines = workingLines.slice(0, 5);
|
||||
if (compactLines[compactLines.length - 1] !== acceptanceLine) {
|
||||
compactLines.push(acceptanceLine);
|
||||
}
|
||||
|
||||
return compactLines.slice(0, 6).join('\n');
|
||||
}
|
||||
|
||||
export function useStoryNpcInteractionFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
getNpcEncounterKey,
|
||||
getResolvedNpcState,
|
||||
updateNpcState,
|
||||
cloneInventoryItemForOwner,
|
||||
runtime,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
getNpcEncounterKey: (encounter: Encounter) => string;
|
||||
getResolvedNpcState: (state: GameState, encounter: Encounter) => GameState['npcStates'][string];
|
||||
updateNpcState: (
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
updater: (npcState: GameState['npcStates'][string]) => GameState['npcStates'][string],
|
||||
) => GameState;
|
||||
cloneInventoryItemForOwner: (
|
||||
item: InventoryItem,
|
||||
owner: 'player' | 'npc',
|
||||
quantity?: number,
|
||||
) => InventoryItem;
|
||||
runtime: StoryNpcInteractionRuntime;
|
||||
}) {
|
||||
const [tradeModal, setTradeModal] = useState<TradeModalState | null>(null);
|
||||
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;
|
||||
releasedNpcId?: string | null;
|
||||
preludeText?: string | null;
|
||||
}) => {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
if (
|
||||
!playerCharacter ||
|
||||
!gameState.worldType ||
|
||||
gameState.currentScene !== 'Story'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||||
gameState,
|
||||
currentStory: runtime.currentStory,
|
||||
option: {
|
||||
functionId: 'npc_recruit',
|
||||
actionText: params.actionText,
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: params.encounter.id ?? getNpcEncounterKey(params.encounter),
|
||||
action: 'recruit',
|
||||
},
|
||||
},
|
||||
payload: {
|
||||
...(params.releasedNpcId
|
||||
? {
|
||||
releaseNpcId: params.releasedNpcId,
|
||||
}
|
||||
: {}),
|
||||
...(params.preludeText
|
||||
? {
|
||||
preludeText: params.preludeText,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
setGameState(hydratedSnapshot.gameState);
|
||||
runtime.setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve npc recruit action on the server:', error);
|
||||
runtime.setAiError(
|
||||
error instanceof Error ? error.message : 'NPC 招募执行失败',
|
||||
);
|
||||
if (!runtime.currentStory) {
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildFallbackStoryForState(gameState, playerCharacter),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
runtime.setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startRecruitmentSequence = async (
|
||||
encounter: Encounter,
|
||||
actionText: string,
|
||||
releasedNpcId?: string | null,
|
||||
) => {
|
||||
if (!gameState.playerCharacter) return;
|
||||
|
||||
const releasedCompanionName = releasedNpcId
|
||||
? (() => {
|
||||
const releasedCompanion = gameState.companions.find(item => item.npcId === releasedNpcId);
|
||||
return releasedCompanion?.characterId
|
||||
? getCharacterById(releasedCompanion.characterId)?.name ?? null
|
||||
: null;
|
||||
})()
|
||||
: null;
|
||||
const provisionalState = {
|
||||
...gameState,
|
||||
};
|
||||
const recruitPromptSummary = releasedCompanionName
|
||||
? `如果对方答应加入,你会先让${releasedCompanionName}离队,为新同伴腾出位置。`
|
||||
: '如果对方答应加入,你们将立刻结伴同行。';
|
||||
|
||||
setRecruitModal(null);
|
||||
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 streamNpcRecruitDialogue(
|
||||
gameState.worldType!,
|
||||
gameState.playerCharacter,
|
||||
encounter,
|
||||
runtime.getStoryGenerationHostileNpcs(provisionalState),
|
||||
gameState.storyHistory,
|
||||
runtime.buildStoryContextFromState(provisionalState, {
|
||||
lastFunctionId: 'npc_recruit',
|
||||
}),
|
||||
actionText,
|
||||
recruitPromptSummary,
|
||||
{
|
||||
onUpdate: text => {
|
||||
streamedTargetText = text;
|
||||
},
|
||||
},
|
||||
);
|
||||
streamedTargetText = dialogueText;
|
||||
streamCompleted = true;
|
||||
await typewriterPromise;
|
||||
} catch (error) {
|
||||
streamCompleted = true;
|
||||
await typewriterPromise;
|
||||
console.error('Failed to stream recruit dialogue:', error);
|
||||
dialogueText = displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName);
|
||||
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
}
|
||||
|
||||
const finalDialogueText = normalizeRecruitDialogue(
|
||||
encounter,
|
||||
dialogueText || displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName),
|
||||
releasedCompanionName,
|
||||
);
|
||||
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, finalDialogueText, [], false));
|
||||
await new Promise(resolve => window.setTimeout(resolve, 260));
|
||||
await resolveRecruitmentOnServer({
|
||||
encounter,
|
||||
actionText,
|
||||
releasedNpcId,
|
||||
preludeText: finalDialogueText,
|
||||
});
|
||||
};
|
||||
|
||||
const openTradeModal = (encounter: Encounter, actionText: string) => {
|
||||
const currentNpcState = getResolvedNpcState(gameState, encounter);
|
||||
const npcState = syncNpcTradeInventory(
|
||||
gameState,
|
||||
encounter,
|
||||
currentNpcState,
|
||||
);
|
||||
|
||||
if (
|
||||
gameState.npcStates[getNpcEncounterKey(encounter)] !== npcState
|
||||
|| npcState !== currentNpcState
|
||||
) {
|
||||
setGameState(updateNpcState(gameState, encounter, () => npcState));
|
||||
}
|
||||
|
||||
setTradeModal(
|
||||
buildNpcTradeModalState(
|
||||
gameState,
|
||||
encounter,
|
||||
actionText,
|
||||
npcState.inventory,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const openGiftModal = (encounter: Encounter, actionText: string) => {
|
||||
const selectedItemId = getPreferredGiftItemId(
|
||||
gameState.playerInventory,
|
||||
encounter,
|
||||
{
|
||||
worldType: gameState.worldType,
|
||||
customWorldProfile: gameState.customWorldProfile,
|
||||
},
|
||||
);
|
||||
if (!selectedItemId) return;
|
||||
|
||||
setGiftModal(
|
||||
buildNpcGiftModalState(
|
||||
gameState,
|
||||
encounter,
|
||||
actionText,
|
||||
selectedItemId,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const openRecruitModal = (encounter: Encounter, actionText: string) => {
|
||||
setRecruitModal(buildNpcRecruitModalState(gameState, encounter, actionText));
|
||||
};
|
||||
|
||||
const clearNpcInteractionUi = () => {
|
||||
setTradeModal(null);
|
||||
setGiftModal(null);
|
||||
setRecruitModal(null);
|
||||
};
|
||||
|
||||
const resolveServerNpcAction = async (params: {
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
functionId: string;
|
||||
action: 'trade' | 'gift' | 'quest_accept' | 'quest_turn_in';
|
||||
payload?: Record<string, unknown>;
|
||||
}) => {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
if (
|
||||
!playerCharacter ||
|
||||
!gameState.worldType ||
|
||||
gameState.currentScene !== 'Story'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
runtime.setAiError(null);
|
||||
runtime.setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||||
gameState,
|
||||
currentStory: runtime.currentStory,
|
||||
option: {
|
||||
functionId: params.functionId,
|
||||
actionText: params.actionText,
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: params.encounter.id ?? getNpcEncounterKey(params.encounter),
|
||||
action: params.action,
|
||||
},
|
||||
},
|
||||
payload: params.payload,
|
||||
});
|
||||
|
||||
setGameState(hydratedSnapshot.gameState);
|
||||
runtime.setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve npc runtime action on the server:', error);
|
||||
runtime.setAiError(
|
||||
error instanceof Error ? error.message : 'NPC 交互执行失败',
|
||||
);
|
||||
if (!runtime.currentStory) {
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildFallbackStoryForState(gameState, playerCharacter),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
runtime.setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmTrade = () => {
|
||||
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;
|
||||
|
||||
setTradeModal(null);
|
||||
void resolveServerNpcAction({
|
||||
encounter,
|
||||
actionText: buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
mode: 'sell',
|
||||
item: playerItem,
|
||||
quantity,
|
||||
}),
|
||||
functionId: 'npc_trade',
|
||||
action: 'trade',
|
||||
payload: {
|
||||
mode: 'sell',
|
||||
itemId: playerItem.id,
|
||||
quantity,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const confirmGift = () => {
|
||||
if (!giftModal || !gameState.playerCharacter) return;
|
||||
|
||||
const encounter = giftModal.encounter;
|
||||
const giftItem = gameState.playerInventory.find(item => item.id === giftModal.selectedItemId);
|
||||
if (!giftItem) return;
|
||||
|
||||
setGiftModal(null);
|
||||
void resolveServerNpcAction({
|
||||
encounter,
|
||||
actionText: buildNpcGiftCommitActionText(encounter, giftItem),
|
||||
functionId: 'npc_gift',
|
||||
action: 'gift',
|
||||
payload: {
|
||||
itemId: giftItem.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
npcUi: {
|
||||
tradeModal,
|
||||
giftModal,
|
||||
recruitModal,
|
||||
setTradeMode: (mode: 'buy' | 'sell') => setTradeModal(current => {
|
||||
if (!current) return current;
|
||||
const nextModal = {
|
||||
...current,
|
||||
mode,
|
||||
selectedNpcItemId: current.selectedNpcItemId ?? getResolvedNpcState(gameState, current.encounter).inventory[0]?.id ?? null,
|
||||
selectedPlayerItemId: current.selectedPlayerItemId ?? gameState.playerInventory[0]?.id ?? null,
|
||||
selectedQuantity: 1,
|
||||
};
|
||||
return {
|
||||
...nextModal,
|
||||
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
|
||||
};
|
||||
}),
|
||||
selectTradeNpcItem: (itemId: string) => setTradeModal(current => {
|
||||
if (!current) return current;
|
||||
const nextModal = {
|
||||
...current,
|
||||
selectedNpcItemId: itemId,
|
||||
};
|
||||
return {
|
||||
...nextModal,
|
||||
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
|
||||
};
|
||||
}),
|
||||
selectTradePlayerItem: (itemId: string) => setTradeModal(current => {
|
||||
if (!current) return current;
|
||||
const nextModal = {
|
||||
...current,
|
||||
selectedPlayerItemId: itemId,
|
||||
};
|
||||
return {
|
||||
...nextModal,
|
||||
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
|
||||
};
|
||||
}),
|
||||
setTradeQuantity: (quantity: number) => setTradeModal(current => current
|
||||
? {
|
||||
...current,
|
||||
selectedQuantity: clampTradeQuantity(gameState, current, quantity),
|
||||
}
|
||||
: current),
|
||||
closeTradeModal: () => setTradeModal(null),
|
||||
confirmTrade,
|
||||
selectGiftItem: (itemId: string) => setGiftModal(current => current ? { ...current, selectedItemId: itemId } : current),
|
||||
closeGiftModal: () => setGiftModal(null),
|
||||
confirmGift,
|
||||
selectRecruitRelease: (npcId: string) => setRecruitModal(current => current ? { ...current, selectedReleaseNpcId: npcId } : current),
|
||||
closeRecruitModal: () => setRecruitModal(null),
|
||||
confirmRecruit: () => {
|
||||
if (!recruitModal) return;
|
||||
void startRecruitmentSequence(
|
||||
recruitModal.encounter,
|
||||
recruitModal.actionText,
|
||||
recruitModal.selectedReleaseNpcId,
|
||||
);
|
||||
},
|
||||
} satisfies StoryGenerationNpcUi,
|
||||
openTradeModal,
|
||||
openGiftModal,
|
||||
openRecruitModal,
|
||||
startRecruitmentSequence,
|
||||
clearNpcInteractionUi,
|
||||
};
|
||||
}
|
||||
790
src/hooks/rpg-runtime-story/progressionActions.ts
Normal file
790
src/hooks/rpg-runtime-story/progressionActions.ts
Normal file
@@ -0,0 +1,790 @@
|
||||
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,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
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;
|
||||
history: GameState['storyHistory'];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
export type CommitGeneratedStateWithEncounterEntry = (
|
||||
entryState: GameState,
|
||||
resolvedState: GameState,
|
||||
character: Character,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
lastFunctionId?: string,
|
||||
) => Promise<void>;
|
||||
|
||||
export function appendStoryHistory(
|
||||
state: GameState,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
): GameState['storyHistory'] {
|
||||
return [
|
||||
...state.storyHistory,
|
||||
createHistoryMoment(actionText, 'action'),
|
||||
createHistoryMoment(resultText, 'result'),
|
||||
];
|
||||
}
|
||||
|
||||
export function createStoryProgressionActions({
|
||||
gameState,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
generateStoryForState,
|
||||
buildFallbackStoryForState,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
generateStoryForState: GenerateStoryForState;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
}) {
|
||||
const commitGeneratedState: CommitGeneratedState = async (
|
||||
nextState,
|
||||
character,
|
||||
actionText,
|
||||
resultText,
|
||||
lastFunctionId,
|
||||
) => {
|
||||
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
|
||||
const stateWithHistory = applyStoryEngineEchoes({
|
||||
previousState: gameState,
|
||||
nextState: {
|
||||
...nextState,
|
||||
storyHistory: nextHistory,
|
||||
} as GameState,
|
||||
actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
|
||||
setGameState(stateWithHistory);
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const nextStory = await generateStoryForState({
|
||||
state: stateWithHistory,
|
||||
character,
|
||||
history: nextHistory,
|
||||
choice: actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
const recoveredState = applyStoryEngineEchoes({
|
||||
previousState: gameState,
|
||||
nextState: applyStoryReasoningRecovery(stateWithHistory),
|
||||
actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
setGameState(recoveredState);
|
||||
setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to continue scripted story:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setCurrentStory(
|
||||
buildFallbackStoryForState(stateWithHistory, character, resultText),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry =
|
||||
async (
|
||||
entryState,
|
||||
resolvedState,
|
||||
character,
|
||||
actionText,
|
||||
resultText,
|
||||
lastFunctionId,
|
||||
) => {
|
||||
setGameState(entryState);
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
if (hasEncounterEntity(resolvedState)) {
|
||||
const runTicks = Math.max(
|
||||
1,
|
||||
Math.ceil(ENCOUNTER_ENTRY_DURATION_MS / ENCOUNTER_ENTRY_TICK_MS),
|
||||
);
|
||||
const tickDurationMs = Math.max(
|
||||
1,
|
||||
Math.round(ENCOUNTER_ENTRY_DURATION_MS / runTicks),
|
||||
);
|
||||
|
||||
for (let tick = 1; tick <= runTicks; tick += 1) {
|
||||
const progress = tick / runTicks;
|
||||
setGameState(
|
||||
interpolateEncounterTransitionState(
|
||||
entryState,
|
||||
resolvedState,
|
||||
progress,
|
||||
),
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
window.setTimeout(resolve, tickDurationMs),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
|
||||
const stateWithHistory = applyStoryEngineEchoes({
|
||||
previousState: gameState,
|
||||
nextState: {
|
||||
...resolvedState,
|
||||
storyHistory: nextHistory,
|
||||
} as GameState,
|
||||
actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
|
||||
setGameState(stateWithHistory);
|
||||
|
||||
try {
|
||||
const nextStory = await generateStoryForState({
|
||||
state: stateWithHistory,
|
||||
character,
|
||||
history: nextHistory,
|
||||
choice: actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
const recoveredState = applyStoryEngineEchoes({
|
||||
previousState: gameState,
|
||||
nextState: applyStoryReasoningRecovery(stateWithHistory),
|
||||
actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
setGameState(recoveredState);
|
||||
setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to continue encounter-entry story:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setCurrentStory(
|
||||
buildFallbackStoryForState(stateWithHistory, character, resultText),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
};
|
||||
}
|
||||
140
src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts
Normal file
140
src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
getRpgRuntimeClientVersion,
|
||||
getRpgRuntimeSessionId,
|
||||
getRpgRuntimeStoryState,
|
||||
resolveRpgRuntimeStoryAction,
|
||||
type RuntimeStorySnapshotRequest,
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryResponse,
|
||||
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
|
||||
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
return response.viewModel.availableOptions.length > 0
|
||||
? response.viewModel.availableOptions
|
||||
: response.presentation.options;
|
||||
}
|
||||
|
||||
function buildRuntimeSnapshotRequest(
|
||||
gameState: GameState,
|
||||
currentStory: StoryMoment | null,
|
||||
): RuntimeStorySnapshotRequest {
|
||||
return {
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端访问服务端 runtime story 的统一网关。
|
||||
* 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。
|
||||
*/
|
||||
export async function loadServerRuntimeOptionCatalog(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
}) {
|
||||
const response = await getRpgRuntimeStoryState({
|
||||
sessionId: getRpgRuntimeSessionId(params.gameState),
|
||||
clientVersion: getRpgRuntimeClientVersion(params.gameState),
|
||||
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
|
||||
});
|
||||
const options = resolveRpgRuntimeStoryMoment({
|
||||
response,
|
||||
hydratedSnapshot: response.snapshot,
|
||||
fallbackGameState: params.gameState,
|
||||
fallbackStoryText: response.presentation.storyText,
|
||||
}).options;
|
||||
|
||||
return options.length > 0 ? options : null;
|
||||
}
|
||||
|
||||
export async function resumeServerRuntimeStory(
|
||||
snapshot: HydratedSavedGameSnapshot,
|
||||
) {
|
||||
const hydratedSnapshot = rehydrateSavedSnapshot(snapshot);
|
||||
const shouldRefreshFromServer =
|
||||
hydratedSnapshot.gameState.currentScene === 'Story' &&
|
||||
Boolean(hydratedSnapshot.gameState.worldType) &&
|
||||
Boolean(hydratedSnapshot.gameState.playerCharacter);
|
||||
|
||||
if (!shouldRefreshFromServer) {
|
||||
return {
|
||||
hydratedSnapshot,
|
||||
nextStory: hydratedSnapshot.currentStory,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await getRpgRuntimeStoryState({
|
||||
sessionId: getRpgRuntimeSessionId(hydratedSnapshot.gameState),
|
||||
});
|
||||
const resumedSnapshot = rehydrateSavedSnapshot(response.snapshot);
|
||||
const runtimeOptions = getRuntimeResponseOptions(response);
|
||||
const nextStory =
|
||||
response.presentation.storyText || runtimeOptions.length > 0
|
||||
? resolveRpgRuntimeStoryMoment({
|
||||
response,
|
||||
hydratedSnapshot: resumedSnapshot,
|
||||
fallbackGameState: hydratedSnapshot.gameState,
|
||||
fallbackStoryText:
|
||||
response.presentation.storyText ||
|
||||
resumedSnapshot.currentStory?.text ||
|
||||
hydratedSnapshot.currentStory?.text ||
|
||||
'',
|
||||
})
|
||||
: resumedSnapshot.currentStory;
|
||||
|
||||
return {
|
||||
hydratedSnapshot: resumedSnapshot,
|
||||
nextStory,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveServerRuntimeChoice(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
option: Pick<StoryOption, 'functionId' | 'actionText'> &
|
||||
Partial<Pick<StoryOption, 'interaction'>>;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
}) {
|
||||
const response = await resolveRpgRuntimeStoryAction({
|
||||
sessionId: getRpgRuntimeSessionId(params.gameState),
|
||||
clientVersion: getRpgRuntimeClientVersion(params.gameState),
|
||||
option: params.option,
|
||||
targetId:
|
||||
params.option.interaction?.kind === 'npc'
|
||||
? params.option.interaction.npcId
|
||||
: undefined,
|
||||
payload: params.payload,
|
||||
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
|
||||
});
|
||||
const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot);
|
||||
|
||||
return {
|
||||
response,
|
||||
hydratedSnapshot,
|
||||
nextStory: resolveRpgRuntimeStoryMoment({
|
||||
response,
|
||||
hydratedSnapshot,
|
||||
fallbackGameState: params.gameState,
|
||||
fallbackStoryText:
|
||||
response.presentation.storyText ||
|
||||
hydratedSnapshot.currentStory?.text ||
|
||||
params.option.actionText,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export type LoadRpgRuntimeOptionCatalogParams = Parameters<
|
||||
typeof loadServerRuntimeOptionCatalog
|
||||
>[0];
|
||||
export type ResolveRpgRuntimeChoiceParams = Parameters<
|
||||
typeof resolveServerRuntimeChoice
|
||||
>[0];
|
||||
|
||||
export const loadRpgRuntimeOptionCatalog = loadServerRuntimeOptionCatalog;
|
||||
export const resumeRpgRuntimeStory = resumeServerRuntimeStory;
|
||||
export const resolveRpgRuntimeChoice = resolveServerRuntimeChoice;
|
||||
642
src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts
Normal file
642
src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts
Normal file
@@ -0,0 +1,642 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
getRuntimeStoryStateMock,
|
||||
resolveRuntimeStoryActionMock,
|
||||
getRuntimeSessionIdMock,
|
||||
getRuntimeClientVersionMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getRuntimeStoryStateMock: vi.fn(),
|
||||
resolveRuntimeStoryActionMock: vi.fn(),
|
||||
getRuntimeSessionIdMock: vi.fn(() => 'runtime-main'),
|
||||
getRuntimeClientVersionMock: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/rpg-runtime/rpgRuntimeStoryClient', async () => {
|
||||
const actual =
|
||||
await vi.importActual<
|
||||
typeof import('../../services/rpg-runtime/rpgRuntimeStoryClient')
|
||||
>(
|
||||
'../../services/rpg-runtime/rpgRuntimeStoryClient',
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getRpgRuntimeStoryState: getRuntimeStoryStateMock,
|
||||
resolveRpgRuntimeStoryAction: resolveRuntimeStoryActionMock,
|
||||
getRpgRuntimeSessionId: getRuntimeSessionIdMock,
|
||||
getRpgRuntimeClientVersion: getRuntimeClientVersionMock,
|
||||
getRuntimeStoryState: getRuntimeStoryStateMock,
|
||||
resolveRuntimeStoryAction: resolveRuntimeStoryActionMock,
|
||||
getRuntimeSessionId: getRuntimeSessionIdMock,
|
||||
getRuntimeClientVersion: getRuntimeClientVersionMock,
|
||||
};
|
||||
});
|
||||
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import { WorldType } from '../../types';
|
||||
import {
|
||||
loadServerRuntimeOptionCatalog,
|
||||
resolveServerRuntimeChoice,
|
||||
resumeServerRuntimeStory,
|
||||
} from './runtimeStoryCoordinator';
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createGameState(): GameState {
|
||||
return {
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 7,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createRuntimeNpcBattleSnapshot(
|
||||
overrides: Partial<HydratedSavedGameSnapshot['gameState']> = {},
|
||||
) {
|
||||
return {
|
||||
version: 8,
|
||||
savedAt: '2026-04-14T00:00:00.000Z',
|
||||
bottomTab: 'adventure' as const,
|
||||
currentStory: createStory('战斗中的服务端故事'),
|
||||
gameState: {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
},
|
||||
runtimeActionVersion: 8,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
currentScene: 'Story',
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: 'idle',
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc-bandit',
|
||||
npcName: '断桥匪首',
|
||||
npcDescription: '拦路的刀客',
|
||||
context: '断桥口',
|
||||
hostile: true,
|
||||
},
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc-bandit',
|
||||
name: '断桥匪首',
|
||||
hp: 21,
|
||||
maxHp: 32,
|
||||
description: '拦路的刀客',
|
||||
},
|
||||
],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: true,
|
||||
playerHp: 42,
|
||||
playerMaxHp: 50,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {
|
||||
'npc-bandit': {
|
||||
affinity: -12,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: 'npc-bandit',
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
} as unknown as GameState,
|
||||
} as HydratedSavedGameSnapshot;
|
||||
}
|
||||
|
||||
describe('runtimeStoryCoordinator', () => {
|
||||
beforeEach(() => {
|
||||
getRuntimeStoryStateMock.mockReset();
|
||||
resolveRuntimeStoryActionMock.mockReset();
|
||||
getRuntimeSessionIdMock.mockReset();
|
||||
getRuntimeSessionIdMock.mockReturnValue('runtime-main');
|
||||
getRuntimeClientVersionMock.mockReset();
|
||||
getRuntimeClientVersionMock.mockReturnValue(7);
|
||||
});
|
||||
|
||||
it('loads runtime option catalogs through the persisted server snapshot flow', async () => {
|
||||
const gameState = createGameState();
|
||||
const currentStory = createStory('当前故事');
|
||||
|
||||
getRuntimeStoryStateMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 3,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
scope: 'npc',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: true,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '服务端故事',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {},
|
||||
currentStory: null,
|
||||
},
|
||||
});
|
||||
|
||||
const options = await loadServerRuntimeOptionCatalog({
|
||||
gameState,
|
||||
currentStory,
|
||||
});
|
||||
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 7,
|
||||
snapshot: {
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
},
|
||||
});
|
||||
expect(options).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('hydrates runtime choices into snapshot state and presentation-safe story data', async () => {
|
||||
const gameState = createGameState();
|
||||
const currentStory = createStory('当前故事');
|
||||
const option = {
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
text: '继续交谈',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-opponent',
|
||||
action: 'chat',
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
const hydratedSnapshot = {
|
||||
version: 8,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
gameState: {
|
||||
...gameState,
|
||||
runtimeActionVersion: 8,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
} as GameState,
|
||||
currentStory: createStory('快照中的故事'),
|
||||
bottomTab: 'adventure',
|
||||
} as HydratedSavedGameSnapshot;
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 96,
|
||||
maxHp: 100,
|
||||
mana: 18,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
scope: 'npc',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: true,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '继续交谈',
|
||||
resultText: '关系已有变化',
|
||||
storyText: '',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: hydratedSnapshot,
|
||||
});
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
payload: {
|
||||
note: 'server-runtime-test',
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveRuntimeStoryActionMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 7,
|
||||
option,
|
||||
targetId: 'npc-opponent',
|
||||
payload: {
|
||||
note: 'server-runtime-test',
|
||||
},
|
||||
snapshot: {
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
},
|
||||
});
|
||||
expect(result.hydratedSnapshot).toBe(hydratedSnapshot);
|
||||
expect(result.nextStory).toEqual(
|
||||
expect.objectContaining({
|
||||
text: '快照中的故事',
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes resumable runtime stories from the server before hydrating the main flow', async () => {
|
||||
const localHydratedSnapshot = {
|
||||
version: 7,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
gameState: {
|
||||
currentScene: 'Story',
|
||||
worldType: 'wuxia',
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
},
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
runtimeActionVersion: 7,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
} as unknown as GameState,
|
||||
currentStory: createStory('本地快照故事'),
|
||||
bottomTab: 'inventory' as const,
|
||||
} as HydratedSavedGameSnapshot;
|
||||
const serverHydratedSnapshot = {
|
||||
version: 8,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
gameState: {
|
||||
currentScene: 'Story',
|
||||
worldType: 'wuxia',
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
},
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
runtimeActionVersion: 8,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
} as unknown as GameState,
|
||||
currentStory: createStory('服务端快照故事'),
|
||||
bottomTab: 'character' as const,
|
||||
} as HydratedSavedGameSnapshot;
|
||||
|
||||
getRuntimeStoryStateMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 90,
|
||||
maxHp: 100,
|
||||
mana: 16,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
scope: 'npc',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '服务端恢复后的故事',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: serverHydratedSnapshot,
|
||||
});
|
||||
|
||||
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
|
||||
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
});
|
||||
expect(result.hydratedSnapshot).toBe(serverHydratedSnapshot);
|
||||
expect(result.nextStory).toEqual(
|
||||
expect.objectContaining({
|
||||
text: '服务端恢复后的故事',
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps local snapshot hydration when the saved state is not an active runtime story', async () => {
|
||||
const localHydratedSnapshot = {
|
||||
version: 7,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
gameState: {
|
||||
currentScene: 'Home',
|
||||
worldType: null,
|
||||
playerCharacter: null,
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
runtimeActionVersion: 7,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
} as unknown as GameState,
|
||||
currentStory: createStory('本地快照故事'),
|
||||
bottomTab: 'adventure' as const,
|
||||
} as HydratedSavedGameSnapshot;
|
||||
|
||||
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
|
||||
|
||||
expect(getRuntimeStoryStateMock).not.toHaveBeenCalled();
|
||||
expect(result.hydratedSnapshot).toBe(localHydratedSnapshot);
|
||||
expect(result.nextStory).toBe(localHydratedSnapshot.currentStory);
|
||||
});
|
||||
|
||||
it('rehydrates npc_fight server snapshots before returning runtime choices', async () => {
|
||||
const gameState = createGameState();
|
||||
const currentStory = createStory('当前故事');
|
||||
const option = {
|
||||
functionId: 'npc_fight',
|
||||
actionText: '直接开战',
|
||||
text: '直接开战',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-bandit',
|
||||
action: 'fight',
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
const rawBattleSnapshot = createRuntimeNpcBattleSnapshot();
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 42,
|
||||
maxHp: 50,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: {
|
||||
id: 'npc-bandit',
|
||||
kind: 'npc',
|
||||
npcName: '断桥匪首',
|
||||
hostile: true,
|
||||
affinity: -12,
|
||||
recruited: false,
|
||||
interactionActive: false,
|
||||
battleMode: 'fight',
|
||||
},
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'battle_probe_pressure',
|
||||
actionText: '稳步试探',
|
||||
scope: 'combat',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: true,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '直接开战',
|
||||
resultText: '当前冲突正式转入战斗结算。',
|
||||
storyText: '断桥匪首已经摆开架势。',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: rawBattleSnapshot,
|
||||
});
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
});
|
||||
|
||||
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'npc-bandit',
|
||||
hp: 21,
|
||||
maxHp: 32,
|
||||
encounter: expect.objectContaining({
|
||||
kind: 'npc',
|
||||
id: 'npc-bandit',
|
||||
npcName: '断桥匪首',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.nextStory.options[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_probe_pressure',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rehydrates mid-battle snapshots when resuming a saved runtime story', async () => {
|
||||
const localHydratedSnapshot = createRuntimeNpcBattleSnapshot({
|
||||
runtimeActionVersion: 7,
|
||||
});
|
||||
const rawServerBattleSnapshot = createRuntimeNpcBattleSnapshot({
|
||||
runtimeActionVersion: 8,
|
||||
playerHp: 39,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc-bandit',
|
||||
name: '断桥匪首',
|
||||
hp: 14,
|
||||
maxHp: 32,
|
||||
description: '拦路的刀客',
|
||||
},
|
||||
] as unknown as GameState['sceneHostileNpcs'],
|
||||
});
|
||||
|
||||
getRuntimeStoryStateMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 39,
|
||||
maxHp: 50,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: {
|
||||
id: 'npc-bandit',
|
||||
kind: 'npc',
|
||||
npcName: '断桥匪首',
|
||||
hostile: true,
|
||||
affinity: -12,
|
||||
recruited: false,
|
||||
interactionActive: false,
|
||||
battleMode: 'fight',
|
||||
},
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'battle_guard_break',
|
||||
actionText: '破架重击',
|
||||
scope: 'combat',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: true,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '断桥匪首还在步步逼近。',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: rawServerBattleSnapshot,
|
||||
});
|
||||
|
||||
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
|
||||
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
});
|
||||
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'npc-bandit',
|
||||
hp: 14,
|
||||
maxHp: 32,
|
||||
encounter: expect.objectContaining({
|
||||
kind: 'npc',
|
||||
id: 'npc-bandit',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.nextStory).not.toBeNull();
|
||||
expect(result.nextStory?.options[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_guard_break',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
7
src/hooks/rpg-runtime-story/runtimeStoryCoordinator.ts
Normal file
7
src/hooks/rpg-runtime-story/runtimeStoryCoordinator.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
loadRpgRuntimeOptionCatalog as loadServerRuntimeOptionCatalog,
|
||||
resolveRpgRuntimeChoice as resolveServerRuntimeChoice,
|
||||
resumeRpgRuntimeStory as resumeServerRuntimeStory,
|
||||
type LoadRpgRuntimeOptionCatalogParams,
|
||||
type ResolveRpgRuntimeChoiceParams,
|
||||
} from './rpgRuntimeStoryGateway';
|
||||
249
src/hooks/rpg-runtime-story/sessionActions.test.ts
Normal file
249
src/hooks/rpg-runtime-story/sessionActions.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildInitialNpcState } from '../../data/npcInteractions';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type GameState,
|
||||
type InventoryItem,
|
||||
type QuestLogEntry,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import {
|
||||
acknowledgeQuestCompletionState,
|
||||
applyQuestRewardClaim,
|
||||
} from './sessionActions';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: 'Hero',
|
||||
title: 'Wanderer',
|
||||
description: 'A reliable test hero.',
|
||||
backstory: 'Travels the land.',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 9,
|
||||
intelligence: 8,
|
||||
spirit: 7,
|
||||
},
|
||||
personality: 'steady',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createInventoryItem(id: string, name: string, quantity = 1): InventoryItem {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: `${name} description`,
|
||||
quantity,
|
||||
category: 'misc',
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
value: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function createQuest(status: QuestLogEntry['status']): QuestLogEntry {
|
||||
return {
|
||||
id: 'quest-1',
|
||||
issuerNpcId: 'npc-trader',
|
||||
issuerNpcName: 'Trader Lin',
|
||||
sceneId: 'scene-1',
|
||||
chapterId: 'chapter:scene:scene-1',
|
||||
title: 'Deliver the cache',
|
||||
description: 'Deliver the cache safely.',
|
||||
summary: 'Help Trader Lin recover the cache.',
|
||||
objective: {
|
||||
kind: 'deliver_item',
|
||||
targetItemId: 'cache',
|
||||
requiredCount: 1,
|
||||
},
|
||||
progress: 1,
|
||||
status,
|
||||
reward: {
|
||||
affinityBonus: 3,
|
||||
currency: 12,
|
||||
items: [createInventoryItem('reward-herb', 'Reward Herb', 2)],
|
||||
},
|
||||
rewardText: 'Trader Lin rewards you for the delivery.',
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseState(): GameState {
|
||||
const encounter = {
|
||||
id: 'npc-trader',
|
||||
kind: 'npc' as const,
|
||||
npcName: 'Trader Lin',
|
||||
npcDescription: 'A traveling merchant.',
|
||||
npcAvatar: 'T',
|
||||
context: 'merchant',
|
||||
};
|
||||
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: encounter,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 5,
|
||||
playerInventory: [createInventoryItem('starter-potion', 'Starter Potion')],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {
|
||||
'npc-trader': {
|
||||
...buildInitialNpcState(encounter, WorldType.WUXIA),
|
||||
affinity: 4,
|
||||
},
|
||||
},
|
||||
quests: [createQuest('completed')],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('sessionActions', () => {
|
||||
it('marks quest completion notifications without changing unrelated quest state', () => {
|
||||
const nextState = acknowledgeQuestCompletionState(createBaseState(), 'quest-1');
|
||||
|
||||
expect(nextState.quests[0]?.completionNotified).toBe(true);
|
||||
expect(nextState.quests[0]?.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('returns null when trying to claim a reward for a quest that is not completed', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
quests: [createQuest('active')],
|
||||
};
|
||||
|
||||
expect(applyQuestRewardClaim(state, 'quest-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('applies quest rewards to currency, inventory, and issuer affinity in one state transition', () => {
|
||||
const rewardClaim = applyQuestRewardClaim(createBaseState(), 'quest-1');
|
||||
|
||||
expect(rewardClaim).not.toBeNull();
|
||||
if (!rewardClaim) {
|
||||
throw new Error('Expected quest reward claim state');
|
||||
}
|
||||
|
||||
expect(rewardClaim.nextState.quests[0]?.status).toBe('turned_in');
|
||||
expect(rewardClaim.nextState.playerCurrency).toBe(17);
|
||||
expect(rewardClaim.nextState.playerInventory.find((item) => item.id === 'reward-herb')?.quantity).toBe(2);
|
||||
expect(rewardClaim.nextState.npcStates['npc-trader']?.affinity).toBe(7);
|
||||
expect(rewardClaim).toHaveProperty('handoff');
|
||||
});
|
||||
|
||||
it('refreshes chapter state after a chapter quest is turned in', () => {
|
||||
const baseState = {
|
||||
...createBaseState(),
|
||||
currentScenePreset: {
|
||||
id: 'scene-1',
|
||||
name: '断桥旧哨',
|
||||
description: '断桥边风声未散。',
|
||||
imageSrc: '/scene-1.png',
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
chapterState: {
|
||||
id: 'chapter:scene:scene-1',
|
||||
title: '断桥旧哨·高潮',
|
||||
theme: '回报遗迹调查',
|
||||
primaryThreadIds: [],
|
||||
stage: 'climax' as const,
|
||||
chapterSummary: '当前章节已逼近最后收束。',
|
||||
sceneId: 'scene-1',
|
||||
chapterQuestId: 'quest-1',
|
||||
},
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: [],
|
||||
inferredFactIds: [],
|
||||
activeThreadIds: [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
openedSceneChapterIds: ['scene-1'],
|
||||
recentSignalIds: [],
|
||||
recentCompanionReactions: [],
|
||||
currentChapter: {
|
||||
id: 'chapter:scene:scene-1',
|
||||
title: '断桥旧哨·高潮',
|
||||
theme: '回报遗迹调查',
|
||||
primaryThreadIds: [],
|
||||
stage: 'climax' as const,
|
||||
chapterSummary: '当前章节已逼近最后收束。',
|
||||
sceneId: 'scene-1',
|
||||
chapterQuestId: 'quest-1',
|
||||
},
|
||||
currentJourneyBeatId: null,
|
||||
currentJourneyBeat: null,
|
||||
companionArcStates: [],
|
||||
worldMutations: [],
|
||||
chronicle: [],
|
||||
factionTensionStates: [],
|
||||
currentCampEvent: null,
|
||||
currentSetpieceDirective: null,
|
||||
continueGameDigest: null,
|
||||
campaignState: null,
|
||||
actState: null,
|
||||
consequenceLedger: [],
|
||||
companionResolutions: [],
|
||||
endingState: null,
|
||||
authorialConstraintPack: null,
|
||||
branchBudgetStatus: null,
|
||||
narrativeQaReport: null,
|
||||
narrativeCodex: [],
|
||||
},
|
||||
} satisfies GameState;
|
||||
|
||||
const rewardClaim = applyQuestRewardClaim(baseState, 'quest-1');
|
||||
expect(rewardClaim).not.toBeNull();
|
||||
if (!rewardClaim) {
|
||||
throw new Error('Expected reward claim result');
|
||||
}
|
||||
|
||||
expect(rewardClaim.nextState.chapterState?.stage).toBe('aftermath');
|
||||
expect(rewardClaim.nextState.storyEngineMemory?.currentChapter?.stage).toBe('aftermath');
|
||||
});
|
||||
});
|
||||
176
src/hooks/rpg-runtime-story/sessionActions.ts
Normal file
176
src/hooks/rpg-runtime-story/sessionActions.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import {
|
||||
findQuestById,
|
||||
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,
|
||||
} from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
import { buildMapTravelResolution } from './storyGenerationState';
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: NonNullable<GameState['playerCharacter']>,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
export function acknowledgeQuestCompletionState(
|
||||
state: GameState,
|
||||
questId: string,
|
||||
): GameState {
|
||||
return {
|
||||
...state,
|
||||
quests: markQuestCompletionNotified(state.quests, questId),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyQuestRewardClaim(
|
||||
state: GameState,
|
||||
questId: string,
|
||||
): {
|
||||
nextState: GameState;
|
||||
handoff: ReturnType<typeof buildGoalHandoffFromState>;
|
||||
} | null {
|
||||
const quest = findQuestById(state.quests, questId);
|
||||
if (!quest || (quest.status !== 'completed' && quest.status !== 'ready_to_turn_in')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const issuerNpcState = state.npcStates[quest.issuerNpcId];
|
||||
|
||||
const nextState = appendStoryEngineCarrierMemory({
|
||||
...state,
|
||||
quests: markQuestTurnedIn(state.quests, questId),
|
||||
playerCurrency: state.playerCurrency + quest.reward.currency,
|
||||
playerInventory: addInventoryItems(state.playerInventory, quest.reward.items),
|
||||
npcStates: issuerNpcState
|
||||
? {
|
||||
...state.npcStates,
|
||||
[quest.issuerNpcId]: {
|
||||
...issuerNpcState,
|
||||
affinity: issuerNpcState.affinity + quest.reward.affinityBonus,
|
||||
},
|
||||
}
|
||||
: 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),
|
||||
};
|
||||
}
|
||||
|
||||
export function createStorySessionActions({
|
||||
gameState,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
clearStoryRuntimeUi,
|
||||
commitGeneratedState,
|
||||
buildFallbackStoryForState,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
clearStoryRuntimeUi: () => void;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
}) {
|
||||
const acknowledgeQuestCompletion = (questId: string) => {
|
||||
setGameState(currentState => acknowledgeQuestCompletionState(currentState, questId));
|
||||
};
|
||||
|
||||
const claimQuestReward = (questId: string) => {
|
||||
const rewardClaim = applyQuestRewardClaim(gameState, questId);
|
||||
if (!rewardClaim) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setGameState(rewardClaim.nextState);
|
||||
return {
|
||||
questId,
|
||||
handoff: rewardClaim.handoff,
|
||||
};
|
||||
};
|
||||
|
||||
const resetStoryState = () => {
|
||||
setCurrentStory(null);
|
||||
clearStoryRuntimeUi();
|
||||
};
|
||||
|
||||
const hydrateStoryState = (story: StoryMoment | null) => {
|
||||
setCurrentStory(story);
|
||||
clearStoryRuntimeUi();
|
||||
};
|
||||
|
||||
const travelToSceneFromMap = (sceneId: string) => {
|
||||
if (!gameState.playerCharacter || isLoading || gameState.inBattle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const travelResolution = buildMapTravelResolution(gameState, sceneId);
|
||||
if (!travelResolution) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setCurrentStory(
|
||||
buildFallbackStoryForState(
|
||||
travelResolution.nextState,
|
||||
gameState.playerCharacter,
|
||||
travelResolution.travelResultText,
|
||||
),
|
||||
);
|
||||
|
||||
void commitGeneratedState(
|
||||
travelResolution.nextState,
|
||||
gameState.playerCharacter,
|
||||
travelResolution.actionText,
|
||||
travelResolution.travelResultText,
|
||||
'idle_travel_next_scene',
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
acknowledgeQuestCompletion,
|
||||
claimQuestReward,
|
||||
resetStoryState,
|
||||
hydrateStoryState,
|
||||
travelToSceneFromMap,
|
||||
};
|
||||
}
|
||||
430
src/hooks/rpg-runtime-story/storyChoiceContinuation.ts
Normal file
430
src/hooks/rpg-runtime-story/storyChoiceContinuation.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import { applyQuestProgressFromHostileNpcDefeat } from '../../data/questFlow';
|
||||
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 type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { EscapePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import {
|
||||
buildCombatResolutionContextText,
|
||||
buildHostileNpcBattleReward,
|
||||
buildReasonedOptionCatalog,
|
||||
} from './storyChoiceRuntime';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
|
||||
type RuntimeStatsIncrements = Partial<
|
||||
Pick<
|
||||
GameState['runtimeStats'],
|
||||
'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'
|
||||
>
|
||||
>;
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type BuildStoryFromResponse = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog?: StoryOption[] | null,
|
||||
) => StoryMoment;
|
||||
|
||||
type BuildNpcStory = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type HandleNpcBattleConversationContinuation = (params: {
|
||||
nextState: GameState;
|
||||
encounter: Encounter;
|
||||
character: Character;
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>;
|
||||
}) => boolean;
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
type UpdateQuestLog = (
|
||||
state: GameState,
|
||||
updater: (quests: GameState['quests']) => GameState['quests'],
|
||||
) => GameState;
|
||||
|
||||
type IncrementRuntimeStats = (
|
||||
state: GameState,
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
export async function runLocalStoryChoiceContinuation(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
setGameState: (state: GameState) => void;
|
||||
setCurrentStory: (story: StoryMoment) => void;
|
||||
setAiError: (message: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setBattleReward: (reward: BattleRewardSummary | null) => void;
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: EscapePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
buildStoryFromResponse: BuildStoryFromResponse;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
generateStoryForState: (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
getAvailableOptionsForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
buildNpcStory: BuildNpcStory;
|
||||
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
|
||||
updateQuestLog: UpdateQuestLog;
|
||||
incrementRuntimeStats: IncrementRuntimeStats;
|
||||
finalizeNpcBattleResult: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>,
|
||||
battleOutcome: GameState['currentNpcBattleOutcome'],
|
||||
) => { nextState: GameState; resultText: string } | null;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
}) {
|
||||
params.setBattleReward(null);
|
||||
params.setAiError(null);
|
||||
params.setIsLoading(true);
|
||||
|
||||
const baseChoiceState =
|
||||
params.isRegularNpcEncounter(params.gameState.currentEncounter) &&
|
||||
!params.gameState.npcInteractionActive &&
|
||||
!params.option.interaction
|
||||
? {
|
||||
...params.gameState,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
}
|
||||
: params.gameState;
|
||||
|
||||
let fallbackState = baseChoiceState;
|
||||
|
||||
try {
|
||||
const history = baseChoiceState.storyHistory;
|
||||
const resolvedChoice = params.buildResolvedChoiceState(
|
||||
baseChoiceState,
|
||||
params.option,
|
||||
params.character,
|
||||
);
|
||||
const projectedState = resolvedChoice.afterSequence;
|
||||
const shouldUseLocalNpcVictory = Boolean(
|
||||
baseChoiceState.currentBattleNpcId &&
|
||||
resolvedChoice.optionKind === 'battle' &&
|
||||
(projectedState.currentNpcBattleOutcome ||
|
||||
(baseChoiceState.currentNpcBattleMode === 'fight' &&
|
||||
!projectedState.inBattle)),
|
||||
);
|
||||
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;
|
||||
fallbackState = projectedStateWithBattleReward;
|
||||
const projectedAvailableOptions = params.getAvailableOptionsForState(
|
||||
projectedStateWithBattleReward,
|
||||
params.character,
|
||||
);
|
||||
const combatResolutionContextText = buildCombatResolutionContextText({
|
||||
baseState: baseChoiceState,
|
||||
afterSequence: projectedStateWithBattleReward,
|
||||
optionKind: resolvedChoice.optionKind,
|
||||
projectedBattleReward,
|
||||
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
|
||||
});
|
||||
const historyForStoryGeneration = combatResolutionContextText
|
||||
? [
|
||||
...history,
|
||||
createHistoryMoment(params.option.actionText, 'action'),
|
||||
createHistoryMoment(combatResolutionContextText, 'result'),
|
||||
]
|
||||
: history;
|
||||
|
||||
const responsePromise = shouldUseLocalNpcVictory
|
||||
? 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 playbackSync: EscapePlaybackSync | undefined =
|
||||
resolvedChoice.optionKind === 'escape'
|
||||
? { waitForStoryResponse: responseSettledPromise }
|
||||
: undefined;
|
||||
const actionPromise = params.playResolvedChoice(
|
||||
baseChoiceState,
|
||||
params.option,
|
||||
params.character,
|
||||
resolvedChoice,
|
||||
playbackSync,
|
||||
);
|
||||
const [actionResult, responseResult] = await Promise.allSettled([
|
||||
actionPromise,
|
||||
responsePromise,
|
||||
]);
|
||||
|
||||
if (actionResult.status === 'rejected') {
|
||||
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,
|
||||
);
|
||||
}
|
||||
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 postBattleOptionCatalog =
|
||||
baseChoiceState.currentNpcBattleMode === 'spar' &&
|
||||
nextState.currentEncounter
|
||||
? buildReasonedOptionCatalog(
|
||||
params.buildNpcStory(
|
||||
nextState,
|
||||
params.character,
|
||||
nextState.currentEncounter,
|
||||
).options,
|
||||
)
|
||||
: null;
|
||||
fallbackState = nextState;
|
||||
params.setGameState(nextState);
|
||||
if (
|
||||
nextState.currentEncounter &&
|
||||
params.handleNpcBattleConversationContinuation({
|
||||
nextState,
|
||||
encounter: nextState.currentEncounter,
|
||||
character: params.character,
|
||||
actionText: params.option.actionText,
|
||||
resultText: victory.resultText,
|
||||
battleMode: baseChoiceState.currentNpcBattleMode!,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const nextStory = await params.generateStoryForState({
|
||||
state: nextState,
|
||||
character: params.character,
|
||||
history: nextHistory,
|
||||
choice: params.option.actionText,
|
||||
lastFunctionId: params.option.functionId,
|
||||
optionCatalog: postBattleOptionCatalog,
|
||||
});
|
||||
const recoveredState = applyStoryReasoningRecovery(nextState);
|
||||
params.setGameState(recoveredState);
|
||||
params.setCurrentStory(nextStory);
|
||||
} catch (storyError) {
|
||||
console.error(
|
||||
'Failed to continue npc battle resolution story:',
|
||||
storyError,
|
||||
);
|
||||
params.setAiError(
|
||||
storyError instanceof Error
|
||||
? storyError.message
|
||||
: '未知智能生成错误',
|
||||
);
|
||||
params.setCurrentStory(
|
||||
params.buildFallbackStoryForState(
|
||||
nextState,
|
||||
params.character,
|
||||
victory.resultText,
|
||||
),
|
||||
);
|
||||
}
|
||||
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,
|
||||
createHistoryMoment(response.storyText, 'result', response.options),
|
||||
]
|
||||
: [
|
||||
...baseChoiceState.storyHistory,
|
||||
createHistoryMoment(params.option.actionText, 'action'),
|
||||
createHistoryMoment(response.storyText, 'result', response.options),
|
||||
];
|
||||
|
||||
const nextState = params.incrementRuntimeStats(
|
||||
{
|
||||
...params.updateQuestLog(afterSequence, (quests) =>
|
||||
applyQuestProgressFromHostileNpcDefeat(
|
||||
quests,
|
||||
baseChoiceState.currentScenePreset?.id ?? null,
|
||||
defeatedHostileNpcIds,
|
||||
),
|
||||
),
|
||||
lastObserveSignsSceneId:
|
||||
params.option.functionId === 'idle_observe_signs'
|
||||
? (afterSequence.currentScenePreset?.id ?? null)
|
||||
: afterSequence.lastObserveSignsSceneId ?? null,
|
||||
lastObserveSignsReport:
|
||||
params.option.functionId === 'idle_observe_signs'
|
||||
? response.storyText
|
||||
: afterSequence.lastObserveSignsReport ?? null,
|
||||
storyHistory: nextHistory,
|
||||
},
|
||||
{
|
||||
hostileNpcsDefeated: defeatedHostileNpcIds.length,
|
||||
},
|
||||
);
|
||||
|
||||
const recoveredState = applyStoryReasoningRecovery(nextState);
|
||||
params.setGameState(recoveredState);
|
||||
if (projectedBattleReward) {
|
||||
params.setBattleReward(projectedBattleReward);
|
||||
}
|
||||
|
||||
params.setCurrentStory(
|
||||
params.buildStoryFromResponse(
|
||||
recoveredState,
|
||||
params.character,
|
||||
{
|
||||
text: response.storyText,
|
||||
options: response.options,
|
||||
},
|
||||
projectedAvailableOptions,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to get next step:', error);
|
||||
params.setAiError(
|
||||
error instanceof Error ? error.message : '未知智能生成错误',
|
||||
);
|
||||
params.setCurrentStory(
|
||||
params.buildFallbackStoryForState(fallbackState, params.character),
|
||||
);
|
||||
} finally {
|
||||
params.setIsLoading(false);
|
||||
}
|
||||
}
|
||||
134
src/hooks/rpg-runtime-story/storyChoiceCoordinator.test.ts
Normal file
134
src/hooks/rpg-runtime-story/storyChoiceCoordinator.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { createStoryChoiceCoordinatorConfig } from './storyChoiceCoordinator';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试角色',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: '谨慎',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createOption(functionId = 'npc_chat', actionText = '继续交谈'): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
currentScene: 'Story',
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
const neverNpcEncounter = (
|
||||
_encounter: GameState['currentEncounter'],
|
||||
): _encounter is Encounter => false;
|
||||
|
||||
describe('storyChoiceCoordinator', () => {
|
||||
it('builds one config object for createStoryChoiceActions from runtime controller and support', () => {
|
||||
const runtimeController = {
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
buildStoryFromResponse: vi.fn(),
|
||||
buildFallbackStoryForState: vi.fn(),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(),
|
||||
getCampCompanionTravelScene: vi.fn(),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
};
|
||||
const runtimeSupport = {
|
||||
buildNpcStory: vi.fn(),
|
||||
updateQuestLog: vi.fn(),
|
||||
updateRuntimeStats: vi.fn(),
|
||||
};
|
||||
|
||||
const config = createStoryChoiceCoordinatorConfig({
|
||||
gameState: createState(),
|
||||
currentStory: createStory('当前故事'),
|
||||
isLoading: false,
|
||||
setGameState: vi.fn(),
|
||||
setCurrentStory: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(),
|
||||
playResolvedChoice: vi.fn(),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn(() => []),
|
||||
runtimeController: runtimeController as never,
|
||||
runtimeSupport: runtimeSupport as never,
|
||||
enterNpcInteraction: vi.fn(),
|
||||
handleNpcInteraction: vi.fn(),
|
||||
handleTreasureInteraction: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(),
|
||||
sortOptions: vi.fn((options: StoryOption[]) => options),
|
||||
buildContinueAdventureOption: vi.fn(() => createOption('continue')),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
expect(config).toEqual(
|
||||
expect.objectContaining({
|
||||
buildStoryContextFromState: runtimeController.buildStoryContextFromState,
|
||||
buildStoryFromResponse: runtimeController.buildStoryFromResponse,
|
||||
buildFallbackStoryForState: runtimeController.buildFallbackStoryForState,
|
||||
generateStoryForState: runtimeController.generateStoryForState,
|
||||
getAvailableOptionsForState: runtimeController.getAvailableOptionsForState,
|
||||
buildNpcStory: runtimeSupport.buildNpcStory,
|
||||
updateQuestLog: runtimeSupport.updateQuestLog,
|
||||
incrementRuntimeStats: runtimeSupport.updateRuntimeStats,
|
||||
getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene,
|
||||
commitGeneratedStateWithEncounterEntry:
|
||||
runtimeController.commitGeneratedStateWithEncounterEntry,
|
||||
}),
|
||||
);
|
||||
|
||||
void createCharacter();
|
||||
});
|
||||
});
|
||||
174
src/hooks/rpg-runtime-story/storyChoiceCoordinator.ts
Normal file
174
src/hooks/rpg-runtime-story/storyChoiceCoordinator.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
|
||||
export type ChoiceRuntimeController = {
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
buildStoryFromResponse: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog?: StoryOption[] | null,
|
||||
) => StoryMoment;
|
||||
buildFallbackStoryForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
generateStoryForState: (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
getAvailableOptionsForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
getCampCompanionTravelScene: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => GameState['currentScenePreset'] | null;
|
||||
commitGeneratedStateWithEncounterEntry: (
|
||||
entryState: GameState,
|
||||
resolvedState: GameState,
|
||||
character: Character,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
lastFunctionId?: string,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export type ChoiceRuntimeSupport = Pick<
|
||||
StoryRuntimeSupport,
|
||||
| 'buildNpcStory'
|
||||
| 'handleNpcBattleConversationContinuation'
|
||||
| 'updateQuestLog'
|
||||
| 'updateRuntimeStats'
|
||||
>;
|
||||
|
||||
export type StoryChoiceCoordinatorParams = {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
isLoading: boolean;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
setBattleReward: Dispatch<SetStateAction<BattleRewardSummary | null>>;
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: ResolvedChoicePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
runtimeController: ChoiceRuntimeController;
|
||||
runtimeSupport: ChoiceRuntimeSupport;
|
||||
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
|
||||
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
|
||||
handleTreasureInteraction: (
|
||||
option: StoryOption,
|
||||
) => void | Promise<void> | boolean | Promise<boolean>;
|
||||
finalizeNpcBattleResult: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>,
|
||||
battleOutcome: GameState['currentNpcBattleOutcome'],
|
||||
) => { nextState: GameState; resultText: string } | null;
|
||||
sortOptions: (options: StoryOption[]) => StoryOption[];
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
npcPreviewTalkFunctionId: string;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
};
|
||||
|
||||
export function createStoryChoiceCoordinatorConfig(
|
||||
params: StoryChoiceCoordinatorParams,
|
||||
) {
|
||||
return {
|
||||
gameState: params.gameState,
|
||||
currentStory: params.currentStory,
|
||||
isLoading: params.isLoading,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
setBattleReward: params.setBattleReward,
|
||||
buildResolvedChoiceState: params.buildResolvedChoiceState,
|
||||
playResolvedChoice: params.playResolvedChoice,
|
||||
buildStoryContextFromState:
|
||||
params.runtimeController.buildStoryContextFromState,
|
||||
buildStoryFromResponse: params.runtimeController.buildStoryFromResponse,
|
||||
buildFallbackStoryForState:
|
||||
params.runtimeController.buildFallbackStoryForState,
|
||||
generateStoryForState: params.runtimeController.generateStoryForState,
|
||||
getAvailableOptionsForState:
|
||||
params.runtimeController.getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
|
||||
buildNpcStory: params.runtimeSupport.buildNpcStory,
|
||||
handleNpcBattleConversationContinuation:
|
||||
params.runtimeSupport.handleNpcBattleConversationContinuation,
|
||||
updateQuestLog: params.runtimeSupport.updateQuestLog,
|
||||
incrementRuntimeStats: params.runtimeSupport.updateRuntimeStats,
|
||||
getCampCompanionTravelScene:
|
||||
params.runtimeController.getCampCompanionTravelScene,
|
||||
enterNpcInteraction: params.enterNpcInteraction,
|
||||
handleNpcInteraction: params.handleNpcInteraction,
|
||||
handleTreasureInteraction: params.handleTreasureInteraction,
|
||||
commitGeneratedStateWithEncounterEntry:
|
||||
params.runtimeController.commitGeneratedStateWithEncounterEntry,
|
||||
finalizeNpcBattleResult: params.finalizeNpcBattleResult,
|
||||
isContinueAdventureOption: params.isContinueAdventureOption,
|
||||
isCampTravelHomeOption: params.isCampTravelHomeOption,
|
||||
isRegularNpcEncounter: params.isRegularNpcEncounter,
|
||||
isNpcEncounter: params.isNpcEncounter,
|
||||
npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName: params.fallbackCompanionName,
|
||||
turnVisualMs: params.turnVisualMs,
|
||||
};
|
||||
}
|
||||
425
src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts
Normal file
425
src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
rollHostileNpcLootMock,
|
||||
resolveServerRuntimeChoiceMock,
|
||||
} = vi.hoisted(() => ({
|
||||
rollHostileNpcLootMock: vi.fn(),
|
||||
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 {
|
||||
buildCombatResolutionContextText,
|
||||
buildHostileNpcBattleReward,
|
||||
buildReasonedOptionCatalog,
|
||||
runServerRuntimeChoiceAction,
|
||||
shouldOpenLocalRuntimeNpcModal,
|
||||
} from './storyChoiceRuntime';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
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: {},
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(
|
||||
functionId: string,
|
||||
interaction?: StoryOption['interaction'],
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText: functionId,
|
||||
text: functionId,
|
||||
interaction,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
function createState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: 'idle',
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
} as 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(
|
||||
createOption('npc_chat', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-friend',
|
||||
action: 'chat',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldOpenLocalRuntimeNpcModal(
|
||||
createOption('npc_trade', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-merchant',
|
||||
action: 'trade',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldOpenLocalRuntimeNpcModal(
|
||||
createOption('npc_gift', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-friend',
|
||||
action: 'gift',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldOpenLocalRuntimeNpcModal(createOption('idle_explore_forward')),
|
||||
).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('applies server runtime responses and falls back locally when the request fails', async () => {
|
||||
const gameState = createState();
|
||||
const currentStory = createStory('当前故事');
|
||||
const setBattleReward = vi.fn();
|
||||
const setAiError = vi.fn();
|
||||
const setIsLoading = vi.fn();
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
hydratedSnapshot: {
|
||||
gameState: {
|
||||
...gameState,
|
||||
runtimeActionVersion: 3,
|
||||
},
|
||||
},
|
||||
nextStory: createStory('服务端故事'),
|
||||
});
|
||||
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory,
|
||||
option: createOption('npc_chat'),
|
||||
character: createCharacter(),
|
||||
setBattleReward,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setGameState,
|
||||
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
||||
buildFallbackStoryForState: () => createStory('fallback'),
|
||||
});
|
||||
|
||||
expect(setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeActionVersion: 3,
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '服务端故事',
|
||||
}),
|
||||
);
|
||||
|
||||
resolveServerRuntimeChoiceMock.mockRejectedValueOnce(new Error('boom'));
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory: null,
|
||||
option: createOption('npc_chat'),
|
||||
character: createCharacter(),
|
||||
setBattleReward,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setGameState,
|
||||
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
||||
buildFallbackStoryForState: () => createStory('fallback'),
|
||||
});
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(setAiError).toHaveBeenCalledWith('boom');
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: 'fallback',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('plays server battle presentation before committing the hydrated snapshot', async () => {
|
||||
const gameState = createState({
|
||||
inBattle: true,
|
||||
playerHp: 30,
|
||||
playerMana: 10,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'wolf',
|
||||
name: '山狼',
|
||||
action: '逼近',
|
||||
description: '山狼',
|
||||
animation: 'idle',
|
||||
xMeters: 3,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1,
|
||||
speed: 1,
|
||||
hp: 18,
|
||||
maxHp: 18,
|
||||
},
|
||||
],
|
||||
});
|
||||
const finalState = createState({
|
||||
...gameState,
|
||||
inBattle: false,
|
||||
playerHp: 26,
|
||||
sceneHostileNpcs: [],
|
||||
});
|
||||
const setGameState = vi.fn();
|
||||
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
response: {
|
||||
presentation: {
|
||||
battle: {
|
||||
targetId: 'wolf',
|
||||
damageDealt: 18,
|
||||
damageTaken: 4,
|
||||
outcome: 'victory',
|
||||
},
|
||||
resultText: '山狼被你压制下去。',
|
||||
},
|
||||
},
|
||||
hydratedSnapshot: {
|
||||
gameState: finalState,
|
||||
},
|
||||
nextStory: createStory('服务端故事'),
|
||||
});
|
||||
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory: createStory('当前故事'),
|
||||
option: createOption('battle_attack_basic'),
|
||||
character: createCharacter(),
|
||||
setBattleReward: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setGameState,
|
||||
setCurrentStory: vi.fn() as (story: StoryMoment) => void,
|
||||
buildFallbackStoryForState: () => createStory('fallback'),
|
||||
turnVisualMs: 1,
|
||||
});
|
||||
|
||||
expect(setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
animationState: 'idle',
|
||||
playerHp: 26,
|
||||
sceneHostileNpcs: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'wolf',
|
||||
hp: 0,
|
||||
animation: 'die',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(setGameState).toHaveBeenLastCalledWith(finalState);
|
||||
});
|
||||
});
|
||||
427
src/hooks/rpg-runtime-story/storyChoiceRuntime.ts
Normal file
427
src/hooks/rpg-runtime-story/storyChoiceRuntime.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import {
|
||||
buildEncounterEntryState,
|
||||
hasEncounterEntity,
|
||||
} from '../../data/encounterTransition';
|
||||
import { rollHostileNpcLoot } from '../../data/hostileNpcPresets';
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import {
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
createSceneEncounterPreview,
|
||||
resolveSceneEncounterPreview,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { resolveRpgRuntimeChoice } from '.';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
|
||||
type RuntimeStatsIncrements = Partial<
|
||||
Pick<
|
||||
GameState['runtimeStats'],
|
||||
'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'
|
||||
>
|
||||
>;
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type IncrementRuntimeStats = (
|
||||
state: GameState,
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
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 (
|
||||
(
|
||||
option.interaction?.kind === 'npc' ||
|
||||
!option.interaction
|
||||
) &&
|
||||
(
|
||||
option.functionId === 'npc_chat' ||
|
||||
option.functionId === 'npc_trade' ||
|
||||
option.functionId === 'npc_gift'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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) => ({
|
||||
id: hostileNpc.id,
|
||||
name: hostileNpc.name,
|
||||
})),
|
||||
items: addInventoryItems([], rolledItems),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCampTravelHomeChoice(params: {
|
||||
gameState: GameState;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
setBattleReward: (reward: BattleRewardSummary | null) => void;
|
||||
setAiError: (message: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setGameState: (state: GameState) => void;
|
||||
incrementRuntimeStats: IncrementRuntimeStats;
|
||||
getCampCompanionTravelScene: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => GameState['currentScenePreset'] | null;
|
||||
commitGeneratedStateWithEncounterEntry: (
|
||||
entryState: GameState,
|
||||
resolvedState: GameState,
|
||||
character: Character,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
lastFunctionId?: string,
|
||||
) => Promise<void> | void;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
}) {
|
||||
const targetScene = params.getCampCompanionTravelScene(
|
||||
params.gameState,
|
||||
params.character,
|
||||
);
|
||||
if (!targetScene) {
|
||||
return false;
|
||||
}
|
||||
|
||||
params.setBattleReward(null);
|
||||
params.setAiError(null);
|
||||
|
||||
const companionName = params.isNpcEncounter(params.gameState.currentEncounter)
|
||||
? params.gameState.currentEncounter.npcName
|
||||
: params.fallbackCompanionName;
|
||||
const travelRunState: GameState = {
|
||||
...params.gameState,
|
||||
ambientIdleMode: undefined,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.RUN,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: true,
|
||||
inBattle: false,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
const travelBaseState: GameState = params.incrementRuntimeStats(
|
||||
{
|
||||
...params.gameState,
|
||||
ambientIdleMode: undefined,
|
||||
currentScenePreset: targetScene,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
},
|
||||
{
|
||||
scenesTraveled: 1,
|
||||
},
|
||||
);
|
||||
const travelPreviewState: GameState = {
|
||||
...travelBaseState,
|
||||
...createSceneEncounterPreview(travelBaseState),
|
||||
};
|
||||
const resolvedState = hasEncounterEntity(travelPreviewState)
|
||||
? resolveSceneEncounterPreview(travelPreviewState)
|
||||
: travelBaseState;
|
||||
const entryState = buildEncounterEntryState(
|
||||
resolvedState,
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
);
|
||||
|
||||
params.setIsLoading(true);
|
||||
params.setGameState(travelRunState);
|
||||
await sleep(params.turnVisualMs);
|
||||
|
||||
await params.commitGeneratedStateWithEncounterEntry(
|
||||
entryState,
|
||||
resolvedState,
|
||||
params.character,
|
||||
params.option.actionText,
|
||||
`You and ${companionName} leave camp and formally step into ${targetScene.name} to begin the adventure.`,
|
||||
params.option.functionId,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function runServerRuntimeChoiceAction(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
setBattleReward: (reward: BattleRewardSummary | null) => void;
|
||||
setAiError: (message: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setGameState: (state: GameState) => void;
|
||||
setCurrentStory: (story: StoryMoment) => void;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
turnVisualMs?: number;
|
||||
}) {
|
||||
params.setBattleReward(null);
|
||||
params.setAiError(null);
|
||||
params.setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { response, hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||||
gameState: params.gameState,
|
||||
currentStory: params.currentStory,
|
||||
option: params.option,
|
||||
payload: params.option.runtimePayload,
|
||||
});
|
||||
|
||||
if (response) {
|
||||
await playServerBattlePresentation({
|
||||
baseState: params.gameState,
|
||||
finalState: hydratedSnapshot.gameState,
|
||||
option: params.option,
|
||||
response,
|
||||
setGameState: params.setGameState,
|
||||
turnVisualMs: params.turnVisualMs ?? 820,
|
||||
});
|
||||
}
|
||||
params.setGameState(hydratedSnapshot.gameState);
|
||||
params.setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve runtime action on the server:', error);
|
||||
params.setAiError(
|
||||
error instanceof Error ? error.message : '运行时动作执行失败',
|
||||
);
|
||||
if (!params.currentStory) {
|
||||
params.setCurrentStory(
|
||||
params.buildFallbackStoryForState(
|
||||
params.gameState,
|
||||
params.character,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
params.setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function getServerBattlePlayerAnimation(option: StoryOption) {
|
||||
if (option.functionId === 'battle_escape_breakout') return AnimationState.RUN;
|
||||
if (option.functionId === 'battle_recover_breath') return AnimationState.IDLE;
|
||||
if (option.functionId === 'inventory_use') return AnimationState.ACQUIRE;
|
||||
return option.visuals?.playerAnimation ?? AnimationState.ATTACK;
|
||||
}
|
||||
|
||||
async function playServerBattlePresentation(params: {
|
||||
baseState: GameState;
|
||||
finalState: GameState;
|
||||
option: StoryOption;
|
||||
response: Awaited<ReturnType<typeof resolveRpgRuntimeChoice>>['response'];
|
||||
setGameState: (state: GameState) => void;
|
||||
turnVisualMs: number;
|
||||
}) {
|
||||
const battle = params.response.presentation.battle;
|
||||
if (!battle || !params.baseState.inBattle) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (battle.outcome === 'escaped') {
|
||||
params.setGameState({
|
||||
...params.baseState,
|
||||
animationState: AnimationState.RUN,
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: true,
|
||||
activeCombatEffects: [],
|
||||
});
|
||||
await sleep(Math.max(220, Math.round(params.turnVisualMs * 0.6)));
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = battle.targetId ?? params.baseState.sceneHostileNpcs[0]?.id ?? null;
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isRecoveryOrItem =
|
||||
params.option.functionId === 'battle_recover_breath' ||
|
||||
params.option.functionId === 'inventory_use';
|
||||
const actingState: GameState = {
|
||||
...params.baseState,
|
||||
animationState: getServerBattlePlayerAnimation(params.option),
|
||||
playerActionMode: isRecoveryOrItem ? 'idle' : 'melee',
|
||||
activeCombatEffects: [],
|
||||
sceneHostileNpcs: params.baseState.sceneHostileNpcs.map((hostileNpc) =>
|
||||
hostileNpc.id === targetId
|
||||
? {
|
||||
...hostileNpc,
|
||||
animation: isRecoveryOrItem ? ('move' as const) : ('attack' as const),
|
||||
action: params.response.presentation.resultText || hostileNpc.action,
|
||||
}
|
||||
: hostileNpc,
|
||||
),
|
||||
};
|
||||
params.setGameState(actingState);
|
||||
await sleep(Math.max(180, Math.round(params.turnVisualMs * 0.55)));
|
||||
|
||||
const finalTarget = params.finalState.sceneHostileNpcs.find(
|
||||
(hostileNpc) => hostileNpc.id === targetId,
|
||||
);
|
||||
const targetDefeated =
|
||||
battle.outcome === 'victory' ||
|
||||
battle.outcome === 'spar_complete' ||
|
||||
(!finalTarget && (battle.damageDealt ?? 0) > 0);
|
||||
params.setGameState({
|
||||
...actingState,
|
||||
playerHp: params.finalState.playerHp,
|
||||
playerMana: params.finalState.playerMana,
|
||||
playerSkillCooldowns: params.finalState.playerSkillCooldowns,
|
||||
activeBuildBuffs: params.finalState.activeBuildBuffs,
|
||||
sceneHostileNpcs: actingState.sceneHostileNpcs.map((hostileNpc) => {
|
||||
if (hostileNpc.id !== targetId) return hostileNpc;
|
||||
return {
|
||||
...hostileNpc,
|
||||
hp: finalTarget?.hp ?? (targetDefeated ? 0 : hostileNpc.hp),
|
||||
animation: targetDefeated ? ('die' as const) : hostileNpc.animation,
|
||||
characterAnimation: targetDefeated ? AnimationState.DIE : hostileNpc.characterAnimation,
|
||||
};
|
||||
}),
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle',
|
||||
});
|
||||
await sleep(Math.max(180, Math.round(params.turnVisualMs * 0.45)));
|
||||
}
|
||||
625
src/hooks/rpg-runtime-story/storyContextBuilder.ts
Normal file
625
src/hooks/rpg-runtime-story/storyContextBuilder.ts
Normal file
@@ -0,0 +1,625 @@
|
||||
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;
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
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);
|
||||
}
|
||||
|
||||
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({
|
||||
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,
|
||||
});
|
||||
}
|
||||
164
src/hooks/rpg-runtime-story/storyEncounterState.test.ts
Normal file
164
src/hooks/rpg-runtime-story/storyEncounterState.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
} from '../../types';
|
||||
import { createStoryStateResolvers } from './storyEncounterState';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试主角',
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createGameState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
worldType: null,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createNpcEncounter(
|
||||
overrides: Partial<Encounter> = {},
|
||||
): Encounter {
|
||||
return {
|
||||
id: 'npc-guard',
|
||||
kind: 'npc',
|
||||
npcName: '山道客',
|
||||
npcDescription: '守在路口的陌生人',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道相遇',
|
||||
...overrides,
|
||||
} as Encounter;
|
||||
}
|
||||
|
||||
describe('storyEncounterState', () => {
|
||||
it('uses preview talk options for regular npc encounters before formal interaction starts', () => {
|
||||
const character = createCharacter();
|
||||
const state = createGameState({
|
||||
currentEncounter: createNpcEncounter(),
|
||||
});
|
||||
const buildNpcStory = vi.fn();
|
||||
|
||||
const { getAvailableOptionsForState } = createStoryStateResolvers({
|
||||
buildNpcStory,
|
||||
});
|
||||
|
||||
expect(getAvailableOptionsForState(state, character)).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_preview_talk',
|
||||
}),
|
||||
]);
|
||||
expect(buildNpcStory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses normal npc story options after the npc interaction has started', () => {
|
||||
const character = createCharacter();
|
||||
const npcStory: StoryMoment = {
|
||||
text: '普通 NPC 正常对话',
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
text: '继续交谈',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const state = createGameState({
|
||||
currentEncounter: createNpcEncounter(),
|
||||
npcInteractionActive: true,
|
||||
});
|
||||
const buildNpcStory = vi.fn(() => npcStory);
|
||||
const { getAvailableOptionsForState } = createStoryStateResolvers({
|
||||
buildNpcStory,
|
||||
});
|
||||
|
||||
expect(getAvailableOptionsForState(state, character)).toEqual(
|
||||
npcStory.options,
|
||||
);
|
||||
expect(buildNpcStory).toHaveBeenCalledWith(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves explicit fallback text when the state falls back to the generic story moment', () => {
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const { buildFallbackStoryForState } = createStoryStateResolvers({
|
||||
buildNpcStory: vi.fn(),
|
||||
});
|
||||
|
||||
const story = buildFallbackStoryForState(state, character, '手动兜底文本');
|
||||
|
||||
expect(story.text).toBe('手动兜底文本');
|
||||
expect(story.options.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
197
src/hooks/rpg-runtime-story/storyEncounterState.ts
Normal file
197
src/hooks/rpg-runtime-story/storyEncounterState.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { buildNpcPreviewTalkOption } from '../../data/functionCatalog';
|
||||
import {
|
||||
getDefaultFunctionIdsForContext,
|
||||
resolveFunctionOption,
|
||||
} from '../../data/stateFunctions';
|
||||
import { buildTreasureEncounterStoryMoment } from '../../data/treasureInteractions';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { buildFallbackStoryMoment } from '../combatStoryUtils';
|
||||
|
||||
type EncounterStoryBuilder = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
export function buildNpcPreviewStory(
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
): StoryMoment {
|
||||
if (!state.worldType) {
|
||||
return {
|
||||
text:
|
||||
overrideText ??
|
||||
`${encounter.npcName}正停在前方,像是在等你先决定要不要真正把注意力落到他身上。`,
|
||||
options: [buildNpcPreviewTalkOption(encounter)],
|
||||
};
|
||||
}
|
||||
|
||||
const functionContext = {
|
||||
worldType: state.worldType,
|
||||
playerCharacter: character,
|
||||
inBattle: false,
|
||||
currentSceneId: state.currentScenePreset?.id ?? null,
|
||||
currentSceneName: state.currentScenePreset?.name ?? null,
|
||||
monsters: [],
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
playerMaxMana: state.playerMaxMana,
|
||||
};
|
||||
|
||||
const locationOptions = getDefaultFunctionIdsForContext(functionContext)
|
||||
.filter((functionId) => functionId !== 'idle_call_out')
|
||||
.map((functionId) => resolveFunctionOption(functionId, functionContext))
|
||||
.filter((option): option is StoryOption => Boolean(option));
|
||||
|
||||
return {
|
||||
text:
|
||||
overrideText ??
|
||||
`${encounter.npcName}出现在${state.currentScenePreset?.name ?? '前方道路'}附近,但你还没有真正把全部注意力落到对方身上。`,
|
||||
options: [buildNpcPreviewTalkOption(encounter), ...locationOptions],
|
||||
};
|
||||
}
|
||||
|
||||
export function getResolvedSceneHostileNpcs(state: GameState) {
|
||||
return state.sceneHostileNpcs;
|
||||
}
|
||||
|
||||
export function getStoryGenerationHostileNpcs(state: GameState) {
|
||||
return state.inBattle ? getResolvedSceneHostileNpcs(state) : [];
|
||||
}
|
||||
|
||||
export function isInitialCompanionEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isNpcEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return Boolean(encounter?.kind === 'npc');
|
||||
}
|
||||
|
||||
export function isRegularNpcEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return Boolean(encounter?.kind === 'npc' && !encounter.specialBehavior);
|
||||
}
|
||||
|
||||
export function isTreasureEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return Boolean(encounter?.kind === 'treasure');
|
||||
}
|
||||
|
||||
export function buildTreasureStory(
|
||||
state: GameState,
|
||||
_character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
): StoryMoment {
|
||||
return buildTreasureEncounterStoryMoment({
|
||||
state,
|
||||
encounter,
|
||||
overrideText,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEncounterStory(params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
buildNpcStory: EncounterStoryBuilder;
|
||||
fallbackText?: string;
|
||||
}) {
|
||||
const { state, character, fallbackText } = params;
|
||||
|
||||
if (isRegularNpcEncounter(state.currentEncounter) && !state.inBattle) {
|
||||
if (!state.npcInteractionActive) {
|
||||
return buildNpcPreviewStory(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
return params.buildNpcStory(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
if (isNpcEncounter(state.currentEncounter) && !state.inBattle) {
|
||||
return params.buildNpcStory(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
if (isTreasureEncounter(state.currentEncounter) && !state.inBattle) {
|
||||
return buildTreasureStory(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createStoryStateResolvers(params: {
|
||||
buildNpcStory: EncounterStoryBuilder;
|
||||
}) {
|
||||
const getAvailableOptionsForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) =>
|
||||
resolveEncounterStory({
|
||||
state,
|
||||
character,
|
||||
buildNpcStory: params.buildNpcStory,
|
||||
})?.options ?? null;
|
||||
|
||||
const buildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => {
|
||||
const resolvedStory = resolveEncounterStory({
|
||||
state,
|
||||
character,
|
||||
fallbackText,
|
||||
buildNpcStory: params.buildNpcStory,
|
||||
});
|
||||
if (resolvedStory) {
|
||||
return resolvedStory;
|
||||
}
|
||||
|
||||
const fallback = buildFallbackStoryMoment(state, character);
|
||||
return fallbackText
|
||||
? {
|
||||
...fallback,
|
||||
text: fallbackText,
|
||||
}
|
||||
: fallback;
|
||||
};
|
||||
|
||||
return {
|
||||
getAvailableOptionsForState,
|
||||
buildFallbackStoryForState,
|
||||
};
|
||||
}
|
||||
328
src/hooks/rpg-runtime-story/storyGenerationState.test.ts
Normal file
328
src/hooks/rpg-runtime-story/storyGenerationState.test.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { scenes } = vi.hoisted(() => ({
|
||||
scenes: [
|
||||
{
|
||||
id: 'scene-1',
|
||||
name: 'Camp',
|
||||
description: 'A quiet camp.',
|
||||
imageSrc: '/camp.png',
|
||||
connectedSceneIds: ['scene-2'],
|
||||
monsterIds: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
{
|
||||
id: 'scene-2',
|
||||
name: 'Trail',
|
||||
description: 'A mountain trail.',
|
||||
imageSrc: '/trail.png',
|
||||
connectedSceneIds: ['scene-1'],
|
||||
monsterIds: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock('../../data/scenePresets', () => ({
|
||||
getScenePresetById: (_worldType: unknown, sceneId: string) =>
|
||||
scenes.find(scene => scene.id === sceneId) ?? null,
|
||||
getSceneFriendlyNpcs: (scene: { npcs?: unknown[] } | null | undefined) => scene?.npcs ?? [],
|
||||
getSceneHostileNpcs: () => [],
|
||||
getScenePresetsByWorld: () => scenes,
|
||||
getWorldCampScenePreset: () => scenes[0] ?? null,
|
||||
}));
|
||||
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
MAX_COMPANIONS,
|
||||
} from '../../data/npcInteractions';
|
||||
import { getScenePresetsByWorld } from '../../data/scenePresets';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type CompanionState,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type InventoryItem,
|
||||
type StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import {
|
||||
buildMapTravelResolution,
|
||||
resolveNpcInteractionDecision,
|
||||
} from './storyGenerationState';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: 'Hero',
|
||||
title: 'Wanderer',
|
||||
description: 'A reliable test hero.',
|
||||
backstory: 'Travels the land.',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 9,
|
||||
intelligence: 8,
|
||||
spirit: 7,
|
||||
},
|
||||
personality: 'steady',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createInventoryItem(
|
||||
id: string,
|
||||
name: string,
|
||||
overrides: Partial<InventoryItem> = {},
|
||||
): InventoryItem {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: `${name} description`,
|
||||
quantity: 1,
|
||||
category: 'misc',
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
value: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createEncounter(): Encounter {
|
||||
return {
|
||||
id: 'npc-trader',
|
||||
kind: 'npc',
|
||||
npcName: 'Trader Lin',
|
||||
npcDescription: 'A traveling merchant.',
|
||||
npcAvatar: 'T',
|
||||
context: 'merchant',
|
||||
};
|
||||
}
|
||||
|
||||
function createCompanion(npcId: string): CompanionState {
|
||||
return {
|
||||
npcId,
|
||||
characterId: `character-${npcId}`,
|
||||
joinedAtAffinity: 10,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
mana: 5,
|
||||
maxMana: 5,
|
||||
skillCooldowns: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseState(): GameState {
|
||||
const scenes = getScenePresetsByWorld(WorldType.WUXIA);
|
||||
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: createEncounter(),
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: scenes[0] ?? null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 10,
|
||||
playerInventory: [createInventoryItem('player-potion', 'Potion')],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {
|
||||
'npc-trader': {
|
||||
...buildInitialNpcState(createEncounter(), WorldType.WUXIA),
|
||||
inventory: [createInventoryItem('npc-herb', 'Herb')],
|
||||
},
|
||||
},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createInteractionOption(action: Extract<NonNullable<StoryOption['interaction']>, { kind: 'npc' }>['action']): StoryOption {
|
||||
return {
|
||||
functionId: `npc_${action}`,
|
||||
actionText: action,
|
||||
text: action,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-trader',
|
||||
action,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('storyGenerationState', () => {
|
||||
it('opens the trade modal with the first npc and player inventory items selected', () => {
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
createBaseState(),
|
||||
createInteractionOption('trade'),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe('trade_modal');
|
||||
if (decision.kind !== 'trade_modal') {
|
||||
throw new Error('Expected trade modal decision');
|
||||
}
|
||||
|
||||
expect(decision.modal.selectedNpcItemId).toBe('npc-herb');
|
||||
expect(decision.modal.selectedPlayerItemId).toBe('player-potion');
|
||||
expect(decision.modal.selectedQuantity).toBe(1);
|
||||
});
|
||||
|
||||
it('skips zero-quantity player items when opening the trade modal', () => {
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
{
|
||||
...createBaseState(),
|
||||
playerInventory: [
|
||||
createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }),
|
||||
createInventoryItem('player-herb', 'Herb'),
|
||||
],
|
||||
},
|
||||
createInteractionOption('trade'),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe('trade_modal');
|
||||
if (decision.kind !== 'trade_modal') {
|
||||
throw new Error('Expected trade modal decision');
|
||||
}
|
||||
|
||||
expect(decision.modal.selectedPlayerItemId).toBe('player-herb');
|
||||
});
|
||||
|
||||
it('forces a recruit replacement modal when the active party is full', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
companions: Array.from({ length: MAX_COMPANIONS }, (_, index) => createCompanion(`npc-${index + 1}`)),
|
||||
};
|
||||
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
state,
|
||||
createInteractionOption('recruit'),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe('recruit_modal');
|
||||
if (decision.kind !== 'recruit_modal') {
|
||||
throw new Error('Expected recruit modal decision');
|
||||
}
|
||||
|
||||
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'],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
state,
|
||||
createInteractionOption('gift'),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe('gift_modal');
|
||||
if (decision.kind !== 'gift_modal') {
|
||||
throw new Error('Expected gift modal decision');
|
||||
}
|
||||
|
||||
expect(decision.modal.selectedItemId).toBe('jade-token');
|
||||
});
|
||||
|
||||
it('does not open the gift modal when there are no gift candidates', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
playerInventory: [],
|
||||
};
|
||||
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
state,
|
||||
createInteractionOption('gift'),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe('none');
|
||||
});
|
||||
|
||||
it('builds a map travel transition that increments runtime stats and clears battle state', () => {
|
||||
const scenes = getScenePresetsByWorld(WorldType.WUXIA);
|
||||
const sourceScene = scenes[0];
|
||||
const targetScene = scenes[1]!;
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
currentScenePreset: sourceScene ?? null,
|
||||
inBattle: true,
|
||||
currentBattleNpcId: 'battle-npc',
|
||||
currentNpcBattleMode: 'fight' as const,
|
||||
currentNpcBattleOutcome: 'fight_victory' as const,
|
||||
sparReturnEncounter: createEncounter(),
|
||||
};
|
||||
|
||||
const resolution = buildMapTravelResolution(state, targetScene.id);
|
||||
|
||||
expect(resolution).not.toBeNull();
|
||||
if (!resolution) {
|
||||
throw new Error('Expected map travel resolution');
|
||||
}
|
||||
|
||||
expect(resolution.nextState.currentScenePreset?.id).toBe(targetScene.id);
|
||||
expect(resolution.nextState.npcInteractionActive).toBe(false);
|
||||
expect(resolution.nextState.inBattle).toBe(false);
|
||||
expect(resolution.nextState.currentBattleNpcId).toBeNull();
|
||||
expect(resolution.nextState.currentNpcBattleMode).toBeNull();
|
||||
expect(resolution.nextState.runtimeStats.scenesTraveled).toBe(1);
|
||||
});
|
||||
});
|
||||
174
src/hooks/rpg-runtime-story/storyGenerationState.ts
Normal file
174
src/hooks/rpg-runtime-story/storyGenerationState.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
buildNpcGiftModalState,
|
||||
buildNpcRecruitModalState,
|
||||
buildNpcTradeModalState,
|
||||
NPC_GIFT_FUNCTION,
|
||||
NPC_RECRUIT_FUNCTION,
|
||||
NPC_TRADE_FUNCTION,
|
||||
shouldNpcRecruitOpenModal,
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
applyQuestProgressFromSceneReached,
|
||||
} from '../../data/questFlow';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
getPreferredGiftItemId,
|
||||
MAX_COMPANIONS,
|
||||
} from '../../data/npcInteractions';
|
||||
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
|
||||
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
|
||||
import { getScenePresetById } from '../../data/scenePresets';
|
||||
import {
|
||||
AnimationState,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type StoryOption,
|
||||
} from '../../types';
|
||||
import type {
|
||||
GiftModalState,
|
||||
RecruitModalState,
|
||||
TradeModalState,
|
||||
} from './uiTypes';
|
||||
|
||||
export type NpcInteractionDecision =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'trade_modal'; modal: TradeModalState }
|
||||
| { kind: 'gift_modal'; modal: GiftModalState }
|
||||
| { kind: 'recruit_modal'; modal: RecruitModalState }
|
||||
| { kind: 'recruit_immediate' };
|
||||
|
||||
export type MapTravelResolution = {
|
||||
nextState: GameState;
|
||||
actionText: string;
|
||||
travelResultText: string;
|
||||
};
|
||||
|
||||
function isNpcEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return Boolean(encounter?.kind === 'npc');
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveNpcInteractionDecision(
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
): NpcInteractionDecision {
|
||||
if (
|
||||
!state.playerCharacter ||
|
||||
!option.interaction ||
|
||||
!isNpcEncounter(state.currentEncounter)
|
||||
) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
};
|
||||
case NPC_GIFT_FUNCTION.id:
|
||||
{
|
||||
const selectedGiftItemId = getPreferredGiftItemId(
|
||||
state.playerInventory,
|
||||
encounter,
|
||||
{
|
||||
worldType: state.worldType,
|
||||
customWorldProfile: state.customWorldProfile,
|
||||
},
|
||||
);
|
||||
if (!selectedGiftItemId) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'gift_modal',
|
||||
modal: buildNpcGiftModalState(
|
||||
state,
|
||||
encounter,
|
||||
option.actionText,
|
||||
selectedGiftItemId,
|
||||
),
|
||||
};
|
||||
}
|
||||
case NPC_RECRUIT_FUNCTION.id:
|
||||
if (shouldNpcRecruitOpenModal(state.companions.length, MAX_COMPANIONS)) {
|
||||
return {
|
||||
kind: 'recruit_modal',
|
||||
modal: buildNpcRecruitModalState(state, encounter, option.actionText),
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: 'recruit_immediate' };
|
||||
default:
|
||||
return { kind: 'none' };
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMapTravelResolution(
|
||||
state: GameState,
|
||||
sceneId: string,
|
||||
): MapTravelResolution | null {
|
||||
if (!state.worldType || !state.playerCharacter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetScene = getScenePresetById(state.worldType, sceneId);
|
||||
if (!targetScene || targetScene.id === state.currentScenePreset?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextState = ensureSceneEncounterPreview({
|
||||
...state,
|
||||
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, {
|
||||
scenesTraveled: 1,
|
||||
}),
|
||||
quests: applyQuestProgressFromSceneReached(state.quests, targetScene.id),
|
||||
currentScenePreset: targetScene,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
});
|
||||
const travelResultText = `你离开${state.currentScenePreset?.name ?? '当前位置'},前往${targetScene.name}。`;
|
||||
|
||||
return {
|
||||
nextState,
|
||||
actionText: `前往${targetScene.name}`,
|
||||
travelResultText,
|
||||
};
|
||||
}
|
||||
155
src/hooks/rpg-runtime-story/storyInteractionCoordinator.test.ts
Normal file
155
src/hooks/rpg-runtime-story/storyInteractionCoordinator.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import { createStoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
|
||||
|
||||
function createOption(
|
||||
functionId = 'npc_chat',
|
||||
actionText = '继续交谈',
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
currentScene: 'Story',
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('storyInteractionCoordinator', () => {
|
||||
it('builds shared interaction configs for treasure, inventory and npc flows', () => {
|
||||
const gameState = createState();
|
||||
const currentStory = createStory('当前故事');
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
const setAiError = vi.fn();
|
||||
const setIsLoading = vi.fn();
|
||||
const buildFallbackStoryForState = vi.fn();
|
||||
const buildStoryContextFromState = vi.fn();
|
||||
const buildDialogueStoryMoment = vi.fn();
|
||||
const generateStoryForState = vi.fn();
|
||||
const getStoryGenerationHostileNpcs = vi.fn(() => []);
|
||||
const getAvailableOptionsForState = vi.fn(() => [createOption()]);
|
||||
const getTypewriterDelay = (_char: string) => 90 as const;
|
||||
const commitGeneratedState = vi.fn();
|
||||
const commitGeneratedStateWithEncounterEntry = vi.fn();
|
||||
const appendHistory = vi.fn();
|
||||
const buildOpeningCampChatContext = vi.fn();
|
||||
const sortOptions = vi.fn((options: StoryOption[]) => options);
|
||||
const buildContinueAdventureOption = vi.fn(() => createOption('continue'));
|
||||
const sanitizeOptions = vi.fn((options: StoryOption[]) => options);
|
||||
const resolveNpcInteractionDecision = vi.fn(() => ({ kind: 'chat' }));
|
||||
const runtimeSupport = {
|
||||
buildNpcStory: vi.fn(),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
cloneInventoryItemForOwner: vi.fn(),
|
||||
getNpcEncounterKey: vi.fn(),
|
||||
getResolvedNpcState: vi.fn(),
|
||||
updateNpcState: vi.fn(),
|
||||
updateQuestLog: vi.fn(),
|
||||
updateRuntimeStats: vi.fn(),
|
||||
};
|
||||
|
||||
const config = createStoryInteractionCoordinatorConfig({
|
||||
gameState,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
currentStory,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
runtimeSupport,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
buildOpeningCampChatContext,
|
||||
sortOptions,
|
||||
buildContinueAdventureOption,
|
||||
sanitizeOptions,
|
||||
resolveNpcInteractionDecision,
|
||||
});
|
||||
|
||||
expect(config.treasureFlow.runtime).toBe(config.inventoryFlow.runtime);
|
||||
expect(config.treasureFlow).toEqual({
|
||||
gameState,
|
||||
runtime: config.inventoryFlow.runtime,
|
||||
});
|
||||
expect(config.npcInteractionFlow).toEqual(
|
||||
expect.objectContaining({
|
||||
gameState,
|
||||
setGameState,
|
||||
getNpcEncounterKey: runtimeSupport.getNpcEncounterKey,
|
||||
getResolvedNpcState: runtimeSupport.getResolvedNpcState,
|
||||
updateNpcState: runtimeSupport.updateNpcState,
|
||||
cloneInventoryItemForOwner: runtimeSupport.cloneInventoryItemForOwner,
|
||||
runtime: expect.objectContaining({
|
||||
currentStory,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(config.npcEncounterActions).toEqual(
|
||||
expect.objectContaining({
|
||||
gameState,
|
||||
currentStory,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
buildOpeningCampChatContext,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
getAvailableOptionsForState,
|
||||
sanitizeOptions,
|
||||
sortOptions,
|
||||
buildContinueAdventureOption,
|
||||
getNpcEncounterKey: runtimeSupport.getNpcEncounterKey,
|
||||
getResolvedNpcState: runtimeSupport.getResolvedNpcState,
|
||||
updateNpcState: runtimeSupport.updateNpcState,
|
||||
cloneInventoryItemForOwner: runtimeSupport.cloneInventoryItemForOwner,
|
||||
resolveNpcInteractionDecision,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
136
src/hooks/rpg-runtime-story/storyInteractionCoordinator.ts
Normal file
136
src/hooks/rpg-runtime-story/storyInteractionCoordinator.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
|
||||
import type { RpgRuntimeStoryControllerResult } from './useRpgRuntimeStoryController';
|
||||
|
||||
type StoryInteractionCoordinatorParams = {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
currentStory: StoryMoment | null;
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
buildFallbackStoryForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
buildDialogueStoryMoment: RpgRuntimeStoryControllerResult['buildDialogueStoryMoment'];
|
||||
generateStoryForState: RpgRuntimeStoryControllerResult['generateStoryForState'];
|
||||
getAvailableOptionsForState: RpgRuntimeStoryControllerResult['getAvailableOptionsForState'];
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getTypewriterDelay: RpgRuntimeStoryControllerResult['getTypewriterDelay'];
|
||||
runtimeSupport: StoryRuntimeSupport;
|
||||
commitGeneratedState: RpgRuntimeStoryControllerResult['commitGeneratedState'];
|
||||
commitGeneratedStateWithEncounterEntry: RpgRuntimeStoryControllerResult['commitGeneratedStateWithEncounterEntry'];
|
||||
appendHistory: RpgRuntimeStoryControllerResult['appendHistory'];
|
||||
buildOpeningCampChatContext: RpgRuntimeStoryControllerResult['buildOpeningCampChatContext'];
|
||||
sortOptions: (options: StoryOption[]) => StoryOption[];
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
sanitizeOptions: (
|
||||
options: StoryOption[],
|
||||
character: Character,
|
||||
state: GameState,
|
||||
) => StoryOption[];
|
||||
resolveNpcInteractionDecision: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
) => { kind: string };
|
||||
};
|
||||
|
||||
export function createStoryInteractionCoordinatorConfig(
|
||||
params: StoryInteractionCoordinatorParams,
|
||||
) {
|
||||
const sharedRuntime = {
|
||||
currentStory: params.currentStory,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
};
|
||||
|
||||
return {
|
||||
treasureFlow: {
|
||||
gameState: params.gameState,
|
||||
runtime: sharedRuntime,
|
||||
},
|
||||
inventoryFlow: {
|
||||
gameState: params.gameState,
|
||||
runtime: sharedRuntime,
|
||||
},
|
||||
npcInteractionFlow: {
|
||||
gameState: params.gameState,
|
||||
setGameState: params.setGameState,
|
||||
getNpcEncounterKey: params.runtimeSupport.getNpcEncounterKey,
|
||||
getResolvedNpcState: params.runtimeSupport.getResolvedNpcState,
|
||||
updateNpcState: params.runtimeSupport.updateNpcState,
|
||||
cloneInventoryItemForOwner:
|
||||
params.runtimeSupport.cloneInventoryItemForOwner,
|
||||
runtime: {
|
||||
currentStory: params.currentStory,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
buildStoryContextFromState: params.buildStoryContextFromState,
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment: params.buildDialogueStoryMoment,
|
||||
generateStoryForState: params.generateStoryForState,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay: params.getTypewriterDelay,
|
||||
},
|
||||
},
|
||||
npcEncounterActions: {
|
||||
gameState: params.gameState,
|
||||
currentStory: params.currentStory,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
commitGeneratedState: params.commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry:
|
||||
params.commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory: params.appendHistory,
|
||||
buildOpeningCampChatContext: params.buildOpeningCampChatContext,
|
||||
buildStoryContextFromState: params.buildStoryContextFromState,
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment: params.buildDialogueStoryMoment,
|
||||
generateStoryForState: params.generateStoryForState,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay: params.getTypewriterDelay,
|
||||
getAvailableOptionsForState: params.getAvailableOptionsForState,
|
||||
sanitizeOptions: params.sanitizeOptions,
|
||||
sortOptions: params.sortOptions,
|
||||
buildContinueAdventureOption: params.buildContinueAdventureOption,
|
||||
getNpcEncounterKey: params.runtimeSupport.getNpcEncounterKey,
|
||||
getResolvedNpcState: params.runtimeSupport.getResolvedNpcState,
|
||||
updateNpcState: params.runtimeSupport.updateNpcState,
|
||||
cloneInventoryItemForOwner:
|
||||
params.runtimeSupport.cloneInventoryItemForOwner,
|
||||
resolveNpcInteractionDecision: params.resolveNpcInteractionDecision,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type StoryInteractionCoordinatorConfig = ReturnType<
|
||||
typeof createStoryInteractionCoordinatorConfig
|
||||
>;
|
||||
149
src/hooks/rpg-runtime-story/storyPresentation.test.ts
Normal file
149
src/hooks/rpg-runtime-story/storyPresentation.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
} from '../../types';
|
||||
import { buildStoryFromResponse } from './storyPresentation';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试主角',
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createGameState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
worldType: null,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createOption(
|
||||
functionId = 'npc_chat',
|
||||
actionText = '继续交谈',
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
function createStory(
|
||||
text: string,
|
||||
options: StoryOption[] = [],
|
||||
): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
describe('storyPresentation', () => {
|
||||
it('keeps provided available options when the AI response omits them', () => {
|
||||
const availableOptions = [createOption('npc_help', '请求援手')];
|
||||
|
||||
const story = buildStoryFromResponse({
|
||||
state: createGameState(),
|
||||
character: createCharacter(),
|
||||
response: createStory('服务端返回正文'),
|
||||
availableOptions,
|
||||
});
|
||||
|
||||
expect(story.text).toBe('服务端返回正文');
|
||||
expect(story.options).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates repeated response options before padding local fallbacks', () => {
|
||||
const duplicatedOptions = [
|
||||
createOption('npc_chat', '继续交谈'),
|
||||
createOption('npc_chat', '继续交谈'),
|
||||
];
|
||||
|
||||
const story = buildStoryFromResponse({
|
||||
state: createGameState(),
|
||||
character: createCharacter(),
|
||||
response: createStory('需要本地归一化', duplicatedOptions),
|
||||
availableOptions: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
story.options.filter(
|
||||
(option) =>
|
||||
option.functionId === 'npc_chat' &&
|
||||
option.actionText === '继续交谈',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(story.options.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
245
src/hooks/rpg-runtime-story/storyPresentation.ts
Normal file
245
src/hooks/rpg-runtime-story/storyPresentation.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryDialogueTurn,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import {
|
||||
buildFallbackStoryMoment,
|
||||
normalizeSkillProbabilities,
|
||||
} from '../combatStoryUtils';
|
||||
import { resolveStoryResponseOptions } from './storyResponseOptions';
|
||||
|
||||
const MIN_OPTION_POOL_SIZE = 6;
|
||||
|
||||
function dedupeStoryOptions(options: StoryOption[]) {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return options.filter((option) => {
|
||||
const identity = `${option.functionId}::${option.actionText}::${option.text}`;
|
||||
if (seen.has(identity)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(identity);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
const specialChars = [
|
||||
'\\',
|
||||
'^',
|
||||
'$',
|
||||
'*',
|
||||
'+',
|
||||
'?',
|
||||
'.',
|
||||
'(',
|
||||
')',
|
||||
'|',
|
||||
'[',
|
||||
']',
|
||||
'{',
|
||||
'}',
|
||||
];
|
||||
return specialChars.reduce(
|
||||
(escaped, char) => escaped.split(char).join('\\' + char),
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDialogueSpeakerName(rawSpeakerName: string) {
|
||||
return rawSpeakerName
|
||||
.trim()
|
||||
.replace(
|
||||
/^[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+/u,
|
||||
'',
|
||||
)
|
||||
.replace(
|
||||
/[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+$/u,
|
||||
'',
|
||||
)
|
||||
.replace(/^(?:\u540c\u4f34|\u961f\u53cb)\s*/u, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function sanitizeStoryOptions(
|
||||
options: StoryOption[],
|
||||
character: Character,
|
||||
state: GameState,
|
||||
) {
|
||||
const normalizedOptions = dedupeStoryOptions(
|
||||
options.map((option) => normalizeSkillProbabilities(option, character)),
|
||||
);
|
||||
|
||||
if (normalizedOptions.length === 0) {
|
||||
return buildFallbackStoryMoment(state, character).options;
|
||||
}
|
||||
|
||||
if (normalizedOptions.length >= MIN_OPTION_POOL_SIZE) {
|
||||
return normalizedOptions;
|
||||
}
|
||||
|
||||
return sortStoryOptionsByPriority(
|
||||
dedupeStoryOptions([
|
||||
...normalizedOptions,
|
||||
...buildFallbackStoryMoment(state, character).options,
|
||||
]).slice(0, MIN_OPTION_POOL_SIZE),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildStoryFromResponse(params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
response: StoryMoment;
|
||||
availableOptions: StoryOption[] | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) {
|
||||
return {
|
||||
text: params.response.text,
|
||||
options: resolveStoryResponseOptions({
|
||||
responseOptions: params.response.options,
|
||||
availableOptions: params.availableOptions,
|
||||
optionCatalog: params.optionCatalog ?? null,
|
||||
getSanitizedOptions: () =>
|
||||
sanitizeStoryOptions(
|
||||
params.response.options,
|
||||
params.character,
|
||||
params.state,
|
||||
),
|
||||
}),
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
export function parseDialogueTurns(
|
||||
text: string,
|
||||
npcName: string,
|
||||
): StoryDialogueTurn[] {
|
||||
const turns: StoryDialogueTurn[] = [];
|
||||
const dialogueColonPattern = '(?:\\uFF1A|:)';
|
||||
const playerPrefixPattern = new RegExp(
|
||||
'^(?:\\\\u4f60|\\\\u73a9\\\\u5bb6|\\\\u4e3b\\\\u89d2)\\\\s*' +
|
||||
dialogueColonPattern +
|
||||
'\\\\s*(.+)$',
|
||||
'u',
|
||||
);
|
||||
const npcPrefixPattern = new RegExp(
|
||||
'^' +
|
||||
escapeRegExp(npcName) +
|
||||
'\\\\s*' +
|
||||
dialogueColonPattern +
|
||||
'\\\\s*(.+)$',
|
||||
'u',
|
||||
);
|
||||
const namedSpeakerPattern = new RegExp(
|
||||
'^(.{1,24}?)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$',
|
||||
'u',
|
||||
);
|
||||
const lines = text
|
||||
.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const playerMatch = line.match(playerPrefixPattern);
|
||||
const playerText = playerMatch?.[1]?.trim();
|
||||
if (playerText) {
|
||||
turns.push({ speaker: 'player', text: playerText });
|
||||
continue;
|
||||
}
|
||||
|
||||
const npcMatch = line.match(npcPrefixPattern);
|
||||
const npcText = npcMatch?.[1]?.trim();
|
||||
if (npcText) {
|
||||
turns.push({ speaker: 'npc', speakerName: npcName, text: npcText });
|
||||
continue;
|
||||
}
|
||||
|
||||
const namedSpeakerMatch = line.match(namedSpeakerPattern);
|
||||
if (namedSpeakerMatch) {
|
||||
const rawSpeakerName = namedSpeakerMatch[1];
|
||||
const rawSpeakerText = namedSpeakerMatch[2];
|
||||
if (!rawSpeakerName || !rawSpeakerText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const speakerName = normalizeDialogueSpeakerName(rawSpeakerName);
|
||||
const speakerText = rawSpeakerText.trim();
|
||||
|
||||
if (speakerName && speakerText) {
|
||||
turns.push({
|
||||
speaker: speakerName === npcName ? 'npc' : 'companion',
|
||||
speakerName,
|
||||
text: speakerText,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (line.startsWith('你:') || line.startsWith('你:')) {
|
||||
turns.push({ speaker: 'player', text: line.slice(2).trim() });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith(npcName + ':') || line.startsWith(npcName + ':')) {
|
||||
turns.push({
|
||||
speaker: 'npc',
|
||||
text: line.slice(npcName.length + 1).trim(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('主角:') || line.startsWith('主角:')) {
|
||||
turns.push({ speaker: 'player', text: line.slice(3).trim() });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (turns.length > 0) {
|
||||
const lastTurnIndex = turns.length - 1;
|
||||
const lastTurn = turns[lastTurnIndex];
|
||||
if (lastTurn) {
|
||||
turns[lastTurnIndex] = {
|
||||
...lastTurn,
|
||||
text: lastTurn.text + line,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return turns.filter((turn) => turn.text.length > 0);
|
||||
}
|
||||
|
||||
export function buildDialogueStoryMoment(
|
||||
npcName: string,
|
||||
text: string,
|
||||
options: StoryOption[],
|
||||
streaming = false,
|
||||
): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options,
|
||||
displayMode: 'dialogue',
|
||||
dialogue: parseDialogueTurns(text, npcName),
|
||||
streaming,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasRenderableDialogueTurns(text: string, npcName: string) {
|
||||
return parseDialogueTurns(text, npcName).length >= 2;
|
||||
}
|
||||
|
||||
export function getTypewriterDelay(char: string) {
|
||||
if (/[。!?!?]/u.test(char)) {
|
||||
return 240;
|
||||
}
|
||||
if (/[,、;;:]/u.test(char)) {
|
||||
return 150;
|
||||
}
|
||||
if (/\s/u.test(char)) {
|
||||
return 45;
|
||||
}
|
||||
return 90;
|
||||
}
|
||||
192
src/hooks/rpg-runtime-story/storyRequestCoordinator.test.ts
Normal file
192
src/hooks/rpg-runtime-story/storyRequestCoordinator.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiService';
|
||||
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import {
|
||||
generateStoryForStateWithCoordinator,
|
||||
resolveStoryRequestOptions,
|
||||
} from './storyRequestCoordinator';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '在风声里辨认危险的旅人。',
|
||||
personality: '谨慎而果断',
|
||||
skills: [],
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createGameState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 3,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(functionId = 'npc_chat', actionText = '继续交谈'): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
describe('storyRequestCoordinator', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('switches to server runtime option catalogs when the local option pool is fully server-backed', async () => {
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const currentStory = createStory('当前故事');
|
||||
const getAvailableOptionsForState = vi.fn(() => [createOption('npc_chat')]);
|
||||
const loadRuntimeOptionCatalog = vi
|
||||
.fn()
|
||||
.mockResolvedValue([createOption('npc_help', '请求援手')]);
|
||||
|
||||
const result = await resolveStoryRequestOptions({
|
||||
state,
|
||||
character,
|
||||
currentStory,
|
||||
getAvailableOptionsForState,
|
||||
loadRuntimeOptionCatalog,
|
||||
});
|
||||
|
||||
expect(loadRuntimeOptionCatalog).toHaveBeenCalledWith({
|
||||
gameState: state,
|
||||
currentStory,
|
||||
});
|
||||
expect(result.availableOptions).toBeNull();
|
||||
expect(result.optionCatalog).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps explicit option catalogs without reloading server runtime options', async () => {
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const currentStory = createStory('当前故事');
|
||||
const optionCatalog = [createOption('npc_help', '请求援手')];
|
||||
const getAvailableOptionsForState = vi.fn(() => [createOption('npc_chat')]);
|
||||
const loadRuntimeOptionCatalog = vi.fn();
|
||||
|
||||
const result = await resolveStoryRequestOptions({
|
||||
state,
|
||||
character,
|
||||
currentStory,
|
||||
optionCatalog,
|
||||
getAvailableOptionsForState,
|
||||
loadRuntimeOptionCatalog,
|
||||
});
|
||||
|
||||
expect(loadRuntimeOptionCatalog).not.toHaveBeenCalled();
|
||||
expect(getAvailableOptionsForState).not.toHaveBeenCalled();
|
||||
expect(result.optionCatalog).toBe(optionCatalog);
|
||||
expect(result.availableOptions).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to local available options when server runtime catalog refresh fails during续写', async () => {
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const currentStory = createStory('当前故事');
|
||||
const history = [createStory('上一轮剧情')];
|
||||
const localOptions = [createOption('npc_chat', '继续交谈')];
|
||||
const getAvailableOptionsForState = vi.fn(() => localOptions);
|
||||
const getStoryGenerationHostileNpcs = vi.fn(() => []);
|
||||
const buildStoryContextFromState = vi.fn(
|
||||
(_state, extras) =>
|
||||
({
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 30,
|
||||
playerMaxMana: 30,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: 'idle',
|
||||
skillCooldowns: {},
|
||||
sceneId: 'inn_room',
|
||||
sceneName: '客栈内室',
|
||||
sceneDescription: '屋里安静得只剩风声。',
|
||||
pendingSceneEncounter: false,
|
||||
lastFunctionId: extras?.lastFunctionId ?? null,
|
||||
}) as StoryGenerationContext,
|
||||
);
|
||||
const buildStoryFromResponse = vi.fn(
|
||||
(
|
||||
_state: GameState,
|
||||
_character: Character,
|
||||
response: StoryMoment,
|
||||
) => response,
|
||||
);
|
||||
const requestInitialStory = vi.fn();
|
||||
const requestNextStep = vi.fn().mockResolvedValue({
|
||||
storyText: '服务端续写完成',
|
||||
options: [createOption('npc_help', '顺势追问')],
|
||||
});
|
||||
const loadRuntimeOptionCatalog = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('server option catalog failed'));
|
||||
const onServerOptionCatalogLoadError = vi.fn();
|
||||
|
||||
const result = await generateStoryForStateWithCoordinator({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
currentStory,
|
||||
choice: '继续交谈',
|
||||
lastFunctionId: 'npc_chat',
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
requestInitialStory,
|
||||
requestNextStep,
|
||||
loadRuntimeOptionCatalog,
|
||||
onServerOptionCatalogLoadError,
|
||||
});
|
||||
|
||||
expect(onServerOptionCatalogLoadError).toHaveBeenCalledTimes(1);
|
||||
expect(requestInitialStory).not.toHaveBeenCalled();
|
||||
expect(requestNextStep).toHaveBeenCalledWith(
|
||||
'WUXIA',
|
||||
character,
|
||||
[],
|
||||
history,
|
||||
'继续交谈',
|
||||
expect.objectContaining({
|
||||
sceneId: 'inn_room',
|
||||
lastFunctionId: 'npc_chat',
|
||||
}),
|
||||
{
|
||||
availableOptions: localOptions,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({
|
||||
text: '服务端续写完成',
|
||||
options: [createOption('npc_help', '顺势追问')],
|
||||
});
|
||||
});
|
||||
});
|
||||
196
src/hooks/rpg-runtime-story/storyRequestCoordinator.ts
Normal file
196
src/hooks/rpg-runtime-story/storyRequestCoordinator.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type {
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
} from '../../services/aiService';
|
||||
import { shouldUseRpgRuntimeServerOptions } from '../../services/rpg-runtime';
|
||||
import type {
|
||||
AIResponse,
|
||||
Character,
|
||||
GameState,
|
||||
SceneHostileNpc,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { loadRpgRuntimeOptionCatalog } from '.';
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
type BuildStoryFromResponse = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog?: StoryOption[] | null,
|
||||
) => StoryMoment;
|
||||
|
||||
type GetAvailableOptionsForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
|
||||
type GetStoryGenerationHostileNpcs = (state: GameState) => SceneHostileNpc[];
|
||||
|
||||
type RequestInitialStory = (
|
||||
worldType: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
requestOptions?: StoryRequestOptions,
|
||||
) => Promise<AIResponse>;
|
||||
|
||||
type RequestNextStep = (
|
||||
worldType: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
choice: string,
|
||||
context: StoryGenerationContext,
|
||||
requestOptions?: StoryRequestOptions,
|
||||
) => Promise<AIResponse>;
|
||||
|
||||
type LoadRuntimeOptionCatalog = typeof loadRpgRuntimeOptionCatalog;
|
||||
|
||||
export type ResolvedStoryRequestOptions = {
|
||||
availableOptions: StoryOption[] | null;
|
||||
optionCatalog: StoryOption[] | null;
|
||||
};
|
||||
|
||||
export async function resolveStoryRequestOptions(params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
currentStory: StoryMoment | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
getAvailableOptionsForState: GetAvailableOptionsForState;
|
||||
loadRuntimeOptionCatalog?: LoadRuntimeOptionCatalog;
|
||||
onServerOptionCatalogLoadError?: (error: unknown) => void;
|
||||
}) {
|
||||
let optionCatalog =
|
||||
params.optionCatalog && params.optionCatalog.length > 0
|
||||
? params.optionCatalog
|
||||
: null;
|
||||
let availableOptions = optionCatalog
|
||||
? null
|
||||
: params.getAvailableOptionsForState(params.state, params.character);
|
||||
|
||||
if (optionCatalog || !shouldUseRpgRuntimeServerOptions(availableOptions)) {
|
||||
return {
|
||||
availableOptions,
|
||||
optionCatalog,
|
||||
} satisfies ResolvedStoryRequestOptions;
|
||||
}
|
||||
|
||||
try {
|
||||
const serverOptionCatalog = await (
|
||||
params.loadRuntimeOptionCatalog ?? loadRpgRuntimeOptionCatalog
|
||||
)({
|
||||
gameState: params.state,
|
||||
currentStory: params.currentStory,
|
||||
});
|
||||
|
||||
if (serverOptionCatalog && serverOptionCatalog.length > 0) {
|
||||
optionCatalog = serverOptionCatalog;
|
||||
availableOptions = null;
|
||||
}
|
||||
} catch (error) {
|
||||
params.onServerOptionCatalogLoadError?.(error);
|
||||
}
|
||||
|
||||
return {
|
||||
availableOptions,
|
||||
optionCatalog,
|
||||
} satisfies ResolvedStoryRequestOptions;
|
||||
}
|
||||
|
||||
export function buildAiStoryRequestOptions(
|
||||
options: ResolvedStoryRequestOptions,
|
||||
) {
|
||||
if (options.availableOptions) {
|
||||
return {
|
||||
availableOptions: options.availableOptions,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.optionCatalog) {
|
||||
return {
|
||||
optionCatalog: options.optionCatalog,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function generateStoryForStateWithCoordinator(params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
currentStory: StoryMoment | null;
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
getAvailableOptionsForState: GetAvailableOptionsForState;
|
||||
getStoryGenerationHostileNpcs: GetStoryGenerationHostileNpcs;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
buildStoryFromResponse: BuildStoryFromResponse;
|
||||
requestInitialStory: RequestInitialStory;
|
||||
requestNextStep: RequestNextStep;
|
||||
loadRuntimeOptionCatalog?: LoadRuntimeOptionCatalog;
|
||||
onServerOptionCatalogLoadError?: (error: unknown) => void;
|
||||
}) {
|
||||
if (!params.state.worldType) {
|
||||
throw new Error(
|
||||
'The current world is not initialized, so story generation cannot continue.',
|
||||
);
|
||||
}
|
||||
const worldType = params.state.worldType;
|
||||
|
||||
const resolvedOptions = await resolveStoryRequestOptions({
|
||||
state: params.state,
|
||||
character: params.character,
|
||||
currentStory: params.currentStory,
|
||||
optionCatalog: params.optionCatalog,
|
||||
getAvailableOptionsForState: params.getAvailableOptionsForState,
|
||||
loadRuntimeOptionCatalog: params.loadRuntimeOptionCatalog,
|
||||
onServerOptionCatalogLoadError: params.onServerOptionCatalogLoadError,
|
||||
});
|
||||
const requestOptions = buildAiStoryRequestOptions(resolvedOptions);
|
||||
const monsters = params.getStoryGenerationHostileNpcs(params.state);
|
||||
const context = params.choice
|
||||
? params.buildStoryContextFromState(params.state, {
|
||||
lastFunctionId: params.lastFunctionId,
|
||||
})
|
||||
: params.buildStoryContextFromState(params.state);
|
||||
const response = params.choice
|
||||
? await params.requestNextStep(
|
||||
worldType,
|
||||
params.character,
|
||||
monsters,
|
||||
params.history,
|
||||
params.choice,
|
||||
context,
|
||||
requestOptions,
|
||||
)
|
||||
: await params.requestInitialStory(
|
||||
worldType,
|
||||
params.character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
|
||||
return params.buildStoryFromResponse(
|
||||
params.state,
|
||||
params.character,
|
||||
{
|
||||
text: response.storyText,
|
||||
options: response.options,
|
||||
},
|
||||
resolvedOptions.availableOptions,
|
||||
resolvedOptions.optionCatalog,
|
||||
);
|
||||
}
|
||||
136
src/hooks/rpg-runtime-story/storyRequestRuntime.test.ts
Normal file
136
src/hooks/rpg-runtime-story/storyRequestRuntime.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { createGenerateStoryForState } from './storyRequestRuntime';
|
||||
|
||||
const { generateStoryForStateWithCoordinatorMock } = vi.hoisted(() => ({
|
||||
generateStoryForStateWithCoordinatorMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./storyRequestCoordinator', () => ({
|
||||
generateStoryForStateWithCoordinator:
|
||||
generateStoryForStateWithCoordinatorMock,
|
||||
}));
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '在风声里辨认危险的旅人。',
|
||||
backstory: '长年行走江湖。',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: '谨慎而果断',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createGameState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
currentScene: 'Story',
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(
|
||||
functionId = 'npc_chat',
|
||||
actionText = '继续交谈',
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
describe('storyRequestRuntime', () => {
|
||||
it('forwards runtime request dependencies and currentStory into the coordinator', async () => {
|
||||
const currentStory = createStory('当前故事');
|
||||
const getAvailableOptionsForState = vi.fn(() => [createOption()]);
|
||||
const getStoryGenerationHostileNpcs = vi.fn(() => []);
|
||||
const buildStoryContextFromState = vi.fn();
|
||||
const buildStoryFromResponse = vi.fn();
|
||||
const requestInitialStory = vi.fn();
|
||||
const requestNextStep = vi.fn();
|
||||
const onServerOptionCatalogLoadError = vi.fn();
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const history = [createStory('上一轮剧情')];
|
||||
|
||||
generateStoryForStateWithCoordinatorMock.mockResolvedValue({
|
||||
text: '生成完成',
|
||||
options: [],
|
||||
});
|
||||
|
||||
const generateStoryForState = createGenerateStoryForState({
|
||||
currentStory,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
requestInitialStory,
|
||||
requestNextStep,
|
||||
onServerOptionCatalogLoadError,
|
||||
});
|
||||
|
||||
const result = await generateStoryForState({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
choice: '继续交谈',
|
||||
lastFunctionId: 'npc_chat',
|
||||
optionCatalog: [createOption('npc_help', '请求援手')],
|
||||
});
|
||||
|
||||
expect(generateStoryForStateWithCoordinatorMock).toHaveBeenCalledWith({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
currentStory,
|
||||
choice: '继续交谈',
|
||||
lastFunctionId: 'npc_chat',
|
||||
optionCatalog: [createOption('npc_help', '请求援手')],
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
requestInitialStory,
|
||||
requestNextStep,
|
||||
onServerOptionCatalogLoadError,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
text: '生成完成',
|
||||
options: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
69
src/hooks/rpg-runtime-story/storyRequestRuntime.ts
Normal file
69
src/hooks/rpg-runtime-story/storyRequestRuntime.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { GenerateStoryForState } from './progressionActions';
|
||||
import { generateStoryForStateWithCoordinator } from './storyRequestCoordinator';
|
||||
|
||||
type GetAvailableOptionsForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
|
||||
type BuildStoryContextFromState = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['buildStoryContextFromState'];
|
||||
|
||||
type BuildStoryFromResponse = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['buildStoryFromResponse'];
|
||||
|
||||
type GetStoryGenerationHostileNpcs = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['getStoryGenerationHostileNpcs'];
|
||||
|
||||
type RequestInitialStory = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['requestInitialStory'];
|
||||
|
||||
type RequestNextStep = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['requestNextStep'];
|
||||
|
||||
export function createGenerateStoryForState(params: {
|
||||
currentStory: StoryMoment | null;
|
||||
getAvailableOptionsForState: GetAvailableOptionsForState;
|
||||
getStoryGenerationHostileNpcs: GetStoryGenerationHostileNpcs;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
buildStoryFromResponse: BuildStoryFromResponse;
|
||||
requestInitialStory: RequestInitialStory;
|
||||
requestNextStep: RequestNextStep;
|
||||
onServerOptionCatalogLoadError?: (error: unknown) => void;
|
||||
}): GenerateStoryForState {
|
||||
return async ({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
choice,
|
||||
lastFunctionId,
|
||||
optionCatalog,
|
||||
}) =>
|
||||
generateStoryForStateWithCoordinator({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
currentStory: params.currentStory,
|
||||
choice,
|
||||
lastFunctionId,
|
||||
optionCatalog,
|
||||
getAvailableOptionsForState: params.getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState: params.buildStoryContextFromState,
|
||||
buildStoryFromResponse: params.buildStoryFromResponse,
|
||||
requestInitialStory: params.requestInitialStory,
|
||||
requestNextStep: params.requestNextStep,
|
||||
onServerOptionCatalogLoadError: params.onServerOptionCatalogLoadError,
|
||||
});
|
||||
}
|
||||
184
src/hooks/rpg-runtime-story/storyResponseOptions.test.ts
Normal file
184
src/hooks/rpg-runtime-story/storyResponseOptions.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnimationState, type StoryOption } from '../../types';
|
||||
import { resolveStoryResponseOptions } from './storyResponseOptions';
|
||||
|
||||
function createOption(
|
||||
functionId: string,
|
||||
actionText: string,
|
||||
priority = 0,
|
||||
interaction?: StoryOption['interaction'],
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
priority,
|
||||
interaction,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('storyResponseOptions', () => {
|
||||
it('keeps rewritten actionText when camp companion follow-up uses available options', () => {
|
||||
const availableOptions = [
|
||||
createOption('npc_chat', '先聊聊营地安排', 3),
|
||||
createOption('npc_gift', '把旧礼物递给你', 2),
|
||||
createOption('camp_travel_home_scene', '前往旧地点', 1),
|
||||
];
|
||||
const responseOptions = [
|
||||
createOption('npc_chat', '顺着你刚才的话继续问下去', 3),
|
||||
createOption('npc_gift', '把刚挑好的礼物正式交给你', 2),
|
||||
createOption('camp_travel_home_scene', '前往云河渡', 1),
|
||||
];
|
||||
|
||||
const resolved = resolveStoryResponseOptions({
|
||||
responseOptions,
|
||||
availableOptions,
|
||||
getSanitizedOptions: () => {
|
||||
throw new Error('available options branch should not sanitize');
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.map((option) => option.actionText)).toEqual([
|
||||
'顺着你刚才的话继续问下去',
|
||||
'把刚挑好的礼物正式交给你',
|
||||
'前往云河渡',
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves interaction metadata when AI rewrites provided npc options', () => {
|
||||
const availableOptions = [
|
||||
createOption('npc_chat', '继续交谈', 3, {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'chat',
|
||||
}),
|
||||
createOption('camp_travel_home_scene', '前往旧地点', 1),
|
||||
];
|
||||
const responseOptions = [
|
||||
createOption('npc_chat', '顺着你刚才那句提醒继续追问', 3),
|
||||
createOption('camp_travel_home_scene', '先回云河渡', 1),
|
||||
];
|
||||
|
||||
const resolved = resolveStoryResponseOptions({
|
||||
responseOptions,
|
||||
availableOptions,
|
||||
getSanitizedOptions: () => {
|
||||
throw new Error('available options branch should not sanitize');
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
actionText: '顺着你刚才那句提醒继续追问',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'chat',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to available options when the response omits them entirely', () => {
|
||||
const availableOptions = [
|
||||
createOption('npc_chat', '继续交谈', 2),
|
||||
createOption('camp_travel_home_scene', '前往山门', 1),
|
||||
];
|
||||
|
||||
const resolved = resolveStoryResponseOptions({
|
||||
responseOptions: [],
|
||||
availableOptions,
|
||||
getSanitizedOptions: () => {
|
||||
throw new Error('available options fallback should not sanitize');
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.map((option) => option.actionText)).toEqual([
|
||||
'继续交谈',
|
||||
'前往山门',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps only AI-selected options when optionCatalog is used for reasoned follow-ups', () => {
|
||||
const optionCatalog = [
|
||||
createOption('npc_chat', '继续交谈', 3, {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'chat',
|
||||
}),
|
||||
createOption('npc_help', '请求援手', 2, {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'help',
|
||||
}),
|
||||
createOption('npc_trade', '看看能交换什么', 1, {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'trade',
|
||||
}),
|
||||
];
|
||||
const responseOptions = [
|
||||
createOption('npc_help', '顺着刚才的话请他搭把手', 3),
|
||||
createOption('npc_chat', '追问他刚才为什么突然沉默', 2),
|
||||
];
|
||||
|
||||
const resolved = resolveStoryResponseOptions({
|
||||
responseOptions,
|
||||
optionCatalog,
|
||||
getSanitizedOptions: () => {
|
||||
throw new Error('option catalog branch should not sanitize');
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '顺着刚才的话请他搭把手',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'help',
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
actionText: '追问他刚才为什么突然沉默',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'chat',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the raw catalog only when the AI omits optionCatalog results entirely', () => {
|
||||
const optionCatalog = [
|
||||
createOption('npc_chat', '继续交谈', 2),
|
||||
createOption('npc_trade', '看看能交换什么', 1),
|
||||
];
|
||||
|
||||
const resolved = resolveStoryResponseOptions({
|
||||
responseOptions: [],
|
||||
optionCatalog,
|
||||
getSanitizedOptions: () => {
|
||||
throw new Error('option catalog fallback should not sanitize');
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.map((option) => option.actionText)).toEqual([
|
||||
'继续交谈',
|
||||
'看看能交换什么',
|
||||
]);
|
||||
});
|
||||
});
|
||||
126
src/hooks/rpg-runtime-story/storyResponseOptions.ts
Normal file
126
src/hooks/rpg-runtime-story/storyResponseOptions.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
|
||||
import type { StoryOption } from '../../types';
|
||||
|
||||
type ResolveStoryResponseOptionsParams = {
|
||||
responseOptions: StoryOption[];
|
||||
availableOptions?: StoryOption[] | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
getSanitizedOptions: () => StoryOption[];
|
||||
};
|
||||
|
||||
function cloneStoryOption(option: StoryOption): StoryOption {
|
||||
return {
|
||||
...option,
|
||||
visuals: {
|
||||
...option.visuals,
|
||||
monsterChanges: option.visuals.monsterChanges.map((change) => ({
|
||||
...change,
|
||||
})),
|
||||
},
|
||||
interaction: option.interaction ? { ...option.interaction } : undefined,
|
||||
goalAffordance: option.goalAffordance
|
||||
? { ...option.goalAffordance }
|
||||
: option.goalAffordance,
|
||||
};
|
||||
}
|
||||
|
||||
function rewriteOptionsFromBaseOptions(
|
||||
responseOptions: StoryOption[],
|
||||
baseOptions: StoryOption[],
|
||||
) {
|
||||
if (responseOptions.length === 0) {
|
||||
return baseOptions.map(cloneStoryOption);
|
||||
}
|
||||
|
||||
const optionBuckets = new Map<string, StoryOption[]>();
|
||||
const consumedOptions = new Set<StoryOption>();
|
||||
|
||||
baseOptions.forEach((option) => {
|
||||
const bucket = optionBuckets.get(option.functionId) ?? [];
|
||||
bucket.push(option);
|
||||
optionBuckets.set(option.functionId, bucket);
|
||||
});
|
||||
|
||||
const resolved: StoryOption[] = [];
|
||||
|
||||
responseOptions.forEach((option) => {
|
||||
const bucket = optionBuckets.get(option.functionId);
|
||||
const matchedOption = bucket?.shift();
|
||||
if (!matchedOption) return;
|
||||
|
||||
consumedOptions.add(matchedOption);
|
||||
const rewrittenText = option.actionText.trim() || matchedOption.actionText;
|
||||
resolved.push({
|
||||
...cloneStoryOption(matchedOption),
|
||||
actionText: rewrittenText,
|
||||
text: rewrittenText || matchedOption.text || matchedOption.actionText,
|
||||
});
|
||||
});
|
||||
|
||||
if (resolved.length === baseOptions.length) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const remainingOptions = baseOptions.filter(
|
||||
(option) => !consumedOptions.has(option),
|
||||
);
|
||||
return [...resolved, ...remainingOptions.map(cloneStoryOption)];
|
||||
}
|
||||
|
||||
function rewriteOptionsFromCatalog(
|
||||
responseOptions: StoryOption[],
|
||||
optionCatalog: StoryOption[],
|
||||
) {
|
||||
if (responseOptions.length === 0) {
|
||||
return optionCatalog.map(cloneStoryOption);
|
||||
}
|
||||
|
||||
const optionBuckets = new Map<string, StoryOption[]>();
|
||||
|
||||
optionCatalog.forEach((option) => {
|
||||
const bucket = optionBuckets.get(option.functionId) ?? [];
|
||||
bucket.push(option);
|
||||
optionBuckets.set(option.functionId, bucket);
|
||||
});
|
||||
|
||||
const resolved = responseOptions.reduce<StoryOption[]>((nextResolved, option) => {
|
||||
const bucket = optionBuckets.get(option.functionId);
|
||||
const matchedOption = bucket?.shift();
|
||||
if (!matchedOption) {
|
||||
return nextResolved;
|
||||
}
|
||||
|
||||
const rewrittenText = option.actionText.trim() || matchedOption.actionText;
|
||||
nextResolved.push({
|
||||
...cloneStoryOption(matchedOption),
|
||||
actionText: rewrittenText,
|
||||
text: rewrittenText || matchedOption.text || matchedOption.actionText,
|
||||
});
|
||||
return nextResolved;
|
||||
}, []);
|
||||
|
||||
return resolved.length > 0
|
||||
? resolved
|
||||
: optionCatalog.map(cloneStoryOption);
|
||||
}
|
||||
|
||||
export function resolveStoryResponseOptions({
|
||||
responseOptions,
|
||||
availableOptions = null,
|
||||
optionCatalog = null,
|
||||
getSanitizedOptions,
|
||||
}: ResolveStoryResponseOptionsParams) {
|
||||
if (availableOptions) {
|
||||
return sortStoryOptionsByPriority(
|
||||
rewriteOptionsFromBaseOptions(responseOptions, availableOptions),
|
||||
);
|
||||
}
|
||||
|
||||
if (optionCatalog) {
|
||||
return sortStoryOptionsByPriority(
|
||||
rewriteOptionsFromCatalog(responseOptions, optionCatalog),
|
||||
);
|
||||
}
|
||||
|
||||
return sortStoryOptionsByPriority(getSanitizedOptions());
|
||||
}
|
||||
123
src/hooks/rpg-runtime-story/storyRuntimeSupport.test.ts
Normal file
123
src/hooks/rpg-runtime-story/storyRuntimeSupport.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GameState, InventoryItem } from '../../types';
|
||||
import {
|
||||
cloneInventoryItemForOwner,
|
||||
updateQuestLog,
|
||||
updateRuntimeStats,
|
||||
} from './storyRuntimeSupport';
|
||||
|
||||
function createGameState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
customWorldProfile: null,
|
||||
playerCharacter: null,
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: 'idle',
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 1,
|
||||
playerMaxHp: 1,
|
||||
playerMana: 1,
|
||||
playerMaxMana: 1,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('storyRuntimeSupport', () => {
|
||||
it('preserves identity-sensitive inventory items when cloning for another owner', () => {
|
||||
const item = {
|
||||
id: 'artifact-1',
|
||||
category: '饰品',
|
||||
name: '旧日秘匣',
|
||||
quantity: 1,
|
||||
rarity: 'epic',
|
||||
tags: ['relic'],
|
||||
runtimeMetadata: {
|
||||
seedKey: 'artifact-seed',
|
||||
},
|
||||
} as InventoryItem;
|
||||
|
||||
expect(cloneInventoryItemForOwner(item, 'npc', 2)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'npc:artifact-1:2',
|
||||
quantity: 2,
|
||||
runtimeMetadata: expect.objectContaining({
|
||||
seedKey: 'artifact-seed:npc',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses synthetic ids for ordinary stackable items when cloning for another owner', () => {
|
||||
const item = {
|
||||
id: 'potion-1',
|
||||
category: '消耗品',
|
||||
name: '回气散',
|
||||
quantity: 3,
|
||||
rarity: 'common',
|
||||
tags: ['healing'],
|
||||
} as InventoryItem;
|
||||
|
||||
expect(cloneInventoryItemForOwner(item, 'player')).toEqual(
|
||||
expect.objectContaining({
|
||||
id: `player:${encodeURIComponent('消耗品-回气散')}`,
|
||||
quantity: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('updates quest logs and runtime stats without keeping that logic in the main hook', () => {
|
||||
const initialState = createGameState();
|
||||
const withQuest = updateQuestLog(initialState, () => [
|
||||
{
|
||||
id: 'quest-1',
|
||||
},
|
||||
] as GameState['quests']);
|
||||
const withStats = updateRuntimeStats(withQuest, {
|
||||
itemsUsed: 2,
|
||||
scenesTraveled: 1,
|
||||
});
|
||||
|
||||
expect(withStats.quests).toEqual([{ id: 'quest-1' }]);
|
||||
expect(withStats.runtimeStats.itemsUsed).toBe(2);
|
||||
expect(withStats.runtimeStats.scenesTraveled).toBe(1);
|
||||
});
|
||||
});
|
||||
136
src/hooks/rpg-runtime-story/storyRuntimeSupport.ts
Normal file
136
src/hooks/rpg-runtime-story/storyRuntimeSupport.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
buildNpcEncounterStoryMoment,
|
||||
normalizeNpcPersistentState,
|
||||
} from '../../data/npcInteractions';
|
||||
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
|
||||
import { syncNpcNarrativeState } from '../../services/storyEngine/echoMemory';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
NpcBattleMode,
|
||||
} from '../../types';
|
||||
import { getNpcEncounterKey } from './storyGenerationState';
|
||||
|
||||
export function cloneInventoryItemForOwner(
|
||||
item: InventoryItem,
|
||||
owner: 'player' | 'npc',
|
||||
quantity = 1,
|
||||
) {
|
||||
const preserveIdentity = Boolean(
|
||||
item.runtimeMetadata ||
|
||||
item.buildProfile ||
|
||||
item.equipmentSlotId ||
|
||||
item.statProfile ||
|
||||
item.attributeResonance,
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
id: preserveIdentity
|
||||
? `${owner}:${item.id}:${quantity}`
|
||||
: `${owner}:${encodeURIComponent(`${item.category}-${item.name}`)}`,
|
||||
quantity,
|
||||
runtimeMetadata: item.runtimeMetadata
|
||||
? {
|
||||
...item.runtimeMetadata,
|
||||
seedKey: `${item.runtimeMetadata.seedKey}:${owner}`,
|
||||
}
|
||||
: item.runtimeMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
export function getResolvedNpcState(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
) {
|
||||
return (
|
||||
state.npcStates[getNpcEncounterKey(encounter)] ??
|
||||
buildInitialNpcState(encounter, state.worldType, state)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildNpcStory(
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
) {
|
||||
return buildNpcEncounterStoryMoment({
|
||||
state,
|
||||
encounter,
|
||||
npcState: getResolvedNpcState(state, encounter),
|
||||
playerCharacter: character,
|
||||
playerInventory: state.playerInventory,
|
||||
activeQuests: state.quests,
|
||||
scene: state.currentScenePreset,
|
||||
partySize: state.companions.length,
|
||||
overrideText,
|
||||
worldType: state.worldType,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateNpcState(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
updater: (
|
||||
npcState: ReturnType<typeof getResolvedNpcState>,
|
||||
) => ReturnType<typeof getResolvedNpcState>,
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
[getNpcEncounterKey(encounter)]: normalizeNpcPersistentState(
|
||||
syncNpcNarrativeState({
|
||||
encounter,
|
||||
npcState: updater(getResolvedNpcState(state, encounter)),
|
||||
customWorldProfile: state.customWorldProfile,
|
||||
storyEngineMemory: state.storyEngineMemory,
|
||||
}),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function updateQuestLog(
|
||||
state: GameState,
|
||||
updater: (quests: GameState['quests']) => GameState['quests'],
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
quests: updater(state.quests),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateRuntimeStats(
|
||||
state: GameState,
|
||||
increments: Parameters<typeof incrementGameRuntimeStats>[1],
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, increments),
|
||||
};
|
||||
}
|
||||
|
||||
export const storyRuntimeSupport = {
|
||||
cloneInventoryItemForOwner,
|
||||
getNpcEncounterKey,
|
||||
getResolvedNpcState,
|
||||
buildNpcStory,
|
||||
handleNpcBattleConversationContinuation: (_params: {
|
||||
nextState: GameState;
|
||||
encounter: Encounter;
|
||||
character: Character;
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
battleMode: NpcBattleMode;
|
||||
}) => false,
|
||||
updateNpcState,
|
||||
updateQuestLog,
|
||||
updateRuntimeStats,
|
||||
};
|
||||
|
||||
export type StoryRuntimeSupport = typeof storyRuntimeSupport;
|
||||
105
src/hooks/rpg-runtime-story/uiTypes.ts
Normal file
105
src/hooks/rpg-runtime-story/uiTypes.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type {
|
||||
Encounter,
|
||||
GoalHandoff,
|
||||
GoalPulseEvent,
|
||||
GoalStackState,
|
||||
InventoryItem,
|
||||
} from '../../types';
|
||||
|
||||
export type TradeModalState = {
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
introText: string | null;
|
||||
mode: 'buy' | 'sell';
|
||||
selectedNpcItemId: string | null;
|
||||
selectedPlayerItemId: string | null;
|
||||
selectedQuantity: number;
|
||||
};
|
||||
|
||||
export type GiftModalState = {
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
introText: string | null;
|
||||
selectedItemId: string | null;
|
||||
};
|
||||
|
||||
export type RecruitModalState = {
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
introText: string | null;
|
||||
selectedReleaseNpcId: string | null;
|
||||
};
|
||||
|
||||
export interface StoryGenerationNpcUi {
|
||||
tradeModal: TradeModalState | null;
|
||||
giftModal: GiftModalState | null;
|
||||
recruitModal: RecruitModalState | null;
|
||||
setTradeMode: (mode: 'buy' | 'sell') => void;
|
||||
selectTradeNpcItem: (itemId: string) => void;
|
||||
selectTradePlayerItem: (itemId: string) => void;
|
||||
setTradeQuantity: (quantity: number) => void;
|
||||
closeTradeModal: () => void;
|
||||
confirmTrade: () => void;
|
||||
selectGiftItem: (itemId: string) => void;
|
||||
closeGiftModal: () => void;
|
||||
confirmGift: () => void;
|
||||
selectRecruitRelease: (npcId: string) => void;
|
||||
closeRecruitModal: () => void;
|
||||
confirmRecruit: () => void;
|
||||
}
|
||||
|
||||
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;
|
||||
}>;
|
||||
craftRecipe: (recipeId: string) => Promise<boolean>;
|
||||
dismantleItem: (itemId: string) => Promise<boolean>;
|
||||
reforgeItem: (itemId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface QuestFlowUi {
|
||||
acknowledgeQuestCompletion: (questId: string) => void;
|
||||
claimQuestReward: (questId: string) => {
|
||||
questId: string;
|
||||
handoff: GoalHandoff | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface NpcChatQuestOfferUi {
|
||||
replacePendingOffer: () => boolean;
|
||||
abandonPendingOffer: () => boolean;
|
||||
acceptPendingOffer: () => string | null;
|
||||
}
|
||||
|
||||
export interface GoalFlowUi {
|
||||
goalStack: GoalStackState;
|
||||
pulse: GoalPulseEvent | null;
|
||||
dismissPulse: () => void;
|
||||
}
|
||||
|
||||
export interface BattleRewardSummary {
|
||||
id: string;
|
||||
defeatedHostileNpcs: Array<{ id: string; name: string }>;
|
||||
items: InventoryItem[];
|
||||
}
|
||||
|
||||
export interface BattleRewardUi {
|
||||
reward: BattleRewardSummary | null;
|
||||
dismiss: () => void;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createRpgRuntimeInteractionUiResetter } from './useRpgRuntimeInteractionFlow';
|
||||
|
||||
describe('useRpgRuntimeInteractionFlow helpers', () => {
|
||||
it('clears interaction ui in the expected order', () => {
|
||||
const calls: string[] = [];
|
||||
const clearStoryInteractionUi = createRpgRuntimeInteractionUiResetter({
|
||||
clearStoryChoiceUi: vi.fn(() => calls.push('choice')),
|
||||
clearNpcInteractionUi: vi.fn(() => calls.push('npc')),
|
||||
});
|
||||
|
||||
clearStoryInteractionUi();
|
||||
|
||||
expect(calls).toEqual(['choice', 'npc']);
|
||||
});
|
||||
});
|
||||
289
src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.ts
Normal file
289
src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import { useTreasureFlow } from '../useTreasureFlow';
|
||||
import { useStoryInventoryActions } from './inventoryActions';
|
||||
import { useStoryNpcInteractionFlow } from './npcInteraction';
|
||||
import type {
|
||||
ChoiceRuntimeController,
|
||||
ChoiceRuntimeSupport,
|
||||
StoryChoiceCoordinatorParams,
|
||||
} from './storyChoiceCoordinator';
|
||||
import type { StoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
|
||||
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
|
||||
import { createRpgRuntimeNpcEncounterActions } from './useRpgRuntimeNpcInteraction';
|
||||
import { useStoryChoiceCoordinator } from './useStoryChoiceCoordinator';
|
||||
|
||||
type RpgRuntimeInteractionFlowParams = {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
interactionConfig: StoryInteractionCoordinatorConfig;
|
||||
runtimeSupport: StoryRuntimeSupport;
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: ResolvedChoicePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
buildStoryFromResponse: ChoiceRuntimeController['buildStoryFromResponse'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getCampCompanionTravelScene: StoryChoiceCoordinatorParams['runtimeController']['getCampCompanionTravelScene'];
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
npcPreviewTalkFunctionId: string;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
};
|
||||
|
||||
export function createClearStoryInteractionUi(params: {
|
||||
clearStoryChoiceUi: () => void;
|
||||
clearNpcInteractionUi: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
params.clearStoryChoiceUi();
|
||||
params.clearNpcInteractionUi();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG runtime 交互分发层。
|
||||
* 统一串起宝箱、背包、NPC 交互与 story choice 的正式分发。
|
||||
*/
|
||||
export function useRpgRuntimeInteractionFlow({
|
||||
gameState,
|
||||
isLoading,
|
||||
interactionConfig,
|
||||
runtimeSupport,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryFromResponse,
|
||||
getResolvedSceneHostileNpcs,
|
||||
getCampCompanionTravelScene,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
}: RpgRuntimeInteractionFlowParams) {
|
||||
const { handleTreasureInteraction } = useTreasureFlow(
|
||||
interactionConfig.treasureFlow,
|
||||
);
|
||||
const { inventoryUi } = useStoryInventoryActions(
|
||||
interactionConfig.inventoryFlow,
|
||||
);
|
||||
const npcInteractionFlow = useStoryNpcInteractionFlow(
|
||||
interactionConfig.npcInteractionFlow,
|
||||
);
|
||||
const {
|
||||
enterNpcInteraction,
|
||||
handleNpcInteraction,
|
||||
finalizeNpcBattleResult,
|
||||
reopenNpcChatAfterBattle,
|
||||
handleNpcChatTurn,
|
||||
exitNpcChat,
|
||||
replacePendingNpcQuestOffer,
|
||||
abandonPendingNpcQuestOffer,
|
||||
acceptPendingNpcQuestOffer,
|
||||
} = createRpgRuntimeNpcEncounterActions({
|
||||
...interactionConfig.npcEncounterActions,
|
||||
buildNpcStory: runtimeSupport.buildNpcStory,
|
||||
npcInteractionFlow,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || gameState.inBattle || gameState.npcInteractionActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNpcEncounter(gameState.currentEncounter)) {
|
||||
enterNpcInteraction(
|
||||
gameState.currentEncounter,
|
||||
`与${gameState.currentEncounter.npcName}搭话`,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
enterNpcInteraction,
|
||||
gameState.currentEncounter,
|
||||
gameState.inBattle,
|
||||
gameState.npcInteractionActive,
|
||||
isLoading,
|
||||
isNpcEncounter,
|
||||
]);
|
||||
|
||||
const choiceRuntimeController: Parameters<
|
||||
typeof useStoryChoiceCoordinator
|
||||
>[0]['runtimeController'] = {
|
||||
currentStory: interactionConfig.npcEncounterActions.currentStory,
|
||||
buildStoryContextFromState:
|
||||
interactionConfig.npcEncounterActions.buildStoryContextFromState,
|
||||
buildStoryFromResponse: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog?: StoryOption[] | null,
|
||||
) =>
|
||||
buildStoryFromResponse(
|
||||
state,
|
||||
character,
|
||||
response,
|
||||
availableOptions,
|
||||
optionCatalog,
|
||||
),
|
||||
buildFallbackStoryForState:
|
||||
interactionConfig.npcEncounterActions.buildFallbackStoryForState,
|
||||
generateStoryForState: async (params) =>
|
||||
interactionConfig.npcEncounterActions.generateStoryForState(params),
|
||||
getAvailableOptionsForState:
|
||||
interactionConfig.npcEncounterActions.getAvailableOptionsForState,
|
||||
getCampCompanionTravelScene: (state, character) =>
|
||||
getCampCompanionTravelScene(state, character),
|
||||
commitGeneratedStateWithEncounterEntry: async (
|
||||
entryState,
|
||||
resolvedState,
|
||||
character,
|
||||
actionText,
|
||||
resultText,
|
||||
lastFunctionId,
|
||||
) => {
|
||||
await interactionConfig.npcEncounterActions.commitGeneratedStateWithEncounterEntry(
|
||||
entryState,
|
||||
resolvedState,
|
||||
character,
|
||||
actionText,
|
||||
resultText,
|
||||
lastFunctionId,
|
||||
);
|
||||
},
|
||||
};
|
||||
const choiceRuntimeSupport: ChoiceRuntimeSupport = {
|
||||
...runtimeSupport,
|
||||
handleNpcBattleConversationContinuation: ({
|
||||
nextState,
|
||||
encounter,
|
||||
actionText,
|
||||
resultText,
|
||||
battleMode,
|
||||
}) =>
|
||||
reopenNpcChatAfterBattle({
|
||||
nextState,
|
||||
encounter,
|
||||
actionText,
|
||||
resultText,
|
||||
battleMode,
|
||||
}),
|
||||
};
|
||||
const { handleChoice, battleRewardUi, clearStoryChoiceUi } =
|
||||
useStoryChoiceCoordinator({
|
||||
gameState,
|
||||
isLoading,
|
||||
setGameState: interactionConfig.npcEncounterActions.setGameState,
|
||||
setCurrentStory: interactionConfig.npcEncounterActions.setCurrentStory,
|
||||
setAiError: interactionConfig.npcEncounterActions.setAiError,
|
||||
setIsLoading: interactionConfig.npcEncounterActions.setIsLoading,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
getStoryGenerationHostileNpcs:
|
||||
interactionConfig.npcEncounterActions.getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
runtimeController: choiceRuntimeController,
|
||||
runtimeSupport: choiceRuntimeSupport,
|
||||
enterNpcInteraction,
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction,
|
||||
finalizeNpcBattleResult,
|
||||
sortOptions: interactionConfig.npcEncounterActions.sortOptions,
|
||||
buildContinueAdventureOption:
|
||||
interactionConfig.npcEncounterActions.buildContinueAdventureOption,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
});
|
||||
|
||||
const clearStoryInteractionUi = useCallback(
|
||||
createClearStoryInteractionUi({
|
||||
clearStoryChoiceUi,
|
||||
clearNpcInteractionUi: npcInteractionFlow.clearNpcInteractionUi,
|
||||
}),
|
||||
[clearStoryChoiceUi, npcInteractionFlow.clearNpcInteractionUi],
|
||||
);
|
||||
|
||||
return {
|
||||
handleChoice,
|
||||
battleRewardUi,
|
||||
npcUi: npcInteractionFlow.npcUi,
|
||||
inventoryUi,
|
||||
clearStoryInteractionUi,
|
||||
handleNpcChatInput: (input: string) => {
|
||||
const encounter = interactionConfig.npcEncounterActions.gameState.currentEncounter;
|
||||
if (!encounter || encounter.kind !== 'npc') {
|
||||
return false;
|
||||
}
|
||||
|
||||
void handleNpcChatTurn(encounter, input);
|
||||
return true;
|
||||
},
|
||||
refreshNpcChatOptions: () => {
|
||||
const story = interactionConfig.npcEncounterActions.currentStory;
|
||||
const encounter = interactionConfig.npcEncounterActions.gameState.currentEncounter;
|
||||
if (!story?.npcChatState || !story.options.length || !encounter || encounter.kind !== 'npc') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [firstOption, ...restOptions] = story.options;
|
||||
if (!firstOption || restOptions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
interactionConfig.npcEncounterActions.setCurrentStory({
|
||||
...story,
|
||||
options: [...restOptions, firstOption],
|
||||
});
|
||||
return true;
|
||||
},
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi: {
|
||||
replacePendingOffer: replacePendingNpcQuestOffer,
|
||||
abandonPendingOffer: abandonPendingNpcQuestOffer,
|
||||
acceptPendingOffer: acceptPendingNpcQuestOffer,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type UseRpgRuntimeInteractionFlowParams = Parameters<
|
||||
typeof useRpgRuntimeInteractionFlow
|
||||
>[0];
|
||||
export type RpgRuntimeInteractionFlowResult = ReturnType<
|
||||
typeof useRpgRuntimeInteractionFlow
|
||||
>;
|
||||
|
||||
export const createRpgRuntimeInteractionUiResetter =
|
||||
createClearStoryInteractionUi;
|
||||
2165
src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts
Normal file
2165
src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts
Normal file
File diff suppressed because it is too large
Load Diff
153
src/hooks/rpg-runtime-story/useRpgRuntimeStory.ts
Normal file
153
src/hooks/rpg-runtime-story/useRpgRuntimeStory.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
buildContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isContinueAdventureOption,
|
||||
NPC_PREVIEW_TALK_FUNCTION,
|
||||
} from '../../data/functionCatalog';
|
||||
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
|
||||
import type { GameState, StoryOption } from '../../types';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import { useCharacterChatFlow } from './characterChat';
|
||||
import { buildStoryContextFromState } from './storyContextBuilder';
|
||||
import {
|
||||
getResolvedSceneHostileNpcs,
|
||||
getStoryGenerationHostileNpcs,
|
||||
isNpcEncounter,
|
||||
isRegularNpcEncounter,
|
||||
} from './storyEncounterState';
|
||||
import {
|
||||
resolveNpcInteractionDecision,
|
||||
} from './storyGenerationState';
|
||||
import { storyRuntimeSupport } from './storyRuntimeSupport';
|
||||
import type { BattleRewardUi, QuestFlowUi } from './uiTypes';
|
||||
import { useRpgRuntimeStoryController } from './useRpgRuntimeStoryController';
|
||||
import { useRpgRuntimeStoryFlow } from './useRpgRuntimeStoryFlow';
|
||||
|
||||
const TURN_VISUAL_MS = 820;
|
||||
const NPC_PREVIEW_TALK_FUNCTION_ID = NPC_PREVIEW_TALK_FUNCTION.id;
|
||||
const FALLBACK_COMPANION_NAME = '同伴';
|
||||
|
||||
export type {
|
||||
CharacterChatModalState,
|
||||
CharacterChatTarget,
|
||||
CharacterChatUi,
|
||||
} from './characterChat';
|
||||
export type {
|
||||
BattleRewardSummary,
|
||||
BattleRewardUi,
|
||||
GiftModalState,
|
||||
GoalFlowUi,
|
||||
InventoryFlowUi,
|
||||
NpcChatQuestOfferUi,
|
||||
QuestFlowUi,
|
||||
RecruitModalState,
|
||||
StoryGenerationNpcUi,
|
||||
TradeModalState,
|
||||
} from './uiTypes';
|
||||
|
||||
/**
|
||||
* RPG runtime story 顶层装配入口。
|
||||
* 这里负责收口角色聊天、story controller 与 story flow 三层能力,
|
||||
* 让运行态主链直接消费 RPG 域命名,不再保留旧 story hook 入口。
|
||||
*/
|
||||
export function useRpgRuntimeStory({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: NonNullable<GameState['playerCharacter']>,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: NonNullable<GameState['playerCharacter']>,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: ResolvedChoicePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
}) {
|
||||
const { characterChatUi, clearCharacterChatModal } = useCharacterChatFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildStoryContextFromState,
|
||||
});
|
||||
|
||||
const runtimeController = useRpgRuntimeStoryController({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildStoryContextFromState,
|
||||
});
|
||||
|
||||
const {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
handleChoice,
|
||||
resetStoryState,
|
||||
hydrateStoryState,
|
||||
travelToSceneFromMap,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
goalUi,
|
||||
npcUi,
|
||||
inventoryUi,
|
||||
handleNpcChatInput,
|
||||
refreshNpcChatOptions,
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi,
|
||||
} = useRpgRuntimeStoryFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
runtimeController,
|
||||
runtimeSupport: storyRuntimeSupport,
|
||||
sortOptions: sortStoryOptionsByPriority,
|
||||
buildContinueAdventureOption,
|
||||
resolveNpcInteractionDecision,
|
||||
clearCharacterChatModal,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId: NPC_PREVIEW_TALK_FUNCTION_ID,
|
||||
fallbackCompanionName: FALLBACK_COMPANION_NAME,
|
||||
turnVisualMs: TURN_VISUAL_MS,
|
||||
});
|
||||
|
||||
return {
|
||||
currentStory: runtimeController.currentStory,
|
||||
isLoading: runtimeController.isLoading,
|
||||
aiError: runtimeController.aiError,
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
handleChoice,
|
||||
resetStoryState,
|
||||
hydrateStoryState,
|
||||
travelToSceneFromMap,
|
||||
battleRewardUi: battleRewardUi satisfies BattleRewardUi,
|
||||
questUi: questUi satisfies QuestFlowUi,
|
||||
goalUi,
|
||||
npcUi,
|
||||
characterChatUi,
|
||||
inventoryUi,
|
||||
handleNpcChatInput,
|
||||
refreshNpcChatOptions,
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseRpgRuntimeStoryParams = Parameters<typeof useRpgRuntimeStory>[0];
|
||||
export type RpgRuntimeStoryResult = ReturnType<typeof useRpgRuntimeStory>;
|
||||
137
src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts
Normal file
137
src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
import { generateInitialStory, generateNextStep } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import {
|
||||
appendStoryHistory,
|
||||
createStoryProgressionActions,
|
||||
} from './progressionActions';
|
||||
import {
|
||||
createStoryStateResolvers,
|
||||
getStoryGenerationHostileNpcs,
|
||||
} from './storyEncounterState';
|
||||
import {
|
||||
buildDialogueStoryMoment,
|
||||
buildStoryFromResponse as buildStoryFromResponseFromPresentation,
|
||||
getTypewriterDelay,
|
||||
} from './storyPresentation';
|
||||
import { buildNpcStory } from './storyRuntimeSupport';
|
||||
import { createGenerateStoryForState } from './storyRequestRuntime';
|
||||
import type { StoryContextBuilderExtras } from './storyContextBuilder';
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: StoryContextBuilderExtras,
|
||||
) => StoryGenerationContext;
|
||||
|
||||
/**
|
||||
* RPG runtime story controller。
|
||||
* 统一管理当前故事、AI 请求状态和生成后的状态提交。
|
||||
*/
|
||||
export function useRpgRuntimeStoryController(params: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
}) {
|
||||
const { gameState, setGameState, buildStoryContextFromState } = params;
|
||||
|
||||
const [currentStory, setCurrentStory] = useState<StoryMoment | null>(null);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { getAvailableOptionsForState, buildFallbackStoryForState } = useMemo(
|
||||
() =>
|
||||
createStoryStateResolvers({
|
||||
buildNpcStory,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const buildStoryFromResponse = useCallback(
|
||||
(
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog: StoryOption[] | null = null,
|
||||
) =>
|
||||
buildStoryFromResponseFromPresentation({
|
||||
state,
|
||||
character,
|
||||
response,
|
||||
availableOptions,
|
||||
optionCatalog,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const generateStoryForState = useMemo(
|
||||
() =>
|
||||
createGenerateStoryForState({
|
||||
currentStory,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
requestInitialStory: generateInitialStory,
|
||||
requestNextStep: generateNextStep,
|
||||
onServerOptionCatalogLoadError: (error) => {
|
||||
console.warn(
|
||||
'[useRpgRuntimeStory] failed to load server runtime option catalog',
|
||||
error,
|
||||
);
|
||||
},
|
||||
}),
|
||||
[
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
currentStory,
|
||||
getAvailableOptionsForState,
|
||||
],
|
||||
);
|
||||
|
||||
const appendHistory = useCallback(appendStoryHistory, []);
|
||||
|
||||
const { commitGeneratedState, commitGeneratedStateWithEncounterEntry } =
|
||||
createStoryProgressionActions({
|
||||
gameState,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
generateStoryForState,
|
||||
buildFallbackStoryForState,
|
||||
});
|
||||
|
||||
return {
|
||||
currentStory,
|
||||
setCurrentStory,
|
||||
aiError,
|
||||
setAiError,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
preparedOpeningAdventure: null,
|
||||
startOpeningAdventure: async () => undefined,
|
||||
resetPreparedOpeningAdventure: () => undefined,
|
||||
buildStoryContextFromState,
|
||||
buildDialogueStoryMoment,
|
||||
getTypewriterDelay,
|
||||
getCampCompanionTravelScene: () => null,
|
||||
buildOpeningCampChatContext: () => ({}),
|
||||
getAvailableOptionsForState,
|
||||
buildFallbackStoryForState,
|
||||
buildStoryFromResponse,
|
||||
generateStoryForState,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseRpgRuntimeStoryControllerParams = Parameters<
|
||||
typeof useRpgRuntimeStoryController
|
||||
>[0];
|
||||
export type RpgRuntimeStoryControllerResult = ReturnType<
|
||||
typeof useRpgRuntimeStoryController
|
||||
>;
|
||||
203
src/hooks/rpg-runtime-story/useRpgRuntimeStoryFlow.ts
Normal file
203
src/hooks/rpg-runtime-story/useRpgRuntimeStoryFlow.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { Character, Encounter, GameState, StoryOption } from '../../types';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import { createStoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
|
||||
import { sanitizeStoryOptions } from './storyPresentation';
|
||||
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
|
||||
import { useRpgRuntimeInteractionFlow } from './useRpgRuntimeInteractionFlow';
|
||||
import type { RpgRuntimeStoryControllerResult } from './useRpgRuntimeStoryController';
|
||||
import { useRpgRuntimeStoryState } from './useRpgRuntimeStoryState';
|
||||
import { useStoryGoalOptionCoordinator } from './useStoryGoalOptionCoordinator';
|
||||
|
||||
type RpgRuntimeStoryFlowParams = {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: ResolvedChoicePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
runtimeController: RpgRuntimeStoryControllerResult;
|
||||
runtimeSupport: StoryRuntimeSupport;
|
||||
sortOptions: (options: StoryOption[]) => StoryOption[];
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
resolveNpcInteractionDecision: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
) => { kind: string };
|
||||
clearCharacterChatModal: () => void;
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
npcPreviewTalkFunctionId: string;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* RPG runtime story 主编排层。
|
||||
* 这里把 option 展示、正式交互分发和 story/session 状态动作收束成稳定出口。
|
||||
*/
|
||||
export function useRpgRuntimeStoryFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
runtimeController,
|
||||
runtimeSupport,
|
||||
sortOptions,
|
||||
buildContinueAdventureOption,
|
||||
resolveNpcInteractionDecision,
|
||||
clearCharacterChatModal,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
}: RpgRuntimeStoryFlowParams) {
|
||||
const {
|
||||
currentStory,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
isLoading,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState,
|
||||
getTypewriterDelay,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
buildOpeningCampChatContext,
|
||||
resetPreparedOpeningAdventure,
|
||||
} = runtimeController;
|
||||
const interactionConfig = createStoryInteractionCoordinatorConfig({
|
||||
gameState,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
currentStory,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
runtimeSupport,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
buildOpeningCampChatContext,
|
||||
sortOptions,
|
||||
buildContinueAdventureOption,
|
||||
sanitizeOptions: sanitizeStoryOptions,
|
||||
resolveNpcInteractionDecision,
|
||||
});
|
||||
const {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
goalUi,
|
||||
clearStoryGoalOptionUi,
|
||||
} = useStoryGoalOptionCoordinator({
|
||||
gameState,
|
||||
currentStory,
|
||||
});
|
||||
const {
|
||||
handleChoice,
|
||||
battleRewardUi,
|
||||
npcUi,
|
||||
inventoryUi,
|
||||
clearStoryInteractionUi,
|
||||
handleNpcChatInput,
|
||||
refreshNpcChatOptions,
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi,
|
||||
} = useRpgRuntimeInteractionFlow({
|
||||
gameState,
|
||||
isLoading,
|
||||
interactionConfig,
|
||||
runtimeSupport,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryFromResponse: runtimeController.buildStoryFromResponse,
|
||||
getResolvedSceneHostileNpcs,
|
||||
getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
});
|
||||
const { questUi, resetStoryState, hydrateStoryState, travelToSceneFromMap } =
|
||||
useRpgRuntimeStoryState({
|
||||
gameState,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
commitGeneratedState,
|
||||
buildFallbackStoryForState,
|
||||
resetPreparedOpeningAdventure,
|
||||
clearStoryGoalOptionUi,
|
||||
clearStoryInteractionUi,
|
||||
clearCharacterChatModal,
|
||||
});
|
||||
|
||||
return {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
handleChoice,
|
||||
resetStoryState,
|
||||
hydrateStoryState,
|
||||
travelToSceneFromMap,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
goalUi,
|
||||
npcUi,
|
||||
inventoryUi,
|
||||
handleNpcChatInput,
|
||||
refreshNpcChatOptions,
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseRpgRuntimeStoryFlowParams = Parameters<
|
||||
typeof useRpgRuntimeStoryFlow
|
||||
>[0];
|
||||
export type RpgRuntimeStoryFlowResult = ReturnType<
|
||||
typeof useRpgRuntimeStoryFlow
|
||||
>;
|
||||
28
src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.test.ts
Normal file
28
src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createRpgRuntimeStoryUiResetter } from './useRpgRuntimeStoryState';
|
||||
|
||||
describe('useRpgRuntimeStoryState helpers', () => {
|
||||
it('clears story runtime ui in the expected order', () => {
|
||||
const calls: string[] = [];
|
||||
const clearStoryRuntimeUi = createRpgRuntimeStoryUiResetter({
|
||||
clearStoryGoalOptionUi: vi.fn(() => calls.push('goal-option')),
|
||||
clearStoryInteractionUi: vi.fn(() => calls.push('interaction')),
|
||||
setAiError: vi.fn((value) => calls.push(`ai:${String(value)}`)),
|
||||
setIsLoading: vi.fn((value) => calls.push(`loading:${String(value)}`)),
|
||||
resetPreparedOpeningAdventure: vi.fn(() => calls.push('opening')),
|
||||
clearCharacterChatModal: vi.fn(() => calls.push('chat')),
|
||||
});
|
||||
|
||||
clearStoryRuntimeUi();
|
||||
|
||||
expect(calls).toEqual([
|
||||
'goal-option',
|
||||
'interaction',
|
||||
'ai:null',
|
||||
'loading:false',
|
||||
'opening',
|
||||
'chat',
|
||||
]);
|
||||
});
|
||||
});
|
||||
103
src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.ts
Normal file
103
src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useCallback, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
import type { StoryMoment, GameState, Character } from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
import { createStorySessionActions } from './sessionActions';
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
export function createClearStoryRuntimeUi(params: {
|
||||
clearStoryGoalOptionUi: () => void;
|
||||
clearStoryInteractionUi: () => void;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
resetPreparedOpeningAdventure: () => void;
|
||||
clearCharacterChatModal: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
params.clearStoryGoalOptionUi();
|
||||
params.clearStoryInteractionUi();
|
||||
params.setAiError(null);
|
||||
params.setIsLoading(false);
|
||||
params.resetPreparedOpeningAdventure();
|
||||
params.clearCharacterChatModal();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG runtime story 状态层。
|
||||
* 负责 story reset、hydration、地图跳转,以及 quest 领取/确认 UI 的收口。
|
||||
*/
|
||||
export function useRpgRuntimeStoryState(params: {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
resetPreparedOpeningAdventure: () => void;
|
||||
clearStoryGoalOptionUi: () => void;
|
||||
clearStoryInteractionUi: () => void;
|
||||
clearCharacterChatModal: () => void;
|
||||
}) {
|
||||
const clearStoryRuntimeUi = useCallback(
|
||||
createClearStoryRuntimeUi({
|
||||
clearStoryGoalOptionUi: params.clearStoryGoalOptionUi,
|
||||
clearStoryInteractionUi: params.clearStoryInteractionUi,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
resetPreparedOpeningAdventure: params.resetPreparedOpeningAdventure,
|
||||
clearCharacterChatModal: params.clearCharacterChatModal,
|
||||
}),
|
||||
[
|
||||
params.clearCharacterChatModal,
|
||||
params.clearStoryGoalOptionUi,
|
||||
params.clearStoryInteractionUi,
|
||||
params.resetPreparedOpeningAdventure,
|
||||
params.setAiError,
|
||||
params.setIsLoading,
|
||||
],
|
||||
);
|
||||
|
||||
const {
|
||||
acknowledgeQuestCompletion,
|
||||
claimQuestReward,
|
||||
resetStoryState,
|
||||
hydrateStoryState,
|
||||
travelToSceneFromMap,
|
||||
} = createStorySessionActions({
|
||||
gameState: params.gameState,
|
||||
isLoading: params.isLoading,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
clearStoryRuntimeUi,
|
||||
commitGeneratedState: params.commitGeneratedState,
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
});
|
||||
|
||||
return {
|
||||
questUi: {
|
||||
acknowledgeQuestCompletion,
|
||||
claimQuestReward,
|
||||
},
|
||||
resetStoryState,
|
||||
hydrateStoryState,
|
||||
travelToSceneFromMap,
|
||||
clearStoryRuntimeUi,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseRpgRuntimeStoryStateParams = Parameters<
|
||||
typeof useRpgRuntimeStoryState
|
||||
>[0];
|
||||
export type RpgRuntimeStoryStateResult = ReturnType<
|
||||
typeof useRpgRuntimeStoryState
|
||||
>;
|
||||
|
||||
export const createRpgRuntimeStoryUiResetter = createClearStoryRuntimeUi;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createClearStoryChoiceUi } from './useStoryChoiceCoordinator';
|
||||
|
||||
describe('useStoryChoiceCoordinator helpers', () => {
|
||||
it('clears choice ui by dismissing battle reward', () => {
|
||||
const calls: string[] = [];
|
||||
const clearStoryChoiceUi = createClearStoryChoiceUi({
|
||||
clearBattleReward: vi.fn(() => calls.push('battle')),
|
||||
});
|
||||
|
||||
clearStoryChoiceUi();
|
||||
|
||||
expect(calls).toEqual(['battle']);
|
||||
});
|
||||
});
|
||||
136
src/hooks/rpg-runtime-story/useStoryChoiceCoordinator.ts
Normal file
136
src/hooks/rpg-runtime-story/useStoryChoiceCoordinator.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { createStoryChoiceActions } from './choiceActions';
|
||||
import {
|
||||
createStoryChoiceCoordinatorConfig,
|
||||
type ChoiceRuntimeController,
|
||||
type ChoiceRuntimeSupport,
|
||||
} from './storyChoiceCoordinator';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||||
|
||||
type StoryChoiceCoordinatorParams = {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
setGameState: Parameters<typeof createStoryChoiceActions>[0]['setGameState'];
|
||||
setCurrentStory: Parameters<
|
||||
typeof createStoryChoiceActions
|
||||
>[0]['setCurrentStory'];
|
||||
setAiError: Parameters<typeof createStoryChoiceActions>[0]['setAiError'];
|
||||
setIsLoading: Parameters<typeof createStoryChoiceActions>[0]['setIsLoading'];
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: ResolvedChoicePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
runtimeController: ChoiceRuntimeController & {
|
||||
currentStory: StoryMoment | null;
|
||||
};
|
||||
runtimeSupport: ChoiceRuntimeSupport;
|
||||
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
|
||||
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
|
||||
handleTreasureInteraction: (
|
||||
option: StoryOption,
|
||||
) => void | Promise<void> | boolean | Promise<boolean>;
|
||||
finalizeNpcBattleResult: Parameters<
|
||||
typeof createStoryChoiceCoordinatorConfig
|
||||
>[0]['finalizeNpcBattleResult'];
|
||||
sortOptions: (options: StoryOption[]) => StoryOption[];
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
npcPreviewTalkFunctionId: string;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
};
|
||||
|
||||
export function createClearStoryChoiceUi(params: {
|
||||
clearBattleReward: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
params.clearBattleReward();
|
||||
};
|
||||
}
|
||||
|
||||
export function useStoryChoiceCoordinator(
|
||||
params: StoryChoiceCoordinatorParams,
|
||||
) {
|
||||
const [battleReward, setBattleReward] = useState<BattleRewardSummary | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions(
|
||||
createStoryChoiceCoordinatorConfig({
|
||||
gameState: params.gameState,
|
||||
currentStory: params.runtimeController.currentStory,
|
||||
isLoading: params.isLoading,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
setBattleReward,
|
||||
buildResolvedChoiceState: params.buildResolvedChoiceState,
|
||||
playResolvedChoice: params.playResolvedChoice,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
|
||||
runtimeController: params.runtimeController,
|
||||
runtimeSupport: params.runtimeSupport,
|
||||
enterNpcInteraction: params.enterNpcInteraction,
|
||||
handleNpcInteraction: params.handleNpcInteraction,
|
||||
handleTreasureInteraction: params.handleTreasureInteraction,
|
||||
finalizeNpcBattleResult: params.finalizeNpcBattleResult,
|
||||
sortOptions: params.sortOptions,
|
||||
buildContinueAdventureOption: params.buildContinueAdventureOption,
|
||||
isContinueAdventureOption: params.isContinueAdventureOption,
|
||||
isCampTravelHomeOption: params.isCampTravelHomeOption,
|
||||
isRegularNpcEncounter: params.isRegularNpcEncounter,
|
||||
isNpcEncounter: params.isNpcEncounter,
|
||||
npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName: params.fallbackCompanionName,
|
||||
turnVisualMs: params.turnVisualMs,
|
||||
}),
|
||||
);
|
||||
|
||||
const clearStoryChoiceUi = useCallback(
|
||||
createClearStoryChoiceUi({
|
||||
clearBattleReward: () => setBattleReward(null),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
handleChoice,
|
||||
battleRewardUi: {
|
||||
reward: battleReward,
|
||||
dismiss: () => setBattleReward(null),
|
||||
},
|
||||
clearStoryChoiceUi,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createClearStoryGoalOptionUi } from './useStoryGoalOptionCoordinator';
|
||||
|
||||
describe('useStoryGoalOptionCoordinator helpers', () => {
|
||||
it('clears story goal and option ui together', () => {
|
||||
const calls: string[] = [];
|
||||
const clearStoryGoalOptionUi = createClearStoryGoalOptionUi({
|
||||
resetStoryOptions: vi.fn(() => calls.push('options')),
|
||||
resetGoalPulseTracking: vi.fn(() => calls.push('goal')),
|
||||
});
|
||||
|
||||
clearStoryGoalOptionUi();
|
||||
|
||||
expect(calls).toEqual(['options', 'goal']);
|
||||
});
|
||||
});
|
||||
50
src/hooks/rpg-runtime-story/useStoryGoalOptionCoordinator.ts
Normal file
50
src/hooks/rpg-runtime-story/useStoryGoalOptionCoordinator.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { GameState, StoryMoment } from '../../types';
|
||||
import { useStoryOptions } from '../useStoryOptions';
|
||||
import { useStoryGoalFlow } from './goalFlow';
|
||||
|
||||
export function createClearStoryGoalOptionUi(params: {
|
||||
resetStoryOptions: () => void;
|
||||
resetGoalPulseTracking: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
params.resetStoryOptions();
|
||||
params.resetGoalPulseTracking();
|
||||
};
|
||||
}
|
||||
|
||||
export function useStoryGoalOptionCoordinator(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
}) {
|
||||
const { runtimeGoalStack, goalUi, resetGoalPulseTracking } = useStoryGoalFlow(
|
||||
params.gameState,
|
||||
);
|
||||
const {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
resetStoryOptions,
|
||||
} = useStoryOptions(params.currentStory, runtimeGoalStack);
|
||||
|
||||
const clearStoryGoalOptionUi = useCallback(
|
||||
createClearStoryGoalOptionUi({
|
||||
resetStoryOptions,
|
||||
resetGoalPulseTracking,
|
||||
}),
|
||||
[resetGoalPulseTracking, resetStoryOptions],
|
||||
);
|
||||
|
||||
return {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
goalUi,
|
||||
clearStoryGoalOptionUi,
|
||||
};
|
||||
}
|
||||
|
||||
export type StoryGoalOptionCoordinatorResult = ReturnType<
|
||||
typeof useStoryGoalOptionCoordinator
|
||||
>;
|
||||
Reference in New Issue
Block a user