Refine NPC interactions and runtime item generation

This commit is contained in:
2026-04-05 17:13:07 +08:00
parent c49c64896a
commit 89cecda7da
58 changed files with 4199 additions and 1562 deletions

View File

@@ -11,8 +11,8 @@ import { AnimationState, Character, GameState, StoryMoment, StoryOption } from '
const FALLBACK_STORY: StoryMoment = {
text: '怪物守在你的正前方,现在只剩战斗或逃跑两类选择。',
options: [
createFallbackOption('battle_all_in_crush', '鎴樻枟锛氬叏鍔涜繘鏀伙紝鍘嬪灝瀵规墜', AnimationState.SKILL1, 0, false),
createFallbackOption('battle_probe_pressure', '鎴樻枟锛氱ǔ鎵庣ǔ鎵擄紝杩炵暘璇曟帰', AnimationState.SKILL2, 0, false),
createFallbackOption('battle_all_in_crush', '战斗:全力进攻,压上对手', AnimationState.SKILL1, 0, false),
createFallbackOption('battle_probe_pressure', '战斗:稳扎稳打,连番试探', AnimationState.SKILL2, 0, false),
createFallbackOption('battle_escape_breakout', '逃跑:抽身后撤,先脱离纠缠', AnimationState.IDLE, -0.6, false),
],
};
@@ -50,7 +50,7 @@ export function inferCombatStyle(option: StoryOption): CombatStyle {
if (category === 'escape') return 'escape';
if (option.functionId === 'battle_all_in_crush' || option.functionId === 'battle_finisher_window') return 'all_in';
if (classifyCombatOption(option) === 'escape') return 'escape';
if (text.includes('鍏ㄥ姏') || text.includes('鍘嬩笂') || text.includes('鐚涙敾')) return 'all_in';
if (text.includes('全力') || text.includes('压上') || text.includes('猛攻')) return 'all_in';
return 'steady';
}
@@ -200,16 +200,16 @@ export function getOptionImpactSummary(
if ((effect.healAmount ?? 0) > 0) {
const healAmount = Math.max(0, Math.min(effect.healAmount ?? 0, maxHp - hp));
parts.push(`鍥炶 ${healAmount}`);
parts.push(`回血 ${healAmount}`);
}
if ((effect.manaRestore ?? 0) > 0) {
const manaRestore = Math.max(0, Math.min(effect.manaRestore ?? 0, maxMana - mana));
parts.push(`鍥炶摑 ${manaRestore}`);
parts.push(`回蓝 ${manaRestore}`);
}
if (parts.length === 0 && (effect.cooldownTickBonus ?? 0) > 0) {
parts.push(`鍑廋D -${effect.cooldownTickBonus} 鍥炲悎`);
parts.push(`减冷却 ${effect.cooldownTickBonus} 回合`);
}
return parts.length > 0 ? parts.join(' / ') : null;
@@ -218,7 +218,7 @@ export function getOptionImpactSummary(
if (functionMeta.category !== 'battle') return null;
if (currentNpcBattleMode === 'spar') {
return '鍒囩浼ゅ 1';
return '切磋伤害 1';
}
const normalizedOption = normalizeSkillProbabilities(option, character);
@@ -226,9 +226,9 @@ export function getOptionImpactSummary(
(a, b) => b.weight - a.weight,
);
const topSkill = availableSkills[0]?.skill;
if (!topSkill) return '鑰楄摑 -- / 浼ゅ --';
if (!topSkill) return '耗蓝 -- / 伤害 --';
const damageMultiplier = getFunctionEffect(option.functionId).damageMultiplier ?? 1;
const damage = Math.max(1, Math.round(topSkill.damage * damageMultiplier));
return `鑰楄摑 ${topSkill.manaCost} / 浼ゅ ${damage}`;
return `耗蓝 ${topSkill.manaCost} / 伤害 ${damage}`;
}

View File

@@ -283,7 +283,7 @@ export function useCharacterChatFlow({
messages: baseMessages,
isSending: false,
isLoadingSuggestions: false,
error: error instanceof Error ? error.message : '未知 AI 错误',
error: error instanceof Error ? error.message : '未知智能生成错误',
suggestions: current.suggestions.length > 0
? current.suggestions
: buildLocalCharacterChatSuggestions(target.character),

View File

@@ -472,7 +472,7 @@ export function createStoryChoiceActions({
setCurrentStory(nextStory);
} catch (storyError) {
console.error('Failed to continue npc battle resolution story:', storyError);
setAiError(storyError instanceof Error ? storyError.message : '未知 AI 错误');
setAiError(storyError instanceof Error ? storyError.message : '未知智能生成错误');
setCurrentStory(buildFallbackStoryForState(nextState, character, victory.resultText));
}
return;
@@ -533,7 +533,7 @@ export function createStoryChoiceActions({
);
} catch (error) {
console.error('Failed to get next step:', error);
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(buildFallbackStoryForState(fallbackState, character));
} finally {
setIsLoading(false);

View File

@@ -11,6 +11,7 @@ import {
buildNpcLeaveResultText,
buildNpcSparResultText,
createNpcBattleMonster,
generateNpcHelpReward,
getChatAffinityOutcome,
getNpcLootItems,
getNpcSparMaxHp,
@@ -36,6 +37,7 @@ import {
} from '../../data/sceneEncounterPreviews';
import { generateNextStep, streamNpcChatDialogue } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { generateQuestForNpcEncounter } from '../../services/questDirector';
import type {
Character,
Encounter,
@@ -459,7 +461,7 @@ export function createStoryNpcEncounterActions({
await typewriterPromise;
console.error('Failed to stream npc chat story:', error);
setAiError(
error instanceof Error ? error.message : 'NPC 对话 AI 不可用。',
error instanceof Error ? error.message : '角色对话智能生成不可用。',
);
const fallbackOptions =
getAvailableOptionsForState(provisionalState, character) ?? [];
@@ -545,51 +547,136 @@ export function createStoryNpcEncounterActions({
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),
]),
);
}
setAiError(null);
setIsLoading(true);
void (async () => {
let committed = false;
let nextState = updateNpcState(
gameState,
encounter,
(currentNpcState) => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
helpUsed: true,
}),
);
try {
const reward = await generateNpcHelpReward(encounter, gameState);
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),
]),
);
}
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,
};
let nextState = updateNpcState(
gameState,
encounter,
(currentNpcState) => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
helpUsed: true,
}),
);
void commitGeneratedState(
nextState,
gameState.playerCharacter,
option.actionText,
buildNpcHelpResultText(encounter, reward),
option.functionId,
);
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.items.length > 0
? addInventoryItems(
nextState.playerInventory,
reward.items.map((item) =>
cloneInventoryItemForOwner(
item,
'player',
item.quantity,
),
),
)
: nextState.playerInventory,
};
await commitGeneratedState(
nextState,
gameState.playerCharacter,
option.actionText,
buildNpcHelpResultText(encounter, reward),
option.functionId,
);
committed = true;
} catch (error) {
console.error('Failed to resolve npc help reward:', error);
const reward = buildNpcHelpReward(encounter, gameState);
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.items.length > 0
? addInventoryItems(
nextState.playerInventory,
reward.items.map((item) =>
cloneInventoryItemForOwner(
item,
'player',
item.quantity,
),
),
)
: nextState.playerInventory,
};
await commitGeneratedState(
nextState,
gameState.playerCharacter,
option.actionText,
buildNpcHelpResultText(encounter, reward),
option.functionId,
);
committed = true;
} finally {
if (!committed) {
setIsLoading(false);
}
}
})();
return true;
}
case 'chat': {
@@ -645,32 +732,83 @@ export function createStoryNpcEncounterActions({
getNpcEncounterKey(encounter),
);
if (existingQuest) return true;
setAiError(null);
setIsLoading(true);
void (async () => {
let committed = false;
const quest = buildQuestForEncounter({
issuerNpcId: getNpcEncounterKey(encounter),
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene: gameState.currentScenePreset,
worldType: gameState.worldType,
});
if (!quest) return true;
try {
const quest =
(await generateQuestForNpcEncounter({
state: gameState,
encounter,
})) ??
buildQuestForEncounter({
issuerNpcId: getNpcEncounterKey(encounter),
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene: gameState.currentScenePreset,
worldType: gameState.worldType,
});
if (!quest) {
return;
}
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,
);
const nextState = incrementRuntimeStats(
updateNpcState(
updateQuestLog(gameState, (quests) => acceptQuest(quests, quest)),
encounter,
(currentNpcState) =>
markNpcFirstMeaningfulContactResolved(currentNpcState),
),
{questsAccepted: 1},
);
await commitGeneratedState(
nextState,
gameState.playerCharacter,
option.actionText,
buildQuestAcceptResultText(quest),
option.functionId,
);
committed = true;
} catch (error) {
console.error('Failed to accept npc quest:', error);
const fallbackQuest = buildQuestForEncounter({
issuerNpcId: getNpcEncounterKey(encounter),
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene: gameState.currentScenePreset,
worldType: gameState.worldType,
});
if (!fallbackQuest) {
return;
}
const nextState = incrementRuntimeStats(
updateNpcState(
updateQuestLog(gameState, (quests) =>
acceptQuest(quests, fallbackQuest),
),
encounter,
(currentNpcState) =>
markNpcFirstMeaningfulContactResolved(currentNpcState),
),
{questsAccepted: 1},
);
await commitGeneratedState(
nextState,
gameState.playerCharacter,
option.actionText,
buildQuestAcceptResultText(fallbackQuest),
option.functionId,
);
committed = true;
} finally {
if (!committed) {
setIsLoading(false);
}
}
})();
return true;
}
case 'quest_turn_in': {

View File

@@ -17,12 +17,16 @@ import {
} 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';
@@ -326,7 +330,7 @@ export function useStoryNpcInteractionFlow({
})
.catch(error => {
console.error('Failed to continue recruit story:', error);
runtime.setAiError(error instanceof Error ? error.message : '未知 AI 错误');
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
runtime.setCurrentStory(
runtime.buildFallbackStoryForState(stateWithHistory, gameState.playerCharacter!, recruitResultText),
);
@@ -412,7 +416,7 @@ export function useStoryNpcInteractionFlow({
await typewriterPromise;
console.error('Failed to stream recruit dialogue:', error);
dialogueText = displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName);
runtime.setAiError(error instanceof Error ? error.message : '未知 AI 错误');
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
}
const finalDialogueText = normalizeRecruitDialogue(
@@ -426,7 +430,20 @@ export function useStoryNpcInteractionFlow({
};
const openTradeModal = (encounter: Encounter, actionText: string) => {
const npcState = getResolvedNpcState(gameState, encounter);
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,
@@ -438,10 +455,20 @@ export function useStoryNpcInteractionFlow({
};
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: gameState.playerInventory[0]?.id ?? null,
selectedItemId,
});
};
@@ -494,7 +521,12 @@ export function useStoryNpcInteractionFlow({
void commitGeneratedState(
nextState,
gameState.playerCharacter,
tradeModal.actionText,
buildNpcTradeTransactionActionText({
encounter,
mode: 'buy',
item: npcItem,
quantity,
}),
buildNpcTradeTransactionResultText({
encounter,
mode: 'buy',
@@ -534,7 +566,12 @@ export function useStoryNpcInteractionFlow({
void commitGeneratedState(
nextState,
gameState.playerCharacter,
tradeModal.actionText,
buildNpcTradeTransactionActionText({
encounter,
mode: 'sell',
item: playerItem,
quantity,
}),
buildNpcTradeTransactionResultText({
encounter,
mode: 'sell',
@@ -590,7 +627,7 @@ export function useStoryNpcInteractionFlow({
void commitGeneratedState(
nextState,
gameState.playerCharacter,
giftModal.actionText,
buildNpcGiftCommitActionText(encounter, giftItem),
buildNpcGiftResultText(encounter, giftItem, affinityGain, nextAffinity, attributeSummary ?? undefined),
'npc_gift',
);

View File

@@ -269,7 +269,7 @@ export async function playOpeningAdventureSequence({
}
} catch (error) {
console.error('Failed to infer opening camp dialogue:', error);
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
}
const finalHistory = [
@@ -313,7 +313,7 @@ export async function playOpeningAdventureSequence({
);
} catch (error) {
console.error('Failed to play opening adventure sequence:', error);
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(
buildDialogueStoryMoment(
encounter.npcName,

View File

@@ -100,7 +100,7 @@ export function createStoryProgressionActions({
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to continue scripted story:', error);
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(buildFallbackStoryForState(stateWithHistory, character, resultText));
} finally {
setIsLoading(false);
@@ -149,7 +149,7 @@ export function createStoryProgressionActions({
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to continue encounter-entry story:', error);
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(buildFallbackStoryForState(stateWithHistory, character, resultText));
} finally {
setIsLoading(false);

View File

@@ -34,7 +34,10 @@ vi.mock('../../data/scenePresets', () => ({
getWorldCampScenePreset: () => scenes[0] ?? null,
}));
import { buildInitialNpcState, MAX_COMPANIONS } from '../../data/npcInteractions';
import {
buildInitialNpcState,
MAX_COMPANIONS,
} from '../../data/npcInteractions';
import { getScenePresetsByWorld } from '../../data/scenePresets';
import {
AnimationState,
@@ -74,7 +77,11 @@ function createCharacter(): Character {
};
}
function createInventoryItem(id: string, name: string): InventoryItem {
function createInventoryItem(
id: string,
name: string,
overrides: Partial<InventoryItem> = {},
): InventoryItem {
return {
id,
name,
@@ -84,6 +91,7 @@ function createInventoryItem(id: string, name: string): InventoryItem {
rarity: 'common',
tags: [],
value: 1,
...overrides,
};
}
@@ -229,6 +237,46 @@ describe('storyGenerationState', () => {
expect(decision.modal.selectedReleaseNpcId).toBe('npc-1');
});
it('opens the gift modal with the preferred gift candidate selected', () => {
const state = {
...createBaseState(),
playerInventory: [
createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }),
createInventoryItem('jade-token', 'Jade Token', {
rarity: 'rare',
category: '专属',
tags: ['merchant'],
}),
],
};
const decision = resolveNpcInteractionDecision(
state,
createInteractionOption('gift'),
);
expect(decision.kind).toBe('gift_modal');
if (decision.kind !== 'gift_modal') {
throw new Error('Expected gift modal decision');
}
expect(decision.modal.selectedItemId).toBe('jade-token');
});
it('does not open the gift modal when there are no gift candidates', () => {
const state = {
...createBaseState(),
playerInventory: [],
};
const decision = resolveNpcInteractionDecision(
state,
createInteractionOption('gift'),
);
expect(decision.kind).toBe('none');
});
it('builds a map travel transition that increments runtime stats and clears battle state', () => {
const scenes = getScenePresetsByWorld(WorldType.WUXIA);
const sourceScene = scenes[0];

View File

@@ -9,6 +9,7 @@ import {
} from '../../data/functionCatalog';
import {
buildInitialNpcState,
getPreferredGiftItemId,
MAX_COMPANIONS,
} from '../../data/npcInteractions';
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
@@ -83,10 +84,29 @@ export function resolveNpcInteractionDecision(
),
};
case NPC_GIFT_FUNCTION.id:
return {
kind: 'gift_modal',
modal: buildNpcGiftModalState(state, encounter, option.actionText),
};
{
const selectedGiftItemId = getPreferredGiftItemId(
state.playerInventory,
encounter,
{
worldType: state.worldType,
customWorldProfile: state.customWorldProfile,
},
);
if (!selectedGiftItemId) {
return { kind: 'none' };
}
return {
kind: 'gift_modal',
modal: buildNpcGiftModalState(
state,
encounter,
option.actionText,
selectedGiftItemId,
),
};
}
case NPC_RECRUIT_FUNCTION.id:
if (shouldNpcRecruitOpenModal(state.companions.length, MAX_COMPANIONS)) {
return {

View File

@@ -162,7 +162,7 @@ export function useGameFlow() {
: null;
const initialEncounter = createInitialCampEncounter(gameState.worldType, character);
const initialNpcState = initialEncounter
? buildInitialNpcState(initialEncounter, gameState.worldType)
? buildInitialNpcState(initialEncounter, gameState.worldType, gameState)
: null;
const initialEquipment = buildInitialEquipmentLoadout(character);

View File

@@ -94,7 +94,7 @@ const TURN_VISUAL_MS = 820;
const OPENING_CAMP_DIALOGUE_FUNCTION_ID =
STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
const NPC_PREVIEW_TALK_FUNCTION_ID = NPC_PREVIEW_TALK_FUNCTION.id;
const FALLBACK_COMPANION_NAME = '鍚屼即';
const FALLBACK_COMPANION_NAME = '同伴';
export type {
CharacterChatModalState,
@@ -152,9 +152,9 @@ function _buildLocalCharacterChatSummary(
function _buildLocalCharacterChatSuggestions(character: Character) {
return [
'I want to hear you explain that a little more clearly.',
`${character.name}, what are you really worried about?`,
'Let the wider situation wait for a moment. I want to understand you a bit more.',
'我想听你把这件事再说得更明白一点。',
`${character.name},你现在真正担心的是什么?`,
'先把外面的局势放一放,我想更了解你一些。',
];
}
@@ -175,10 +175,10 @@ function buildPartyRelationshipNotes(state: GameState) {
};
state.companions.forEach((companion) =>
appendNote(companion.characterId, '褰撳墠鍚岃'),
appendNote(companion.characterId, '当前同行'),
);
state.roster.forEach((companion) =>
appendNote(companion.characterId, '钀ュ湴寰呭懡'),
appendNote(companion.characterId, '营地待命'),
);
return lines.length > 0 ? lines.join('\n') : null;
@@ -190,12 +190,12 @@ function buildRecentConversationEventText(state: GameState) {
.map((item) => item.text)
.join('\n');
if (
/|||||/u.test(recentText)
/|||||/u.test(recentText)
) {
return 'You just went through a clash or spar, and the tension has not fully faded yet.';
return '你们刚经历过一场交锋或切磋,空气里的紧张感还没有完全散去。';
}
if (/|稿||/u.test(recentText)) {
return 'You have just cooperated in practice, so the atmosphere is a little less distant now.';
if (/|||/u.test(recentText)) {
return '你们刚并肩配合过一次,彼此之间的距离感稍微淡了一些。';
}
return null;
}
@@ -221,7 +221,7 @@ function inferConversationSituation(
.map((item) => item.text)
.join('\n');
if (
/|||||/u.test(recentText)
/|||||/u.test(recentText)
) {
return 'post_battle_breath' as const;
}
@@ -307,7 +307,7 @@ function buildStoryContextFromState(
const encounter = state.currentEncounter;
return extras.encounterNpcStateOverride
?? state.npcStates[getNpcEncounterKey(encounter)]
?? buildInitialNpcState(encounter, state.worldType);
?? buildInitialNpcState(encounter, state.worldType, state);
})()
: null;
const encounterDirective =
@@ -659,8 +659,8 @@ function hasRenderableDialogueTurns(text: string, npcName: string) {
}
function getTypewriterDelay(char: string) {
if (/[??]/u.test(char)) return 240;
if (/[?;:]/u.test(char)) return 150;
if (/[!?]/u.test(char)) return 240;
if (/[;:]/u.test(char)) return 150;
if (/\s/u.test(char)) return 45;
return 90;
}
@@ -831,7 +831,7 @@ export function useStoryGeneration({
const getResolvedNpcState = (state: GameState, encounter: Encounter) =>
state.npcStates[getNpcEncounterKey(encounter)] ??
buildInitialNpcState(encounter, state.worldType);
buildInitialNpcState(encounter, state.worldType, state);
const buildNpcStory = useCallback(
(
@@ -841,6 +841,7 @@ export function useStoryGeneration({
overrideText?: string,
) =>
buildNpcEncounterStoryMoment({
state,
encounter,
npcState: getResolvedNpcState(state, encounter),
playerCharacter: character,
@@ -960,7 +961,7 @@ export function useStoryGeneration({
const npcState =
state.npcStates[getNpcEncounterKey(encounter)] ??
buildInitialNpcState(encounter, state.worldType);
buildInitialNpcState(encounter, state.worldType, state);
if (npcState.chattedCount > 2) {
return {};
}
@@ -1484,7 +1485,7 @@ export function useStoryGeneration({
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to start story:', error);
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(
buildFallbackStoryForState(gameState, gameState.playerCharacter),
);