706 lines
23 KiB
TypeScript
706 lines
23 KiB
TypeScript
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,
|
||
buildNpcGiftCommitActionText,
|
||
buildNpcGiftResultText,
|
||
buildNpcRecruitResultText,
|
||
buildNpcTradeTransactionActionText,
|
||
buildNpcTradeTransactionResultText,
|
||
getGiftCandidates,
|
||
getPreferredGiftItemId,
|
||
markNpcFirstMeaningfulContactResolved,
|
||
removeInventoryItem,
|
||
syncNpcTradeInventory,
|
||
} 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 : '未知智能生成错误');
|
||
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 : '未知智能生成错误');
|
||
}
|
||
|
||
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 currentNpcState = getResolvedNpcState(gameState, encounter);
|
||
const npcState = syncNpcTradeInventory(
|
||
gameState,
|
||
encounter,
|
||
currentNpcState,
|
||
);
|
||
|
||
if (
|
||
gameState.npcStates[getNpcEncounterKey(encounter)] !== npcState
|
||
|| npcState !== currentNpcState
|
||
) {
|
||
setGameState(updateNpcState(gameState, encounter, () => npcState));
|
||
}
|
||
|
||
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) => {
|
||
const selectedItemId = getPreferredGiftItemId(
|
||
gameState.playerInventory,
|
||
encounter,
|
||
{
|
||
worldType: gameState.worldType,
|
||
customWorldProfile: gameState.customWorldProfile,
|
||
},
|
||
);
|
||
if (!selectedItemId) return;
|
||
|
||
setGiftModal({
|
||
encounter,
|
||
actionText,
|
||
selectedItemId,
|
||
});
|
||
};
|
||
|
||
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,
|
||
buildNpcTradeTransactionActionText({
|
||
encounter,
|
||
mode: 'buy',
|
||
item: npcItem,
|
||
quantity,
|
||
}),
|
||
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,
|
||
buildNpcTradeTransactionActionText({
|
||
encounter,
|
||
mode: 'sell',
|
||
item: playerItem,
|
||
quantity,
|
||
}),
|
||
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,
|
||
buildNpcGiftCommitActionText(encounter, giftItem),
|
||
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,
|
||
};
|
||
}
|