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