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,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': {