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

@@ -3,6 +3,7 @@ import {
Character,
CharacterConversationStyle,
Encounter,
GameState,
InventoryItem,
ItemRarity,
NpcAnswerMode,
@@ -70,8 +71,16 @@ import {
buildQuestTurnInDetail,
getQuestForIssuer,
} from './questFlow';
import { buildLooseRuntimeItemGenerationContext } from './runtimeItemContext';
import { buildRuntimeInventoryStock } from './runtimeItemDirector';
import {
buildLooseRuntimeItemGenerationContext,
buildRuntimeItemGenerationContext,
} from './runtimeItemContext';
import {
buildDirectedRuntimeReward,
buildRuntimeInventoryStock,
generateDirectedRuntimeReward,
} from './runtimeItemDirector';
import { flattenDirectedRuntimeRewardItems } from './runtimeItemNarrative';
import {
getStoryOptionPriority,
sortStoryOptionsByPriority,
@@ -81,7 +90,8 @@ export type NpcHelpReward = {
hp?: number;
mana?: number;
cooldownBonus?: number;
item?: InventoryItem;
items: InventoryItem[];
storyHint?: string;
};
export type GiftCandidate = {
@@ -337,9 +347,51 @@ function getRuntimeTradeKinds(encounter: Encounter) {
return ['consumable', 'material', 'relic', 'equipment'] as const;
}
function isRuntimeTradeDrivenRoleNpc(encounter: Encounter) {
return !encounter.characterId && !encounter.monsterPresetId;
}
function buildRuntimeTradeSeedKey(
encounter: Encounter,
sceneId?: string | null,
) {
return `npc-role:${encounter.id ?? encounter.npcName}:${encounter.context}:${sceneId ?? 'scene'}`;
}
function buildNpcTradeStockSignature(
state: GameState,
encounter: Encounter,
) {
const context = buildRuntimeItemGenerationContext({
state,
generationChannel: 'npc_trade',
encounter,
});
return [
context.worldType ?? 'unknown-world',
encounter.id ?? encounter.npcName,
context.playerBuildTags.join('|'),
context.playerEquipmentTags.join('|'),
].join('::');
}
function buildRuntimeTradeStock(
encounter: Encounter,
context: ReturnType<typeof buildRuntimeItemGenerationContext>
| ReturnType<typeof buildLooseRuntimeItemGenerationContext>,
) {
return buildRuntimeInventoryStock(context, {
seedKey: buildRuntimeTradeSeedKey(encounter, context.sceneId),
itemCount: 4,
fixedKinds: [...getRuntimeTradeKinds(encounter)],
});
}
function buildRoleInventory(
encounter: Encounter,
worldType: WorldType | null = WorldType.WUXIA,
state?: GameState | null,
) {
if (getRuntimeCustomWorldProfile()) {
return sortInventoryItems(
@@ -353,18 +405,20 @@ function buildRoleInventory(
);
}
const runtimeContext = buildLooseRuntimeItemGenerationContext({
worldType,
encounter,
generationChannel: 'npc_trade',
playerCharacterId: 'npc-trade-preview',
playerBuildTags: getNpcPreferenceTags(encounter),
});
const runtimeStock = buildRuntimeInventoryStock(runtimeContext, {
seedKey: `npc-role:${encounter.id ?? encounter.npcName}:${encounter.context}`,
itemCount: 4,
fixedKinds: [...getRuntimeTradeKinds(encounter)],
});
const runtimeContext = state
? buildRuntimeItemGenerationContext({
state,
generationChannel: 'npc_trade',
encounter,
})
: buildLooseRuntimeItemGenerationContext({
worldType,
encounter,
generationChannel: 'npc_trade',
playerCharacterId: 'npc-trade-preview',
playerBuildTags: getNpcPreferenceTags(encounter),
});
const runtimeStock = buildRuntimeTradeStock(encounter, runtimeContext);
if (runtimeStock.length > 0) {
return sortInventoryItems(runtimeStock);
@@ -665,6 +719,7 @@ export function normalizeNpcPersistentState(
knownAttributeRumors: Array.isArray(npcState.knownAttributeRumors)
? npcState.knownAttributeRumors.filter((fact): fact is string => typeof fact === 'string')
: [],
tradeStockSignature: npcState.tradeStockSignature ?? null,
firstMeaningfulContactResolved: npcState.firstMeaningfulContactResolved ?? false,
seenBackstoryChapterIds: Array.isArray(npcState.seenBackstoryChapterIds)
? npcState.seenBackstoryChapterIds.filter((fact): fact is string => typeof fact === 'string')
@@ -672,6 +727,47 @@ export function normalizeNpcPersistentState(
};
}
export function syncNpcTradeInventory(
state: GameState,
encounter: Encounter,
npcState: NpcPersistentState,
) {
if (getRuntimeCustomWorldProfile() || !isRuntimeTradeDrivenRoleNpc(encounter)) {
return npcState;
}
const tradeStockSignature = buildNpcTradeStockSignature(state, encounter);
if (npcState.tradeStockSignature === tradeStockSignature) {
return npcState;
}
const runtimeContext = buildRuntimeItemGenerationContext({
state,
generationChannel: 'npc_trade',
encounter,
});
const runtimeStock = buildRuntimeTradeStock(encounter, runtimeContext);
if (runtimeStock.length <= 0) {
return normalizeNpcPersistentState({
...npcState,
tradeStockSignature,
});
}
const preservedInventory = npcState.tradeStockSignature
? npcState.inventory.filter(
(item) => item.runtimeMetadata?.generationChannel !== 'npc_trade',
)
: [];
return normalizeNpcPersistentState({
...npcState,
inventory: sortInventoryItems([...preservedInventory, ...runtimeStock]),
tradeStockSignature,
});
}
export function isNpcFirstMeaningfulContact(
encounter: Encounter,
npcState: NpcPersistentState,
@@ -935,59 +1031,130 @@ function getNpcChatTopics(encounter: Encounter, npcState?: NpcPersistentState) {
];
}
function getHelpItem(
prefix: string,
category: string,
name: string,
rarity: ItemRarity,
tags: string[],
) {
return buildInventoryItem(prefix, category, name, 1, rarity, tags);
}
export function buildNpcHelpReward(encounter: Encounter): NpcHelpReward {
function resolveNpcHelpRewardConfig(encounter: Encounter) {
const source = getRoleSource(encounter);
if (/||/u.test(source)) {
return {
mana: 14,
item: getHelpItem('npc-help', '消耗品', '临时补给', 'uncommon', [
'healing',
'mana',
]),
baseMana: 14,
fixedKinds: ['consumable'] as const,
fixedPermanence: ['timed'] as const,
itemCount: 1,
cooldownBonus: 0,
};
}
if (/|/u.test(source)) {
return {
mana: 18,
baseMana: 18,
cooldownBonus: 1,
fixedKinds: ['consumable', 'relic'] as const,
fixedPermanence: ['timed', 'permanent'] as const,
itemCount: 1,
};
}
if (/|||/u.test(source)) {
return {
hp: 28,
baseHp: 28,
cooldownBonus: 1,
fixedKinds: ['consumable', 'equipment'] as const,
fixedPermanence: ['timed', 'permanent'] as const,
itemCount: 1,
};
}
if (/|||/u.test(source)) {
return {
mana: 20,
item: getHelpItem('npc-help', '稀有品', '旧卷残页', 'rare', [
'relic',
'mana',
]),
baseMana: 20,
fixedKinds: ['relic'] as const,
fixedPermanence: ['permanent'] as const,
itemCount: 1,
cooldownBonus: 0,
};
}
return {
hp: 18,
mana: 10,
baseHp: 18,
baseMana: 10,
fixedKinds: ['consumable'] as const,
fixedPermanence: ['timed'] as const,
itemCount: 1,
cooldownBonus: 0,
};
}
function buildNpcHelpRewardFromDirectedReward(
directedReward: ReturnType<typeof buildDirectedRuntimeReward>,
cooldownBonus = 0,
): NpcHelpReward {
return {
hp: directedReward.hp,
mana: directedReward.mana,
cooldownBonus,
items: flattenDirectedRuntimeRewardItems(directedReward),
storyHint: directedReward.storyHint,
};
}
export function buildNpcHelpReward(
encounter: Encounter,
state?: GameState | null,
): NpcHelpReward {
const rewardConfig = resolveNpcHelpRewardConfig(encounter);
const runtimeContext = state
? buildRuntimeItemGenerationContext({
state,
generationChannel: 'npc_reward',
encounter,
})
: buildLooseRuntimeItemGenerationContext({
worldType: resolveNpcInsightWorldType(null, encounter),
encounter,
generationChannel: 'npc_reward',
playerCharacterId: 'npc-help-preview',
playerBuildTags: getNpcPreferenceTags(encounter),
});
const directedReward = buildDirectedRuntimeReward(runtimeContext, {
seedKey: `npc-help:${encounter.id ?? encounter.npcName}:${runtimeContext.sceneId ?? 'scene'}`,
itemCount: rewardConfig.itemCount,
fixedKinds: [...rewardConfig.fixedKinds],
fixedPermanence: [...rewardConfig.fixedPermanence],
baseHp: rewardConfig.baseHp,
baseMana: rewardConfig.baseMana,
});
return buildNpcHelpRewardFromDirectedReward(
directedReward,
rewardConfig.cooldownBonus,
);
}
export async function generateNpcHelpReward(
encounter: Encounter,
state: GameState,
): Promise<NpcHelpReward> {
const rewardConfig = resolveNpcHelpRewardConfig(encounter);
const runtimeContext = buildRuntimeItemGenerationContext({
state,
generationChannel: 'npc_reward',
encounter,
});
const directedReward = await generateDirectedRuntimeReward(runtimeContext, {
seedKey: `npc-help:${encounter.id ?? encounter.npcName}:${runtimeContext.sceneId ?? 'scene'}`,
itemCount: rewardConfig.itemCount,
fixedKinds: [...rewardConfig.fixedKinds],
fixedPermanence: [...rewardConfig.fixedPermanence],
baseHp: rewardConfig.baseHp,
baseMana: rewardConfig.baseMana,
});
return buildNpcHelpRewardFromDirectedReward(
directedReward,
rewardConfig.cooldownBonus,
);
}
export function describeHelpReward(reward: NpcHelpReward) {
const parts: string[] = [];
@@ -995,7 +1162,8 @@ export function describeHelpReward(reward: NpcHelpReward) {
if ((reward.mana ?? 0) > 0) parts.push(`回蓝 ${reward.mana}`);
if ((reward.cooldownBonus ?? 0) > 0)
parts.push(`冷却 -${reward.cooldownBonus}`);
if (reward.item) parts.push(`获得 ${reward.item.name}`);
if (reward.items.length > 0)
parts.push(`获得 ${reward.items.map((item) => item.name).join('、')}`);
return parts.join('、') || '获得一些支援';
}
@@ -1168,6 +1336,7 @@ function getMonsterPresetForEncounter(encounter: Encounter) {
export function buildInitialNpcState(
encounter: Encounter,
worldType: WorldType | null,
state?: GameState | null,
): NpcPersistentState {
const initialAffinity =
encounter.initialAffinity ??
@@ -1177,11 +1346,11 @@ export function buildInitialNpcState(
const character = getCharacterById(encounter.characterId);
return character
? buildCharacterNpcInventory(character, worldType)
: buildRoleInventory(encounter);
: buildRoleInventory(encounter, worldType, state);
})()
: encounter.monsterPresetId
? buildMonsterPresetInventory(encounter, worldType)
: buildRoleInventory(encounter);
: buildRoleInventory(encounter, worldType, state);
const attributeRumors = buildEncounterAttributeRumors(encounter, {
worldType: resolveNpcInsightWorldType(worldType, encounter),
customWorldProfile: getRuntimeCustomWorldProfile(),
@@ -1194,6 +1363,10 @@ export function buildInitialNpcState(
chattedCount: 0,
giftsGiven: 0,
inventory,
tradeStockSignature:
state && isRuntimeTradeDrivenRoleNpc(encounter) && !getRuntimeCustomWorldProfile()
? buildNpcTradeStockSignature(state, encounter)
: null,
recruited: false,
revealedFacts: [],
knownAttributeRumors: attributeRumors,
@@ -1348,6 +1521,34 @@ export function getGiftCandidates(
});
}
export function getPreferredGiftItemId(
playerInventory: InventoryItem[],
encounter: Encounter,
options: {
worldType?: WorldType | null;
customWorldProfile?: ReturnType<typeof getRuntimeCustomWorldProfile> | null;
} = {},
) {
return (
getGiftCandidates(playerInventory, encounter, options)[0]?.item.id ?? null
);
}
export function buildGiftCandidateSummary(
giftCandidates: GiftCandidate[],
limit = 3,
) {
const preview = giftCandidates
.slice(0, limit)
.map((candidate) => `${candidate.item.name}(好感 +${candidate.affinityGain}`);
if (preview.length === 0) {
return '暂无合适礼物';
}
return preview.join('、');
}
export function checkTradeItem(
playerItem: InventoryItem | null,
npcItem: InventoryItem,
@@ -1495,6 +1696,7 @@ export function getNpcLootItems(
}
export function buildNpcEncounterStoryMoment({
state,
encounter,
npcState,
playerCharacter,
@@ -1505,6 +1707,7 @@ export function buildNpcEncounterStoryMoment({
partySize,
overrideText,
}: {
state?: GameState | null;
encounter: Encounter;
npcState: NpcPersistentState;
playerCharacter: Character;
@@ -1532,7 +1735,7 @@ export function buildNpcEncounterStoryMoment({
worldType: resolvedWorldType,
customWorldProfile: runtimeCustomWorldProfile,
});
const helpReward = buildNpcHelpReward(encounter);
const helpReward = buildNpcHelpReward(encounter, state);
const recruitable = canRecruitAnyNpc(encounter);
const recruitInsight = recruitable
? buildRecruitmentInsight({
@@ -1637,7 +1840,7 @@ export function buildNpcEncounterStoryMoment({
buildNpcOption(
NPC_GIFT_FUNCTION.id,
NPC_GIFT_FUNCTION.title,
`打开礼物面板并显示可能获得的好感度。高品质礼物更容易打动对方`,
`当前较适合送出的礼物有:${buildGiftCandidateSummary(giftCandidates)}。打开礼物面板后可查看详细好感收益`,
npcId,
'gift',
),
@@ -1750,6 +1953,13 @@ export function buildNpcGiftResultText(
return `${encounter.npcName}收下了${item.name}${describeAffinityShift(affinityGain)}${describeNpcAffinityInWords(encounter, nextAffinity)}${summaryText}`;
}
export function buildNpcGiftCommitActionText(
encounter: Encounter,
item: InventoryItem,
) {
return `${item.name}赠给${encounter.npcName}`;
}
export function buildNpcTradeResultText(
encounter: Encounter,
gainedItem: InventoryItem,
@@ -1789,11 +1999,32 @@ export function buildNpcTradeTransactionResultText({
return `${encounter.npcName}收下了${formatCurrency(totalPrice, worldType)},把${quantityText}卖给了你。`;
}
export function buildNpcTradeTransactionActionText({
encounter,
mode,
item,
quantity,
}: {
encounter: Encounter;
mode: 'buy' | 'sell';
item: InventoryItem;
quantity: number;
}) {
const quantityText = quantity > 1 ? `${item.name} x${quantity}` : item.name;
if (mode === 'sell') {
return `${quantityText}卖给${encounter.npcName}`;
}
return `${encounter.npcName}手里买下${quantityText}`;
}
export function buildNpcHelpResultText(
encounter: Encounter,
reward: NpcHelpReward,
) {
return `${encounter.npcName}向你伸出了援手。你获得了${describeHelpReward(reward)}`;
const storyHintText = reward.storyHint ? ` ${reward.storyHint}` : '';
return `${encounter.npcName}向你伸出了援手。你获得了${describeHelpReward(reward)}${storyHintText}`;
}
export function buildNpcRecruitResultText(