初始仓库迁移
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-04 23:57:06 +08:00
parent 80986b790d
commit c49c64896a
18446 changed files with 532435 additions and 2 deletions

View File

@@ -0,0 +1,837 @@
import type { Dispatch, SetStateAction } from 'react';
import { buildRelationState } from '../../data/attributeResolver';
import { hasEncounterEntity } from '../../data/encounterTransition';
import { NPC_PREVIEW_TALK_FUNCTION } from '../../data/functionCatalog';
import {
addInventoryItems,
buildNpcChatResultText,
buildNpcHelpResultText,
buildNpcHelpReward,
buildNpcLeaveResultText,
buildNpcSparResultText,
createNpcBattleMonster,
getChatAffinityOutcome,
getNpcLootItems,
getNpcSparMaxHp,
markNpcFirstMeaningfulContactResolved,
NPC_SPAR_AFFINITY_GAIN,
removeInventoryItem,
} from '../../data/npcInteractions';
import {
acceptQuest,
applyQuestProgressFromHostileNpcDefeat,
applyQuestProgressFromSpar,
buildQuestAcceptResultText,
buildQuestForEncounter,
buildQuestTurnInResultText,
findQuestById,
getQuestForIssuer,
markQuestTurnedIn,
} from '../../data/questFlow';
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
import {
createSceneCallOutEncounter,
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import { generateNextStep, streamNpcChatDialogue } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import type {
Character,
Encounter,
GameState,
InventoryItem,
NpcBattleMode,
NpcBattleOutcome,
StoryMoment,
StoryOption,
} from '../../types';
import { AnimationState } from '../../types';
import type { CommitGeneratedState } from '../generatedState';
type CommitGeneratedStateWithEncounterEntry = (
entryState: GameState,
resolvedState: GameState,
character: Character,
actionText: string,
resultText: string,
lastFunctionId?: string,
) => Promise<void> | void;
type NpcInteractionFlowActions = {
openTradeModal: (encounter: Encounter, actionText: string) => void;
openGiftModal: (encounter: Encounter, actionText: string) => void;
openRecruitModal: (encounter: Encounter, actionText: string) => void;
startRecruitmentSequence: (
encounter: Encounter,
actionText: string,
) => Promise<void>;
};
type BuildStoryContextExtras = {
lastFunctionId?: string | null;
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
};
function buildCampCompanionChatResultText(
encounter: Encounter,
affinityGain: number,
_nextAffinity: number,
) {
const teamworkText =
affinityGain > 0
? 'You also feel a little more confident about how you will work together next.'
: 'You at least realign your rhythm for what comes next.';
return `${encounter.npcName}閸滃奔缍樻禍銈嗗床娴滃棔绔存潪顔藉厒濞夋洩绱?{describeNpcAffinityInWords(encounter, nextAffinity)}${teamworkText}`;
}
function isNpcEncounter(
encounter: GameState['currentEncounter'],
): encounter is Encounter {
return Boolean(encounter?.kind === 'npc');
}
export function createStoryNpcEncounterActions({
gameState,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
commitGeneratedState,
commitGeneratedStateWithEncounterEntry,
appendHistory,
buildOpeningCampChatContext,
buildStoryContextFromState,
buildFallbackStoryForState,
buildDialogueStoryMoment,
getStoryGenerationHostileNpcs,
getTypewriterDelay,
getAvailableOptionsForState,
sanitizeOptions,
sortOptions,
buildContinueAdventureOption,
getNpcEncounterKey,
getResolvedNpcState,
updateNpcState,
cloneInventoryItemForOwner,
resolveNpcInteractionDecision,
npcInteractionFlow,
}: {
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
commitGeneratedState: CommitGeneratedState;
commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry;
appendHistory: (
state: GameState,
actionText: string,
resultText: string,
) => GameState['storyHistory'];
buildOpeningCampChatContext: (
state: GameState,
character: Character,
encounter: Encounter,
) => BuildStoryContextExtras;
buildStoryContextFromState: (
state: GameState,
extras?: BuildStoryContextExtras,
) => StoryGenerationContext;
buildFallbackStoryForState: (
state: GameState,
character: Character,
fallbackText?: string,
) => StoryMoment;
buildDialogueStoryMoment: (
npcName: string,
text: string,
options: StoryOption[],
streaming?: boolean,
) => StoryMoment;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneMonsters'];
getTypewriterDelay: (char: string) => number;
getAvailableOptionsForState: (
state: GameState,
character: Character,
) => StoryOption[] | null;
sanitizeOptions: (
options: StoryOption[],
character: Character,
state: GameState,
) => StoryOption[];
sortOptions: (options: StoryOption[]) => StoryOption[];
buildContinueAdventureOption: () => StoryOption;
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;
resolveNpcInteractionDecision: (
state: GameState,
option: StoryOption,
) => { kind: string };
npcInteractionFlow: NpcInteractionFlowActions;
}) {
const updateQuestLog = (
state: GameState,
updater: (quests: GameState['quests']) => GameState['quests'],
) => ({
...state,
quests: updater(state.quests),
});
const incrementRuntimeStats = (
state: GameState,
increments: Parameters<typeof incrementGameRuntimeStats>[1],
) => ({
...state,
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, increments),
});
const finalizeNpcBattleResult = (
state: GameState,
character: Character,
battleMode: NpcBattleMode,
battleOutcome: NpcBattleOutcome | null,
) => {
if (!state.currentBattleNpcId) return null;
const battleNpcId = state.currentBattleNpcId;
const npcState = state.npcStates[battleNpcId];
if (!npcState) return null;
if (battleMode === 'spar' && battleOutcome === 'spar_complete') {
const nextAffinity = npcState.affinity + NPC_SPAR_AFFINITY_GAIN;
const restoredEncounter = state.sparReturnEncounter;
const progressedQuests = applyQuestProgressFromSpar(
state.quests,
battleNpcId,
);
const nextState = {
...state,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
currentEncounter: restoredEncounter,
npcInteractionActive: true,
sceneHostileNpcs: [],
npcStates: {
...state.npcStates,
[battleNpcId]: {
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: nextAffinity,
relationState: buildRelationState(nextAffinity),
},
},
quests: progressedQuests,
playerX: 0,
playerHp: state.sparPlayerHpBefore ?? state.playerHp,
playerMaxHp: state.sparPlayerMaxHpBefore ?? state.playerMaxHp,
playerFacing: 'right' as const,
animationState: state.animationState,
activeCombatEffects: [],
scrollWorld: false,
inBattle: false,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
return {
nextState,
resultText: buildNpcSparResultText(
NPC_SPAR_AFFINITY_GAIN,
nextAffinity,
),
};
}
const lootItems = getNpcLootItems(npcState, character).map((item) =>
cloneInventoryItemForOwner(item, 'player'),
);
const defeatedHostileNpcIds = (
state.sceneHostileNpcs ?? state.sceneMonsters
).map((hostileNpc) => hostileNpc.id);
const progressedQuests = applyQuestProgressFromHostileNpcDefeat(
state.quests,
state.currentScenePreset?.id ?? null,
defeatedHostileNpcIds,
);
let nextNpcInventory = npcState.inventory;
for (const item of lootItems) {
nextNpcInventory = removeInventoryItem(nextNpcInventory, item.id, 1);
}
const nextState: GameState = incrementRuntimeStats(
{
...state,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerInventory: addInventoryItems(state.playerInventory, lootItems),
quests: progressedQuests,
npcStates: {
...state.npcStates,
[battleNpcId]: {
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: 0,
relationState: buildRelationState(0),
recruited: false,
inventory: nextNpcInventory,
},
},
playerX: 0,
playerFacing: 'right' as const,
animationState: state.animationState,
activeCombatEffects: [],
scrollWorld: false,
inBattle: false,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
},
{
hostileNpcsDefeated: defeatedHostileNpcIds.length,
},
);
const lootText =
lootItems.length > 0
? lootItems.map((item) => item.name).join(', ')
: '无战利品';
return {
nextState,
resultText: `胜利奖励:${lootText}`,
};
};
const commitNpcChatState = async (
nextState: GameState,
character: Character,
encounter: Encounter,
actionText: string,
resultText: string,
lastFunctionId?: string,
contextNpcStateOverride?: GameState['npcStates'][string] | null,
) => {
const provisionalHistory = appendHistory(gameState, actionText, resultText);
const provisionalState = {
...nextState,
storyHistory: provisionalHistory,
};
const provisionalOpeningCampContext = buildOpeningCampChatContext(
provisionalState,
character,
encounter,
);
setGameState(provisionalState);
setAiError(null);
setIsLoading(true);
setCurrentStory(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;
setCurrentStory(
buildDialogueStoryMoment(encounter.npcName, displayedText, [], true),
);
await new Promise((resolve) =>
window.setTimeout(resolve, getTypewriterDelay(nextChar)),
);
}
})();
try {
dialogueText = await streamNpcChatDialogue(
gameState.worldType!,
character,
encounter,
getStoryGenerationHostileNpcs(provisionalState),
gameState.storyHistory,
buildStoryContextFromState(provisionalState, {
lastFunctionId,
...provisionalOpeningCampContext,
encounterNpcStateOverride: contextNpcStateOverride,
}),
actionText,
resultText,
{
onUpdate: (text) => {
streamedTargetText = text;
},
},
);
streamedTargetText = dialogueText;
streamCompleted = true;
await typewriterPromise;
const finalHistory = appendHistory(
gameState,
actionText,
dialogueText || resultText,
);
const finalState = {
...nextState,
storyHistory: finalHistory,
};
const availableOptions = getAvailableOptionsForState(
finalState,
character,
);
const finalOpeningCampContext = buildOpeningCampChatContext(
finalState,
character,
encounter,
);
setGameState(finalState);
const response = await generateNextStep(
gameState.worldType!,
character,
getStoryGenerationHostileNpcs(finalState),
finalHistory,
actionText,
buildStoryContextFromState(finalState, {
lastFunctionId,
...finalOpeningCampContext,
}),
availableOptions ? { availableOptions } : undefined,
);
const resolvedOptions = sortOptions(
availableOptions
? response.options
: sanitizeOptions(response.options, character, finalState),
);
setCurrentStory({
...buildDialogueStoryMoment(
encounter.npcName,
dialogueText || resultText,
[buildContinueAdventureOption()],
false,
),
deferredOptions: resolvedOptions,
});
} catch (error) {
streamCompleted = true;
await typewriterPromise;
console.error('Failed to stream npc chat story:', error);
setAiError(
error instanceof Error ? error.message : 'NPC 对话 AI 不可用。',
);
const fallbackOptions =
getAvailableOptionsForState(provisionalState, character) ?? [];
setCurrentStory(
displayedText
? {
...buildDialogueStoryMoment(
encounter.npcName,
displayedText,
fallbackOptions.length > 0
? [buildContinueAdventureOption()]
: [],
false,
),
deferredOptions:
fallbackOptions.length > 0
? sortOptions(fallbackOptions)
: undefined,
}
: buildFallbackStoryForState(provisionalState, character, resultText),
);
} finally {
setIsLoading(false);
}
};
const enterNpcInteraction = (encounter: Encounter, actionText: string) => {
if (!gameState.playerCharacter) return false;
const nextState: GameState = {
...gameState,
npcInteractionActive: true,
};
void commitGeneratedState(
nextState,
gameState.playerCharacter,
actionText,
`${encounter.npcName} turns their attention toward you, as if waiting for you to speak first.`,
NPC_PREVIEW_TALK_FUNCTION.id,
);
return true;
};
const handleNpcInteraction = (option: StoryOption) => {
if (
!gameState.playerCharacter ||
!option.interaction ||
!isNpcEncounter(gameState.currentEncounter)
) {
return false;
}
const encounter = gameState.currentEncounter;
const npcState = getResolvedNpcState(gameState, encounter);
const interactionDecision = resolveNpcInteractionDecision(
gameState,
option,
);
if (interactionDecision.kind === 'trade_modal') {
npcInteractionFlow.openTradeModal(encounter, option.actionText);
return true;
}
if (interactionDecision.kind === 'gift_modal') {
npcInteractionFlow.openGiftModal(encounter, option.actionText);
return true;
}
if (interactionDecision.kind === 'recruit_modal') {
npcInteractionFlow.openRecruitModal(encounter, option.actionText);
return true;
}
if (interactionDecision.kind === 'recruit_immediate') {
void npcInteractionFlow.startRecruitmentSequence(
encounter,
option.actionText,
);
return true;
}
switch (option.interaction.action) {
case 'help': {
const reward = buildNpcHelpReward(encounter);
let cooldowns = gameState.playerSkillCooldowns;
for (let index = 0; index < (reward.cooldownBonus ?? 0); index += 1) {
cooldowns = Object.fromEntries(
Object.entries(cooldowns).map(([skillId, turns]) => [
skillId,
Math.max(0, turns - 1),
]),
);
}
let nextState = updateNpcState(
gameState,
encounter,
(currentNpcState) => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
helpUsed: true,
}),
);
nextState = {
...nextState,
playerHp: Math.min(
nextState.playerMaxHp,
nextState.playerHp + (reward.hp ?? 0),
),
playerMana: Math.min(
nextState.playerMaxMana,
nextState.playerMana + (reward.mana ?? 0),
),
playerSkillCooldowns: cooldowns,
playerInventory: reward.item
? addInventoryItems(nextState.playerInventory, [
cloneInventoryItemForOwner(reward.item, 'player'),
])
: nextState.playerInventory,
};
void commitGeneratedState(
nextState,
gameState.playerCharacter,
option.actionText,
buildNpcHelpResultText(encounter, reward),
option.functionId,
);
return true;
}
case 'chat': {
const chatOutcome = getChatAffinityOutcome({
playerCharacter: gameState.playerCharacter,
encounter,
npcState,
actionText: option.actionText,
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
});
const affinityGain = chatOutcome.affinityGain;
const attributeSummary = chatOutcome.summary;
let nextAffinity = npcState.affinity;
const nextState = updateNpcState(
gameState,
encounter,
(currentNpcState) => {
nextAffinity = currentNpcState.affinity + affinityGain;
return {
...markNpcFirstMeaningfulContactResolved(currentNpcState),
affinity: nextAffinity,
relationState: buildRelationState(nextAffinity),
chattedCount: currentNpcState.chattedCount + 1,
};
},
);
void commitNpcChatState(
nextState,
gameState.playerCharacter,
encounter,
option.actionText,
npcState.recruited
? buildCampCompanionChatResultText(
encounter,
affinityGain,
nextAffinity,
)
: buildNpcChatResultText(
encounter,
affinityGain,
nextAffinity,
attributeSummary,
),
option.functionId,
npcState,
);
return true;
}
case 'quest_accept': {
const existingQuest = getQuestForIssuer(
gameState.quests,
getNpcEncounterKey(encounter),
);
if (existingQuest) return true;
const quest = buildQuestForEncounter({
issuerNpcId: getNpcEncounterKey(encounter),
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene: gameState.currentScenePreset,
worldType: gameState.worldType,
});
if (!quest) return true;
const nextState = incrementRuntimeStats(
updateNpcState(
updateQuestLog(gameState, (quests) => acceptQuest(quests, quest)),
encounter,
(currentNpcState) =>
markNpcFirstMeaningfulContactResolved(currentNpcState),
),
{ questsAccepted: 1 },
);
void commitGeneratedState(
nextState,
gameState.playerCharacter,
option.actionText,
buildQuestAcceptResultText(quest),
option.functionId,
);
return true;
}
case 'quest_turn_in': {
const questId = option.interaction.questId;
const quest = questId ? findQuestById(gameState.quests, questId) : null;
if (!quest || quest.status !== 'completed') return true;
const nextState = {
...updateQuestLog(gameState, (quests) =>
markQuestTurnedIn(quests, quest.id),
),
npcStates: {
...gameState.npcStates,
[getNpcEncounterKey(encounter)]: {
...npcState,
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: npcState.affinity + quest.reward.affinityBonus,
relationState: buildRelationState(
npcState.affinity + quest.reward.affinityBonus,
),
},
},
playerCurrency: gameState.playerCurrency + quest.reward.currency,
playerInventory: addInventoryItems(
gameState.playerInventory,
quest.reward.items,
),
};
void commitGeneratedState(
nextState,
gameState.playerCharacter,
option.actionText,
buildQuestTurnInResultText(quest),
option.functionId,
);
return true;
}
case 'leave': {
const baseState: GameState = {
...gameState,
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: gameState.animationState,
scrollWorld: false,
inBattle: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
const entryState = {
...baseState,
...createSceneCallOutEncounter(baseState),
} as GameState;
const resolvedState = hasEncounterEntity(entryState)
? resolveSceneEncounterPreview(entryState)
: baseState;
void commitGeneratedStateWithEncounterEntry(
entryState,
resolvedState,
gameState.playerCharacter,
option.actionText,
buildNpcLeaveResultText(encounter),
option.functionId,
);
return true;
}
case 'fight': {
const nextState = {
...gameState,
npcStates: {
...gameState.npcStates,
[getNpcEncounterKey(encounter)]: markNpcFirstMeaningfulContactResolved(
npcState,
),
},
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [
createNpcBattleMonster(encounter, npcState, 'fight'),
],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
scrollWorld: false,
inBattle: true,
currentBattleNpcId: getNpcEncounterKey(encounter),
currentNpcBattleMode: 'fight' as const,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
void commitGeneratedState(
nextState,
gameState.playerCharacter,
option.actionText,
`You lunge at ${encounter.npcName} with clear hostile intent, and the atmosphere turns dangerous at once.`,
option.functionId,
);
return true;
}
case 'spar': {
const sparPlayerMaxHp = getNpcSparMaxHp(gameState.playerCharacter);
const nextState = {
...gameState,
npcStates: {
...gameState.npcStates,
[getNpcEncounterKey(encounter)]: markNpcFirstMeaningfulContactResolved(
npcState,
),
},
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [
createNpcBattleMonster(encounter, npcState, 'spar'),
],
playerX: 0,
playerHp: sparPlayerMaxHp,
playerMaxHp: sparPlayerMaxHp,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
scrollWorld: false,
inBattle: true,
currentBattleNpcId: getNpcEncounterKey(encounter),
currentNpcBattleMode: 'spar' as const,
currentNpcBattleOutcome: null,
sparReturnEncounter: encounter,
sparPlayerHpBefore: gameState.playerHp,
sparPlayerMaxHpBefore: gameState.playerMaxHp,
sparStoryHistoryBefore: gameState.storyHistory,
};
void commitGeneratedState(
nextState,
gameState.playerCharacter,
option.actionText,
`${encounter.npcName} salutes you and agrees to keep the spar controlled and respectful.`,
option.functionId,
);
return true;
}
default:
return false;
}
};
return {
enterNpcInteraction,
handleNpcInteraction,
finalizeNpcBattleResult,
};
}