Refine NPC interactions and runtime item generation
This commit is contained in:
@@ -161,15 +161,18 @@ export function buildCustomWorldPlayableNpcAttributeProfile(
|
||||
entityId: npc.id,
|
||||
schema,
|
||||
legacyAttributes: templateAttributes,
|
||||
textBlocks: [
|
||||
npc.title,
|
||||
npc.description,
|
||||
npc.backstory,
|
||||
npc.personality,
|
||||
npc.combatStyle,
|
||||
...(npc.tags ?? []),
|
||||
],
|
||||
}).profile;
|
||||
textBlocks: [
|
||||
npc.title,
|
||||
npc.role,
|
||||
npc.description,
|
||||
npc.backstory,
|
||||
npc.personality,
|
||||
npc.motivation,
|
||||
npc.combatStyle,
|
||||
...(npc.relationshipHooks ?? []),
|
||||
...(npc.tags ?? []),
|
||||
],
|
||||
}).profile;
|
||||
}
|
||||
|
||||
export function buildCustomWorldStoryNpcAttributeProfile(npc: CustomWorldNpc, schema: WorldAttributeSchema) {
|
||||
@@ -177,10 +180,15 @@ export function buildCustomWorldStoryNpcAttributeProfile(npc: CustomWorldNpc, sc
|
||||
entityId: npc.id,
|
||||
schema,
|
||||
textBlocks: [
|
||||
npc.title,
|
||||
npc.role,
|
||||
npc.description,
|
||||
npc.backstory,
|
||||
npc.personality,
|
||||
npc.motivation,
|
||||
npc.combatStyle,
|
||||
...(npc.relationshipHooks ?? []),
|
||||
...(npc.tags ?? []),
|
||||
],
|
||||
}).profile;
|
||||
}
|
||||
|
||||
@@ -11,56 +11,56 @@ import { getBuildTagAttributeAffinity } from './buildTagAttributeAffinity';
|
||||
type RawBuildTagDefinition = Omit<BuildTagDefinition, 'attributeAffinity'>;
|
||||
|
||||
const TAG_CATEGORIES = {
|
||||
flow: '娴佹淳' as BuildTagCategory,
|
||||
style: '椋庢牸' as BuildTagCategory,
|
||||
resource: '璧勬簮' as BuildTagCategory,
|
||||
defense: '闃插尽' as BuildTagCategory,
|
||||
element: '鍏冪礌' as BuildTagCategory,
|
||||
craft: '宸ヨ壓' as BuildTagCategory,
|
||||
flow: '流派' as BuildTagCategory,
|
||||
style: '风格' as BuildTagCategory,
|
||||
resource: '资源' as BuildTagCategory,
|
||||
defense: '防御' as BuildTagCategory,
|
||||
element: '元素' as BuildTagCategory,
|
||||
craft: '工艺' as BuildTagCategory,
|
||||
} satisfies Record<string, BuildTagCategory>;
|
||||
|
||||
const RAW_BUILD_TAG_DEFINITIONS: RawBuildTagDefinition[] = [
|
||||
{ id: 'quickblade', label: '快剑', category: TAG_CATEGORIES.style, aliases: ['quickblade', '快剑', '快刀', '决斗者'], description: 'Fast melee pressure.' },
|
||||
{ id: 'combo', label: '连段', category: TAG_CATEGORIES.style, aliases: ['combo', '连段', '连击', '连锁'], description: 'Chain-hit rhythm and multi-stage output.' },
|
||||
{ id: 'dash', label: '突进', category: TAG_CATEGORIES.style, aliases: ['dash', '突进', '冲锋'], description: 'Gap-closing and front-loaded engage.' },
|
||||
{ id: 'pursuit', label: '追击', category: TAG_CATEGORIES.style, aliases: ['pursuit', '追击'], description: 'Chasing and follow-up punishment.' },
|
||||
{ id: 'swiftstrike', label: '快袭', category: TAG_CATEGORIES.style, aliases: ['swiftstrike', '快袭', '刺袭', '伏击'], description: 'Short-window assassination and weak-point bursts.' },
|
||||
{ id: 'ranged', label: '远射', category: TAG_CATEGORIES.style, aliases: ['ranged', '远射', '射击', '箭矢'], description: 'Mid-long range damage with spacing.' },
|
||||
{ id: 'guerrilla', label: '游击', category: TAG_CATEGORIES.style, aliases: ['guerrilla', '游击', '骚扰'], description: 'Hit-and-run skirmishing.' },
|
||||
{ id: 'mobility', label: '机动', category: TAG_CATEGORIES.style, aliases: ['mobility', '机动', '敏捷', '灵活'], description: 'High movement and repositioning.' },
|
||||
{ id: 'windrun', label: '风行', category: TAG_CATEGORIES.style, aliases: ['windrun', '风行', '疾行'], description: 'Light-footed speed advantage.' },
|
||||
{ id: 'heavyhit', label: '重击', category: TAG_CATEGORIES.style, aliases: ['heavyhit', '重击'], description: 'Heavy swings and one-hit pressure.' },
|
||||
{ id: 'burst', label: '爆发', category: TAG_CATEGORIES.style, aliases: ['burst', '爆发'], description: 'Short-window damage spikes.' },
|
||||
{ id: 'armorbreak', label: '破甲', category: TAG_CATEGORIES.style, aliases: ['armorbreak', '破甲'], description: 'Breaking defense and hard targets.' },
|
||||
{ id: 'pressure', label: '压制', category: TAG_CATEGORIES.style, aliases: ['pressure', '压制'], description: 'Tempo control through relentless offense.' },
|
||||
{ id: 'bloodrush', label: '压血', category: TAG_CATEGORIES.resource, aliases: ['bloodrush', '压血'], description: 'Trading safety for damage when low.' },
|
||||
{ id: 'guard', label: '守御', category: TAG_CATEGORIES.defense, aliases: ['guard', '守御', '守卫', '防御'], description: 'Reliable defense and front-line stability.' },
|
||||
{ id: 'barrier', label: '护体', category: TAG_CATEGORIES.defense, aliases: ['barrier', '护体', '护罩', '护盾'], description: 'Barrier, protection, and status resistance.' },
|
||||
{ id: 'heavyarmor', label: '重甲', category: TAG_CATEGORIES.defense, aliases: ['heavyarmor', '重甲'], description: 'Armor hardness and standing power.' },
|
||||
{ id: 'counter', label: '反击', category: TAG_CATEGORIES.defense, aliases: ['counter', '反击', '回击'], description: 'Counterplay after blocks or openings.' },
|
||||
{ id: 'banish', label: '镇邪', category: TAG_CATEGORIES.defense, aliases: ['banish', '镇邪'], description: 'Suppressing curses and hostile energies.' },
|
||||
{ id: 'caster', label: '法修', category: TAG_CATEGORIES.element, aliases: ['caster', '法修', '法师'], description: 'Spell-driven output and control.' },
|
||||
{ id: 'mana', label: '法力', category: TAG_CATEGORIES.resource, aliases: ['mana', '法力'], description: 'Mana pool, spend, and recovery loop.' },
|
||||
{ id: 'thunder', label: '雷法', category: TAG_CATEGORIES.element, aliases: ['thunder', '雷法'], description: 'Lightning damage and instant suppression.' },
|
||||
{ id: 'formation', label: '符阵', category: TAG_CATEGORIES.element, aliases: ['formation', '符阵', '法阵'], description: 'Prepared effects and battlefield shaping.' },
|
||||
{ id: 'control', label: '控场', category: TAG_CATEGORIES.style, aliases: ['control', '控场', '控制'], description: 'Restricting movement and action choices.' },
|
||||
{ id: 'overload', label: '过载', category: TAG_CATEGORIES.resource, aliases: ['overload', '过载'], description: 'High-cost high-output casting windows.' },
|
||||
{ id: 'heal', label: '回复', category: TAG_CATEGORIES.resource, aliases: ['heal', '回复', '治疗'], description: 'Recovery and fight reset.' },
|
||||
{ id: 'support', label: '护持', category: TAG_CATEGORIES.resource, aliases: ['support', '护持', '支援', '祝福'], description: 'Buffing and stabilizing allies.' },
|
||||
{ id: 'sustain', label: '续战', category: TAG_CATEGORIES.resource, aliases: ['sustain', '续战'], description: 'Long-fight consistency and tolerance.' },
|
||||
{ id: 'fate', label: '命纹', category: TAG_CATEGORIES.flow, aliases: ['fate', '命纹'], description: 'Marks, triggers, and destiny loops.' },
|
||||
{ id: 'fortune', label: '机缘', category: TAG_CATEGORIES.flow, aliases: ['fortune', '机缘'], description: 'Timing and fortunate trigger value.' },
|
||||
{ id: 'cooldown', label: '冷却', category: TAG_CATEGORIES.resource, aliases: ['cooldown', '冷却'], description: 'Faster rotation and recharge.' },
|
||||
{ id: 'command', label: '统御', category: TAG_CATEGORIES.flow, aliases: ['command', '统御'], description: 'Coordinating team actions.' },
|
||||
{ id: 'balanced', label: '均衡', category: TAG_CATEGORIES.flow, aliases: ['balanced', '均衡', '平衡', '全能'], description: 'Generalist and low-risk growth.' },
|
||||
{ id: 'craft', label: '工巧', category: TAG_CATEGORIES.craft, aliases: ['craft', '工巧', '工艺'], description: 'Crafting, devices, and engineered support.' },
|
||||
{ id: 'alchemy', label: '炼药', category: TAG_CATEGORIES.craft, aliases: ['alchemy', '炼药', '药剂'], description: 'Potion and temporary enhancement making.' },
|
||||
{ id: 'vanguard', label: '先锋', category: TAG_CATEGORIES.flow, aliases: ['vanguard', '先锋'], description: 'Frontline initiative and lane opening.' },
|
||||
{ id: 'berserk', label: '狂战', category: TAG_CATEGORIES.flow, aliases: ['berserk', '狂战'], description: 'Risky offense for high return.' },
|
||||
{ id: 'spellblade', label: '法剑', category: TAG_CATEGORIES.flow, aliases: ['spellblade', '法剑'], description: 'Hybrid blade-and-magic combat.' },
|
||||
{ id: 'paladin', label: '圣佑', category: TAG_CATEGORIES.flow, aliases: ['paladin', '圣佑', '圣骑士'], description: 'Protection, recovery, and holy punishment.' },
|
||||
{ id: 'fortress', label: '堡垒', category: TAG_CATEGORIES.flow, aliases: ['fortress', '堡垒'], description: 'Extreme defense and counter-anchoring.' },
|
||||
{ id: 'starter', label: '起手', category: TAG_CATEGORIES.flow, aliases: ['starter', '起手'], description: 'Early-shape and beginner-friendly setup.' },
|
||||
{ id: 'quickblade', label: '快剑', category: TAG_CATEGORIES.style, aliases: ['quickblade', '快剑', '快刀', '决斗者'], description: '快速近身施压。' },
|
||||
{ id: 'combo', label: '连段', category: TAG_CATEGORIES.style, aliases: ['combo', '连段', '连击', '连锁'], description: '连续命中与多段输出节奏。' },
|
||||
{ id: 'dash', label: '突进', category: TAG_CATEGORIES.style, aliases: ['dash', '突进', '冲锋'], description: '拉近身位并抢先发起接敌。' },
|
||||
{ id: 'pursuit', label: '追击', category: TAG_CATEGORIES.style, aliases: ['pursuit', '追击'], description: '追身压制与后续补刀。' },
|
||||
{ id: 'swiftstrike', label: '快袭', category: TAG_CATEGORIES.style, aliases: ['swiftstrike', '快袭', '刺袭', '伏击'], description: '短窗口刺杀与弱点爆发。' },
|
||||
{ id: 'ranged', label: '远射', category: TAG_CATEGORIES.style, aliases: ['ranged', '远射', '射击', '箭矢'], description: '依靠身位经营的中远程输出。' },
|
||||
{ id: 'guerrilla', label: '游击', category: TAG_CATEGORIES.style, aliases: ['guerrilla', '游击', '骚扰'], description: '打了就走的周旋消耗。' },
|
||||
{ id: 'mobility', label: '机动', category: TAG_CATEGORIES.style, aliases: ['mobility', '机动', '敏捷', '灵活'], description: '高机动与快速换位。' },
|
||||
{ id: 'windrun', label: '风行', category: TAG_CATEGORIES.style, aliases: ['windrun', '风行', '疾行'], description: '轻身疾行带来的速度优势。' },
|
||||
{ id: 'heavyhit', label: '重击', category: TAG_CATEGORIES.style, aliases: ['heavyhit', '重击'], description: '重击蓄势与一击压迫。' },
|
||||
{ id: 'burst', label: '爆发', category: TAG_CATEGORIES.style, aliases: ['burst', '爆发'], description: '短时间内抬高伤害峰值。' },
|
||||
{ id: 'armorbreak', label: '破甲', category: TAG_CATEGORIES.style, aliases: ['armorbreak', '破甲'], description: '专破防御与硬目标。' },
|
||||
{ id: 'pressure', label: '压制', category: TAG_CATEGORIES.style, aliases: ['pressure', '压制'], description: '靠连续进攻掌控节奏。' },
|
||||
{ id: 'bloodrush', label: '压血', category: TAG_CATEGORIES.resource, aliases: ['bloodrush', '压血'], description: '残血时用安全换更高输出。' },
|
||||
{ id: 'guard', label: '守御', category: TAG_CATEGORIES.defense, aliases: ['guard', '守御', '守卫', '防御'], description: '稳定承伤与前线站位。' },
|
||||
{ id: 'barrier', label: '护体', category: TAG_CATEGORIES.defense, aliases: ['barrier', '护体', '护罩', '护盾'], description: '护体、防护与异常抵抗。' },
|
||||
{ id: 'heavyarmor', label: '重甲', category: TAG_CATEGORIES.defense, aliases: ['heavyarmor', '重甲'], description: '甲胄硬度与站场能力。' },
|
||||
{ id: 'counter', label: '反击', category: TAG_CATEGORIES.defense, aliases: ['counter', '反击', '回击'], description: '抓住破绽后的反制回击。' },
|
||||
{ id: 'banish', label: '镇邪', category: TAG_CATEGORIES.defense, aliases: ['banish', '镇邪'], description: '压制邪祟与敌对异力。' },
|
||||
{ id: 'caster', label: '法修', category: TAG_CATEGORIES.element, aliases: ['caster', '法修', '法师'], description: '以术法驱动输出与控场。' },
|
||||
{ id: 'mana', label: '法力', category: TAG_CATEGORIES.resource, aliases: ['mana', '法力'], description: '围绕法力池展开的消耗与回转。' },
|
||||
{ id: 'thunder', label: '雷法', category: TAG_CATEGORIES.element, aliases: ['thunder', '雷法'], description: '雷属性打击与瞬时压制。' },
|
||||
{ id: 'formation', label: '符阵', category: TAG_CATEGORIES.element, aliases: ['formation', '符阵', '法阵'], description: '预布效果与战场塑形。' },
|
||||
{ id: 'control', label: '控场', category: TAG_CATEGORIES.style, aliases: ['control', '控场', '控制'], description: '限制对手移动与出手选择。' },
|
||||
{ id: 'overload', label: '过载', category: TAG_CATEGORIES.resource, aliases: ['overload', '过载'], description: '高消耗高回报的施法窗口。' },
|
||||
{ id: 'heal', label: '回复', category: TAG_CATEGORIES.resource, aliases: ['heal', '回复', '治疗'], description: '恢复状态并重整战线。' },
|
||||
{ id: 'support', label: '护持', category: TAG_CATEGORIES.resource, aliases: ['support', '护持', '支援', '祝福'], description: '增益队友并稳住队形。' },
|
||||
{ id: 'sustain', label: '续战', category: TAG_CATEGORIES.resource, aliases: ['sustain', '续战'], description: '持久在线战斗的稳定性。' },
|
||||
{ id: 'fate', label: '命纹', category: TAG_CATEGORIES.flow, aliases: ['fate', '命纹'], description: '围绕标记、触发与命数循环。' },
|
||||
{ id: 'fortune', label: '机缘', category: TAG_CATEGORIES.flow, aliases: ['fortune', '机缘'], description: '时机与机缘触发价值。' },
|
||||
{ id: 'cooldown', label: '冷却', category: TAG_CATEGORIES.resource, aliases: ['cooldown', '冷却'], description: '加快轮转与恢复速度。' },
|
||||
{ id: 'command', label: '统御', category: TAG_CATEGORIES.flow, aliases: ['command', '统御'], description: '协调队伍行动与站位。' },
|
||||
{ id: 'balanced', label: '均衡', category: TAG_CATEGORIES.flow, aliases: ['balanced', '均衡', '平衡', '全能'], description: '泛用稳健、风险较低的成长路线。' },
|
||||
{ id: 'craft', label: '工巧', category: TAG_CATEGORIES.craft, aliases: ['craft', '工巧', '工艺'], description: '工艺制造、装置与工程支援。' },
|
||||
{ id: 'alchemy', label: '炼药', category: TAG_CATEGORIES.craft, aliases: ['alchemy', '炼药', '药剂'], description: '药剂调配与临时强化。' },
|
||||
{ id: 'vanguard', label: '先锋', category: TAG_CATEGORIES.flow, aliases: ['vanguard', '先锋'], description: '抢前排节奏并打开战线。' },
|
||||
{ id: 'berserk', label: '狂战', category: TAG_CATEGORIES.flow, aliases: ['berserk', '狂战'], description: '高风险高收益的进攻态势。' },
|
||||
{ id: 'spellblade', label: '法剑', category: TAG_CATEGORIES.flow, aliases: ['spellblade', '法剑'], description: '兵刃与术法并行的混合战斗。' },
|
||||
{ id: 'paladin', label: '圣佑', category: TAG_CATEGORIES.flow, aliases: ['paladin', '圣佑', '圣骑士'], description: '兼顾防护、恢复与神圣惩戒。' },
|
||||
{ id: 'fortress', label: '堡垒', category: TAG_CATEGORIES.flow, aliases: ['fortress', '堡垒'], description: '极端防守与反打锚点。' },
|
||||
{ id: 'starter', label: '起手', category: TAG_CATEGORIES.flow, aliases: ['starter', '起手'], description: '适合作为起手与新手成型路线。' },
|
||||
];
|
||||
|
||||
const BUILD_TAG_DEFINITIONS: BuildTagDefinition[] = RAW_BUILD_TAG_DEFINITIONS.map(definition => ({
|
||||
|
||||
@@ -197,10 +197,14 @@ function hydrateCharacterRoleData(
|
||||
id: character.id,
|
||||
name: character.name,
|
||||
title: character.title,
|
||||
role: character.title,
|
||||
description: character.description,
|
||||
backstory: character.backstory,
|
||||
personality: character.personality,
|
||||
motivation: character.description,
|
||||
combatStyle: character.skills.map(skill => skill.name).join('、'),
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: character.combatTags?.slice(0, 3) ?? [],
|
||||
tags: character.combatTags ?? [],
|
||||
},
|
||||
options.customWorldProfile.attributeSchema,
|
||||
|
||||
@@ -21,6 +21,10 @@ import {coerceWorldAttributeSchema} from './attributeValidation';
|
||||
const CUSTOM_WORLD_LIBRARY_STORAGE_KEY = 'tavernrealms.custom-world-library.v1';
|
||||
const CUSTOM_WORLD_LIBRARY_VERSION = 1;
|
||||
const MAX_SAVED_CUSTOM_WORLDS = 12;
|
||||
const MIN_CUSTOM_WORLD_AFFINITY = -40;
|
||||
const MAX_CUSTOM_WORLD_AFFINITY = 90;
|
||||
const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18;
|
||||
const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6;
|
||||
const ITEM_RARITIES = new Set<ItemRarity>(['common', 'uncommon', 'rare', 'epic', 'legendary']);
|
||||
const EQUIPMENT_SLOTS = new Set<EquipmentSlotId>(['weapon', 'armor', 'relic']);
|
||||
const CUSTOM_WORLD_NPC_VISUAL_RACES = new Set<CustomWorldNpcVisualRace>(['human', 'elf', 'orc', 'goblin']);
|
||||
@@ -52,6 +56,13 @@ function toOptionalInteger(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : undefined;
|
||||
}
|
||||
|
||||
function normalizeInitialAffinity(value: unknown, fallback: number) {
|
||||
const resolved = typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.round(value)
|
||||
: fallback;
|
||||
return Math.max(MIN_CUSTOM_WORLD_AFFINITY, Math.min(MAX_CUSTOM_WORLD_AFFINITY, resolved));
|
||||
}
|
||||
|
||||
function normalizeEquipmentSlot(value: unknown) {
|
||||
return typeof value === 'string' && EQUIPMENT_SLOTS.has(value as EquipmentSlotId)
|
||||
? value as EquipmentSlotId
|
||||
@@ -129,16 +140,24 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
|
||||
|
||||
const name = toText(value.name);
|
||||
if (!name) return null;
|
||||
const title = toText(value.title, toText(value.role, '未命名角色'));
|
||||
const role = toText(value.role, title);
|
||||
const relationshipHooks = toStringArray(value.relationshipHooks);
|
||||
const tags = toStringArray(value.tags);
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-playable-${index + 1}`),
|
||||
name,
|
||||
title: toText(value.title, '未命名角色'),
|
||||
title,
|
||||
role,
|
||||
description: toText(value.description),
|
||||
backstory: toText(value.backstory),
|
||||
personality: toText(value.personality),
|
||||
motivation: toText(value.motivation, toText(value.description)),
|
||||
combatStyle: toText(value.combatStyle),
|
||||
tags: toStringArray(value.tags),
|
||||
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_PLAYABLE_INITIAL_AFFINITY),
|
||||
relationshipHooks: relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
|
||||
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
|
||||
templateCharacterId: toText(value.templateCharacterId) || undefined,
|
||||
};
|
||||
}
|
||||
@@ -148,14 +167,24 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
|
||||
|
||||
const name = toText(value.name);
|
||||
if (!name) return null;
|
||||
const title = toText(value.title, toText(value.role, '未命名场景角色'));
|
||||
const role = toText(value.role, title);
|
||||
const relationshipHooks = toStringArray(value.relationshipHooks);
|
||||
const tags = toStringArray(value.tags);
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-story-${index + 1}`),
|
||||
name,
|
||||
role: toText(value.role, '未命名场景角色'),
|
||||
title,
|
||||
role,
|
||||
description: toText(value.description),
|
||||
backstory: toText(value.backstory),
|
||||
personality: toText(value.personality),
|
||||
motivation: toText(value.motivation),
|
||||
relationshipHooks: toStringArray(value.relationshipHooks),
|
||||
combatStyle: toText(value.combatStyle),
|
||||
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_STORY_NPC_INITIAL_AFFINITY),
|
||||
relationshipHooks: relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
|
||||
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
|
||||
imageSrc: toText(value.imageSrc) || undefined,
|
||||
visual: normalizeCustomWorldNpcVisual(value.visual),
|
||||
};
|
||||
|
||||
159
src/data/customWorldNpcMonsters.ts
Normal file
159
src/data/customWorldNpcMonsters.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { type CustomWorldNpc, type CustomWorldPlayableNpc, WorldType } from '../types';
|
||||
import {
|
||||
getMonsterPresetsByWorld,
|
||||
type HostileNpcPreset,
|
||||
} from './hostileNpcPresets';
|
||||
|
||||
type CustomWorldMonsterSource = Partial<
|
||||
Pick<
|
||||
CustomWorldNpc & CustomWorldPlayableNpc,
|
||||
| 'name'
|
||||
| 'title'
|
||||
| 'role'
|
||||
| 'description'
|
||||
| 'backstory'
|
||||
| 'personality'
|
||||
| 'motivation'
|
||||
| 'combatStyle'
|
||||
| 'initialAffinity'
|
||||
| 'relationshipHooks'
|
||||
| 'tags'
|
||||
>
|
||||
>;
|
||||
|
||||
const MONSTER_SIGNAL_PATTERN =
|
||||
/妖|魔|鬼|怪|兽|灵|尸|蛛|蛇|虫|菇|傀|骸|骨|眼|蜗|藤|象|蝠|蛙|蛾|蟾|狼|狐|蛟|龙|祟/u;
|
||||
const MONSTER_SIGNAL_STOP_CHARS = new Set([
|
||||
'妖',
|
||||
'魔',
|
||||
'鬼',
|
||||
'怪',
|
||||
'兽',
|
||||
'灵',
|
||||
'尸',
|
||||
'祟',
|
||||
'凶',
|
||||
'异',
|
||||
'夜',
|
||||
'古',
|
||||
]);
|
||||
|
||||
function hashText(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function getMonsterPresetPool(worldType?: WorldType | null) {
|
||||
if (worldType) {
|
||||
return getMonsterPresetsByWorld(worldType);
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
return [
|
||||
...getMonsterPresetsByWorld(WorldType.WUXIA),
|
||||
...getMonsterPresetsByWorld(WorldType.XIANXIA),
|
||||
].filter((preset) => {
|
||||
if (seen.has(preset.id)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(preset.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueText(values: Array<string | null | undefined>) {
|
||||
return [
|
||||
...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)),
|
||||
];
|
||||
}
|
||||
|
||||
function buildMonsterSourceText(npc: CustomWorldMonsterSource) {
|
||||
return uniqueText([
|
||||
npc.name,
|
||||
npc.title,
|
||||
npc.role,
|
||||
npc.description,
|
||||
npc.backstory,
|
||||
npc.personality,
|
||||
npc.motivation,
|
||||
npc.combatStyle,
|
||||
...(npc.relationshipHooks ?? []),
|
||||
...(npc.tags ?? []),
|
||||
]).join(' ');
|
||||
}
|
||||
|
||||
function buildSignalChars(label: string) {
|
||||
return [
|
||||
...new Set(
|
||||
label
|
||||
.replace(/[^\u4e00-\u9fa5]+/g, '')
|
||||
.split('')
|
||||
.filter((char) => char && !MONSTER_SIGNAL_STOP_CHARS.has(char)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function scoreMonsterPreset(preset: HostileNpcPreset, sourceText: string) {
|
||||
let score = 0;
|
||||
|
||||
if (sourceText.includes(preset.name)) {
|
||||
score += 24;
|
||||
}
|
||||
|
||||
for (const signalChar of buildSignalChars(preset.name)) {
|
||||
if (sourceText.includes(signalChar)) {
|
||||
score += 3;
|
||||
}
|
||||
}
|
||||
|
||||
for (const tag of [...preset.habitatTags, ...preset.combatTags]) {
|
||||
if (tag && sourceText.includes(tag)) {
|
||||
score += 2;
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export function resolveCustomWorldNpcMonsterPreset(
|
||||
npc: CustomWorldMonsterSource,
|
||||
worldType?: WorldType | null,
|
||||
) {
|
||||
const sourceText = buildMonsterSourceText(npc);
|
||||
if (!sourceText || !MONSTER_SIGNAL_PATTERN.test(sourceText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostileBias = (npc.initialAffinity ?? 0) < 0;
|
||||
if (!hostileBias) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = getMonsterPresetPool(worldType);
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scoredCandidates = candidates
|
||||
.map((candidate) => ({
|
||||
candidate,
|
||||
score: scoreMonsterPreset(candidate, sourceText),
|
||||
}))
|
||||
.sort((left, right) => right.score - left.score);
|
||||
|
||||
if ((scoredCandidates[0]?.score ?? 0) >= 3) {
|
||||
return scoredCandidates[0]?.candidate ?? null;
|
||||
}
|
||||
|
||||
return candidates[hashText(sourceText) % candidates.length] ?? null;
|
||||
}
|
||||
|
||||
export function resolveCustomWorldNpcMonsterPresetId(
|
||||
npc: CustomWorldMonsterSource,
|
||||
worldType?: WorldType | null,
|
||||
) {
|
||||
return resolveCustomWorldNpcMonsterPreset(npc, worldType)?.id ?? null;
|
||||
}
|
||||
@@ -12,11 +12,12 @@ export function buildNpcGiftModalState(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
actionText: string,
|
||||
selectedItemId: string | null = state.playerInventory[0]?.id ?? null,
|
||||
): GiftModalState {
|
||||
return {
|
||||
encounter,
|
||||
actionText,
|
||||
selectedItemId: state.playerInventory[0]?.id ?? null,
|
||||
selectedItemId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,7 +42,7 @@ export const NPC_GIFT_FUNCTION: FunctionDocumentationEntry = {
|
||||
animationNote: '第一次点击不驱动额外演出,重点是切到礼物面板。',
|
||||
storyNote:
|
||||
'真正的剧情推进发生在 confirmGift 之后,届时才会写入好感变化与结果文本。',
|
||||
uiNote: '会先打开 gift modal,并默认选中背包第一件可见物品。',
|
||||
uiNote: '会先打开 gift modal,并默认选中当前最适合作为礼物的物品。',
|
||||
compactDetailText: '送礼提升好感',
|
||||
},
|
||||
};
|
||||
|
||||
91
src/data/hostileNpcPresets.test.ts
Normal file
91
src/data/hostileNpcPresets.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
import type {GameState} from '../types';
|
||||
import {AnimationState, WorldType} from '../types';
|
||||
import {rollHostileNpcLoot} from './hostileNpcPresets';
|
||||
|
||||
function createGameState(): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: null,
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: {
|
||||
id: 'ruins',
|
||||
name: '断碑古道',
|
||||
description: '阴气与碎骨混在旧路之间。',
|
||||
imageSrc: '/ruins.png',
|
||||
monsterIds: ['monster-03'],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 60,
|
||||
playerMaxMana: 60,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('hostileNpcPresets', () => {
|
||||
it('combines preset loot with runtime semantic drops', () => {
|
||||
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||
|
||||
try {
|
||||
const loot = rollHostileNpcLoot(createGameState(), [
|
||||
{
|
||||
id: 'monster-03',
|
||||
name: '断骨祟灵',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(loot.some(item => item.id === 'monster-loot:bone-dust')).toBe(true);
|
||||
expect(
|
||||
loot.some(item => item.runtimeMetadata?.generationChannel === 'monster_drop'),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
randomSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -102,7 +102,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(976, 61, 4, 11),
|
||||
},
|
||||
baseStats: { attackRange: 1.3, speed: 6.8, hp: 102, maxHp: 102 },
|
||||
habitatTags: ['鍦板', '鑽掓潙', '瀵哄簷', '閬楄抗'],
|
||||
habitatTags: ['地宫', '荒村', '废寺', '遗迹'],
|
||||
},
|
||||
{
|
||||
id: 'monster-04',
|
||||
@@ -121,7 +121,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(870, 58, 3, 15),
|
||||
},
|
||||
baseStats: { attackRange: 1.1, speed: 4.8, hp: 152, maxHp: 152 },
|
||||
habitatTags: ['鐭抽樁', '娓″彛', '妗?', '闆箔'],
|
||||
habitatTags: ['石阶', '渡口', '古桥', '山门'],
|
||||
},
|
||||
{
|
||||
id: 'monster-06',
|
||||
@@ -140,7 +140,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(1606, 73, 3, 11),
|
||||
},
|
||||
baseStats: { attackRange: 1.2, speed: 5.8, hp: 140, maxHp: 140 },
|
||||
habitatTags: ['鐭块亾', '鐭抽樁', '搴熷煄', '鍦板'],
|
||||
habitatTags: ['矿道', '石阶', '废城', '地宫'],
|
||||
},
|
||||
{
|
||||
id: 'monster-07',
|
||||
@@ -159,7 +159,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(1444, 76, 3, 11),
|
||||
},
|
||||
baseStats: { attackRange: 1.1, speed: 6.1, hp: 108, maxHp: 108 },
|
||||
habitatTags: ['闆炬灄', '鑽掓潙', '瀵哄簷', '灞辫矾'],
|
||||
habitatTags: ['雾林', '荒村', '废寺', '山路'],
|
||||
},
|
||||
{
|
||||
id: 'monster-08',
|
||||
@@ -178,7 +178,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(950, 50, 3, 17),
|
||||
},
|
||||
baseStats: { attackRange: 1.0, speed: 6.7, hp: 118, maxHp: 118 },
|
||||
habitatTags: ['绔规灄', '闆炬灄', '娌兼辰', '鑽掗噹'],
|
||||
habitatTags: ['竹林', '雾林', '沼泽', '荒野'],
|
||||
},
|
||||
{
|
||||
id: 'monster-11',
|
||||
@@ -197,7 +197,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(770, 70, 3, 10),
|
||||
},
|
||||
baseStats: { attackRange: 1.4, speed: 7.4, hp: 124, maxHp: 124 },
|
||||
habitatTags: ['闀胯', '钀ュ湴', '鏂灒', '瀹嫅'],
|
||||
habitatTags: ['长街', '营地', '断垣', '宫苑'],
|
||||
},
|
||||
{
|
||||
id: 'monster-13',
|
||||
@@ -214,7 +214,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
attack: rowAnimation(1056, 66, 1, 15),
|
||||
},
|
||||
baseStats: { attackRange: 1.7, speed: 8.2, hp: 96, maxHp: 96 },
|
||||
habitatTags: ['绔规灄', '闆炬灄', '灞辫矾', '鑺卞洯'],
|
||||
habitatTags: ['竹林', '雾林', '山路', '花圃'],
|
||||
},
|
||||
{
|
||||
id: 'monster-18',
|
||||
@@ -233,7 +233,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(1771, 77, 1, 17),
|
||||
},
|
||||
baseStats: { attackRange: 1.8, speed: 4.4, hp: 186, maxHp: 186 },
|
||||
habitatTags: ['鐭块亾', '閾稿潑', '搴熷煄', '杈瑰叧'],
|
||||
habitatTags: ['矿道', '铸坊', '废城', '边关'],
|
||||
},
|
||||
{
|
||||
id: 'monster-02',
|
||||
@@ -252,7 +252,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(688, 43, 4, 7),
|
||||
},
|
||||
baseStats: { attackRange: 1.4, speed: 7.3, hp: 112, maxHp: 112 },
|
||||
habitatTags: ['浠欓棬', '闀垮粖', '閬楄抗', '绁潧'],
|
||||
habitatTags: ['仙门', '长廊', '遗迹', '祭坛'],
|
||||
},
|
||||
{
|
||||
id: 'monster-05',
|
||||
@@ -271,7 +271,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(840, 60, 4, 14),
|
||||
},
|
||||
baseStats: { attackRange: 1.6, speed: 7.0, hp: 126, maxHp: 126 },
|
||||
habitatTags: ['濡栭浘', '娲炲ぉ', '璋峰湴', '绉樻'],
|
||||
habitatTags: ['妖雾', '洞天', '谷地', '秘境'],
|
||||
},
|
||||
{
|
||||
id: 'monster-10',
|
||||
@@ -290,7 +290,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(720, 48, 3, 15),
|
||||
},
|
||||
baseStats: { attackRange: 1.2, speed: 5.0, hp: 136, maxHp: 136 },
|
||||
habitatTags: ['娲炲ぉ', '璋峰湴', '鏈堟箹', '鐭垮潙'],
|
||||
habitatTags: ['洞天', '谷地', '月湖', '石坑'],
|
||||
},
|
||||
{
|
||||
id: 'monster-12',
|
||||
@@ -309,7 +309,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(506, 46, 3, 11),
|
||||
},
|
||||
baseStats: { attackRange: 1.6, speed: 7.8, hp: 120, maxHp: 120 },
|
||||
habitatTags: ['娲炲ぉ', '宕?', '浠欏矝', '鍙よ抗'],
|
||||
habitatTags: ['洞天', '崖壁', '仙岛', '古迹'],
|
||||
},
|
||||
{
|
||||
id: 'monster-14',
|
||||
@@ -328,7 +328,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(671, 61, 4, 11),
|
||||
},
|
||||
baseStats: { attackRange: 1.7, speed: 7.1, hp: 128, maxHp: 128 },
|
||||
habitatTags: ['鏈堟箹', '澶╂渤', '浠欐床', '鐏垫硥'],
|
||||
habitatTags: ['月湖', '天河', '仙洲', '灵泉'],
|
||||
},
|
||||
{
|
||||
id: 'monster-15',
|
||||
@@ -346,7 +346,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(2400, 75, 2, 17),
|
||||
},
|
||||
baseStats: { attackRange: 1.5, speed: 4.6, hp: 168, maxHp: 168 },
|
||||
habitatTags: ['鑺卞渻', '绁炴湪', '璋峰湴', '绉樺'],
|
||||
habitatTags: ['花圃', '神木', '谷地', '秘境'],
|
||||
},
|
||||
{
|
||||
id: 'monster-16',
|
||||
@@ -365,7 +365,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
die: rowAnimation(728, 52, 3, 9),
|
||||
},
|
||||
baseStats: { attackRange: 1.8, speed: 6.9, hp: 130, maxHp: 130 },
|
||||
habitatTags: ['濡栭浘', '浠欓棬', '椋炵€?', '鎮┖'],
|
||||
habitatTags: ['妖雾', '仙门', '星舟', '悬空'],
|
||||
},
|
||||
{
|
||||
id: 'monster-20',
|
||||
@@ -383,7 +383,7 @@ const BASE_HOSTILE_NPC_PRESETS: Array<
|
||||
attack: rowAnimation(804, 67, 2, 12),
|
||||
},
|
||||
baseStats: { attackRange: 1.4, speed: 6.4, hp: 138, maxHp: 138 },
|
||||
habitatTags: ['鏈堟箹', '鐏垫硥', '澶╂渤', '瀵掔帀'],
|
||||
habitatTags: ['月湖', '灵泉', '天河', '寒玉'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -394,12 +394,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.74,
|
||||
buildHostileNpcLootItem(
|
||||
'script-fragment',
|
||||
'鏉愭枡',
|
||||
'娈嬮〉纰庣墖',
|
||||
'材料',
|
||||
'残页碎片',
|
||||
'common',
|
||||
['material'],
|
||||
2,
|
||||
'鏁h惤鐨勪功椤靛拰鏄撶鐨勭鐗囷紝浠嶆畫鐣欑潃鎶ょ澧ㄦ按鐨勭棔杩广€?',
|
||||
'散落的书页和易碎纸片上,还残留着护符墨水的痕迹。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -407,12 +407,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.24,
|
||||
buildHostileNpcLootItem(
|
||||
'archive-sigil',
|
||||
'閬楃墿',
|
||||
'妗f鍗拌',
|
||||
'稀有品',
|
||||
'档案印记',
|
||||
'rare',
|
||||
['relic', 'mana'],
|
||||
1,
|
||||
'涓€涓偓娴殑鍗拌锛屽瓨鍌ㄧ潃鍛ㄥ洿鐨勭伒鏂囥€?',
|
||||
'一枚悬浮的印记,内部储着周围灵文的回响。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -422,12 +422,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.74,
|
||||
buildHostileNpcLootItem(
|
||||
'bone-dust',
|
||||
'鏉愭枡',
|
||||
'楠ㄥ皹',
|
||||
'材料',
|
||||
'骨尘',
|
||||
'common',
|
||||
['material'],
|
||||
2,
|
||||
'鐮寸骞界伒鐣欎笅鐨勭矇鏈姸娈嬮銆?',
|
||||
'破碎骨灵散落下来的粉末状残渣。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -435,12 +435,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.22,
|
||||
buildHostileNpcLootItem(
|
||||
'whisper-ember',
|
||||
'閬楃墿',
|
||||
'浣庤浣欑儸',
|
||||
'稀有品',
|
||||
'低语余烬',
|
||||
'rare',
|
||||
['relic', 'mana'],
|
||||
1,
|
||||
'寰急鐨勭伀鑺憋紝浠嶅甫鐫€涓嶅畨鐨勯榄傝兘閲忓棥鍡′綔鍝嶃€?',
|
||||
'微弱的火星里还带着不安魂力的回响。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -450,12 +450,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.7,
|
||||
buildHostileNpcLootItem(
|
||||
'stone-shell-shard',
|
||||
'鏉愭枡',
|
||||
'鐭冲3纰庣墖',
|
||||
'材料',
|
||||
'石壳碎片',
|
||||
'uncommon',
|
||||
['material'],
|
||||
2,
|
||||
'浠庣埇琛岀煶鑳屾帬椋熻€呰韩涓婂墺钀界殑鐮寸鎶ょ敳鐗囥€?',
|
||||
'从石背蜗怪身上剥落下来的坚硬护壳碎片。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -463,12 +463,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.32,
|
||||
buildHostileNpcLootItem(
|
||||
'venom-gland',
|
||||
'鏉愭枡',
|
||||
'姣掕吅',
|
||||
'材料',
|
||||
'毒腺',
|
||||
'uncommon',
|
||||
['material'],
|
||||
1,
|
||||
'浠嶅甫鐫€浣欐俯鐨勫瘑灏佹瘨鍥娿€?',
|
||||
'仍带着余温的密封毒囊。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -478,12 +478,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.68,
|
||||
buildHostileNpcLootItem(
|
||||
'watcher-tendon',
|
||||
'鏉愭枡',
|
||||
'瀹堟湜鑰呰倢鑵?',
|
||||
'材料',
|
||||
'守望筋丝',
|
||||
'common',
|
||||
['material'],
|
||||
2,
|
||||
'浠庢紓娴溂 stalker 韬笂鎵笅鐨勭粏涓濄€?',
|
||||
'从漂浮妖眼身上扯下的细丝筋络。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -491,12 +491,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.3,
|
||||
buildHostileNpcLootItem(
|
||||
'blood-lens',
|
||||
'閬楃墿',
|
||||
'琛€鏅堕€忛暅',
|
||||
'稀有品',
|
||||
'血瞳透镜',
|
||||
'rare',
|
||||
['relic', 'mana'],
|
||||
1,
|
||||
'缁忚繃鎶涘厜鐨勭溂鏅讹紝鍙寮烘晫鎰忓嚌瑙嗘妧宸с€?',
|
||||
'经由打磨的眼晶,可放大敌意与凝视类术式。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -506,12 +506,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.38,
|
||||
buildHostileNpcLootItem(
|
||||
'carapace-plate',
|
||||
'鎶ょ敳',
|
||||
'鐢插3鏉?',
|
||||
'护甲',
|
||||
'甲壳板',
|
||||
'rare',
|
||||
['armor', 'material'],
|
||||
1,
|
||||
'鍙噸閾镐负閲嶅瀷闃叉姢鐨勮嚧瀵嗙敳澹炦ి€?',
|
||||
'可重铸成重型防具的致密甲板。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -519,12 +519,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.18,
|
||||
buildHostileNpcLootItem(
|
||||
'guard-core',
|
||||
'閬楃墿',
|
||||
'瀹堝崼鏍稿績',
|
||||
'稀有品',
|
||||
'守御核心',
|
||||
'rare',
|
||||
['relic'],
|
||||
1,
|
||||
'钑村惈閲庢€ч槻寰℃湰鑳界殑鏍稿績锛屽潥涓嶅彲鎽ං€?',
|
||||
'蕴着本能防御意志的核心,坚实得近乎难摧。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -534,12 +534,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.72,
|
||||
buildHostileNpcLootItem(
|
||||
'spore-pouch',
|
||||
'鏉愭枡',
|
||||
'瀛㈠泭',
|
||||
'材料',
|
||||
'孢囊',
|
||||
'uncommon',
|
||||
['material'],
|
||||
2,
|
||||
'瑁呮弧涓嶇ǔ瀹氳槕鑿囧瀛愮殑鍥婅銆?',
|
||||
'装满不稳定菌孢的鼓胀囊袋。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -547,12 +547,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.28,
|
||||
buildHostileNpcLootItem(
|
||||
'burst-cap',
|
||||
'娑堣€楀搧',
|
||||
'鐖嗚弴甯?',
|
||||
'消耗品',
|
||||
'爆菇帽',
|
||||
'uncommon',
|
||||
['healing'],
|
||||
1,
|
||||
'鍙姞宸ヤ负鎴樺湴鑽墏鐨勬尌鍙戞€ц弴甯姐€?',
|
||||
'可加工成战地药剂的挥发菌帽。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -562,12 +562,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.76,
|
||||
buildHostileNpcLootItem(
|
||||
'vine-tendril',
|
||||
'鏉愭枡',
|
||||
'钘ら』',
|
||||
'材料',
|
||||
'藤须',
|
||||
'common',
|
||||
['material'],
|
||||
2,
|
||||
'浠庝紡鍑昏棨钄撲笂鑾峰彇鐨勫潥闊х氦缁淬€?',
|
||||
'从枯藤伏虫身上取得的韧性藤丝。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -575,12 +575,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.2,
|
||||
buildHostileNpcLootItem(
|
||||
'ambush-fang',
|
||||
'姝﹀櫒',
|
||||
'浼忓嚮鐗?',
|
||||
'武器',
|
||||
'伏袭牙刃',
|
||||
'rare',
|
||||
['weapon', 'material'],
|
||||
1,
|
||||
'杩戞垬鐚庝汉鐝嶈鐨勫ぉ鐒跺皷鍒€¢€?',
|
||||
'近战猎手格外珍视的天然尖牙刃。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -590,12 +590,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.72,
|
||||
buildHostileNpcLootItem(
|
||||
'spirit-slime',
|
||||
'鏉愭枡',
|
||||
'鐏佃厫榛忔恫',
|
||||
'材料',
|
||||
'灵腐黏液',
|
||||
'common',
|
||||
['material'],
|
||||
2,
|
||||
'鍦ㄩ粦鏆椾腑缂撴參鍙戝厜鐨勭矘绋犳畫鐣欑墿銆?',
|
||||
'在黑暗中微微发光的腐灵残液。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -603,12 +603,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.2,
|
||||
buildHostileNpcLootItem(
|
||||
'marsh-core',
|
||||
'閬楃墿',
|
||||
'娌兼辰鏍稿績',
|
||||
'稀有品',
|
||||
'沼泽核心',
|
||||
'rare',
|
||||
['relic', 'mana'],
|
||||
1,
|
||||
'娌兼辰鐏垫皵涓庢畫鐣欑槾姘旂殑鍑濊仛鑺傜偣銆?',
|
||||
'沼气与阴湿灵力凝成的核心结节。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -618,12 +618,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.58,
|
||||
buildHostileNpcLootItem(
|
||||
'night-fang',
|
||||
'鏉愭枡',
|
||||
'澶滅墮',
|
||||
'材料',
|
||||
'夜牙',
|
||||
'uncommon',
|
||||
['material'],
|
||||
1,
|
||||
'閫傚悎鍒朵綔姣掕嵂鎴栭櫡闃辩殑鍒€鍒冪姸鐛犵墮銆?',
|
||||
'适合炼毒与设陷的刃状兽牙。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -631,12 +631,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.24,
|
||||
buildHostileNpcLootItem(
|
||||
'shadow-pelt',
|
||||
'鎶ょ敳',
|
||||
'鏆楀奖鐨?',
|
||||
'护甲',
|
||||
'暗影兽皮',
|
||||
'rare',
|
||||
['armor', 'material'],
|
||||
1,
|
||||
'绌挎埓鏃跺彲娑堝辑鍔ㄩ潤鐨勬繁鑹插吔鐨€?',
|
||||
'披戴后能稍稍融入夜色的深色皮膜。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -646,12 +646,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.64,
|
||||
buildHostileNpcLootItem(
|
||||
'ember-wing',
|
||||
'鏉愭枡',
|
||||
'鐑考',
|
||||
'材料',
|
||||
'烬翼',
|
||||
'uncommon',
|
||||
['material'],
|
||||
2,
|
||||
'浠嶅湪鏁h惤鏆栫伆鐨勭儳鐒︾繀缈肩鐗囥€?',
|
||||
'仍散着余热灰烬的焦脆翼片。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -659,12 +659,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.22,
|
||||
buildHostileNpcLootItem(
|
||||
'ashfire-feather',
|
||||
'閬楃墿',
|
||||
'鐏扮儸鐏窘',
|
||||
'稀有品',
|
||||
'灰焰翎',
|
||||
'rare',
|
||||
['relic', 'mana'],
|
||||
1,
|
||||
'钑村惈寰皬浣嗘寔涔呯殑鐑伒鐨勭窘灏栥€?',
|
||||
'羽尖里封着微弱却持久的灼灵余火。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -674,12 +674,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.66,
|
||||
buildHostileNpcLootItem(
|
||||
'serpent-venom-sac',
|
||||
'鏉愭枡',
|
||||
'铔囨瘨鍥?',
|
||||
'材料',
|
||||
'蛇毒囊',
|
||||
'uncommon',
|
||||
['material'],
|
||||
1,
|
||||
'鐐奸噾甯堜笌鍒哄閮界弽瑙嗙殑姣掔礌鍥娿€?',
|
||||
'炼药师与刺客都很看重的高浓毒囊。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -687,12 +687,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.16,
|
||||
buildHostileNpcLootItem(
|
||||
'serpent-eye',
|
||||
'閬楃墿',
|
||||
'铔囩溂',
|
||||
'稀有品',
|
||||
'蛇瞳',
|
||||
'rare',
|
||||
['relic', 'mana'],
|
||||
1,
|
||||
'鍗充娇闈欐涔熻兘杩借釜闄勮繎鍔ㄩ潤鐨勮寮傜溂鏅躲€?',
|
||||
'即使静止不动,也像仍在追索附近的活物。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -702,12 +702,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.7,
|
||||
buildHostileNpcLootItem(
|
||||
'tide-ink',
|
||||
'鏉愭枡',
|
||||
'娼ⅷ',
|
||||
'材料',
|
||||
'潮墨',
|
||||
'uncommon',
|
||||
['material'],
|
||||
2,
|
||||
'鐢ㄤ簬灏佸嵃銆侀櫡闃变笌姘村睘鎬х绠撶殑娴撶榛戞恫銆?',
|
||||
'适合封印、符阵与水属术式的浓黑灵墨。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -715,12 +715,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.18,
|
||||
buildHostileNpcLootItem(
|
||||
'lake-pearl',
|
||||
'閬楃墿',
|
||||
'婀栫彔',
|
||||
'稀有品',
|
||||
'湖珠',
|
||||
'rare',
|
||||
['relic', 'mana'],
|
||||
1,
|
||||
'钑村惈鏈堝厜婀挎皵鐨勫厜婊戠弽鐝犮€?',
|
||||
'裹着月华水气的光润珍珠。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -730,12 +730,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.46,
|
||||
buildHostileNpcLootItem(
|
||||
'thorn-nectar',
|
||||
'Consumable',
|
||||
'Thorn Nectar',
|
||||
'消耗品',
|
||||
'棘露蜜浆',
|
||||
'uncommon',
|
||||
['healing', 'material'],
|
||||
1,
|
||||
'Sticky sap that can be refined into emergency recovery tonic.',
|
||||
'黏稠树露可炼成紧急疗伤的回生药浆。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -743,12 +743,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.12,
|
||||
buildHostileNpcLootItem(
|
||||
'devour-bloom',
|
||||
'Relic',
|
||||
'Devour Bloom',
|
||||
'稀有品',
|
||||
'噬灵花核',
|
||||
'epic',
|
||||
['relic'],
|
||||
1,
|
||||
'A predatory blossom that stores concentrated life force.',
|
||||
'会掠食灵机的妖花花核,积着浓缩生机。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -758,12 +758,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.72,
|
||||
buildHostileNpcLootItem(
|
||||
'mist-wing',
|
||||
'Material',
|
||||
'Mist Wing',
|
||||
'材料',
|
||||
'雾翼膜',
|
||||
'common',
|
||||
['material'],
|
||||
2,
|
||||
'Thin membrane steeped in drifting fog and static charge.',
|
||||
'浸着游雾与静电的轻薄翼膜。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -771,12 +771,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.2,
|
||||
buildHostileNpcLootItem(
|
||||
'chase-rune',
|
||||
'Relic',
|
||||
'Chase Rune',
|
||||
'稀有品',
|
||||
'追猎符印',
|
||||
'rare',
|
||||
['relic'],
|
||||
1,
|
||||
'A pursuit mark that resonates with speed and pressure.',
|
||||
'能与速度和压迫感共鸣的追索印记。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -786,12 +786,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.62,
|
||||
buildHostileNpcLootItem(
|
||||
'ancient-hide',
|
||||
'Armor',
|
||||
'Ancient Hide',
|
||||
'护甲',
|
||||
'古苔兽皮',
|
||||
'uncommon',
|
||||
['armor', 'material'],
|
||||
1,
|
||||
'Thick hide layered with old dust, moss, and impact scars.',
|
||||
'厚重皮层间压着旧尘、苔痕与碰撞裂纹。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -799,12 +799,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.14,
|
||||
buildHostileNpcLootItem(
|
||||
'ruin-heart',
|
||||
'Relic',
|
||||
'Ruin Heart',
|
||||
'稀有品',
|
||||
'遗墟之心',
|
||||
'epic',
|
||||
['relic'],
|
||||
1,
|
||||
'A heavy core pulsing with the stubborn will of abandoned ruins.',
|
||||
'沉重的核心里跳着废墟不肯坍塌的执念。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -814,12 +814,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.44,
|
||||
buildHostileNpcLootItem(
|
||||
'spring-essence',
|
||||
'Consumable',
|
||||
'Spring Essence',
|
||||
'消耗品',
|
||||
'灵泉精露',
|
||||
'uncommon',
|
||||
['mana'],
|
||||
1,
|
||||
'A cool droplet that restores focus and spiritual rhythm.',
|
||||
'清凉的泉露能稳住心神与灵息运转。',
|
||||
),
|
||||
),
|
||||
buildHostileNpcLootEntry(
|
||||
@@ -827,12 +827,12 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
0.11,
|
||||
buildHostileNpcLootItem(
|
||||
'tide-mother-core',
|
||||
'Relic',
|
||||
'Tide Mother Core',
|
||||
'稀有品',
|
||||
'潮母灵核',
|
||||
'epic',
|
||||
['relic', 'mana'],
|
||||
1,
|
||||
'A refined water-heart formed only in the deepest luminous springs.',
|
||||
'只有在最深的明泉里才会凝出的水华灵核。',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -841,22 +841,22 @@ const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
|
||||
const HOSTILE_NPC_OVERRIDES = hostileNpcOverridesJson as Record<string, HostileNpcPresetOverride>;
|
||||
|
||||
const BASE_HOSTILE_NPC_COMBAT_TAGS: Record<string, string[]> = {
|
||||
'monster-03': ['闀囬偑', '鎺у満', '鏈哄姩'],
|
||||
'monster-04': ['閲嶇敳', '瀹堝尽', '鍙嶅嚮'],
|
||||
'monster-06': ['閲嶇敳', '瀹堝尽', '鍘嬪埗'],
|
||||
'monster-07': ['鎺у満', '鍥炲', '鐐艰嵂'],
|
||||
'monster-08': ['蹇', '杩藉嚮', '鏈哄姩'],
|
||||
'monster-11': ['蹇', '绐佽繘', '鍘嬪埗'],
|
||||
'monster-13': ['蹇', '杩藉嚮', '椋庤'],
|
||||
'monster-18': ['閲嶅嚮', '瀹堝尽', '鍫″瀿'],
|
||||
'monster-02': ['娉曚慨', '绗﹂樀', '鎺у満'],
|
||||
'monster-05': ['鎺у満', '娉曚慨', '闀囬偑'],
|
||||
'monster-10': ['娉曞姏', '鍥炲', '鎶や綋'],
|
||||
'monster-12': ['鏈哄姩', '杩滃皠', '椋庤'],
|
||||
'monster-14': ['娉曞姏', '绗﹂樀', '鍥炲'],
|
||||
'monster-15': ['鍥炲', '鎶や綋', '閲嶇敳'],
|
||||
'monster-16': ['闆锋硶', '鏈哄姩', '杩囪浇'],
|
||||
'monster-20': ['娉曞姏', '鍥炲', '闀囬偑'],
|
||||
'monster-03': ['镇邪', '控场', '机动'],
|
||||
'monster-04': ['重甲', '守御', '反击'],
|
||||
'monster-06': ['重甲', '守御', '压制'],
|
||||
'monster-07': ['控场', '回复', '炼药'],
|
||||
'monster-08': ['快袭', '追击', '机动'],
|
||||
'monster-11': ['快袭', '突进', '压制'],
|
||||
'monster-13': ['快袭', '追击', '风行'],
|
||||
'monster-18': ['重击', '守御', '堡垒'],
|
||||
'monster-02': ['法修', '符阵', '控场'],
|
||||
'monster-05': ['控场', '法修', '镇邪'],
|
||||
'monster-10': ['法力', '回复', '护体'],
|
||||
'monster-12': ['机动', '远射', '风行'],
|
||||
'monster-14': ['法力', '符阵', '回复'],
|
||||
'monster-15': ['回复', '护体', '重甲'],
|
||||
'monster-16': ['雷法', '机动', '过载'],
|
||||
'monster-20': ['法力', '回复', '镇邪'],
|
||||
};
|
||||
|
||||
function mergeHostileNpcPreset(
|
||||
@@ -894,7 +894,7 @@ function buildHostileNpcBehaviorVectors(preset: {
|
||||
combatTags: string[];
|
||||
baseStats: Pick<SceneHostileNpc, 'attackRange' | 'speed' | 'maxHp'>;
|
||||
}) {
|
||||
const controlBias = preset.combatTags.some(tag => ['鎺у満', '绗﹂樀', '娉曞姏'].includes(tag)) ? 0.28 : 0.12;
|
||||
const controlBias = preset.combatTags.some(tag => ['控场', '符阵', '法力'].includes(tag)) ? 0.28 : 0.12;
|
||||
const mobilityBias = preset.baseStats.speed >= 7 ? 0.34 : 0.16;
|
||||
const pressureBias = preset.baseStats.attackRange >= 1.5 ? 0.32 : 0.18;
|
||||
const enduranceBias = preset.baseStats.maxHp >= 150 ? 0.3 : 0.16;
|
||||
@@ -902,7 +902,7 @@ function buildHostileNpcBehaviorVectors(preset: {
|
||||
return [
|
||||
{
|
||||
id: `${preset.id}:predatory-strike`,
|
||||
name: `${preset.name}鐨勬湰鑳藉帇杩玚`,
|
||||
name: `${preset.name}的本能压迫`,
|
||||
category: 'combat' as const,
|
||||
baseScore: 0.52,
|
||||
intentVector: buildDefaultAxisVector({
|
||||
@@ -974,12 +974,20 @@ export function rollHostileNpcLoot(
|
||||
`monster-loot:${monster.id}:${monster.name}`,
|
||||
{
|
||||
count: 2,
|
||||
categories: ['鏉愭枡', '娑堣€楀搧', '绋€鏈夊搧', '涓撳睘鐗?'],
|
||||
categories: ['材料', '消耗品', '稀有品', '专属品'],
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
return defeatedHostileNpcs.flatMap(monster => {
|
||||
const preset = getHostileNpcPresetById(state.worldType!, monster.id);
|
||||
const presetLoot = preset
|
||||
? preset.lootTable
|
||||
.filter(entry => Math.random() <= entry.dropRate)
|
||||
.map(entry => ({
|
||||
...entry.item,
|
||||
}))
|
||||
: [];
|
||||
const context = buildRuntimeItemGenerationContext({
|
||||
state,
|
||||
generationChannel: 'monster_drop',
|
||||
@@ -1000,17 +1008,6 @@ export function rollHostileNpcLoot(
|
||||
fixedPermanence: ['resource', 'timed'],
|
||||
});
|
||||
const runtimeItems = flattenDirectedRuntimeRewardItems(directedReward);
|
||||
if (runtimeItems.length > 0) {
|
||||
return runtimeItems;
|
||||
}
|
||||
|
||||
const preset = getHostileNpcPresetById(state.worldType!, monster.id);
|
||||
if (!preset) return [];
|
||||
|
||||
return preset.lootTable
|
||||
.filter(entry => Math.random() <= entry.dropRate)
|
||||
.map(entry => ({
|
||||
...entry.item,
|
||||
}));
|
||||
return [...presetLoot, ...runtimeItems];
|
||||
});
|
||||
}
|
||||
|
||||
247
src/data/npcInteractions.test.ts
Normal file
247
src/data/npcInteractions.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Character, Encounter, GameState, InventoryItem } from '../types';
|
||||
import { AnimationState, WorldType } from '../types';
|
||||
import {
|
||||
buildNpcHelpReward,
|
||||
buildGiftCandidateSummary,
|
||||
buildInitialNpcState,
|
||||
buildNpcEncounterStoryMoment,
|
||||
buildNpcTradeTransactionActionText,
|
||||
syncNpcTradeInventory,
|
||||
} from './npcInteractions';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: 'Hero',
|
||||
title: 'Wanderer',
|
||||
description: 'A reliable test hero.',
|
||||
backstory: 'Travels the land.',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 9,
|
||||
intelligence: 8,
|
||||
spirit: 7,
|
||||
},
|
||||
personality: 'steady',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createEncounter(): Encounter {
|
||||
return {
|
||||
id: 'npc-trader',
|
||||
kind: 'npc',
|
||||
npcName: 'Trader Lin',
|
||||
npcDescription: 'A traveling merchant.',
|
||||
npcAvatar: 'T',
|
||||
context: 'merchant',
|
||||
};
|
||||
}
|
||||
|
||||
function createInventoryItem(
|
||||
id: string,
|
||||
name: string,
|
||||
overrides: Partial<InventoryItem> = {},
|
||||
): InventoryItem {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: `${name} description`,
|
||||
quantity: 1,
|
||||
category: 'misc',
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
value: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createGameState(
|
||||
encounter: Encounter,
|
||||
overrides: Partial<GameState> = {},
|
||||
): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: encounter,
|
||||
npcInteractionActive: true,
|
||||
currentScenePreset: {
|
||||
id: 'scene-camp',
|
||||
name: 'Camp',
|
||||
description: 'A temporary camp.',
|
||||
imageSrc: '/camp.png',
|
||||
monsterIds: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 80,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 40,
|
||||
playerMaxMana: 60,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('npcInteractions', () => {
|
||||
it('builds a readable fallback summary for empty gift candidates', () => {
|
||||
expect(buildGiftCandidateSummary([])).toBe('暂无合适礼物');
|
||||
});
|
||||
|
||||
it('includes gift candidate context in the npc gift option detail text', () => {
|
||||
const encounter = createEncounter();
|
||||
const story = buildNpcEncounterStoryMoment({
|
||||
encounter,
|
||||
npcState: buildInitialNpcState(encounter, WorldType.WUXIA),
|
||||
playerCharacter: createCharacter(),
|
||||
playerInventory: [
|
||||
createInventoryItem('jade-token', 'Jade Token', {
|
||||
rarity: 'rare',
|
||||
category: '专属',
|
||||
tags: ['merchant'],
|
||||
}),
|
||||
createInventoryItem('tea-brick', 'Tea Brick'),
|
||||
],
|
||||
activeQuests: [],
|
||||
scene: {
|
||||
id: 'scene-1',
|
||||
name: 'Camp',
|
||||
monsterIds: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
worldType: WorldType.WUXIA,
|
||||
partySize: 0,
|
||||
});
|
||||
|
||||
const giftOption = story.options.find((option) => option.functionId === 'npc_gift');
|
||||
expect(giftOption).toBeTruthy();
|
||||
expect(giftOption?.detailText).toContain('Jade Token');
|
||||
expect(giftOption?.detailText).toContain('Tea Brick');
|
||||
});
|
||||
|
||||
it('omits the npc gift option when the player has no gift candidates', () => {
|
||||
const encounter = createEncounter();
|
||||
const story = buildNpcEncounterStoryMoment({
|
||||
encounter,
|
||||
npcState: buildInitialNpcState(encounter, WorldType.WUXIA),
|
||||
playerCharacter: createCharacter(),
|
||||
playerInventory: [],
|
||||
activeQuests: [],
|
||||
scene: {
|
||||
id: 'scene-1',
|
||||
name: 'Camp',
|
||||
monsterIds: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
worldType: WorldType.WUXIA,
|
||||
partySize: 0,
|
||||
});
|
||||
|
||||
expect(story.options.some((option) => option.functionId === 'npc_gift')).toBe(false);
|
||||
});
|
||||
|
||||
it('builds concrete trade action text for story continuation', () => {
|
||||
const encounter = createEncounter();
|
||||
|
||||
expect(
|
||||
buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
mode: 'buy',
|
||||
item: createInventoryItem('jade-token', 'Jade Token'),
|
||||
quantity: 2,
|
||||
}),
|
||||
).toBe('从Trader Lin手里买下Jade Token x2');
|
||||
|
||||
expect(
|
||||
buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
mode: 'sell',
|
||||
item: createInventoryItem('tea-brick', 'Tea Brick'),
|
||||
quantity: 1,
|
||||
}),
|
||||
).toBe('把Tea Brick卖给Trader Lin');
|
||||
});
|
||||
|
||||
it('syncs generic trade stock to the current build while preserving sold-in items', () => {
|
||||
const encounter: Encounter = {
|
||||
...createEncounter(),
|
||||
context: '商贩',
|
||||
};
|
||||
const state = createGameState(encounter);
|
||||
const syncedState = syncNpcTradeInventory(state, encounter, {
|
||||
...buildInitialNpcState(encounter, WorldType.WUXIA),
|
||||
inventory: [createInventoryItem('sold-tea', 'Tea Brick')],
|
||||
tradeStockSignature: 'stale-build',
|
||||
});
|
||||
|
||||
expect(syncedState.tradeStockSignature).not.toBe('stale-build');
|
||||
expect(syncedState.inventory.some(item => item.id === 'sold-tea')).toBe(true);
|
||||
expect(
|
||||
syncedState.inventory.some(
|
||||
item => item.runtimeMetadata?.generationChannel === 'npc_trade',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('builds npc help rewards from the runtime director', () => {
|
||||
const encounter: Encounter = {
|
||||
...createEncounter(),
|
||||
context: '商贩',
|
||||
};
|
||||
const reward = buildNpcHelpReward(encounter, createGameState(encounter));
|
||||
|
||||
expect(reward.items.length).toBeGreaterThan(0);
|
||||
expect(reward.items[0]?.runtimeMetadata?.generationChannel).toBe('npc_reward');
|
||||
expect(reward.storyHint).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('questFlow', () => {
|
||||
expect(requireStep(quest!, 'step_primary').kind).toBe('defeat_hostile_npc');
|
||||
expect(requireStep(quest!, 'step_report_back').kind).toBe('talk_to_npc');
|
||||
expect(quest?.status).toBe('active');
|
||||
expect(quest?.reward.items[0]?.runtimeMetadata?.generationChannel).toBe('quest_reward');
|
||||
});
|
||||
|
||||
it('advances from primary objective to report-back step and then reward-ready', () => {
|
||||
|
||||
@@ -7,9 +7,9 @@ import type {
|
||||
QuestProgressSignal,
|
||||
QuestSceneSnapshot,
|
||||
} from '../services/questTypes';
|
||||
import type {QuestGenerationContext} from '../services/aiTypes';
|
||||
import {
|
||||
type CustomWorldProfile,
|
||||
type InventoryItem,
|
||||
type QuestLogEntry,
|
||||
type QuestObjective,
|
||||
type QuestObjectiveKind,
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
} from '../types';
|
||||
import {formatCurrency} from './economy';
|
||||
import {getHostileNpcPresetById} from './hostileNpcPresets';
|
||||
import {
|
||||
buildLooseRuntimeItemGenerationContext,
|
||||
buildQuestRuntimeItemGenerationContext,
|
||||
} from './runtimeItemContext';
|
||||
import {buildDirectedRuntimeReward} from './runtimeItemDirector';
|
||||
import {flattenDirectedRuntimeRewardItems} from './runtimeItemNarrative';
|
||||
import {getSceneHostileNpcs} from './scenePresets';
|
||||
|
||||
const REWARD_READY_STATUSES: QuestStatus[] = ['ready_to_turn_in', 'completed'];
|
||||
@@ -44,50 +50,129 @@ type SceneQuestThreat =
|
||||
suggestedThreatType: 'relationship';
|
||||
};
|
||||
|
||||
function buildQuestItem(
|
||||
prefix: string,
|
||||
category: string,
|
||||
name: string,
|
||||
rarity: InventoryItem['rarity'],
|
||||
tags: string[],
|
||||
): InventoryItem {
|
||||
function resolveQuestRewardRuntimeConfig(params: {
|
||||
roleText: string;
|
||||
rewardTheme: QuestIntent['rewardTheme'];
|
||||
narrativeType: QuestIntent['narrativeType'];
|
||||
}) {
|
||||
const {roleText, rewardTheme, narrativeType} = params;
|
||||
|
||||
if (rewardTheme === 'resource') {
|
||||
return {
|
||||
itemCount: 2,
|
||||
fixedKinds: ['material', 'consumable'] as const,
|
||||
fixedPermanence: ['resource', 'timed'] as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (rewardTheme === 'intel') {
|
||||
return {
|
||||
itemCount: 2,
|
||||
fixedKinds: ['relic', 'consumable'] as const,
|
||||
fixedPermanence: ['permanent', 'timed'] as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (rewardTheme === 'rare_item' || narrativeType === 'trial') {
|
||||
return {
|
||||
itemCount: 2,
|
||||
fixedKinds: ['equipment', 'relic'] as const,
|
||||
fixedPermanence: ['permanent', 'permanent'] as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (/猎|山|追踪/u.test(roleText)) {
|
||||
return {
|
||||
itemCount: 2,
|
||||
fixedKinds: ['consumable', 'equipment'] as const,
|
||||
fixedPermanence: ['timed', 'permanent'] as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (/商|军需/u.test(roleText)) {
|
||||
return {
|
||||
itemCount: 2,
|
||||
fixedKinds: ['material', 'relic'] as const,
|
||||
fixedPermanence: ['resource', 'permanent'] as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (rewardTheme === 'relationship' || narrativeType === 'relationship') {
|
||||
return {
|
||||
itemCount: 2,
|
||||
fixedKinds: ['relic', 'equipment'] as const,
|
||||
fixedPermanence: ['permanent', 'permanent'] as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${prefix}:${encodeURIComponent(`${category}-${name}`)}`,
|
||||
category,
|
||||
name,
|
||||
quantity: 1,
|
||||
rarity,
|
||||
tags,
|
||||
itemCount: 2,
|
||||
fixedKinds: ['equipment', 'consumable'] as const,
|
||||
fixedPermanence: ['permanent', 'timed'] as const,
|
||||
};
|
||||
}
|
||||
|
||||
function buildQuestReward(params: {
|
||||
issuerNpcId: string;
|
||||
issuerNpcName: string;
|
||||
worldType: WorldType | null;
|
||||
roleText: string;
|
||||
rewardTheme: QuestIntent['rewardTheme'];
|
||||
narrativeType: QuestIntent['narrativeType'];
|
||||
scene: QuestSceneSnapshot | null;
|
||||
context?: QuestGenerationContext;
|
||||
}): QuestReward {
|
||||
const {worldType, roleText, rewardTheme, narrativeType, scene} = params;
|
||||
const sharedRelic = worldType === 'XIANXIA'
|
||||
? buildQuestItem('quest', '稀有品', '灵纹符匣', 'rare', ['relic', 'mana'])
|
||||
: buildQuestItem('quest', '稀有品', '江湖悬赏令', 'rare', ['relic']);
|
||||
const roleItem = /猎|山|追踪/u.test(roleText)
|
||||
? buildQuestItem('quest', '消耗品', '追踪药包', 'uncommon', ['healing'])
|
||||
: /商|军需/u.test(roleText)
|
||||
? buildQuestItem('quest', '材料', '精制布包', 'uncommon', ['material'])
|
||||
: rewardTheme === 'resource'
|
||||
? buildQuestItem('quest', '材料', '补给包', 'uncommon', ['material'])
|
||||
: buildQuestItem('quest', '消耗品', '回气散', 'uncommon', ['mana']);
|
||||
const {
|
||||
issuerNpcId,
|
||||
issuerNpcName,
|
||||
worldType,
|
||||
roleText,
|
||||
rewardTheme,
|
||||
narrativeType,
|
||||
scene,
|
||||
context,
|
||||
} = params;
|
||||
const runtimeConfig = resolveQuestRewardRuntimeConfig({
|
||||
roleText,
|
||||
rewardTheme,
|
||||
narrativeType,
|
||||
});
|
||||
const runtimeContext = context
|
||||
? buildQuestRuntimeItemGenerationContext({
|
||||
context,
|
||||
issuerNpcId,
|
||||
issuerNpcName,
|
||||
roleText,
|
||||
scene,
|
||||
})
|
||||
: buildLooseRuntimeItemGenerationContext({
|
||||
worldType,
|
||||
scene,
|
||||
encounter: {
|
||||
id: issuerNpcId,
|
||||
kind: 'npc',
|
||||
npcName: issuerNpcName,
|
||||
npcDescription: roleText,
|
||||
npcAvatar: '',
|
||||
context: roleText,
|
||||
},
|
||||
playerCharacterId: 'quest-preview-player',
|
||||
generationChannel: 'quest_reward',
|
||||
});
|
||||
const directedReward = buildDirectedRuntimeReward(runtimeContext, {
|
||||
seedKey: `quest:${issuerNpcId}:${scene?.id ?? 'scene'}:${rewardTheme}:${narrativeType}`,
|
||||
itemCount: runtimeConfig.itemCount,
|
||||
fixedKinds: [...runtimeConfig.fixedKinds],
|
||||
fixedPermanence: [...runtimeConfig.fixedPermanence],
|
||||
});
|
||||
|
||||
const reward: QuestReward = {
|
||||
affinityBonus: narrativeType === 'relationship' || narrativeType === 'trial' ? 14 : 12,
|
||||
currency: rewardTheme === 'intel'
|
||||
? (worldType === 'XIANXIA' ? 40 : 58)
|
||||
: (worldType === 'XIANXIA' ? 54 : 72),
|
||||
items: rewardTheme === 'resource'
|
||||
? [roleItem, buildQuestItem('quest', '材料', '补给包', 'uncommon', ['material'])]
|
||||
: [sharedRelic, roleItem],
|
||||
items: flattenDirectedRuntimeRewardItems(directedReward),
|
||||
storyHint: directedReward.storyHint,
|
||||
};
|
||||
|
||||
if (rewardTheme === 'intel') {
|
||||
@@ -103,7 +188,7 @@ function buildQuestReward(params: {
|
||||
}
|
||||
|
||||
function buildRewardText(reward: QuestReward, worldType: WorldType | null) {
|
||||
const itemText = reward.items.map(item => item.name).join('、');
|
||||
const itemText = reward.items.map(item => item.name).join('、') || '当前局势相关的补给';
|
||||
const intelText = reward.intel?.rumorText ? `,以及情报“${reward.intel.rumorText}”` : '';
|
||||
return `完成后可获得好感 +${reward.affinityBonus}、${formatCurrency(reward.currency, worldType)}、${itemText}${intelText}。`;
|
||||
}
|
||||
@@ -701,11 +786,14 @@ export function compileQuestIntentToQuest(
|
||||
|
||||
const steps = [primaryStep, buildTalkBackStep(params.issuerNpcId, params.issuerNpcName)];
|
||||
const reward = buildQuestReward({
|
||||
issuerNpcId: params.issuerNpcId,
|
||||
issuerNpcName: params.issuerNpcName,
|
||||
worldType: params.worldType,
|
||||
roleText: params.roleText,
|
||||
rewardTheme: intent.rewardTheme,
|
||||
narrativeType: intent.narrativeType,
|
||||
scene: params.scene,
|
||||
context: params.context,
|
||||
});
|
||||
const rewardText = buildRewardText(reward, params.worldType);
|
||||
const contract: QuestContract = {
|
||||
@@ -791,7 +879,8 @@ export function buildQuestTurnInResultText(quest: QuestLogEntry) {
|
||||
const intelText = quest.reward.intel?.rumorText
|
||||
? `,并额外告诉了你一条消息:${quest.reward.intel.rumorText}`
|
||||
: '';
|
||||
return `${quest.issuerNpcName} 确认你已经完成委托,交给了你 ${quest.reward.currency} 赏金和 ${itemText}${intelText}。`;
|
||||
const storyHintText = quest.reward.storyHint ? ` ${quest.reward.storyHint}` : '';
|
||||
return `${quest.issuerNpcName} 确认你已经完成委托,交给了你 ${quest.reward.currency} 赏金和 ${itemText}${intelText}。${storyHintText}`;
|
||||
}
|
||||
|
||||
export function acceptQuest(quests: QuestLogEntry[], quest: QuestLogEntry) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
RuntimeItemPlan,
|
||||
RuntimeRelationAnchor,
|
||||
} from '../types';
|
||||
import {generateRuntimeItemAiIntents} from '../services/runtimeItemAiDirector';
|
||||
import {compileRuntimeItem} from './runtimeItemCompiler';
|
||||
import {
|
||||
applyRuntimeItemNarrative,
|
||||
@@ -157,8 +158,9 @@ function compilePlannedItem(
|
||||
context: RuntimeItemGenerationContext,
|
||||
plan: RuntimeItemPlan,
|
||||
seedKey: string,
|
||||
intentOverride?: ReturnType<typeof buildRuntimeItemAiIntent>,
|
||||
) {
|
||||
const intent = buildRuntimeItemAiIntent(context, plan);
|
||||
const intent = intentOverride ?? buildRuntimeItemAiIntent(context, plan);
|
||||
const compiled = compileRuntimeItem({
|
||||
seedKey,
|
||||
context,
|
||||
@@ -174,15 +176,10 @@ function compilePlannedItem(
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDirectedRuntimeReward(
|
||||
context: RuntimeItemGenerationContext,
|
||||
function buildDirectedRewardFromItems(
|
||||
compiledItems: InventoryItem[],
|
||||
options: RuntimeRewardOptions,
|
||||
): DirectedRuntimeReward {
|
||||
const plans = planRuntimeItems(context, options);
|
||||
const compiledItems = plans.map((plan, index) =>
|
||||
compilePlannedItem(context, plan, `${options.seedKey}:${plan.slot}:${index}`),
|
||||
);
|
||||
|
||||
const reward: DirectedRuntimeReward = {
|
||||
primaryItem: compiledItems[0] ?? null,
|
||||
supportItems: compiledItems.slice(1),
|
||||
@@ -198,6 +195,45 @@ export function buildDirectedRuntimeReward(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDirectedRuntimeReward(
|
||||
context: RuntimeItemGenerationContext,
|
||||
options: RuntimeRewardOptions,
|
||||
): DirectedRuntimeReward {
|
||||
const plans = planRuntimeItems(context, options);
|
||||
const compiledItems = plans.map((plan, index) =>
|
||||
compilePlannedItem(context, plan, `${options.seedKey}:${plan.slot}:${index}`),
|
||||
);
|
||||
|
||||
return buildDirectedRewardFromItems(compiledItems, options);
|
||||
}
|
||||
|
||||
export async function generateDirectedRuntimeReward(
|
||||
context: RuntimeItemGenerationContext,
|
||||
options: RuntimeRewardOptions,
|
||||
): Promise<DirectedRuntimeReward> {
|
||||
const plans = planRuntimeItems(context, options);
|
||||
|
||||
try {
|
||||
const aiIntents = await generateRuntimeItemAiIntents({
|
||||
context,
|
||||
plans,
|
||||
});
|
||||
const compiledItems = plans.map((plan, index) =>
|
||||
compilePlannedItem(
|
||||
context,
|
||||
plan,
|
||||
`${options.seedKey}:${plan.slot}:${index}`,
|
||||
aiIntents[index],
|
||||
),
|
||||
);
|
||||
|
||||
return buildDirectedRewardFromItems(compiledItems, options);
|
||||
} catch (error) {
|
||||
console.warn('[RuntimeItemDirector] falling back to deterministic item intent', error);
|
||||
return buildDirectedRuntimeReward(context, options);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRuntimeInventoryStock(
|
||||
context: RuntimeItemGenerationContext,
|
||||
options: RuntimeRewardOptions,
|
||||
|
||||
@@ -27,7 +27,7 @@ function getNpcEncounterKey(encounter: Encounter) {
|
||||
}
|
||||
|
||||
function getResolvedNpcState(state: GameState, encounter: Encounter) {
|
||||
return state.npcStates[getNpcEncounterKey(encounter)] ?? buildInitialNpcState(encounter, state.worldType);
|
||||
return state.npcStates[getNpcEncounterKey(encounter)] ?? buildInitialNpcState(encounter, state.worldType, state);
|
||||
}
|
||||
|
||||
function shouldAutoStartBattleForEncounter(state: GameState, encounter: Encounter) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getCharacterNpcSceneIds,
|
||||
PRESET_CHARACTERS,
|
||||
} from './characterPresets';
|
||||
import { resolveCustomWorldNpcMonsterPreset } from './customWorldNpcMonsters';
|
||||
import { getRuntimeCustomWorldProfile, resolveRuleWorldType } from './customWorldRuntime';
|
||||
import { getMonsterPresetById } from './hostileNpcPresets';
|
||||
import sceneNpcOverridesJson from './sceneNpcOverrides.json';
|
||||
@@ -191,12 +192,32 @@ export function buildEncounterFromSceneNpc(
|
||||
};
|
||||
}
|
||||
|
||||
function buildCustomSceneNpc(npc: CustomWorldProfile['storyNpcs'][number], profile: CustomWorldProfile): SceneNpc {
|
||||
const attributeProfile = npc.attributeProfile
|
||||
function buildCustomSceneNpc(
|
||||
npc: CustomWorldProfile['storyNpcs'][number],
|
||||
profile: CustomWorldProfile,
|
||||
anchorWorldType: WorldType,
|
||||
): SceneNpc {
|
||||
const monsterPreset =
|
||||
npc.initialAffinity < 0
|
||||
? resolveCustomWorldNpcMonsterPreset(npc, anchorWorldType)
|
||||
: null;
|
||||
const hostile = npc.initialAffinity < 0 || Boolean(monsterPreset);
|
||||
const attributeProfile = monsterPreset?.attributeProfile
|
||||
?? npc.attributeProfile
|
||||
?? buildRoleAttributeProfileFromLegacyData({
|
||||
entityId: npc.id,
|
||||
schema: resolveAttributeSchema(WorldType.CUSTOM, profile),
|
||||
textBlocks: [npc.role, npc.description, npc.motivation],
|
||||
textBlocks: [
|
||||
npc.title,
|
||||
npc.role,
|
||||
npc.description,
|
||||
npc.backstory,
|
||||
npc.personality,
|
||||
npc.motivation,
|
||||
npc.combatStyle,
|
||||
...npc.relationshipHooks,
|
||||
...npc.tags,
|
||||
],
|
||||
}).profile;
|
||||
|
||||
return {
|
||||
@@ -206,8 +227,14 @@ function buildCustomSceneNpc(npc: CustomWorldProfile['storyNpcs'][number], profi
|
||||
avatar: npc.name.slice(0, 1) || '?',
|
||||
description: `${npc.description} 动机:${npc.motivation}`,
|
||||
gender: inferCustomNpcGender(npc.id, npc.name),
|
||||
recruitable: true,
|
||||
functions: ['trade', 'fight', 'spar', 'help', 'chat', 'recruit', 'gift'],
|
||||
monsterPresetId: monsterPreset?.id,
|
||||
hostileNpcPresetId: monsterPreset?.id,
|
||||
initialAffinity: npc.initialAffinity,
|
||||
hostile,
|
||||
recruitable: !hostile,
|
||||
functions: hostile
|
||||
? ['fight']
|
||||
: ['trade', 'fight', 'spar', 'help', 'chat', 'recruit', 'gift'],
|
||||
attributeProfile,
|
||||
};
|
||||
}
|
||||
@@ -238,7 +265,9 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
: null;
|
||||
}).filter(Boolean) as SceneNpc[];
|
||||
|
||||
const customStoryNpcs = profile.storyNpcs.map(npc => buildCustomSceneNpc(npc, profile));
|
||||
const customStoryNpcs = profile.storyNpcs.map(npc =>
|
||||
buildCustomSceneNpc(npc, profile, anchorWorldType),
|
||||
);
|
||||
const chunkSize = Math.max(4, Math.ceil(customStoryNpcs.length / Math.max(1, profile.landmarks.length)));
|
||||
const customScenes: ScenePreset[] = [
|
||||
{
|
||||
@@ -394,7 +423,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-13', 'monster-08'],
|
||||
connectedSceneIds: ['wuxia-mountain-gate', 'wuxia-mist-woods', 'wuxia-ferry-bridge'],
|
||||
forwardSceneId: 'wuxia-mountain-gate',
|
||||
treasureHints: ['???????', '??????'],
|
||||
treasureHints: ['竹根旁半埋的刀鞘', '倒竹间的旧药囊'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-bamboo-woodcutter', '樵夫老周', '樵夫', '樵', '常在竹海边缘砍柴,对附近路数和兽踪了如指掌。'),
|
||||
],
|
||||
@@ -407,7 +436,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-04', 'monster-06'],
|
||||
connectedSceneIds: ['wuxia-temple-forecourt', 'wuxia-border-camp', 'wuxia-bamboo-road'],
|
||||
forwardSceneId: 'wuxia-temple-forecourt',
|
||||
treasureHints: ['瑁傜紳涓殑閾滃專', '鐭崇嫯搴曞骇鏃侀仐钀界殑浠ょ墝'],
|
||||
treasureHints: ['裂缝里的铜钥', '石狮座下遗落的令牌'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-gate-disciple', '守山弟子', '门派弟子', '守', '一直盯着石阶尽头的动静,像在等某位重要来客。'),
|
||||
],
|
||||
@@ -420,7 +449,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-11', 'monster-07'],
|
||||
connectedSceneIds: ['wuxia-ferry-bridge', 'wuxia-palace-court', 'wuxia-ruined-village'],
|
||||
forwardSceneId: 'wuxia-ferry-bridge',
|
||||
treasureHints: ['???????', '????????'],
|
||||
treasureHints: ['灯檐下浸湿的布包', '排水沟边翻起的账册残页'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-night-vendor', '夜灯摊主', '摊主', '灯', '深夜仍在街口守着灯摊,见过太多不该见的人。'),
|
||||
],
|
||||
@@ -433,7 +462,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-03', 'monster-07'],
|
||||
connectedSceneIds: ['wuxia-mist-woods', 'wuxia-rain-street', 'wuxia-border-camp'],
|
||||
forwardSceneId: 'wuxia-border-camp',
|
||||
treasureHints: ['????????', '????????'],
|
||||
treasureHints: ['断墙后压着的木匣', '枯井边散落的旧簪'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-village-remnant', '守村妇人', '遗民', '民', '不肯离开这片断垣,似乎还在等某个人归来。'),
|
||||
],
|
||||
@@ -446,7 +475,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-04', 'monster-11'],
|
||||
connectedSceneIds: ['wuxia-rain-street', 'wuxia-bamboo-road', 'wuxia-border-camp'],
|
||||
forwardSceneId: 'wuxia-border-camp',
|
||||
treasureHints: ['???????', '????????'],
|
||||
treasureHints: ['桥柱缝里的油纸包', '渡船板下藏着的旧钱袋'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-ferryman', '老渡工', '渡工', '渡', '常年摆渡,看人看路都很准,有些话只肯对识货的人说。'),
|
||||
],
|
||||
@@ -459,7 +488,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-08', 'monster-13', 'monster-07'],
|
||||
connectedSceneIds: ['wuxia-bamboo-road', 'wuxia-ruined-village', 'wuxia-temple-forecourt'],
|
||||
forwardSceneId: 'wuxia-ruined-village',
|
||||
treasureHints: ['缂犲湪鏍戞牴涓婄殑閿﹀泭', '琚浘姘存场婀跨殑鍦板浘娈嬮〉'],
|
||||
treasureHints: ['缠在树根上的锦囊', '被雾水泡湿的地图残页'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-hunter', '追迹猎户', '猎户', '猎', '脚边总带着兽夹和草药,对林中异动非常敏感。'),
|
||||
],
|
||||
@@ -472,7 +501,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-18', 'monster-11'],
|
||||
connectedSceneIds: ['wuxia-ferry-bridge', 'wuxia-mountain-gate', 'wuxia-ruined-village'],
|
||||
forwardSceneId: 'wuxia-rain-street',
|
||||
treasureHints: ['????????', '?????????'],
|
||||
treasureHints: ['废营帐里的箭囊', '火盆旁埋着的军需匣'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-quartermaster', '军需官', '营地官', '营', '管着兵器和粮草,对各路来客始终保持戒心。'),
|
||||
],
|
||||
@@ -485,7 +514,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-03', 'monster-06'],
|
||||
connectedSceneIds: ['wuxia-temple-forecourt', 'wuxia-mine-depths', 'wuxia-palace-court'],
|
||||
forwardSceneId: 'wuxia-mine-depths',
|
||||
treasureHints: ['???????', '????????'],
|
||||
treasureHints: ['砖缝里的陪葬铜匣', '石灯底座后的残卷'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-tomb-scholar', '探碑书生', '学者', '碑', '抱着拓本在地宫里转来转去,似乎在找某段缺失铭文。'),
|
||||
],
|
||||
@@ -498,7 +527,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-04', 'monster-03'],
|
||||
connectedSceneIds: ['wuxia-mountain-gate', 'wuxia-crypt-passage', 'wuxia-mist-woods'],
|
||||
forwardSceneId: 'wuxia-crypt-passage',
|
||||
treasureHints: ['????????', '???????'],
|
||||
treasureHints: ['香炉灰里的玉珠', '石灯下压着的签牌'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-temple-host', '守庙僧', '僧人', '僧', '白日扫院夜里守灯,似乎知道地宫里曾封过什么。'),
|
||||
],
|
||||
@@ -511,7 +540,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-06', 'monster-18'],
|
||||
connectedSceneIds: ['wuxia-crypt-passage', 'wuxia-forge-works', 'wuxia-border-camp'],
|
||||
forwardSceneId: 'wuxia-forge-works',
|
||||
treasureHints: ['鐭胯溅澶瑰眰閲岀殑閾剁洅', '鍩嬪湪鐭挎福涓殑绮鹃搧'],
|
||||
treasureHints: ['矿车夹层里的银匣', '埋在碎矿中的精铁'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-miner', '老矿头', '矿工', '矿', '靠耳朵分辨坑道深处的回响,比谁都先知道危险会从哪边来。'),
|
||||
],
|
||||
@@ -524,7 +553,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-18', 'monster-04'],
|
||||
connectedSceneIds: ['wuxia-mine-depths', 'wuxia-palace-court', 'wuxia-border-camp'],
|
||||
forwardSceneId: 'wuxia-palace-court',
|
||||
treasureHints: ['????????', '?????????'],
|
||||
treasureHints: ['淬火池旁的铁匣', '风箱后压着的旧兵谱'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-blacksmith', '老铸匠', '铸匠', '铸', '看一眼兵器缺口就知道你刚从什么地方杀出来。'),
|
||||
],
|
||||
@@ -537,7 +566,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-11', 'monster-13'],
|
||||
connectedSceneIds: ['wuxia-forge-works', 'wuxia-rain-street', 'wuxia-crypt-passage'],
|
||||
forwardSceneId: 'wuxia-rain-street',
|
||||
treasureHints: ['????????', '?????????'],
|
||||
treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'],
|
||||
extraNpcs: [
|
||||
makeNpc('wuxia-npc-maid', '旧宫侍女', '宫人', '侍', '嘴上说得少,却总知道哪条回廊最近不该过去。'),
|
||||
],
|
||||
@@ -553,7 +582,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-02', 'monster-16'],
|
||||
connectedSceneIds: ['xianxia-floating-isle', 'xianxia-celestial-corridor', 'xianxia-star-vessel'],
|
||||
forwardSceneId: 'xianxia-celestial-corridor',
|
||||
treasureHints: ['?????????', '????????'],
|
||||
treasureHints: ['云阶尽头的灵符匣', '门阙阴影里的玉牌'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-gate-attendant', '守门灵官', '门官', '门', '站在门阙侧旁观来者,像在等一份迟迟未到的回报。'),
|
||||
],
|
||||
@@ -566,7 +595,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-12', 'monster-16'],
|
||||
connectedSceneIds: ['xianxia-cloud-gate', 'xianxia-waterfall-cliff', 'xianxia-moon-lake'],
|
||||
forwardSceneId: 'xianxia-moon-lake',
|
||||
treasureHints: ['??????????', '???????????'],
|
||||
treasureHints: ['浮岛边缘的灵羽匣', '云藤下悬着的小玉瓶'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-cloud-hermit', '云栖散修', '散修', '云', '常坐在浮岛边缘打坐,对天风和禁制的变化很敏感。'),
|
||||
],
|
||||
@@ -579,7 +608,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-02', 'monster-14'],
|
||||
connectedSceneIds: ['xianxia-cloud-gate', 'xianxia-thunder-altar', 'xianxia-ancient-ruins'],
|
||||
forwardSceneId: 'xianxia-thunder-altar',
|
||||
treasureHints: ['??????????', '?????????'],
|
||||
treasureHints: ['廊柱暗槽里的玉简', '风铃后藏着的封签'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-palace-page', '抄经侍者', '侍者', '卷', '抱着卷册在廊下快步穿行,像是在躲某种会翻页的东西。'),
|
||||
],
|
||||
@@ -592,7 +621,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-15', 'monster-05'],
|
||||
connectedSceneIds: ['xianxia-jade-cavern', 'xianxia-sacred-tree', 'xianxia-moon-lake'],
|
||||
forwardSceneId: 'xianxia-sacred-tree',
|
||||
treasureHints: ['????????', '?????????'],
|
||||
treasureHints: ['药圃深处的灵壶', '花架下压着的采录册'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-herbal-keeper', '药圃执事', '药师', '药', '守着花圃记录灵植开谢,也清楚哪些地方最近长出了怪东西。'),
|
||||
],
|
||||
@@ -605,7 +634,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-10', 'monster-12', 'monster-20'],
|
||||
connectedSceneIds: ['xianxia-herb-garden', 'xianxia-moon-lake', 'xianxia-ancient-ruins'],
|
||||
forwardSceneId: 'xianxia-moon-lake',
|
||||
treasureHints: ['瀵掔帀瑁傞殭閲岀殑鐏甸珦', '鍐伴潰涓嬮棯鐫€鍏夌殑璐濆專'],
|
||||
treasureHints: ['寒玉裂隙里的灵髓', '冰面下闪着光的贝匣'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-cold-scholar', '寒洞客', '访客', '玉', '在洞天里采样寒玉碎屑,像在研究更深处的封禁。'),
|
||||
],
|
||||
@@ -618,7 +647,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-14', 'monster-10'],
|
||||
connectedSceneIds: ['xianxia-thunder-altar', 'xianxia-waterfall-cliff', 'xianxia-jade-cavern'],
|
||||
forwardSceneId: 'xianxia-waterfall-cliff',
|
||||
treasureHints: ['????????', '?????????'],
|
||||
treasureHints: ['熔岩边冷却的矿匣', '焦岩后藏着的火纹石'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-fire-forger', '熔炉匠修', '炼匠', '炉', '在热浪里锻器不歇,见惯灵火失控的后果。'),
|
||||
],
|
||||
@@ -631,7 +660,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-02', 'monster-16'],
|
||||
connectedSceneIds: ['xianxia-celestial-corridor', 'xianxia-molten-realm', 'xianxia-star-vessel'],
|
||||
forwardSceneId: 'xianxia-star-vessel',
|
||||
treasureHints: ['????????', '?????????'],
|
||||
treasureHints: ['祭坛角落的雷纹匣', '断碑背面的青铜铃'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-thunder-keeper', '祭雷守使', '守使', '雷', '总站在祭坛边缘看天,像在确认下一道雷会落到哪里。'),
|
||||
],
|
||||
@@ -644,7 +673,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-12', 'monster-16', 'monster-02'],
|
||||
connectedSceneIds: ['xianxia-thunder-altar', 'xianxia-cloud-gate', 'xianxia-floating-isle'],
|
||||
forwardSceneId: 'xianxia-floating-isle',
|
||||
treasureHints: ['????????', '??????????'],
|
||||
treasureHints: ['舵台后的星图匣', '甲板缝里卡着的灵罗盘'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-helmsman', '星舟舵手', '舵手', '舟', '守着老旧星舟的航线图,对高空中的异动异常敏感。'),
|
||||
],
|
||||
@@ -657,7 +686,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-20', 'monster-14', 'monster-15'],
|
||||
connectedSceneIds: ['xianxia-jade-cavern', 'xianxia-floating-isle', 'xianxia-herb-garden'],
|
||||
forwardSceneId: 'xianxia-herb-garden',
|
||||
treasureHints: ['婀栧哺杈规紓鏉ョ殑鐜夌洅', '鏈堣壊涓嬭嫢闅愯嫢鐜扮殑閾堕搩'],
|
||||
treasureHints: ['湖岸边漂来的玉匣', '月色下若隐若现的银铃'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-lake-watcher', '湖畔琴师', '琴师', '琴', '常在月湖边抚琴,像在等某段旋律把什么引出来。'),
|
||||
],
|
||||
@@ -670,7 +699,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-02', 'monster-05', 'monster-12'],
|
||||
connectedSceneIds: ['xianxia-celestial-corridor', 'xianxia-jade-cavern', 'xianxia-sacred-tree'],
|
||||
forwardSceneId: 'xianxia-sacred-tree',
|
||||
treasureHints: ['娈嬮樀涓績鍩嬬潃鐨勭帀绠€', '鍊掑纰戞煴閲岀殑灏忓專'],
|
||||
treasureHints: ['残阵中心埋着的玉简', '倒塌碑柱里的小匣'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-ruin-scholar', '寻迹司录', '司录', '录', '拿着一卷旧图在断墙间比对,像快要找到重要坐标。'),
|
||||
],
|
||||
@@ -683,7 +712,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-15', 'monster-05'],
|
||||
connectedSceneIds: ['xianxia-herb-garden', 'xianxia-ancient-ruins', 'xianxia-waterfall-cliff'],
|
||||
forwardSceneId: 'xianxia-waterfall-cliff',
|
||||
treasureHints: ['?????????', '??????????'],
|
||||
treasureHints: ['盘根间的木纹匣', '树洞深处垂着的灵种'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-tree-ward', '守木灵侍', '灵侍', '木', '一直绕着古树巡看,像是担心有人惊动树心。'),
|
||||
],
|
||||
@@ -696,7 +725,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
monsterIds: ['monster-12', 'monster-20', 'monster-16'],
|
||||
connectedSceneIds: ['xianxia-sacred-tree', 'xianxia-molten-realm', 'xianxia-floating-isle'],
|
||||
forwardSceneId: 'xianxia-cloud-gate',
|
||||
treasureHints: ['鐎戝箷鍚庨棯鐫€鍏夌殑鐭冲專', '宕栬竟钘や笂鎸傜潃鐨勬姢韬搩'],
|
||||
treasureHints: ['瀑幕后闪着光的石匣', '崖边藤上挂着的护身铃'],
|
||||
extraNpcs: [
|
||||
makeNpc('xianxia-npc-cliff-scout', '崖巡女修', '巡修', '崖', '长期在飞瀑边巡看,脚步轻得像从不曾碰到过石面。'),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user