Files
Genarrative/src/data/npcInteractions.ts
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

2486 lines
71 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,
SceneHostileNpc,
ScenePresetInfo,
StoryMoment,
StoryOption,
WorldType,
} from '../types';
import { resolveRoleCombatStats } from './attributeCombat';
import {
buildRelationState,
resolveAttributeSchema,
scoreAttributeFit,
} from './attributeResolver';
import {
getCharacterAdventureOpening,
getCharacterById,
getCharacterCombatStats,
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 {
buildQuestTurnInDetail,
evaluateQuestOpportunity,
getQuestForIssuer,
} from './questFlow';
import {
buildLooseRuntimeItemGenerationContext,
buildRuntimeItemGenerationContext,
} from './runtimeItemContext';
import {
buildDirectedRuntimeReward,
buildRuntimeInventoryStock,
generateDirectedRuntimeReward,
} from './runtimeItemDirector';
import { flattenDirectedRuntimeRewardItems } from './runtimeItemNarrative';
import {
getStoryOptionPriority,
resolveFunctionOption,
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 clampStanceMetric(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
function normalizeRecentStanceNotes(value: unknown) {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0).slice(-3)
: [];
}
export function buildInitialStanceProfile(
affinity: number,
options: {
recruited?: boolean;
hostile?: boolean;
roleText?: string | null;
} = {},
) {
const recruitedBonus = options.recruited ? 14 : 0;
const hostilePenalty = options.hostile ? 18 : 0;
const roleText = options.roleText ?? '';
const currentConflictTag =
/||/u.test(roleText)
? '旧案'
: /||/u.test(roleText)
? '守线'
: /||/u.test(roleText)
? '交易'
: null;
return {
trust: clampStanceMetric(42 + affinity * 0.55 + recruitedBonus - hostilePenalty),
warmth: clampStanceMetric(36 + affinity * 0.5 + recruitedBonus),
ideologicalFit: clampStanceMetric(48 + affinity * 0.25),
fearOrGuard: clampStanceMetric(62 - affinity * 0.55 + hostilePenalty),
loyalty: clampStanceMetric(24 + affinity * 0.35 + (options.recruited ? 26 : 0)),
currentConflictTag,
recentApprovals: [],
recentDisapprovals: [],
};
}
export function applyStoryChoiceToStanceProfile(
stanceProfile: NpcPersistentState['stanceProfile'],
action: 'npc_chat' | 'npc_help' | 'npc_gift' | 'npc_recruit' | 'npc_quest_accept',
options: {
affinityGain?: number;
recruited?: boolean;
} = {},
) {
const base =
stanceProfile ??
buildInitialStanceProfile(0, {
recruited: options.recruited,
});
const affinityGain = options.affinityGain ?? 0;
const approvalNotes = [...base.recentApprovals];
const disapprovalNotes = [...base.recentDisapprovals];
const applyApproval = (note: string) => {
approvalNotes.push(note);
while (approvalNotes.length > 3) approvalNotes.shift();
};
const applyDisapproval = (note: string) => {
disapprovalNotes.push(note);
while (disapprovalNotes.length > 3) disapprovalNotes.shift();
};
const next = {
...base,
trust: base.trust,
warmth: base.warmth,
ideologicalFit: base.ideologicalFit,
fearOrGuard: base.fearOrGuard,
loyalty: base.loyalty,
};
switch (action) {
case 'npc_chat':
next.trust += 6 + affinityGain * 2;
next.warmth += 4 + affinityGain * 2;
next.fearOrGuard -= 5 + affinityGain;
if (affinityGain >= 0) {
applyApproval('你愿意先从眼前局势和试探开始说话。');
} else {
applyDisapproval('这轮交流没能真正对上节奏。');
}
break;
case 'npc_help':
next.trust += 12;
next.warmth += 6;
next.fearOrGuard -= 8;
applyApproval('你在对方需要的时候搭了手。');
break;
case 'npc_gift':
next.trust += 6 + affinityGain;
next.warmth += 10 + affinityGain * 2;
next.fearOrGuard -= 4;
applyApproval('你给出的东西回应了对方眼下的处境。');
break;
case 'npc_recruit':
next.trust += 8;
next.warmth += 6;
next.loyalty += 18;
next.fearOrGuard -= 10;
applyApproval('你正式把对方纳入了同行关系。');
break;
case 'npc_quest_accept':
next.trust += 7;
next.ideologicalFit += 5;
next.loyalty += 4;
applyApproval('你接住了对方主动交出来的事。');
break;
}
return {
...next,
trust: clampStanceMetric(next.trust),
warmth: clampStanceMetric(next.warmth),
ideologicalFit: clampStanceMetric(next.ideologicalFit),
fearOrGuard: clampStanceMetric(next.fearOrGuard),
loyalty: clampStanceMetric(next.loyalty),
recentApprovals: approvalNotes,
recentDisapprovals: disapprovalNotes,
};
}
function normalizeStanceProfile(
stanceProfile: NpcPersistentState['stanceProfile'],
npcState: NpcPersistentState,
) {
if (!stanceProfile) {
return buildInitialStanceProfile(npcState.affinity, {
recruited: npcState.recruited,
});
}
return {
trust: clampStanceMetric(stanceProfile.trust ?? 40),
warmth: clampStanceMetric(stanceProfile.warmth ?? 35),
ideologicalFit: clampStanceMetric(stanceProfile.ideologicalFit ?? 45),
fearOrGuard: clampStanceMetric(stanceProfile.fearOrGuard ?? 55),
loyalty: clampStanceMetric(stanceProfile.loyalty ?? 20),
currentConflictTag: stanceProfile.currentConflictTag ?? null,
recentApprovals: normalizeRecentStanceNotes(stanceProfile.recentApprovals),
recentDisapprovals: normalizeRecentStanceNotes(stanceProfile.recentDisapprovals),
};
}
export function describeNpcNarrativePressure(
encounter: Encounter,
npcState: NpcPersistentState,
) {
const narrativeProfile = encounter.narrativeProfile;
const guardedText =
npcState.stanceProfile?.fearOrGuard && npcState.stanceProfile.fearOrGuard > 68
? '对方明显绷着一口气,不愿先把主动权让出去。'
: '对方把分寸拿得很紧,像是随时准备把话题拨回表层。';
if (!narrativeProfile) {
return guardedText;
}
return [
narrativeProfile.immediatePressure || guardedText,
narrativeProfile.contradiction
? `话里还带着一点错位:${narrativeProfile.contradiction}`
: null,
narrativeProfile.reactionHooks[0]
? `只要提到${narrativeProfile.reactionHooks[0]},对方就可能立刻变调。`
: null,
]
.filter(Boolean)
.join(' ');
}
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,
customWorldProfile = getRuntimeCustomWorldProfile(),
) {
if (worldType === WorldType.CUSTOM && customWorldProfile) {
return sortInventoryItems(
buildCustomWorldStarterInventoryItems(character, customWorldProfile),
);
}
const packItems = getInventoryItems(
character,
worldType,
customWorldProfile,
).map((item) =>
buildInventoryItem('player', item.category, item.name, item.quantity),
);
return sortInventoryItems(mergeInventory(packItems));
}
function buildCharacterNpcInventory(
character: Character,
worldType: WorldType | null,
customWorldProfile = getRuntimeCustomWorldProfile(),
) {
if (worldType === WorldType.CUSTOM && customWorldProfile) {
const starterEquipment = buildCustomWorldStarterEquipmentItems(
character,
customWorldProfile,
);
const starterInventory = buildCustomWorldStarterInventoryItems(
character,
customWorldProfile,
);
return sortInventoryItems(
mergeInventory([
...(Object.values(starterEquipment).filter(Boolean) as InventoryItem[]),
...starterInventory,
]),
);
}
const equipmentItems = getCharacterEquipment(character, customWorldProfile).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')
: [],
stanceProfile: normalizeStanceProfile(npcState.stanceProfile, npcState),
};
}
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;
}
function ensureDialogueSentence(text: string | null | undefined) {
const normalized = text?.trim() ?? '';
if (!normalized) {
return '';
}
return /[!?]$/u.test(normalized) ? normalized : `${normalized}`;
}
export function buildNpcChatOpeningText(
encounter: Encounter,
npcState: NpcPersistentState,
worldType: WorldType | null,
recruitCharacterOverride?: Character | null,
) {
const recruitCharacter =
recruitCharacterOverride ?? resolveEncounterRecruitCharacter(encounter);
const opening = recruitCharacter
? getCharacterAdventureOpening(recruitCharacter, worldType)
: null;
const stance = getFirstContactRelationStance(npcState);
if (isNpcFirstMeaningfulContact(encounter, npcState)) {
const greeting =
stance === 'guarded' ? '先打个招呼。' : '先和你打个招呼。';
const surfaceHook = ensureDialogueSentence(opening?.surfaceHook);
const immediateConcern = ensureDialogueSentence(opening?.immediateConcern);
const guardedMotive = ensureDialogueSentence(opening?.guardedMotive);
const fallbackLine =
stance === 'bonded'
? '这一步我既然亲自来了,就说明眼前这件事得先和你对齐。'
: stance === 'cooperative'
? '我先来和你碰个头,眼下这局势最好别各说各话。'
: stance === 'neutral'
? '我会出现在这里不是没有缘由,不过咱们最好先把眼前情况看清。'
: '前面的动静不太对,我想先看看你会怎么开口。';
if (
encounter.specialBehavior === 'camp_companion'
|| encounter.specialBehavior === 'initial_companion'
) {
return [
greeting,
surfaceHook || immediateConcern || fallbackLine,
surfaceHook && immediateConcern && surfaceHook !== immediateConcern
? immediateConcern
: null,
guardedMotive,
]
.filter(Boolean)
.join('');
}
return [greeting, immediateConcern || surfaceHook || fallbackLine]
.filter(Boolean)
.join('');
}
switch (stance) {
case 'bonded':
return '又见面了。你想先从哪件事接着说?';
case 'cooperative':
return '你开口吧,我先听听你想聊哪一件。';
case 'neutral':
return '先说吧,你想从哪里问起?';
default:
return '说吧,你想先问什么?';
}
}
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 buildHostileNpcDialogueText(
encounter: Encounter,
affinity: number,
) {
const hostilityText =
affinity <= -20
? '旧账就留到今天一起清。'
: affinity <= -10
? '我们之间已经没什么可谈的了。'
: '你再往前一步,我就当你是在挑衅。';
const contextText = encounter.context?.trim()
? `你居然还敢带着${encounter.context}的事来见我,`
: '';
return `${contextText}${hostilityText} 要么现在转身逃开,要么就拔刀。`;
}
function buildHostileNpcEscapeOption(params: {
state?: GameState | null;
worldType: WorldType | null;
playerCharacter: Character;
}) {
const functionContext =
params.worldType
? {
worldType: params.worldType,
playerCharacter: params.playerCharacter,
inBattle: false,
currentSceneId: params.state?.currentScenePreset?.id ?? null,
currentSceneName: params.state?.currentScenePreset?.name ?? null,
monsters: [],
playerHp: params.state?.playerHp ?? 1,
playerMaxHp: params.state?.playerMaxHp ?? 1,
playerMana: params.state?.playerMana ?? 0,
playerMaxMana: params.state?.playerMaxMana ?? 0,
}
: null;
const resolvedOption = functionContext
? resolveFunctionOption(
'battle_escape_breakout',
functionContext,
'逃跑',
)
: null;
if (resolvedOption) {
return {
...resolvedOption,
actionText: '逃跑',
text: '逃跑',
detailText: '',
} satisfies StoryOption;
}
return {
functionId: 'battle_escape_breakout',
actionText: '逃跑',
text: '逃跑',
detailText: '',
priority: getStoryOptionPriority('battle_escape_breakout'),
visuals: {
playerAnimation: AnimationState.RUN,
playerMoveMeters: -0.6,
playerOffsetY: 0,
playerFacing: 'left',
scrollWorld: true,
monsterChanges: [],
},
} satisfies StoryOption;
}
function buildQuestAcceptOpportunityDetail(params: {
issuerNpcId: string;
issuerNpcName: string;
roleText: string;
scene: Pick<ScenePresetInfo, 'id' | 'name' | 'npcs' | 'treasureHints'> | null;
worldType: WorldType | null;
currentQuests: QuestLogEntry[];
}) {
const opportunity = evaluateQuestOpportunity({
issuerNpcId: params.issuerNpcId,
issuerNpcName: params.issuerNpcName,
roleText: params.roleText,
scene: params.scene,
worldType: params.worldType,
currentQuests: params.currentQuests,
});
if (!opportunity.shouldOffer) {
return null;
}
return `${opportunity.reason} 接取后将由 AI 剧情引擎根据当前局势生成具体目标、步骤与奖励。`;
}
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,
customWorldProfile = getRuntimeCustomWorldProfile(),
) {
return buildCharacterInventory(character, worldType, customWorldProfile);
}
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,
state?.customWorldProfile ?? getRuntimeCustomWorldProfile(),
)
: 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: [],
stanceProfile: buildInitialStanceProfile(initialAffinity, {
recruited: false,
hostile: Boolean(encounter.monsterPresetId) || initialAffinity < 0,
roleText: encounter.context,
}),
});
}
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,
worldType: WorldType | null = null,
customWorldProfile: GameState['customWorldProfile'] = getRuntimeCustomWorldProfile(),
) {
if (!character) return 8;
const sparStats = getCharacterCombatStats(
character,
worldType,
customWorldProfile,
);
return Math.max(7, Math.min(12, Math.round(sparStats.maxHpBonus / 4)));
}
export function createNpcBattleMonster(
encounter: Encounter,
npcState: NpcPersistentState,
mode: NpcBattleMode = 'fight',
options: {
worldType?: WorldType | null;
customWorldProfile?: GameState['customWorldProfile'];
} = {},
) {
const monsterPreset = getMonsterPresetForEncounter(encounter);
const recruitCharacter = resolveEncounterRecruitCharacter(encounter);
const resolvedWorldType = options.worldType ?? null;
const resolvedCustomWorldProfile =
options.customWorldProfile ?? getRuntimeCustomWorldProfile();
if (monsterPreset) {
const monsterCombatStats = resolveRoleCombatStats(
monsterPreset.attributeProfile,
{
baseSpeed: monsterPreset.baseStats.speed,
},
);
const resolvedMonsterMaxHp =
monsterPreset.baseStats.maxHp + monsterCombatStats.maxHpBonus;
const hostileMaxHp =
mode === 'spar'
? Math.max(
8,
Math.min(14, Math.round(resolvedMonsterMaxHp / 18)),
)
: resolvedMonsterMaxHp;
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: monsterCombatStats.turnSpeed,
hp: hostileMaxHp,
maxHp: hostileMaxHp,
renderKind: 'npc' as const,
combatTags: monsterPreset.combatTags,
attributeProfile: monsterPreset.attributeProfile,
behaviorVectors: monsterPreset.behaviorVectors,
levelProfile: encounter.levelProfile,
experienceReward: encounter.experienceReward ?? 0,
encounter: {
...encounter,
hostile: true,
xMeters: 3.2,
},
} satisfies SceneHostileNpc;
}
const recruitCombatStats = recruitCharacter
? getCharacterCombatStats(
recruitCharacter,
resolvedWorldType,
resolvedCustomWorldProfile,
)
: null;
const baseHp = recruitCharacter
? getCharacterMaxHp(
recruitCharacter,
resolvedWorldType,
resolvedCustomWorldProfile,
)
: 120;
const baseSpeed = recruitCharacter
? Math.max(
5,
Math.round((recruitCombatStats?.turnSpeed ?? 4.5) + 1.5),
)
: 7;
const maxHp =
mode === 'spar'
? getNpcSparMaxHp(
recruitCharacter,
resolvedWorldType,
resolvedCustomWorldProfile,
)
: 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,
levelProfile: encounter.levelProfile,
experienceReward: 0,
encounter: {
...encounter,
xMeters: 3.2,
},
} satisfies SceneHostileNpc;
}
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,
levelProfile: encounter.levelProfile,
experienceReward: encounter.experienceReward ?? 0,
encounter: {
...encounter,
xMeters: 3.2,
},
} satisfies SceneHostileNpc;
}
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' | '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 questAcceptDetail = !activeQuest ? buildQuestAcceptOpportunityDetail({
issuerNpcId: npcId,
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene,
worldType,
currentQuests: activeQuests,
}) : null;
const options: StoryOption[] = [];
const isHostileEncounter =
npcState.affinity < 0 ||
Boolean(encounter.hostile) ||
Boolean(encounter.monsterPresetId);
if (isHostileEncounter) {
const hostileDialogueText =
overrideText ?? buildHostileNpcDialogueText(encounter, npcState.affinity);
options.push(
buildHostileNpcEscapeOption({
state,
worldType,
playerCharacter,
}),
);
options.push(
buildNpcOption(
NPC_FIGHT_FUNCTION.id,
'与他对战',
'',
npcId,
'fight',
),
);
return {
text: hostileDialogueText,
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: encounter.npcName,
text: hostileDialogueText,
},
],
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 && questAcceptDetail) {
options.push(
buildNpcOption(
NPC_QUEST_ACCEPT_FUNCTION.id,
`接下${encounter.npcName}的委托`,
questAcceptDetail,
npcId,
'quest_accept',
),
);
}
options.push(
buildNpcOption(
NPC_LEAVE_FUNCTION.id,
NPC_LEAVE_FUNCTION.title,
'离开当前角色,重新回到探索状态。',
npcId,
'leave',
),
);
return {
text:
overrideText ??
(
isNpcFirstMeaningfulContact(encounter, npcState)
? `${buildNpcFirstContactStoryText(encounter, npcState, scene?.name)} ${describeNpcNarrativePressure(encounter, npcState)} ${describeNpcAffinityInWords(encounter, npcState.affinity)}`
: `${scene?.name ?? '当前地界'}里,你遇见了${encounter.npcName}${getNpcActionText(encounter)} ${describeNpcNarrativePressure(encounter, npcState)} ${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(
npcName: string,
affinityGain: number,
nextAffinity: number,
) {
const sparEncounter = {
npcName,
npcDescription: '',
npcAvatar: '',
context: '',
} satisfies Encounter;
return `你和${npcName}点到为止地切磋了一场,彼此都更认可对方的身手。${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 buildNpcHelpCommitActionText(
encounter: Encounter,
reward: NpcHelpReward,
) {
const goals: string[] = [];
if ((reward.hp ?? 0) > 0) goals.push('疗伤');
if ((reward.mana ?? 0) > 0) goals.push('回气');
if ((reward.cooldownBonus ?? 0) > 0) goals.push('调整招式节奏');
if (reward.items.length > 0) goals.push('补给');
return goals.length > 0
? `${encounter.npcName}请求${goals.join('、')}`
: `${encounter.npcName}寻求支援`;
}
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}纠缠,转身把注意力重新放回前路。`;
}