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

@@ -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,