Split custom world generation into staged lightweight batches
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-05 22:20:30 +08:00
parent 89cecda7da
commit fcd8d727b0
57 changed files with 7646 additions and 1425 deletions

View File

@@ -0,0 +1,322 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../services/ai', () => ({
generateNextStep: vi.fn(),
}));
import { generateNextStep } from '../../services/ai';
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,
sceneMonsters: [],
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', () => {
it('keeps the finishing action in history before npc victory follow-up generation', async () => {
const state = createBaseState();
const option = createBattleOption();
const afterSequence = {
...state,
inBattle: false,
sceneMonsters: [],
sceneHostileNpcs: [],
currentNpcBattleOutcome: 'fight_victory' as const,
};
const generateStoryForState = vi.fn().mockResolvedValue(createFallbackStory('战后续写'));
const setCurrentStory = vi.fn();
const setGameState = vi.fn();
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory: createFallbackStory(),
isLoading: false,
setGameState,
setCurrentStory,
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState: vi.fn(() => ({
optionKind: 'battle' as const,
battlePlan: null,
afterSequence,
})),
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
buildStoryContextFromState: vi.fn(() => ({
playerHp: 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.sceneMonsters),
buildNpcStory: vi.fn(() => createFallbackStory()),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
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),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(option);
expect(generateStoryForState).toHaveBeenCalledTimes(1);
const [{ history }] = generateStoryForState.mock.calls[0] as [
{ history: StoryMoment[] },
];
expect(history.map((entry) => `${entry.historyRole}:${entry.text}`)).toEqual([
'action:挥刀抢攻',
'result:山道客已经败下阵来。胜利奖励:无战利品。',
]);
expect(setCurrentStory).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,
sceneMonsters: [
{
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,
playerX: -1.2,
};
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(() => ({
optionKind: 'escape' as const,
battlePlan: null,
afterSequence,
})),
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
buildStoryContextFromState: vi.fn(() => ({
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
inBattle: false,
playerX: -1.2,
playerFacing: 'right',
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
})),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneMonsters),
buildNpcStory: vi.fn(() => createFallbackStory()),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
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),
isInitialCompanionEncounter: neverNpcEncounter,
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:你已经摆脱与山狼的交战,暂时把对方甩在身后,当前不再处于战斗状态。',
]);
});
});

View File

@@ -15,6 +15,7 @@ import {
createSceneEncounterPreview,
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { generateNextStep } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { createHistoryMoment } from '../../services/storyHistory';
@@ -89,6 +90,51 @@ function buildReasonedOptionCatalog(options: StoryOption[]) {
});
}
function buildCombatResolutionContextText(params: {
baseState: GameState;
afterSequence: GameState;
optionKind: ResolvedChoiceState['optionKind'];
projectedBattleReward: BattleRewardSummary | null;
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneMonsters'];
}) {
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}`;
}
function buildHostileNpcBattleReward(
state: GameState,
afterSequence: GameState,
@@ -227,6 +273,7 @@ export function createStoryChoiceActions({
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
@@ -251,6 +298,7 @@ export function createStoryChoiceActions({
currentScenePreset: targetScene,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
@@ -389,6 +437,20 @@ export function createStoryChoiceActions({
projectedStateWithBattleReward,
character,
);
const combatResolutionContextText = buildCombatResolutionContextText({
baseState: baseChoiceState,
afterSequence: projectedStateWithBattleReward,
optionKind: resolvedChoice.optionKind,
projectedBattleReward,
getResolvedSceneHostileNpcs,
});
const historyForStoryGeneration = combatResolutionContextText
? [
...history,
createHistoryMoment(option.actionText, 'action'),
createHistoryMoment(combatResolutionContextText, 'result'),
]
: history;
const responsePromise = shouldUseLocalNpcVictory
? Promise.resolve(null)
@@ -396,7 +458,7 @@ export function createStoryChoiceActions({
gameState.worldType,
character,
getStoryGenerationHostileNpcs(projectedStateWithBattleReward),
history,
historyForStoryGeneration,
option.actionText,
buildStoryContextFromState(projectedStateWithBattleReward, {
lastFunctionId: option.functionId,
@@ -443,6 +505,7 @@ export function createStoryChoiceActions({
: baseChoiceState.storyHistory;
const nextHistory = [
...historyBase,
createHistoryMoment(option.actionText, 'action'),
createHistoryMoment(victory.resultText, 'result'),
];
const nextState = {
@@ -469,6 +532,8 @@ export function createStoryChoiceActions({
lastFunctionId: option.functionId,
optionCatalog: postBattleOptionCatalog,
});
const recoveredState = applyStoryReasoningRecovery(nextState);
setGameState(recoveredState);
setCurrentStory(nextStory);
} catch (storyError) {
console.error('Failed to continue npc battle resolution story:', storyError);
@@ -489,11 +554,16 @@ export function createStoryChoiceActions({
: getResolvedSceneHostileNpcs(baseChoiceState)
.map(hostileNpc => hostileNpc.id)
.filter(hostileNpcId => !getResolvedSceneHostileNpcs(afterSequence).some(hostileNpc => hostileNpc.id === hostileNpcId));
const nextHistory = [
...baseChoiceState.storyHistory,
createHistoryMoment(option.actionText, 'action'),
createHistoryMoment(response.storyText, 'result', response.options),
];
const nextHistory = combatResolutionContextText
? [
...historyForStoryGeneration,
createHistoryMoment(response.storyText, 'result', response.options),
]
: [
...baseChoiceState.storyHistory,
createHistoryMoment(option.actionText, 'action'),
createHistoryMoment(response.storyText, 'result', response.options),
];
const nextState = incrementRuntimeStats({
...updateQuestLog(
@@ -515,14 +585,15 @@ export function createStoryChoiceActions({
hostileNpcsDefeated: defeatedHostileNpcIds.length,
});
setGameState(nextState);
const recoveredState = applyStoryReasoningRecovery(nextState);
setGameState(recoveredState);
if (projectedBattleReward) {
setBattleReward(projectedBattleReward);
}
setCurrentStory(
buildStoryFromResponse(
nextState,
recoveredState,
character,
{
text: response.storyText,

View File

@@ -6,6 +6,7 @@ import { NPC_PREVIEW_TALK_FUNCTION } from '../../data/functionCatalog';
import {
addInventoryItems,
buildNpcChatResultText,
buildNpcHelpCommitActionText,
buildNpcHelpResultText,
buildNpcHelpReward,
buildNpcLeaveResultText,
@@ -35,9 +36,11 @@ import {
createSceneCallOutEncounter,
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { generateNextStep, streamNpcChatDialogue } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { generateQuestForNpcEncounter } from '../../services/questDirector';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
Character,
Encounter,
@@ -60,6 +63,15 @@ type CommitGeneratedStateWithEncounterEntry = (
lastFunctionId?: string,
) => Promise<void> | void;
type GenerateStoryForState = (params: {
state: GameState;
character: Character;
history: StoryMoment[];
choice?: string;
lastFunctionId?: string | null;
optionCatalog?: StoryOption[] | null;
}) => Promise<StoryMoment>;
type NpcInteractionFlowActions = {
openTradeModal: (encounter: Encounter, actionText: string) => void;
openGiftModal: (encounter: Encounter, actionText: string) => void;
@@ -108,6 +120,7 @@ export function createStoryNpcEncounterActions({
buildStoryContextFromState,
buildFallbackStoryForState,
buildDialogueStoryMoment,
generateStoryForState,
getStoryGenerationHostileNpcs,
getTypewriterDelay,
getAvailableOptionsForState,
@@ -153,6 +166,7 @@ export function createStoryNpcEncounterActions({
options: StoryOption[],
streaming?: boolean,
) => StoryMoment;
generateStoryForState: GenerateStoryForState;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneMonsters'];
@@ -218,6 +232,10 @@ export function createStoryNpcEncounterActions({
const battleNpcId = state.currentBattleNpcId;
const npcState = state.npcStates[battleNpcId];
if (!npcState) return null;
const activeBattleHostiles =
state.sceneMonsters.length > 0
? state.sceneMonsters
: (state.sceneHostileNpcs ?? []);
if (battleMode === 'spar' && battleOutcome === 'spar_complete') {
const nextAffinity = npcState.affinity + NPC_SPAR_AFFINITY_GAIN;
@@ -233,6 +251,7 @@ export function createStoryNpcEncounterActions({
currentNpcBattleOutcome: null,
currentEncounter: restoredEncounter,
npcInteractionActive: true,
sceneMonsters: [],
sceneHostileNpcs: [],
npcStates: {
...state.npcStates,
@@ -260,6 +279,7 @@ export function createStoryNpcEncounterActions({
return {
nextState,
resultText: buildNpcSparResultText(
activeBattleHostiles[0]?.name ?? '对方',
NPC_SPAR_AFFINITY_GAIN,
nextAffinity,
),
@@ -269,9 +289,9 @@ export function createStoryNpcEncounterActions({
const lootItems = getNpcLootItems(npcState, character).map((item) =>
cloneInventoryItemForOwner(item, 'player'),
);
const defeatedHostileNpcIds = (
state.sceneHostileNpcs ?? state.sceneMonsters
).map((hostileNpc) => hostileNpc.id);
const defeatedHostileNpcIds = activeBattleHostiles.map(
(hostileNpc) => hostileNpc.id,
);
const progressedQuests = applyQuestProgressFromHostileNpcDefeat(
state.quests,
state.currentScenePreset?.id ?? null,
@@ -291,6 +311,7 @@ export function createStoryNpcEncounterActions({
currentNpcBattleOutcome: null,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerInventory: addInventoryItems(state.playerInventory, lootItems),
quests: progressedQuests,
@@ -324,9 +345,13 @@ export function createStoryNpcEncounterActions({
lootItems.length > 0
? lootItems.map((item) => item.name).join(', ')
: '无战利品';
const defeatedNames =
activeBattleHostiles.map((hostileNpc) => hostileNpc.name).join('、') ||
battleNpcId ||
'对手';
return {
nextState,
resultText: `胜利奖励:${lootText}`,
resultText: `${defeatedNames}已经败下阵来。胜利奖励:${lootText}`,
};
};
@@ -337,7 +362,11 @@ export function createStoryNpcEncounterActions({
actionText: string,
resultText: string,
lastFunctionId?: string,
contextNpcStateOverride?: GameState['npcStates'][string] | null,
options: {
contextNpcStateOverride?: GameState['npcStates'][string] | null;
preserveResultTextInHistory?: boolean;
revealMode?: 'deferred_options' | 'immediate_story';
} = {},
) => {
const provisionalHistory = appendHistory(gameState, actionText, resultText);
const provisionalState = {
@@ -391,11 +420,11 @@ export function createStoryNpcEncounterActions({
character,
encounter,
getStoryGenerationHostileNpcs(provisionalState),
gameState.storyHistory,
provisionalHistory,
buildStoryContextFromState(provisionalState, {
lastFunctionId,
...provisionalOpeningCampContext,
encounterNpcStateOverride: contextNpcStateOverride,
encounterNpcStateOverride: options.contextNpcStateOverride,
}),
actionText,
resultText,
@@ -409,19 +438,19 @@ export function createStoryNpcEncounterActions({
streamCompleted = true;
await typewriterPromise;
const finalHistory = appendHistory(
gameState,
actionText,
dialogueText || resultText,
);
const finalDialogueText = dialogueText || resultText;
const finalHistory = options.preserveResultTextInHistory
? finalDialogueText && finalDialogueText !== resultText
? [
...provisionalHistory,
createHistoryMoment(finalDialogueText, 'result'),
]
: provisionalHistory
: appendHistory(gameState, actionText, finalDialogueText);
const finalState = {
...nextState,
storyHistory: finalHistory,
};
const availableOptions = getAvailableOptionsForState(
finalState,
character,
);
const finalOpeningCampContext = buildOpeningCampChatContext(
finalState,
character,
@@ -429,6 +458,35 @@ export function createStoryNpcEncounterActions({
);
setGameState(finalState);
if (options.revealMode === 'immediate_story') {
setCurrentStory(
buildDialogueStoryMoment(
encounter.npcName,
finalDialogueText,
[],
false,
),
);
await new Promise((resolve) => window.setTimeout(resolve, 260));
const nextStory = await generateStoryForState({
state: finalState,
character,
history: finalHistory,
choice: actionText,
lastFunctionId,
});
const recoveredState = applyStoryReasoningRecovery(finalState);
setGameState(recoveredState);
setCurrentStory(nextStory);
return;
}
const availableOptions = getAvailableOptionsForState(
finalState,
character,
);
const response = await generateNextStep(
gameState.worldType!,
character,
@@ -446,6 +504,8 @@ export function createStoryNpcEncounterActions({
? response.options
: sanitizeOptions(response.options, character, finalState),
);
const recoveredState = applyStoryReasoningRecovery(finalState);
setGameState(recoveredState);
setCurrentStory({
...buildDialogueStoryMoment(
@@ -463,6 +523,12 @@ export function createStoryNpcEncounterActions({
setAiError(
error instanceof Error ? error.message : '角色对话智能生成不可用。',
);
if (options.revealMode === 'immediate_story') {
setCurrentStory(
buildFallbackStoryForState(provisionalState, character, resultText),
);
return;
}
const fallbackOptions =
getAvailableOptionsForState(provisionalState, character) ?? [];
setCurrentStory(
@@ -489,7 +555,8 @@ export function createStoryNpcEncounterActions({
};
const enterNpcInteraction = (encounter: Encounter, actionText: string) => {
if (!gameState.playerCharacter) return false;
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter) return false;
const nextState: GameState = {
...gameState,
@@ -498,7 +565,7 @@ export function createStoryNpcEncounterActions({
void commitGeneratedState(
nextState,
gameState.playerCharacter,
playerCharacter,
actionText,
`${encounter.npcName} turns their attention toward you, as if waiting for you to speak first.`,
NPC_PREVIEW_TALK_FUNCTION.id,
@@ -507,11 +574,8 @@ export function createStoryNpcEncounterActions({
};
const handleNpcInteraction = (option: StoryOption) => {
if (
!gameState.playerCharacter ||
!option.interaction ||
!isNpcEncounter(gameState.currentEncounter)
) {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !option.interaction || !isNpcEncounter(gameState.currentEncounter)) {
return false;
}
@@ -603,12 +667,19 @@ export function createStoryNpcEncounterActions({
: nextState.playerInventory,
};
await commitGeneratedState(
await commitNpcChatState(
nextState,
gameState.playerCharacter,
option.actionText,
playerCharacter,
encounter,
buildNpcHelpCommitActionText(encounter, reward),
buildNpcHelpResultText(encounter, reward),
option.functionId,
{
contextNpcStateOverride:
nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
preserveResultTextInHistory: true,
revealMode: 'immediate_story',
},
);
committed = true;
} catch (error) {
@@ -663,12 +734,19 @@ export function createStoryNpcEncounterActions({
: nextState.playerInventory,
};
await commitGeneratedState(
await commitNpcChatState(
nextState,
gameState.playerCharacter,
option.actionText,
playerCharacter,
encounter,
buildNpcHelpCommitActionText(encounter, reward),
buildNpcHelpResultText(encounter, reward),
option.functionId,
{
contextNpcStateOverride:
nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
preserveResultTextInHistory: true,
revealMode: 'immediate_story',
},
);
committed = true;
} finally {
@@ -681,7 +759,7 @@ export function createStoryNpcEncounterActions({
}
case 'chat': {
const chatOutcome = getChatAffinityOutcome({
playerCharacter: gameState.playerCharacter,
playerCharacter,
encounter,
npcState,
actionText: option.actionText,
@@ -706,7 +784,7 @@ export function createStoryNpcEncounterActions({
);
void commitNpcChatState(
nextState,
gameState.playerCharacter,
playerCharacter,
encounter,
option.actionText,
npcState.recruited
@@ -722,7 +800,9 @@ export function createStoryNpcEncounterActions({
attributeSummary,
),
option.functionId,
npcState,
{
contextNpcStateOverride: npcState,
},
);
return true;
}
@@ -765,7 +845,7 @@ export function createStoryNpcEncounterActions({
);
await commitGeneratedState(
nextState,
gameState.playerCharacter,
playerCharacter,
option.actionText,
buildQuestAcceptResultText(quest),
option.functionId,
@@ -797,7 +877,7 @@ export function createStoryNpcEncounterActions({
);
await commitGeneratedState(
nextState,
gameState.playerCharacter,
playerCharacter,
option.actionText,
buildQuestAcceptResultText(fallbackQuest),
option.functionId,
@@ -840,7 +920,7 @@ export function createStoryNpcEncounterActions({
void commitGeneratedState(
nextState,
gameState.playerCharacter,
playerCharacter,
option.actionText,
buildQuestTurnInResultText(quest),
option.functionId,
@@ -853,6 +933,7 @@ export function createStoryNpcEncounterActions({
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
@@ -878,7 +959,7 @@ export function createStoryNpcEncounterActions({
void commitGeneratedStateWithEncounterEntry(
entryState,
resolvedState,
gameState.playerCharacter,
playerCharacter,
option.actionText,
buildNpcLeaveResultText(encounter),
option.functionId,
@@ -886,6 +967,10 @@ export function createStoryNpcEncounterActions({
return true;
}
case 'fight': {
const battleMonster = createNpcBattleMonster(encounter, npcState, 'fight', {
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
});
const nextState = {
...gameState,
npcStates: {
@@ -896,9 +981,8 @@ export function createStoryNpcEncounterActions({
},
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [
createNpcBattleMonster(encounter, npcState, 'fight'),
],
sceneMonsters: [battleMonster],
sceneHostileNpcs: [battleMonster],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
@@ -915,7 +999,7 @@ export function createStoryNpcEncounterActions({
};
void commitGeneratedState(
nextState,
gameState.playerCharacter,
playerCharacter,
option.actionText,
`You lunge at ${encounter.npcName} with clear hostile intent, and the atmosphere turns dangerous at once.`,
option.functionId,
@@ -923,7 +1007,15 @@ export function createStoryNpcEncounterActions({
return true;
}
case 'spar': {
const sparPlayerMaxHp = getNpcSparMaxHp(gameState.playerCharacter);
const sparPlayerMaxHp = getNpcSparMaxHp(
playerCharacter,
gameState.worldType,
gameState.customWorldProfile,
);
const battleMonster = createNpcBattleMonster(encounter, npcState, 'spar', {
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
});
const nextState = {
...gameState,
npcStates: {
@@ -934,9 +1026,8 @@ export function createStoryNpcEncounterActions({
},
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [
createNpcBattleMonster(encounter, npcState, 'spar'),
],
sceneMonsters: [battleMonster],
sceneHostileNpcs: [battleMonster],
playerX: 0,
playerHp: sparPlayerMaxHp,
playerMaxHp: sparPlayerMaxHp,
@@ -955,7 +1046,7 @@ export function createStoryNpcEncounterActions({
};
void commitGeneratedState(
nextState,
gameState.playerCharacter,
playerCharacter,
option.actionText,
`${encounter.npcName} salutes you and agrees to keep the spar controlled and respectful.`,
option.functionId,

View File

@@ -15,6 +15,11 @@ import {
getNpcBuybackPrice,
getNpcPurchasePrice,
} from '../../data/economy';
import {
buildNpcGiftModalState,
buildNpcRecruitModalState,
buildNpcTradeModalState,
} from '../../data/functionCatalog';
import {
addInventoryItems,
buildNpcGiftCommitActionText,
@@ -28,7 +33,7 @@ import {
removeInventoryItem,
syncNpcTradeInventory,
} from '../../data/npcInteractions';
import { streamNpcRecruitDialogue } from '../../services/ai';
import { streamNpcChatDialogue, streamNpcRecruitDialogue } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
@@ -62,7 +67,10 @@ type StoryNpcInteractionRuntime = {
setIsLoading: Dispatch<SetStateAction<boolean>>;
buildStoryContextFromState: (
state: GameState,
extras?: { lastFunctionId?: string | null },
extras?: {
lastFunctionId?: string | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
},
) => StoryGenerationContext;
buildFallbackStoryForState: (
state: GameState,
@@ -216,6 +224,150 @@ export function useStoryNpcInteractionFlow({
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 buildRecruitmentOutcome = (
encounter: Encounter,
releasedNpcId?: string | null,
@@ -248,6 +400,8 @@ export function useStoryNpcInteractionFlow({
recruitKey,
recruitCharacter,
npcState.affinity,
gameState.worldType,
gameState.customWorldProfile,
);
const rosterState = recruitCompanionToParty(gameState, recruitedCompanion, releasedNpcId);
@@ -256,7 +410,8 @@ export function useStoryNpcInteractionFlow({
npcStates: nextNpcStates,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: gameState.animationState,
@@ -444,14 +599,14 @@ export function useStoryNpcInteractionFlow({
setGameState(updateNpcState(gameState, encounter, () => npcState));
}
setTradeModal({
encounter,
actionText,
mode: 'buy',
selectedNpcItemId: npcState.inventory[0]?.id ?? null,
selectedPlayerItemId: gameState.playerInventory[0]?.id ?? null,
selectedQuantity: 1,
});
setTradeModal(
buildNpcTradeModalState(
gameState,
encounter,
actionText,
npcState.inventory,
),
);
};
const openGiftModal = (encounter: Encounter, actionText: string) => {
@@ -465,19 +620,18 @@ export function useStoryNpcInteractionFlow({
);
if (!selectedItemId) return;
setGiftModal({
encounter,
actionText,
selectedItemId,
});
setGiftModal(
buildNpcGiftModalState(
gameState,
encounter,
actionText,
selectedItemId,
),
);
};
const openRecruitModal = (encounter: Encounter, actionText: string) => {
setRecruitModal({
encounter,
actionText,
selectedReleaseNpcId: gameState.companions[0]?.npcId ?? null,
});
setRecruitModal(buildNpcRecruitModalState(gameState, encounter, actionText));
};
const clearNpcInteractionUi = () => {
@@ -518,16 +672,16 @@ export function useStoryNpcInteractionFlow({
};
setTradeModal(null);
void commitGeneratedState(
void commitNpcReactionAndGenerate({
nextState,
gameState.playerCharacter,
buildNpcTradeTransactionActionText({
encounter,
actionText: buildNpcTradeTransactionActionText({
encounter,
mode: 'buy',
item: npcItem,
quantity,
}),
buildNpcTradeTransactionResultText({
resultText: buildNpcTradeTransactionResultText({
encounter,
mode: 'buy',
item: npcItem,
@@ -535,8 +689,9 @@ export function useStoryNpcInteractionFlow({
totalPrice,
worldType: gameState.worldType,
}),
'npc_trade',
);
lastFunctionId: 'npc_trade',
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
});
return;
}
@@ -563,16 +718,16 @@ export function useStoryNpcInteractionFlow({
};
setTradeModal(null);
void commitGeneratedState(
void commitNpcReactionAndGenerate({
nextState,
gameState.playerCharacter,
buildNpcTradeTransactionActionText({
encounter,
actionText: buildNpcTradeTransactionActionText({
encounter,
mode: 'sell',
item: playerItem,
quantity,
}),
buildNpcTradeTransactionResultText({
resultText: buildNpcTradeTransactionResultText({
encounter,
mode: 'sell',
item: playerItem,
@@ -580,8 +735,9 @@ export function useStoryNpcInteractionFlow({
totalPrice,
worldType: gameState.worldType,
}),
'npc_trade',
);
lastFunctionId: 'npc_trade',
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
});
};
const confirmGift = () => {
@@ -624,13 +780,20 @@ export function useStoryNpcInteractionFlow({
};
setGiftModal(null);
void commitGeneratedState(
void commitNpcReactionAndGenerate({
nextState,
gameState.playerCharacter,
buildNpcGiftCommitActionText(encounter, giftItem),
buildNpcGiftResultText(encounter, giftItem, affinityGain, nextAffinity, attributeSummary ?? undefined),
'npc_gift',
);
encounter,
actionText: buildNpcGiftCommitActionText(encounter, giftItem),
resultText: buildNpcGiftResultText(
encounter,
giftItem,
affinityGain,
nextAffinity,
attributeSummary ?? undefined,
),
lastFunctionId: 'npc_gift',
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
});
};
return {

View File

@@ -7,6 +7,7 @@ import {
hasEncounterEntity,
interpolateEncounterTransitionState,
} from '../../data/encounterTransition';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
Character,
@@ -97,6 +98,8 @@ export function createStoryProgressionActions({
choice: actionText,
lastFunctionId,
});
const recoveredState = applyStoryReasoningRecovery(stateWithHistory);
setGameState(recoveredState);
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to continue scripted story:', error);
@@ -146,6 +149,8 @@ export function createStoryProgressionActions({
choice: actionText,
lastFunctionId,
});
const recoveredState = applyStoryReasoningRecovery(stateWithHistory);
setGameState(recoveredState);
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to continue encounter-entry story:', error);

View File

@@ -6,6 +6,7 @@ import type {
export type TradeModalState = {
encounter: Encounter;
actionText: string;
introText: string | null;
mode: 'buy' | 'sell';
selectedNpcItemId: string | null;
selectedPlayerItemId: string | null;
@@ -15,12 +16,14 @@ export type TradeModalState = {
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;
};