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