Files
Genarrative/src/data/npcInteractions.ts

2043 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AnimationState,
Character,
CharacterConversationStyle,
Encounter,
GameState,
InventoryItem,
ItemRarity,
NpcAnswerMode,
NpcBattleMode,
NpcDisclosureStage,
NpcInteractionAction,
NpcPersistentState,
NpcWarmthStage,
QuestLogEntry,
SceneMonster,
ScenePresetInfo,
StoryMoment,
StoryOption,
WorldType,
} from '../types';
import {
buildRelationState,
resolveAttributeSchema,
scoreAttributeFit,
} from './attributeResolver';
import {
getCharacterById,
getCharacterEquipment,
getCharacterMaxHp,
getInventoryItems,
resolveEncounterRecruitCharacter,
} from './characterPresets';
import {
buildCustomWorldStarterEquipmentItems,
buildCustomWorldStarterInventoryItems,
} from './customWorldCharacterLoadout';
import {
buildRuntimeCustomWorldInventoryItems,
getRuntimeCustomWorldProfile,
} from './customWorldRuntime';
import {
formatCurrency,
getDiscountTierForAffinity,
getInventoryItemValue,
getNpcPurchasePrice,
} from './economy';
import {
NPC_CHAT_FUNCTION,
NPC_FIGHT_FUNCTION,
NPC_GIFT_FUNCTION,
NPC_HELP_FUNCTION,
NPC_LEAVE_FUNCTION,
NPC_QUEST_ACCEPT_FUNCTION,
NPC_QUEST_TURN_IN_FUNCTION,
NPC_RECRUIT_FUNCTION,
NPC_SPAR_FUNCTION,
NPC_TRADE_FUNCTION,
} from './functionCatalog';
import { getMonsterPresetById } from './hostileNpcPresets';
import {
buildChatAffinityOutcome,
buildEncounterAttributeRumors,
buildGiftAffinityInsight,
buildRecruitmentInsight,
type GiftAffinityInsight,
} from './npcAttributeInsights';
import {
buildQuestAcceptDetail,
buildQuestForEncounter,
buildQuestTurnInDetail,
getQuestForIssuer,
} from './questFlow';
import {
buildLooseRuntimeItemGenerationContext,
buildRuntimeItemGenerationContext,
} from './runtimeItemContext';
import {
buildDirectedRuntimeReward,
buildRuntimeInventoryStock,
generateDirectedRuntimeReward,
} from './runtimeItemDirector';
import { flattenDirectedRuntimeRewardItems } from './runtimeItemNarrative';
import {
getStoryOptionPriority,
sortStoryOptionsByPriority,
} from './stateFunctions';
export type NpcHelpReward = {
hp?: number;
mana?: number;
cooldownBonus?: number;
items: InventoryItem[];
storyHint?: string;
};
export type GiftCandidate = {
item: InventoryItem;
affinityGain: number;
attributeInsight?: GiftAffinityInsight | null;
};
export type TradeCheckResult = {
canBarter: boolean;
canPurchase: boolean;
canAcquire: boolean;
offeredValue: number;
requiredValue: number;
purchasePrice: number;
discountTier: number;
currencyShortfall: number;
};
export const MAX_COMPANIONS = 2;
export const NPC_RECRUIT_AFFINITY = 60;
export const NPC_SPAR_AFFINITY_GAIN = 3;
const IDLE_VISUALS = {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right' as const,
scrollWorld: false,
monsterChanges: [],
};
const RARITY_SCORES: Record<ItemRarity, number> = {
common: 1,
uncommon: 2,
rare: 3,
epic: 4,
legendary: 5,
};
const RARITY_LABELS: Record<ItemRarity, string> = {
common: '普通',
uncommon: '优秀',
rare: '稀有',
epic: '史诗',
legendary: '传说',
};
function makeItemId(prefix: string, category: string, name: string) {
return `${prefix}:${encodeURIComponent(`${category}-${name}`)}`;
}
function inferItemRarity(category: string, name: string): ItemRarity {
if (/||||||/u.test(name)) return 'legendary';
if (
/|||||||/u.test(name) ||
//u.test(category)
)
return 'epic';
if (
//u.test(category) ||
/||||||||/u.test(name)
)
return 'rare';
if (/|||||||/u.test(name) || //u.test(category))
return 'uncommon';
return 'common';
}
function inferItemTags(category: string, name: string, slot?: string) {
const tags = new Set<string>();
if (/|||/u.test(name) || //u.test(category))
tags.add('healing');
if (/|||/u.test(name)) tags.add('mana');
if (/||||||/u.test(name) || //u.test(slot ?? ''))
tags.add('weapon');
if (/|||/u.test(name) || //u.test(slot ?? ''))
tags.add('armor');
if (
/||||| relic /iu.test(name) ||
/|/u.test(category)
)
tags.add('relic');
if (/||||/u.test(name) || //u.test(category))
tags.add('material');
if (tags.size === 0) tags.add('general');
return [...tags];
}
function buildInventoryItem(
prefix: string,
category: string,
name: string,
quantity: number,
rarity?: ItemRarity,
tags?: string[],
) {
return {
id: makeItemId(prefix, category, name),
category,
name,
quantity,
rarity: rarity ?? inferItemRarity(category, name),
tags: tags ?? inferItemTags(category, name),
} satisfies InventoryItem;
}
function isIdentitySensitiveInventoryItem(item: InventoryItem) {
return Boolean(
item.runtimeMetadata ||
item.equipmentSlotId ||
item.buildProfile ||
item.statProfile ||
item.attributeResonance ||
item.category.includes('专属') ||
item.rarity === 'epic' ||
item.rarity === 'legendary',
);
}
function buildInventoryMergeKey(item: InventoryItem) {
if (isIdentitySensitiveInventoryItem(item)) {
return `identity:${item.id}`;
}
const buildBuffKey = (item.useProfile?.buildBuffs ?? [])
.map((buff) => `${buff.name}:${buff.durationTurns}:${buff.tags.join('|')}`)
.join(',');
return [
item.category,
item.name,
item.rarity,
[...item.tags].sort().join('|'),
item.useProfile?.hpRestore ?? 0,
item.useProfile?.manaRestore ?? 0,
item.useProfile?.cooldownReduction ?? 0,
buildBuffKey,
].join('::');
}
function mergeInventory(items: InventoryItem[]) {
const merged = new Map<string, InventoryItem>();
for (const item of items) {
const key = buildInventoryMergeKey(item);
const existing = merged.get(key);
if (existing) {
merged.set(key, {
...existing,
quantity: existing.quantity + item.quantity,
tags: [...new Set([...existing.tags, ...item.tags])],
runtimeMetadata:
existing.runtimeMetadata ?? item.runtimeMetadata ?? null,
});
} else {
merged.set(key, {
...item,
tags: [...new Set(item.tags)],
});
}
}
return [...merged.values()];
}
function buildCharacterInventory(
character: Character,
worldType: WorldType | null,
) {
if (worldType === WorldType.CUSTOM && getRuntimeCustomWorldProfile()) {
return sortInventoryItems(buildCustomWorldStarterInventoryItems(character));
}
const packItems = getInventoryItems(character, worldType).map((item) =>
buildInventoryItem('player', item.category, item.name, item.quantity),
);
return sortInventoryItems(mergeInventory(packItems));
}
function buildCharacterNpcInventory(
character: Character,
worldType: WorldType | null,
) {
if (worldType === WorldType.CUSTOM && getRuntimeCustomWorldProfile()) {
const starterEquipment = buildCustomWorldStarterEquipmentItems(character);
const starterInventory = buildCustomWorldStarterInventoryItems(character);
return sortInventoryItems(
mergeInventory([
...(Object.values(starterEquipment).filter(Boolean) as InventoryItem[]),
...starterInventory,
]),
);
}
const equipmentItems = getCharacterEquipment(character).map((item) =>
buildInventoryItem(
`npc-${character.id}`,
item.slot,
item.item,
1,
item.rarity === '史诗'
? 'epic'
: item.rarity === '稀有'
? 'rare'
: 'common',
inferItemTags(item.slot, item.item, item.slot),
),
);
const packItems = getInventoryItems(character, worldType).map((item) =>
buildInventoryItem(
`npc-${character.id}`,
item.category,
item.name,
item.quantity,
),
);
return sortInventoryItems(mergeInventory([...equipmentItems, ...packItems]));
}
function getRoleSource(encounter: Encounter) {
return `${encounter.context} ${encounter.npcName}`;
}
function getEncounterConversationStyle(
encounter: Encounter,
): CharacterConversationStyle {
const recruitCharacter = resolveEncounterRecruitCharacter(encounter);
return (
recruitCharacter?.conversationStyle ?? {
guardStyle: 'measured',
warmStyle: 'steady',
truthStyle: 'fragmented',
}
);
}
function getRuntimeTradeKinds(encounter: Encounter) {
const source = getRoleSource(encounter);
if (/||/u.test(source))
return ['consumable', 'material', 'relic', 'equipment'] as const;
if (/|/u.test(source))
return ['material', 'relic', 'consumable', 'relic'] as const;
if (//u.test(source))
return ['material', 'consumable', 'equipment', 'relic'] as const;
if (/|||/u.test(source))
return ['relic', 'material', 'consumable', 'quest'] as const;
if (/||/u.test(source))
return ['equipment', 'equipment', 'consumable', 'relic'] as const;
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(
buildRuntimeCustomWorldInventoryItems(
`npc-role:${encounter.id ?? encounter.npcName}:${encounter.context}`,
{
count: 4,
categories: ['消耗品', '材料', '拥有品', '食物', '专属物品'],
},
),
);
}
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);
}
const source = getRoleSource(encounter);
if (/||/u.test(source)) {
return sortInventoryItems([
buildInventoryItem('npc-role', '消耗品', '疗伤丹', 3, 'uncommon', [
'healing',
]),
buildInventoryItem('npc-role', '消耗品', '聚气散', 2, 'uncommon', [
'mana',
]),
buildInventoryItem('npc-role', '随身物', '商人令牌', 1, 'rare', [
'relic',
]),
buildInventoryItem('npc-role', '材料', '优质矿石', 4, 'uncommon', [
'material',
]),
]);
}
if (/|/u.test(source)) {
return sortInventoryItems([
buildInventoryItem('npc-role', '材料', '渡水船符', 1, 'rare', [
'material',
]),
buildInventoryItem('npc-role', '随身物', '渡河图卷', 1, 'epic', [
'relic',
]),
buildInventoryItem('npc-role', '消耗品', '回气丹', 2, 'uncommon', [
'healing',
'mana',
]),
]);
}
if (//u.test(source)) {
return sortInventoryItems([
buildInventoryItem('npc-role', '材料', '兽皮', 3, 'uncommon', [
'material',
]),
buildInventoryItem('npc-role', '消耗品', '丹药盒', 2, 'uncommon', [
'healing',
]),
buildInventoryItem('npc-role', '随身物', '狩猎指南', 1, 'rare', [
'relic',
]),
]);
}
if (/|||/u.test(source)) {
return sortInventoryItems([
buildInventoryItem('npc-role', '随身物', '拓本残卷', 1, 'rare', [
'relic',
]),
buildInventoryItem('npc-role', '材料', '古地图', 1, 'uncommon', [
'material',
]),
buildInventoryItem('npc-role', '消耗品', '静心符', 2, 'uncommon', [
'mana',
]),
]);
}
if (/||/u.test(source)) {
return sortInventoryItems([
buildInventoryItem('npc-role', '武器', '制式腰刀', 1, 'rare', ['weapon']),
buildInventoryItem('npc-role', '防具', '护腕', 1, 'uncommon', ['armor']),
buildInventoryItem('npc-role', '消耗品', '止血散', 2, 'uncommon', [
'healing',
]),
]);
}
return sortInventoryItems([
buildInventoryItem('npc-role', '消耗品', '续命药盒', 2, 'uncommon', [
'healing',
]),
buildInventoryItem('npc-role', '材料', '普通矿石', 2, 'common', [
'material',
]),
buildInventoryItem('npc-role', '随身物', '随身杂物', 1, 'rare', ['relic']),
]);
}
function buildRecruitablePreferences(character: Character) {
const values = character.attributeProfile?.values ?? {};
const scores = [
{
tag: 'weapon',
value: (values.axis_a ?? 0) + (values.axis_b ?? 0) + (values.axis_d ?? 0),
},
{ tag: 'armor', value: (values.axis_a ?? 0) + (values.axis_f ?? 0) },
{ tag: 'mana', value: (values.axis_c ?? 0) + (values.axis_f ?? 0) },
{ tag: 'relic', value: (values.axis_c ?? 0) + (values.axis_e ?? 0) },
]
.sort((a, b) => b.value - a.value)
.slice(0, 2)
.map((item) => item.tag);
return [...new Set(scores)];
}
function getNpcPreferenceTags(encounter: Encounter) {
const recruitCharacter = resolveEncounterRecruitCharacter(encounter);
if (recruitCharacter) return buildRecruitablePreferences(recruitCharacter);
const source = getRoleSource(encounter);
if (/||/u.test(source)) return ['relic', 'material', 'mana'];
if (/|/u.test(source)) return ['relic', 'material'];
if (//u.test(source)) return ['healing', 'material', 'weapon'];
if (/|||/u.test(source)) return ['relic', 'mana', 'material'];
if (/||/u.test(source)) return ['weapon', 'armor', 'relic'];
return ['relic', 'healing'];
}
function resolveNpcInsightWorldType(
worldType: WorldType | null,
encounter: Encounter,
) {
if (worldType) return worldType;
if (getRuntimeCustomWorldProfile()) return WorldType.CUSTOM;
if (encounter.attributeProfile?.schemaId?.includes('xianxia'))
return WorldType.XIANXIA;
return WorldType.WUXIA;
}
function _canTradeWithNpc(encounter: Encounter) {
return (
encounter.characterId !== undefined ||
/||||||||/u.test(getRoleSource(encounter))
);
}
function _canOfferHelp(encounter: Encounter) {
return (
/|||||||||/u.test(getRoleSource(encounter)) ||
Boolean(encounter.characterId)
);
}
function _canRecruitNpc(encounter: Encounter) {
return Boolean(
encounter.characterId && getCharacterById(encounter.characterId),
);
}
function canTradeWithAnyNpc(_encounter: Encounter) {
return true;
}
function canOfferHelpToAnyNpc(_encounter: Encounter) {
return true;
}
function canRecruitAnyNpc(encounter: Encounter) {
return Boolean(resolveEncounterRecruitCharacter(encounter));
}
export function getNpcDisclosureStage(
affinity: number,
options: { recruited?: boolean } = {},
): NpcDisclosureStage {
if (options.recruited || affinity >= 50) return 'deep';
if (affinity >= 30) return 'honest';
if (affinity >= 15) return 'partial';
return 'guarded';
}
export function getNpcWarmthStage(
affinity: number,
options: { recruited?: boolean } = {},
): NpcWarmthStage {
if (options.recruited || affinity >= 50) return 'warm';
if (affinity >= 30) return 'cooperative';
if (affinity >= 15) return 'neutral';
return 'distant';
}
export function getNpcAnswerMode(stage: NpcDisclosureStage): NpcAnswerMode {
switch (stage) {
case 'deep':
return 'candid';
case 'honest':
return 'true_but_incomplete';
case 'partial':
return 'half_truth';
default:
return 'situational_only';
}
}
export function describeDisclosureStage(stage: NpcDisclosureStage) {
switch (stage) {
case 'deep':
return '已经愿意逐步谈到真实来历、真正目标与旧事恩怨。';
case 'honest':
return '开始认真回答,会说真话,但仍会保留最深的一层。';
case 'partial':
return '会松口一点,给出半真半假的说明。';
default:
return '只谈眼前局势、态度和试探,不会主动交底。';
}
}
export function describeWarmthStage(stage: NpcWarmthStage) {
switch (stage) {
case 'warm':
return '语气明显更友善亲切,也更照顾你的感受。';
case 'cooperative':
return '语气愿意配合,会把话说得更实在。';
case 'neutral':
return '语气恢复正常交流,但仍保留分寸。';
default:
return '语气偏冷,仍在观察你值不值得信。';
}
}
export function describeConversationStyle(style: CharacterConversationStyle) {
const guardText =
style.guardStyle === 'blunt'
? '守口风格:直硬,不喜欢绕弯'
: style.guardStyle === 'wary'
? '守口风格:谨慎,先看局势再开口'
: style.guardStyle === 'evasive'
? '守口风格:会回避锋芒,拿别的话挡一下'
: '守口风格:克制,有分寸地往外放信息';
const warmText =
style.warmStyle === 'dry'
? '亲和风格:冷淡'
: style.warmStyle === 'gentle'
? '亲和风格:温和'
: style.warmStyle === 'teasing'
? '亲和风格:带点松弛感和轻微调侃'
: '亲和风格:平稳';
const truthText =
style.truthStyle === 'direct'
? '讲真话方式:一旦说,就会直给'
: style.truthStyle === 'deflecting'
? '讲真话方式:先绕一下再说'
: '讲真话方式:会一段一段碎片式透露';
return [guardText, warmText, truthText].join('');
}
export function getNpcConversationDirective(
encounter: Encounter,
npcState: NpcPersistentState,
) {
const style = getEncounterConversationStyle(encounter);
const disclosureStage = getNpcDisclosureStage(npcState.affinity, {
recruited: npcState.recruited,
});
const warmthStage = getNpcWarmthStage(npcState.affinity, {
recruited: npcState.recruited,
});
const answerMode = getNpcAnswerMode(disclosureStage);
const allowTopics =
disclosureStage === 'guarded'
? ['眼前危险', '现场判断', '对玩家的态度', '模糊钩子']
: disclosureStage === 'partial'
? ['眼前危险', '表层理由', '试探性解释', '有限背景']
: disclosureStage === 'honest'
? ['真实动机的轮廓', '旧事碎片', '真正目标的一部分']
: ['真实来历', '真正目标', '旧事恩怨', '未说完的核心问题'];
const blockedTopics =
disclosureStage === 'guarded'
? ['完整来历', '真正目标', '旧事全貌']
: disclosureStage === 'partial'
? ['完整来历', '旧事全貌']
: disclosureStage === 'honest'
? ['把全部底牌一次说完']
: [];
return {
affinity: npcState.affinity,
disclosureStage,
warmthStage,
answerMode,
style,
allowTopics,
blockedTopics,
};
}
export function normalizeNpcPersistentState(
npcState: NpcPersistentState,
): NpcPersistentState {
return {
...npcState,
relationState: buildRelationState(npcState.affinity),
revealedFacts: Array.isArray(npcState.revealedFacts)
? npcState.revealedFacts.filter((fact): fact is string => typeof fact === 'string')
: [],
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')
: [],
};
}
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,
) {
return Boolean(
encounter.characterId
&& !npcState.recruited
&& npcState.firstMeaningfulContactResolved !== true,
);
}
export function markNpcFirstMeaningfulContactResolved(
npcState: NpcPersistentState,
) {
return normalizeNpcPersistentState({
...npcState,
firstMeaningfulContactResolved: true,
});
}
function getFirstContactRelationStance(npcState: NpcPersistentState) {
return npcState.relationState?.stance ?? buildRelationState(npcState.affinity).stance;
}
export function getNpcFirstContactTopics(
encounter: Encounter,
npcState: NpcPersistentState,
) {
const stance = getFirstContactRelationStance(npcState);
switch (stance) {
case 'cooperative':
return [
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '先问问你刚才为什么会主动提醒我',
detailText: '确认你主动靠近的理由',
},
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '问问你对眼前局势已经看出了什么',
detailText: '先交换第一判断',
},
];
case 'bonded':
return [
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '先确认你为什么会在这一步亲自现身',
detailText: '确认这次正面对接的来意',
},
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '问问你现在最想先和我处理哪件事',
detailText: '先对齐眼前优先级',
},
];
case 'neutral':
return [
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '先问问你为什么会出现在这里',
detailText: '先摸清来意',
},
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '试着确认你刚才那句提醒到底是什么意思',
detailText: '探探你对局势的判断',
},
];
default:
return [
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: `先问问${encounter.npcName}刚才为什么一直在观察你`,
detailText: '从彼此试探切入',
},
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '问问前面到底有什么不对劲',
detailText: '先谈眼前动静',
},
];
}
}
function buildNpcFirstContactStoryText(
encounter: Encounter,
npcState: NpcPersistentState,
sceneName?: string | null,
) {
const stance = getFirstContactRelationStance(npcState);
const sceneText = sceneName ?? '当前地界';
switch (stance) {
case 'cooperative':
return `${sceneText}里,你第一次真正和${encounter.npcName}正面对上。对方对你并不生分,但仍把分寸拿得很稳,像是在等你先把话说开。`;
case 'bonded':
return `${sceneText}里,你第一次真正和${encounter.npcName}把话对上。你们之间已有相当信任,但此刻仍像一次需要重新校准节奏的正式会面。`;
case 'neutral':
return `${sceneText}里,你第一次真正和${encounter.npcName}打了照面。对方没有立刻疏远你,却仍在观察你会先怎么开口。`;
default:
return `${sceneText}里,你第一次真正和${encounter.npcName}对上视线。对方明显在打量你,像是在判断你值不值得回应。`;
}
}
function getNpcChatTopics(encounter: Encounter, npcState?: NpcPersistentState) {
const source = getRoleSource(encounter);
const directive = npcState
? getNpcConversationDirective(encounter, npcState)
: null;
const stage = directive?.disclosureStage ?? 'guarded';
if (npcState && isNpcFirstMeaningfulContact(encounter, npcState)) {
return getNpcFirstContactTopics(encounter, npcState);
}
if (encounter.characterId) {
if (stage === 'guarded') {
return [
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '先问问你刚才在留意什么',
detailText: '从眼前动静切入',
},
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '试探你为什么提醒我别硬闯',
detailText: '探探你的态度和判断',
},
];
}
if (stage === 'partial') {
return [
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '问问你为什么还留在这里没走',
detailText: '追一点表层理由',
},
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '探探你是不是早知道前面会出事',
detailText: '试着撬开半句真话',
},
];
}
if (stage === 'honest') {
return [
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '问问你和前面的麻烦到底有什么牵连',
detailText: '追动机和旧事轮廓',
},
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '聊聊你现在最想先解决的事',
detailText: '开始碰真正目标',
},
];
}
return [
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '让你把这一路没说完的事讲清楚',
detailText: '追问真实来历和目标',
},
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '问问你真正想把这条路走到哪里',
detailText: '追问最终目标',
},
];
}
if (/||/u.test(source)) {
return [
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '问问这条路上最近的行情',
detailText: '打听最近行情',
},
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '旁敲侧击最近见过哪些可疑人物',
detailText: '打听可疑来客',
},
];
}
if (/|/u.test(source)) {
return [
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '打听最近经过渡口的人',
detailText: '问问往来人影',
},
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '聊聊这条水路最近安不安稳',
detailText: '问问水路动静',
},
];
}
if (//u.test(source)) {
return [
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '询问附近的兽踪和异动',
detailText: '摸清周围异动',
},
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '问问这片地界有没有稳妥的走法',
detailText: '问问稳妥走法',
},
];
}
if (/|||/u.test(source)) {
return [
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '聊聊这片地界的旧事',
detailText: '打听旧闻背景',
},
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '问问这里最近有没有反常的征兆',
detailText: '问问异常征兆',
},
];
}
if (/||/u.test(source)) {
return [
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '打听这附近的规矩和动向',
detailText: '摸清规矩风声',
},
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '试探最近有没有人在暗中盯着这里',
detailText: '试探暗中埋伏',
},
];
}
return [
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '随口聊聊眼前的局势',
detailText: '试探眼前局势',
},
{
functionId: NPC_CHAT_FUNCTION.id,
actionText: '问问前面这条路值不值得继续走',
detailText: '问问前路风险',
},
];
}
function resolveNpcHelpRewardConfig(encounter: Encounter) {
const source = getRoleSource(encounter);
if (/||/u.test(source)) {
return {
baseMana: 14,
fixedKinds: ['consumable'] as const,
fixedPermanence: ['timed'] as const,
itemCount: 1,
cooldownBonus: 0,
};
}
if (/|/u.test(source)) {
return {
baseMana: 18,
cooldownBonus: 1,
fixedKinds: ['consumable', 'relic'] as const,
fixedPermanence: ['timed', 'permanent'] as const,
itemCount: 1,
};
}
if (/|||/u.test(source)) {
return {
baseHp: 28,
cooldownBonus: 1,
fixedKinds: ['consumable', 'equipment'] as const,
fixedPermanence: ['timed', 'permanent'] as const,
itemCount: 1,
};
}
if (/|||/u.test(source)) {
return {
baseMana: 20,
fixedKinds: ['relic'] as const,
fixedPermanence: ['permanent'] as const,
itemCount: 1,
cooldownBonus: 0,
};
}
return {
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[] = [];
if ((reward.hp ?? 0) > 0) parts.push(`回血 ${reward.hp}`);
if ((reward.mana ?? 0) > 0) parts.push(`回蓝 ${reward.mana}`);
if ((reward.cooldownBonus ?? 0) > 0)
parts.push(`冷却 -${reward.cooldownBonus}`);
if (reward.items.length > 0)
parts.push(`获得 ${reward.items.map((item) => item.name).join('、')}`);
return parts.join('、') || '获得一些支援';
}
function getNpcActionText(encounter: Encounter) {
return encounter.characterId
? `${encounter.npcName}看上去对你保持着戒备,也在观察你的反应。`
: `${encounter.npcName}停在你面前,像是在等你先开口。`;
}
function buildNpcOption(
functionId: string,
actionText: string,
detailText: string,
npcId: string,
action: NpcInteractionAction,
questId?: string,
) {
const resolvedDetailText =
functionId === NPC_CHAT_FUNCTION.id ? '' : detailText;
return {
functionId,
actionText,
detailText: resolvedDetailText,
priority: getStoryOptionPriority(functionId),
visuals: IDLE_VISUALS,
interaction: {
kind: 'npc',
npcId,
action,
questId,
},
} as StoryOption;
}
function getPlayerBenefitScore(item: InventoryItem, character: Character) {
let score = getInventoryItemValue(item);
const customWorldProfile = getRuntimeCustomWorldProfile();
const schema = resolveAttributeSchema(
customWorldProfile ? WorldType.CUSTOM : WorldType.WUXIA,
customWorldProfile,
);
if (item.tags.includes('healing')) score += 8;
if (
item.tags.includes('mana') &&
character.attributes.intelligence + character.attributes.spirit >= 12
)
score += 6;
if (item.tags.includes('weapon')) score += 5;
if (item.tags.includes('armor')) score += 4;
if (item.tags.includes('relic')) score += 5;
if (item.attributeResonance) {
score += Math.round(
scoreAttributeFit(
character.attributeProfile,
item.attributeResonance.resonanceVector,
schema,
) * 20,
);
}
return score;
}
function pickTopItems(
items: InventoryItem[],
score: (item: InventoryItem) => number,
limit = 2,
) {
return [...items]
.filter((item) => item.quantity > 0)
.sort((a, b) => {
const diff = score(b) - score(a);
if (diff !== 0) return diff;
return b.quantity - a.quantity;
})
.slice(0, limit);
}
export function summarizeInventoryItems(items: InventoryItem[], limit = 2) {
const names = items
.filter((item) => item.quantity > 0)
.slice(0, limit)
.map((item) => item.name);
return names.length > 0 ? names.join('、') : '暂无值得一提的物品';
}
export function getRarityScore(rarity: ItemRarity) {
return RARITY_SCORES[rarity];
}
export function getRarityLabel(rarity: ItemRarity) {
return RARITY_LABELS[rarity];
}
export function sortInventoryItems(items: InventoryItem[]) {
return [...items].sort((a, b) => {
const rarityDiff = getRarityScore(b.rarity) - getRarityScore(a.rarity);
if (rarityDiff !== 0) return rarityDiff;
const categoryDiff = a.category.localeCompare(b.category, 'zh-Hans-CN');
if (categoryDiff !== 0) return categoryDiff;
return a.name.localeCompare(b.name, 'zh-Hans-CN');
});
}
export function addInventoryItems(
base: InventoryItem[],
additions: InventoryItem[],
) {
return sortInventoryItems(mergeInventory([...base, ...additions]));
}
export function removeInventoryItem(
base: InventoryItem[],
itemId: string,
quantity = 1,
) {
return sortInventoryItems(
base
.map((item) =>
item.id === itemId
? {
...item,
quantity: Math.max(0, item.quantity - quantity),
}
: item,
)
.filter((item) => item.quantity > 0),
);
}
export function buildInitialPlayerInventory(
character: Character,
worldType: WorldType | null,
) {
return buildCharacterInventory(character, worldType);
}
function buildMonsterPresetInventory(
encounter: Encounter,
worldType: WorldType | null,
) {
if (!worldType || !encounter.monsterPresetId)
return buildRoleInventory(encounter);
const preset = getMonsterPresetById(worldType, encounter.monsterPresetId);
if (!preset) return buildRoleInventory(encounter);
return sortInventoryItems(
preset.lootTable.map((entry) => ({
...entry.item,
quantity: Math.max(1, entry.item.quantity),
})),
);
}
function getMonsterPresetForEncounter(encounter: Encounter) {
if (!encounter.monsterPresetId) return null;
return (
getMonsterPresetById(WorldType.WUXIA, encounter.monsterPresetId) ??
getMonsterPresetById(WorldType.XIANXIA, encounter.monsterPresetId) ??
getMonsterPresetById(WorldType.CUSTOM, encounter.monsterPresetId)
);
}
export function buildInitialNpcState(
encounter: Encounter,
worldType: WorldType | null,
state?: GameState | null,
): NpcPersistentState {
const initialAffinity =
encounter.initialAffinity ??
(encounter.monsterPresetId ? -40 : encounter.characterId ? 18 : 6);
const inventory = encounter.characterId
? (() => {
const character = getCharacterById(encounter.characterId);
return character
? buildCharacterNpcInventory(character, worldType)
: buildRoleInventory(encounter, worldType, state);
})()
: encounter.monsterPresetId
? buildMonsterPresetInventory(encounter, worldType)
: buildRoleInventory(encounter, worldType, state);
const attributeRumors = buildEncounterAttributeRumors(encounter, {
worldType: resolveNpcInsightWorldType(worldType, encounter),
customWorldProfile: getRuntimeCustomWorldProfile(),
});
return normalizeNpcPersistentState({
affinity: initialAffinity,
relationState: buildRelationState(initialAffinity),
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory,
tradeStockSignature:
state && isRuntimeTradeDrivenRoleNpc(encounter) && !getRuntimeCustomWorldProfile()
? buildNpcTradeStockSignature(state, encounter)
: null,
recruited: false,
revealedFacts: [],
knownAttributeRumors: attributeRumors,
firstMeaningfulContactResolved: false,
seenBackstoryChapterIds: [],
});
}
export function getNpcDiscountTier(affinity: number) {
return getDiscountTierForAffinity(affinity);
}
export function getNpcDiscountText(affinity: number) {
const tier = getNpcDiscountTier(affinity);
if (tier <= 0) return '当前暂无折扣,需要按原价交换或购买。';
return `当前折扣:商品价格降低 ${tier * 8}% 。`;
}
export function getNpcProgressText(encounter: Encounter, affinity: number) {
const parts = ['好感 30/60/90 可继续解锁更高折扣'];
if (canRecruitAnyNpc(encounter)) {
parts.push(`好感达到 ${NPC_RECRUIT_AFFINITY} 可招募入队`);
}
parts.push(`当前好感 ${affinity}`);
return parts.join('');
}
export function describeNpcAffinityInWords(
encounter: Encounter,
affinity: number,
options: {
recruited?: boolean;
} = {},
) {
if (options.recruited) {
return '已经把你视为并肩而行的同伴,交流时天然站在你这一边。';
}
if (affinity >= 90) {
return canRecruitAnyNpc(encounter)
? '对你高度信赖,言谈间明显亲近,几乎已经把你当成自己人。'
: '对你高度信赖,态度亲近,也愿意主动照应你的处境。';
}
if (affinity >= 50) {
return canRecruitAnyNpc(encounter)
? '已经真正信任你,愿意认真考虑与你同行。'
: '已经真正信任你,交流坦率,也愿意提供实在帮助。';
}
if (affinity >= 30) {
return '态度已经友善了许多,愿意配合你,也会给出更真诚的回应。';
}
if (affinity >= 15) {
return '戒备正在松动,愿意正常交谈,并试探性地和你合作。';
}
if (affinity > 0) {
return '仍带着明显戒心,只在礼貌范围内回应你。';
}
return '关系已经降到冰点,对你几乎不再保留善意。';
}
function describeAffinityShift(affinityGain: number) {
if (affinityGain >= 12) return '态度一下子软化了许多';
if (affinityGain >= 8) return '态度明显和缓下来';
if (affinityGain >= 5) return '态度比先前亲近了一些';
return '态度略微放松了些';
}
export function getChatAffinityGain(npcState: NpcPersistentState) {
return Math.max(4, 8 - npcState.chattedCount);
}
export function getChatAffinityOutcome(params: {
playerCharacter: Character;
encounter: Encounter;
npcState: NpcPersistentState;
actionText: string;
worldType?: WorldType | null;
customWorldProfile?: ReturnType<typeof getRuntimeCustomWorldProfile> | null;
}) {
return buildChatAffinityOutcome({
playerCharacter: params.playerCharacter,
encounter: params.encounter,
npcState: params.npcState,
actionText: params.actionText,
worldType: resolveNpcInsightWorldType(
params.worldType ?? null,
params.encounter,
),
customWorldProfile:
params.customWorldProfile ?? getRuntimeCustomWorldProfile(),
});
}
function buildGiftCandidate(
item: InventoryItem,
encounter: Encounter,
options: {
worldType?: WorldType | null;
customWorldProfile?: ReturnType<typeof getRuntimeCustomWorldProfile> | null;
} = {},
): GiftCandidate {
const base = 4 + getRarityScore(item.rarity) * 3;
const preferenceTags = getNpcPreferenceTags(encounter);
const preferenceBonus = item.tags.some((tag) => preferenceTags.includes(tag))
? 5
: 0;
const specialBonus = item.category.includes('专属') ? 4 : 0;
const insight = buildGiftAffinityInsight(item, encounter, {
worldType: resolveNpcInsightWorldType(options.worldType ?? null, encounter),
customWorldProfile:
options.customWorldProfile ?? getRuntimeCustomWorldProfile(),
});
const resonanceBonus = insight.resonanceBonus ?? 0;
return {
item,
affinityGain: Math.min(
24,
base + preferenceBonus + specialBonus + resonanceBonus,
),
attributeInsight: insight,
};
}
export function calculateGiftAffinityGain(
item: InventoryItem,
encounter: Encounter,
) {
return buildGiftCandidate(item, encounter).affinityGain;
}
export function getGiftCandidates(
playerInventory: InventoryItem[],
encounter: Encounter,
options: {
worldType?: WorldType | null;
customWorldProfile?: ReturnType<typeof getRuntimeCustomWorldProfile> | null;
} = {},
) {
return [...playerInventory]
.filter((item) => item.quantity > 0)
.map((item) => buildGiftCandidate(item, encounter, options))
.sort((a, b) => {
const diff = b.affinityGain - a.affinityGain;
if (diff !== 0) return diff;
return getRarityScore(b.item.rarity) - getRarityScore(a.item.rarity);
});
}
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,
affinity: number,
playerCurrency = 0,
): TradeCheckResult {
const offeredValue = playerItem ? getInventoryItemValue(playerItem) : 0;
const discountTier = getNpcDiscountTier(affinity);
const purchasePrice = getNpcPurchasePrice(npcItem, affinity);
const canBarter = Boolean(playerItem && offeredValue >= purchasePrice);
const canPurchase = playerCurrency >= purchasePrice;
return {
canBarter,
canPurchase,
canAcquire: canBarter || canPurchase,
offeredValue,
requiredValue: purchasePrice,
purchasePrice,
discountTier,
currencyShortfall: Math.max(0, purchasePrice - playerCurrency),
};
}
export function getNpcSparMaxHp(character: Character | null) {
if (!character) return 8;
const values = character.attributeProfile?.values ?? {};
const sparScore =
((values.axis_a ?? 0) + (values.axis_b ?? 0) + (values.axis_f ?? 0)) / 10;
return Math.max(7, Math.min(12, Math.round(sparScore / 3)));
}
export function createNpcBattleMonster(
encounter: Encounter,
npcState: NpcPersistentState,
mode: NpcBattleMode = 'fight',
) {
const monsterPreset = getMonsterPresetForEncounter(encounter);
const recruitCharacter = resolveEncounterRecruitCharacter(encounter);
if (monsterPreset) {
const hostileMaxHp =
mode === 'spar'
? Math.max(
8,
Math.min(14, Math.round(monsterPreset.baseStats.maxHp / 18)),
)
: monsterPreset.baseStats.maxHp;
return {
id: monsterPreset.id,
name: encounter.npcName,
action:
mode === 'spar'
? '敌对/切磋前蓄力,点击后转为原地闪避'
: monsterPreset.introAction,
description: encounter.npcDescription,
animation: 'idle' as const,
xMeters: 3.2,
yOffset: 0,
facing: 'left' as const,
attackRange: monsterPreset.baseStats.attackRange,
speed: monsterPreset.baseStats.speed,
hp: hostileMaxHp,
maxHp: hostileMaxHp,
renderKind: 'npc' as const,
combatTags: monsterPreset.combatTags,
attributeProfile: monsterPreset.attributeProfile,
behaviorVectors: monsterPreset.behaviorVectors,
encounter: {
...encounter,
hostile: true,
xMeters: 3.2,
},
} satisfies SceneMonster;
}
const baseHp = recruitCharacter ? getCharacterMaxHp(recruitCharacter) : 120;
const baseSpeed = recruitCharacter
? Math.max(
6,
Math.round(
(recruitCharacter.attributeProfile?.values.axis_b ?? 48) / 12 + 1,
),
)
: 7;
const maxHp =
mode === 'spar'
? getNpcSparMaxHp(recruitCharacter)
: Math.max(baseHp, 80 + npcState.affinity);
if (mode === 'spar') {
return {
id: `npc-opponent-${encounter.id ?? encounter.npcName}`,
name: encounter.npcName,
action: '抱拳行礼,准备点到为止地切磋武艺',
description: encounter.npcDescription,
animation: 'idle' as const,
xMeters: 3.2,
yOffset: 0,
facing: 'left' as const,
attackRange: 1.8,
speed: baseSpeed,
hp: maxHp,
maxHp,
renderKind: 'npc' as const,
encounter: {
...encounter,
xMeters: 3.2,
},
} satisfies SceneMonster;
}
return {
id: `npc-opponent-${encounter.id ?? encounter.npcName}`,
name: encounter.npcName,
action: '摆开架势,随时准备出手',
description: encounter.npcDescription,
animation: 'idle' as const,
xMeters: 3.2,
yOffset: 0,
facing: 'left' as const,
attackRange: 1.8,
speed: baseSpeed,
hp: Math.max(baseHp, 80 + npcState.affinity),
maxHp: Math.max(baseHp, 80 + npcState.affinity),
renderKind: 'npc' as const,
encounter: {
...encounter,
xMeters: 3.2,
},
} satisfies SceneMonster;
}
export function getNpcLootItems(
npcState: NpcPersistentState,
character: Character,
) {
return pickTopItems(
npcState.inventory,
(item) => getPlayerBenefitScore(item, character),
2,
).map((item) => ({
...item,
quantity: 1,
}));
}
export function buildNpcEncounterStoryMoment({
state,
encounter,
npcState,
playerCharacter,
playerInventory,
activeQuests,
scene,
worldType,
partySize,
overrideText,
}: {
state?: GameState | null;
encounter: Encounter;
npcState: NpcPersistentState;
playerCharacter: Character;
playerInventory: InventoryItem[];
activeQuests: QuestLogEntry[];
scene: Pick<
ScenePresetInfo,
'id' | 'name' | 'monsterIds' | 'npcs' | 'treasureHints'
> | null;
worldType: WorldType | null;
partySize: number;
overrideText?: string;
}): StoryMoment {
const npcId = encounter.id ?? encounter.npcName;
const resolvedWorldType = resolveNpcInsightWorldType(worldType, encounter);
const runtimeCustomWorldProfile = getRuntimeCustomWorldProfile();
const lootHighlights = pickTopItems(
npcState.inventory,
(item) =>
getPlayerBenefitScore(item, playerCharacter) +
getRarityScore(item.rarity),
2,
);
const giftCandidates = getGiftCandidates(playerInventory, encounter, {
worldType: resolvedWorldType,
customWorldProfile: runtimeCustomWorldProfile,
});
const helpReward = buildNpcHelpReward(encounter, state);
const recruitable = canRecruitAnyNpc(encounter);
const recruitInsight = recruitable
? buildRecruitmentInsight({
encounter,
npcState,
playerCharacter,
worldType: resolvedWorldType,
customWorldProfile: runtimeCustomWorldProfile,
})
: null;
const activeQuest = getQuestForIssuer(activeQuests, npcId);
const generatedQuest = buildQuestForEncounter({
issuerNpcId: npcId,
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene,
worldType,
});
const options: StoryOption[] = [];
const isHostileEncounter =
npcState.affinity < 0 ||
Boolean(encounter.hostile) ||
Boolean(encounter.monsterPresetId);
if (isHostileEncounter) {
options.push(
buildNpcOption(
NPC_FIGHT_FUNCTION.id,
`迎战${encounter.npcName}`,
'对方敌意已明确,靠近后就会直接进入战斗。',
npcId,
'fight',
),
);
return {
text:
overrideText ??
`${scene?.name ?? '当前地界'}里,${encounter.npcName}已将你视为敌人。它一照面就摆出了进攻姿态,当前好感为 ${npcState.affinity}`,
options: sortStoryOptionsByPriority(options),
};
}
if (canTradeWithAnyNpc(encounter) && npcState.inventory.length > 0) {
options.push(
buildNpcOption(
NPC_TRADE_FUNCTION.id,
NPC_TRADE_FUNCTION.title,
'查看库存与价格',
npcId,
'trade',
),
);
}
options.push(
buildNpcOption(
NPC_FIGHT_FUNCTION.id,
NPC_FIGHT_FUNCTION.title,
`以恶意掠夺为目的强行开战。好感度归 0并可能掠夺${summarizeInventoryItems(lootHighlights)}`,
npcId,
'fight',
),
);
options.push(
buildNpcOption(
NPC_SPAR_FUNCTION.id,
NPC_SPAR_FUNCTION.title,
'点到为止地过招。战斗中双方每次仅造成 1 点伤害,结束后小幅提升好感度。',
npcId,
'spar',
),
);
if (canOfferHelpToAnyNpc(encounter) && !npcState.helpUsed) {
options.push(
buildNpcOption(
NPC_HELP_FUNCTION.id,
NPC_HELP_FUNCTION.title,
`可能获得${describeHelpReward(helpReward)}。每位角色仅限一次。`,
npcId,
'help',
),
);
}
getNpcChatTopics(encounter, npcState).forEach((topic) => {
options.push(
buildNpcOption(
topic.functionId,
topic.actionText,
topic.detailText,
npcId,
'chat',
),
);
});
if (giftCandidates.length > 0) {
options.push(
buildNpcOption(
NPC_GIFT_FUNCTION.id,
NPC_GIFT_FUNCTION.title,
`当前较适合送出的礼物有:${buildGiftCandidateSummary(giftCandidates)}。打开礼物面板后可查看详细好感收益。`,
npcId,
'gift',
),
);
}
if (
recruitable &&
!npcState.recruited &&
npcState.affinity >= NPC_RECRUIT_AFFINITY
) {
options.push(
buildNpcOption(
NPC_RECRUIT_FUNCTION.id,
NPC_RECRUIT_FUNCTION.title,
partySize >= MAX_COMPANIONS
? '好感已达标,但当前队伍已满,需要先放生一名同伴。'
: isNpcFirstMeaningfulContact(encounter, npcState)
? `关系已足够稳固,你可以正式邀请对方同行。${recruitInsight?.summary ?? ''}`
: `好感已达标,对方愿意加入你的队伍。${recruitInsight?.summary ?? ''}`,
npcId,
'recruit',
),
);
}
if (activeQuest?.status === 'completed') {
options.push(
buildNpcOption(
NPC_QUEST_TURN_IN_FUNCTION.id,
`${encounter.npcName}交付委托`,
buildQuestTurnInDetail(activeQuest),
npcId,
'quest_turn_in',
activeQuest.id,
),
);
} else if (!activeQuest && generatedQuest) {
options.push(
buildNpcOption(
NPC_QUEST_ACCEPT_FUNCTION.id,
`接下${encounter.npcName}的委托`,
buildQuestAcceptDetail(generatedQuest),
npcId,
'quest_accept',
generatedQuest.id,
),
);
}
options.push(
buildNpcOption(
NPC_LEAVE_FUNCTION.id,
NPC_LEAVE_FUNCTION.title,
'离开当前角色,重新回到探索状态。',
npcId,
'leave',
),
);
return {
text:
overrideText ??
(
isNpcFirstMeaningfulContact(encounter, npcState)
? `${buildNpcFirstContactStoryText(encounter, npcState, scene?.name)} ${describeNpcAffinityInWords(encounter, npcState.affinity)}`
: `${scene?.name ?? '当前地界'}里,你遇见了${encounter.npcName}${getNpcActionText(encounter)} ${describeNpcAffinityInWords(encounter, npcState.affinity)}`
),
options: sortStoryOptionsByPriority(
options,
),
};
}
export function buildNpcChatResultText(
encounter: Encounter,
affinityGain: number,
nextAffinity: number,
attributeSummary?: string,
) {
const recruitText =
canRecruitAnyNpc(encounter) && nextAffinity >= NPC_RECRUIT_AFFINITY
? '看起来只要你正式开口邀请,对方多半不会再回避。'
: '你们之间的气氛比刚见面时自然了不少。';
const summaryText = attributeSummary ? `你注意到:${attributeSummary}` : '';
return `${encounter.npcName}和你聊了几句,${describeAffinityShift(affinityGain)}${describeNpcAffinityInWords(encounter, nextAffinity)}${summaryText}${recruitText}`;
}
export function buildNpcSparResultText(
affinityGain: number,
nextAffinity: number,
) {
const sparEncounter = {
npcName: '对方',
npcDescription: '',
npcAvatar: '',
context: '',
} satisfies Encounter;
return `你们点到为止地切磋了一场,彼此都更认可对方的身手。${describeAffinityShift(affinityGain)}${describeNpcAffinityInWords(sparEncounter, nextAffinity)}`;
}
export function buildNpcGiftResultText(
encounter: Encounter,
item: InventoryItem,
affinityGain: number,
nextAffinity: number,
attributeSummary?: string,
) {
const summaryText = attributeSummary ? `你感到:${attributeSummary}` : '';
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,
affinity: number,
worldType: WorldType | null,
paidItem?: InventoryItem | null,
paidCurrency?: number,
) {
if (paidItem) {
return `${encounter.npcName}收下了${paidItem.name},把${gainedItem.name}交给了你。${getNpcDiscountText(affinity)}`;
}
return `${encounter.npcName}收下了${formatCurrency(paidCurrency ?? getNpcPurchasePrice(gainedItem, affinity), worldType)},把${gainedItem.name}卖给了你。${getNpcDiscountText(affinity)}`;
}
export function buildNpcTradeTransactionResultText({
encounter,
mode,
item,
quantity,
totalPrice,
worldType,
}: {
encounter: Encounter;
mode: 'buy' | 'sell';
item: InventoryItem;
quantity: number;
totalPrice: number;
worldType: WorldType | null;
}) {
const quantityText = quantity > 1 ? `${item.name} x${quantity}` : item.name;
if (mode === 'sell') {
return `${encounter.npcName}收下了${quantityText},付给你${formatCurrency(totalPrice, worldType)}`;
}
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,
) {
const storyHintText = reward.storyHint ? ` ${reward.storyHint}` : '';
return `${encounter.npcName}向你伸出了援手。你获得了${describeHelpReward(reward)}${storyHintText}`;
}
export function buildNpcRecruitResultText(
encounter: Encounter,
releasedCompanionName?: string | null,
) {
const releaseText = releasedCompanionName
? `你把${releasedCompanionName}放回了世界中,让出了队伍位置。`
: '';
return `${encounter.npcName}点头答应,正式加入了你的队伍。${releaseText}`;
}
export function buildNpcLeaveResultText(encounter: Encounter) {
return `你暂时没有继续和${encounter.npcName}纠缠,转身把注意力重新放回前路。`;
}