初始仓库迁移
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,668 @@
import type {
Dispatch,
SetStateAction,
} from 'react';
import { useState } from 'react';
import { buildRelationState } from '../../data/attributeResolver';
import {
buildCompanionState,
getCharacterById,
resolveEncounterRecruitCharacter,
} from '../../data/characterPresets';
import { recruitCompanionToParty } from '../../data/companionRoster';
import {
getNpcBuybackPrice,
getNpcPurchasePrice,
} from '../../data/economy';
import {
addInventoryItems,
buildNpcGiftResultText,
buildNpcRecruitResultText,
buildNpcTradeTransactionResultText,
getGiftCandidates,
markNpcFirstMeaningfulContactResolved,
removeInventoryItem,
} from '../../data/npcInteractions';
import { streamNpcRecruitDialogue } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
Character,
Encounter,
GameState,
InventoryItem,
StoryMoment,
StoryOption,
} from '../../types';
import type { CommitGeneratedState } from '../generatedState';
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 = {
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
buildStoryContextFromState: (
state: GameState,
extras?: { lastFunctionId?: 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['sceneMonsters'];
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,
commitGeneratedState,
getNpcEncounterKey,
getResolvedNpcState,
updateNpcState,
cloneInventoryItemForOwner,
runtime,
}: {
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
commitGeneratedState: CommitGeneratedState;
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 buildRecruitmentOutcome = (
encounter: Encounter,
releasedNpcId?: string | null,
) => {
if (!gameState.playerCharacter) return null;
const npcState = getResolvedNpcState(gameState, encounter);
const recruitKey = getNpcEncounterKey(encounter);
let releasedCompanionName: string | null = null;
const nextNpcStates = {
...gameState.npcStates,
[recruitKey]: {
...markNpcFirstMeaningfulContactResolved(npcState),
recruited: true,
},
};
if (releasedNpcId) {
const releasedCompanion = gameState.companions.find(item => item.npcId === releasedNpcId);
releasedCompanionName = releasedCompanion?.characterId
? getCharacterById(releasedCompanion.characterId)?.name ?? null
: null;
}
const recruitCharacter = resolveEncounterRecruitCharacter(encounter);
if (!recruitCharacter) return null;
const recruitedCompanion = buildCompanionState(
recruitKey,
recruitCharacter,
npcState.affinity,
);
const rosterState = recruitCompanionToParty(gameState, recruitedCompanion, releasedNpcId);
const nextState: GameState = {
...rosterState,
npcStates: nextNpcStates,
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,
ambientIdleMode: undefined,
activeCombatEffects: [],
};
return {
nextState,
releasedCompanionName,
};
};
const executeRecruitment = (
encounter: Encounter,
actionText: string,
releasedNpcId?: string | null,
preludeText?: string | null,
) => {
if (!gameState.playerCharacter) return;
const outcome = buildRecruitmentOutcome(encounter, releasedNpcId);
if (!outcome) return;
const recruitResultText = buildNpcRecruitResultText(encounter, outcome.releasedCompanionName);
setRecruitModal(null);
if (!preludeText) {
void commitGeneratedState(
outcome.nextState,
gameState.playerCharacter,
actionText,
recruitResultText,
'npc_recruit',
);
return;
}
const nextHistory = [
...gameState.storyHistory,
createHistoryMoment(actionText, 'action'),
createHistoryMoment(preludeText, 'result'),
createHistoryMoment(recruitResultText, 'result'),
];
const stateWithHistory = {
...outcome.nextState,
storyHistory: nextHistory,
};
setGameState(stateWithHistory);
runtime.setAiError(null);
void runtime.generateStoryForState({
state: stateWithHistory,
character: gameState.playerCharacter,
history: nextHistory,
choice: actionText,
lastFunctionId: 'npc_recruit',
})
.then(nextStory => {
runtime.setCurrentStory(nextStory);
})
.catch(error => {
console.error('Failed to continue recruit story:', error);
runtime.setAiError(error instanceof Error ? error.message : '未知 AI 错误');
runtime.setCurrentStory(
runtime.buildFallbackStoryForState(stateWithHistory, gameState.playerCharacter!, recruitResultText),
);
})
.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 : '未知 AI 错误');
}
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));
executeRecruitment(encounter, actionText, releasedNpcId, finalDialogueText);
};
const openTradeModal = (encounter: Encounter, actionText: string) => {
const npcState = getResolvedNpcState(gameState, encounter);
setTradeModal({
encounter,
actionText,
mode: 'buy',
selectedNpcItemId: npcState.inventory[0]?.id ?? null,
selectedPlayerItemId: gameState.playerInventory[0]?.id ?? null,
selectedQuantity: 1,
});
};
const openGiftModal = (encounter: Encounter, actionText: string) => {
setGiftModal({
encounter,
actionText,
selectedItemId: gameState.playerInventory[0]?.id ?? null,
});
};
const openRecruitModal = (encounter: Encounter, actionText: string) => {
setRecruitModal({
encounter,
actionText,
selectedReleaseNpcId: gameState.companions[0]?.npcId ?? null,
});
};
const clearNpcInteractionUi = () => {
setTradeModal(null);
setGiftModal(null);
setRecruitModal(null);
};
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;
let nextState = updateNpcState(
gameState,
encounter,
currentNpcState => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
inventory: removeInventoryItem(currentNpcState.inventory, npcItem.id, quantity),
}),
);
nextState = {
...nextState,
playerCurrency: nextState.playerCurrency - totalPrice,
playerInventory: addInventoryItems(
nextState.playerInventory,
[cloneInventoryItemForOwner(npcItem, 'player', quantity)],
),
};
setTradeModal(null);
void commitGeneratedState(
nextState,
gameState.playerCharacter,
tradeModal.actionText,
buildNpcTradeTransactionResultText({
encounter,
mode: 'buy',
item: npcItem,
quantity,
totalPrice,
worldType: gameState.worldType,
}),
'npc_trade',
);
return;
}
const playerItem = getTradePlayerItem(gameState, tradeModal);
if (!playerItem || quantity <= 0) return;
if (playerItem.quantity < quantity) return;
let nextState = updateNpcState(
gameState,
encounter,
currentNpcState => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
inventory: addInventoryItems(
currentNpcState.inventory,
[cloneInventoryItemForOwner(playerItem, 'npc', quantity)],
),
}),
);
nextState = {
...nextState,
playerCurrency: nextState.playerCurrency + totalPrice,
playerInventory: removeInventoryItem(nextState.playerInventory, playerItem.id, quantity),
};
setTradeModal(null);
void commitGeneratedState(
nextState,
gameState.playerCharacter,
tradeModal.actionText,
buildNpcTradeTransactionResultText({
encounter,
mode: 'sell',
item: playerItem,
quantity,
totalPrice,
worldType: gameState.worldType,
}),
'npc_trade',
);
};
const confirmGift = () => {
if (!giftModal || !gameState.playerCharacter) return;
const encounter = giftModal.encounter;
const giftItem = gameState.playerInventory.find(item => item.id === giftModal.selectedItemId);
if (!giftItem) return;
const giftCandidate = getGiftCandidates(gameState.playerInventory, encounter, {
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
})
.find(candidate => candidate.item.id === giftItem.id);
const affinityGain = giftCandidate?.affinityGain ?? 0;
const attributeSummary = giftCandidate?.attributeInsight?.reasonText ?? null;
let nextAffinity = 0;
let nextState = updateNpcState(
gameState,
encounter,
currentNpcState => {
nextAffinity = currentNpcState.affinity + affinityGain;
return {
...markNpcFirstMeaningfulContactResolved(currentNpcState),
affinity: nextAffinity,
relationState: buildRelationState(nextAffinity),
giftsGiven: currentNpcState.giftsGiven + 1,
inventory: addInventoryItems(
currentNpcState.inventory,
[cloneInventoryItemForOwner(giftItem, 'npc')],
),
};
},
);
nextState = {
...nextState,
playerInventory: removeInventoryItem(nextState.playerInventory, giftItem.id, 1),
};
setGiftModal(null);
void commitGeneratedState(
nextState,
gameState.playerCharacter,
giftModal.actionText,
buildNpcGiftResultText(encounter, giftItem, affinityGain, nextAffinity, attributeSummary ?? undefined),
'npc_gift',
);
};
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,
};
}