init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View 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),
};
}

View 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 }),
);
});
});

View 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,
};
}

View 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,
};
}

View 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';

View 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,
};
}

File diff suppressed because it is too large Load Diff

View 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,
};
}

View 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,
};
}

View 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;

View 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',
}),
);
});
});

View File

@@ -0,0 +1,7 @@
export {
loadRpgRuntimeOptionCatalog as loadServerRuntimeOptionCatalog,
resolveRpgRuntimeChoice as resolveServerRuntimeChoice,
resumeRpgRuntimeStory as resumeServerRuntimeStory,
type LoadRpgRuntimeOptionCatalogParams,
type ResolveRpgRuntimeChoiceParams,
} from './rpgRuntimeStoryGateway';

View 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');
});
});

View 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,
};
}

View 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);
}
}

View 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();
});
});

View 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,
};
}

View 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);
});
});

View 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)));
}

View 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,
});
}

View 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);
});
});

View 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,
};
}

View 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);
});
});

View 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,
};
}

View 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,
}),
);
});
});

View 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
>;

View 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);
});
});

View 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;
}

View 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', '顺势追问')],
});
});
});

View 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,
);
}

View 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: [],
});
});
});

View 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,
});
}

View 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([
'继续交谈',
'看看能交换什么',
]);
});
});

View 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());
}

View 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);
});
});

View 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;

View 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;
}

View File

@@ -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']);
});
});

View 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;

File diff suppressed because it is too large Load Diff

View 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>;

View 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
>;

View 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
>;

View 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',
]);
});
});

View 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;

View File

@@ -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']);
});
});

View 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,
};
}

View File

@@ -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']);
});
});

View 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
>;