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 = { common: 1, uncommon: 2, rare: 3, epic: 4, legendary: 5, }; const RARITY_LABELS: Record = { 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(); 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(); 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 | ReturnType, ) { 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, 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 { 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 | 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 | 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 | 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 | 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}纠缠,转身把注意力重新放回前路。`; }