Refine NPC interactions and runtime item generation
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user