import { Character, FunctionCategory, PlayerStateMode, SceneDirective, SceneHostileNpc, SkillStyle, StoryOption, WorldType, } from '../types'; import { CAMP_TRAVEL_HOME_FUNCTION, NPC_CHAT_FUNCTION, NPC_PREVIEW_TALK_FUNCTION, NPC_RECRUIT_FUNCTION, STATE_FUNCTION_DEFINITIONS as SPLIT_STATE_FUNCTION_DEFINITIONS, STATE_FUNCTION_PROMPT_DESCRIPTIONS as SPLIT_STATE_FUNCTION_PROMPT_DESCRIPTIONS, } from './functionCatalog'; import { getForwardScenePreset, getTravelScenePreset, getWorldCampScenePreset, } from './scenePresets'; import stateFunctionOverridesJson from './stateFunctionOverrides.json'; export interface FunctionEffectConfig { damageMultiplier?: number; incomingDamageMultiplier?: number; healAmount?: number; manaRestore?: number; cooldownTickBonus?: number; turnTimeMultiplier?: number; skillWeights?: Partial>; escapeDurationMs?: number; escapeDistance?: number; monsterLagStart?: number; monsterLagEnd?: number; sceneShift?: number; enterBattle?: boolean; } export type FunctionVisualConfig = Omit & { monsterActionTemplate?: string; monsterAnimation?: 'idle' | 'move' | 'attack'; monsterMoveMeters?: number; }; export interface StateFunctionDefinition { id: string; state: PlayerStateMode; category: FunctionCategory; text: string; description: string; visual: FunctionVisualConfig; effect: FunctionEffectConfig; } export type StateFunctionDefinitionOverride = Partial< Pick > & { visual?: Partial; effect?: Partial; }; export type StateFunctionOverrideMap = Record< string, StateFunctionDefinitionOverride >; export interface FunctionAvailabilityContext { worldType: WorldType; playerCharacter: Character | null; inBattle: boolean; currentSceneId?: string | null; currentSceneName?: string | null; monsters: SceneHostileNpc[]; playerHp: number; playerMaxHp: number; playerMana: number; playerMaxMana: number; } const FUNCTION_OPTION_PRIORITIES: Record = { [CAMP_TRAVEL_HOME_FUNCTION.id]: 4, [NPC_PREVIEW_TALK_FUNCTION.id]: 3, [NPC_RECRUIT_FUNCTION.id]: 3, [NPC_CHAT_FUNCTION.id]: 1, }; const MODEL_PRIORITY_LOCKED_OPTION_COUNT = 2; export function getFunctionPromptDescription( functionId: string, fallback?: string, ) { return ( SPLIT_STATE_FUNCTION_PROMPT_DESCRIPTIONS[functionId] ?? fallback ?? functionId ); } const STATE_FUNCTION_OVERRIDES = stateFunctionOverridesJson as StateFunctionOverrideMap; const BASE_FUNCTIONS = [...SPLIT_STATE_FUNCTION_DEFINITIONS]; function mergeStateFunctionDefinition( definition: StateFunctionDefinition, override?: StateFunctionDefinitionOverride, ): StateFunctionDefinition { if (!override) { return { ...definition, visual: { ...definition.visual }, effect: { ...definition.effect, skillWeights: definition.effect.skillWeights ? { ...definition.effect.skillWeights } : undefined, }, }; } const mergedSkillWeights = override.effect?.skillWeights ? { ...(definition.effect.skillWeights ?? {}), ...override.effect.skillWeights, } : definition.effect.skillWeights ? { ...definition.effect.skillWeights } : undefined; return { ...definition, ...override, visual: { ...definition.visual, ...(override.visual ?? {}), }, effect: { ...definition.effect, ...(override.effect ?? {}), skillWeights: mergedSkillWeights, }, }; } function applyRuntimeFunctionAdjustments( definitions: StateFunctionDefinition[], ) { return definitions .filter((definition) => definition.id !== 'idle_follow_clue') .map((definition) => { if (definition.id === 'idle_explore_forward') { return { ...definition, text: '继续向前探索', description: '沿着当前场景继续深入,把前路真正探出来,下一刻就可能撞上新的危险或际遇。', }; } if (definition.id === 'idle_call_out') { return { ...definition, text: '主动出声试探', description: '主动朝前方喊话试探,可能把附近潜着的角色或怪物直接从远处引出来。', }; } return definition; }); } export function buildStateFunctionDefinitions( overrides: StateFunctionOverrideMap = STATE_FUNCTION_OVERRIDES, ) { return applyRuntimeFunctionAdjustments( BASE_FUNCTIONS.map((definition) => mergeStateFunctionDefinition(definition, overrides[definition.id]), ), ); } const ALL_FUNCTIONS = buildStateFunctionDefinitions(); function hasAliveMonsters(monsters: SceneHostileNpc[]) { return monsters.some((monster) => monster.hp > 0); } function getPrimaryMonster(context: FunctionAvailabilityContext) { return ( context.monsters.find((monster) => monster.hp > 0) ?? context.monsters[0] ?? null ); } function getPlayerHpRatio(context: FunctionAvailabilityContext) { return context.playerHp / Math.max(context.playerMaxHp, 1); } function getPlayerManaRatio(context: FunctionAvailabilityContext) { return context.playerMana / Math.max(context.playerMaxMana, 1); } function getMonsterHpRatio(context: FunctionAvailabilityContext) { const monster = getPrimaryMonster(context); if (!monster) return 0; return monster.hp / Math.max(monster.maxHp, 1); } function buildSuggestedActionText( definition: StateFunctionDefinition, context: FunctionAvailabilityContext, ) { const monster = getPrimaryMonster(context); const monsterName = monster?.name ?? '前方怪物'; const playerHpRatio = getPlayerHpRatio(context); const playerManaRatio = getPlayerManaRatio(context); const monsterHpRatio = getMonsterHpRatio(context); const forwardScene = getForwardScenePreset( context.worldType, context.currentSceneId, ); const travelScene = getTravelScenePreset( context.worldType, context.currentSceneId, ); const sceneName = context.currentSceneName ?? '前路'; if (definition.id === 'idle_explore_forward') { if (playerHpRatio <= 0.35) return `按着伤口,沿着${sceneName}继续往深处摸去`; if (forwardScene) return `顺着${sceneName}的路势,继续朝前方深处探去`; return `拨开${sceneName}前的遮挡,继续朝更深处探去`; } if (definition.id === 'idle_call_out') { return `冲着${sceneName}前方扬声试探,看是谁先被逼出来`; } switch (definition.id) { case 'battle_finisher_window': if (monsterHpRatio <= 0.25) return `完成对${monsterName}的残血收割`; if (monsterHpRatio <= 0.45) return `抓住${monsterName}露出的破绽补上重击`; return `盯住${monsterName}的空当准备终结一击`; case 'battle_all_in_crush': if (monsterHpRatio <= 0.25) return `压上去收掉${monsterName}最后一口气`; if (playerHpRatio <= 0.35) return `顶着伤势强压${monsterName}赌一波强杀`; return `正面强压${monsterName}不给喘息`; case 'battle_guard_break': if (monsterHpRatio <= 0.35) return `砸开${monsterName}的架势直接斩落`; return `重击破开${monsterName}的招架`; case 'battle_probe_pressure': if (playerManaRatio <= 0.3) return `稳住节奏试探${monsterName},先省下灵力`; if (monsterHpRatio <= 0.3) return `稳步逼近,补掉${monsterName}残余血量`; return `稳扎稳打继续试探${monsterName}`; case 'battle_feint_step': if (monsterHpRatio <= 0.35) return `虚晃切进去收掉${monsterName}`; return `借假动作切进${monsterName}身前`; case 'battle_recover_breath': if (playerHpRatio <= 0.35) return '原地打坐恢复血量'; if (playerManaRatio <= 0.3) return '收势调息回一口灵力'; return '边守边调息稳住节奏'; case 'battle_escape_breakout': if (playerHpRatio <= 0.35) return `撑着伤势先脱离${monsterName}的追杀`; return `转身拉开距离,甩开${monsterName}`; case 'idle_explore_forward': if (forwardScene) return `继续向前探路`; if (playerHpRatio <= 0.35) return '拖着伤势继续向前摸索'; return '继续向前探索前路'; case 'idle_travel_next_scene': return travelScene ? `前往${travelScene.name}` : '前往其他场景'; case 'idle_rest_focus': if (playerHpRatio <= 0.35) return '原地打坐恢复气血'; if (playerManaRatio <= 0.35) return '盘坐调息恢复灵力'; return '原地调息整理状态'; case 'idle_observe_signs': return '停步观察附近的风吹草动'; case 'idle_follow_clue': return '顺着可疑痕迹继续靠近'; case 'idle_call_out': return '朝前方主动出声试探'; default: return definition.text; } } function buildOptionDetailText( definition: StateFunctionDefinition, context: FunctionAvailabilityContext, ) { const forwardScene = getForwardScenePreset( context.worldType, context.currentSceneId, ); const travelScene = getTravelScenePreset( context.worldType, context.currentSceneId, ); const sceneName = context.currentSceneName ?? '当前区域'; if (definition.id === 'idle_explore_forward') { return forwardScene ? `沿着${sceneName}继续往前压过去,真正把前方会遇到的人影、怪物或宝藏探出来。` : `继续深入${sceneName}前方未探明的地带,下一刻就可能撞见新的动静。`; } if (definition.id === 'idle_call_out') { return '主动打破寂静,把附近潜着的角色或怪物从屏幕外直接引到眼前。'; } switch (definition.id) { case 'idle_explore_forward': return forwardScene ? `沿当前路径继续深入,可能会遇到角色、怪物、宝藏……` : '继续向前试探这片区域,可能会遇到角色、怪物、宝藏……'; case 'idle_travel_next_scene': return travelScene?.description ?? '离开当前区域,前往相邻场景继续冒险。'; case 'idle_observe_signs': return '先确认附近是否潜伏着人影、怪物或其他值得靠近的东西。'; case 'idle_follow_clue': return '沿着声音、脚印或灵气痕迹继续摸过去,可能更快接近前方目标。'; case 'idle_call_out': return '主动打破寂静,看看附近是谁或什么东西先有反应。'; default: return undefined; } } function getFunctionPriority( definition: StateFunctionDefinition, context: FunctionAvailabilityContext, ) { const playerHpRatio = getPlayerHpRatio(context); const playerManaRatio = getPlayerManaRatio(context); const monsterHpRatio = getMonsterHpRatio(context); if (definition.id === 'idle_call_out') { return 5; } switch (definition.id) { case 'battle_recover_breath': return ( (playerHpRatio <= 0.35 ? 10 : 0) + (playerManaRatio <= 0.3 ? 6 : 0) ); case 'battle_finisher_window': return monsterHpRatio <= 0.25 ? 10 : monsterHpRatio <= 0.45 ? 6 : 1; case 'battle_all_in_crush': return monsterHpRatio <= 0.25 ? 8 : playerHpRatio <= 0.35 ? 2 : 4; case 'battle_guard_break': return monsterHpRatio <= 0.4 ? 6 : 3; case 'battle_probe_pressure': return playerManaRatio <= 0.3 ? 8 : 4; case 'battle_feint_step': return monsterHpRatio <= 0.5 ? 5 : 3; case 'battle_escape_breakout': return playerHpRatio <= 0.2 ? 9 : playerHpRatio <= 0.35 ? 5 : 1; case 'idle_rest_focus': return playerHpRatio <= 0.35 || playerManaRatio <= 0.35 ? 8 : 2; case 'idle_explore_forward': return playerHpRatio > 0.45 ? 6 : 2; case 'idle_travel_next_scene': return playerHpRatio > 0.45 ? 5 : 3; case 'idle_observe_signs': return 4; case 'idle_follow_clue': return 5; case 'idle_call_out': return 3; default: return 0; } } function matchesCategory( definition: StateFunctionDefinition, context: FunctionAvailabilityContext, ) { switch (definition.category) { case 'battle': case 'escape': return context.inBattle && hasAliveMonsters(context.monsters); case 'idle': return !context.inBattle; case 'recovery': return definition.state === 'battle' ? context.inBattle && hasAliveMonsters(context.monsters) : !context.inBattle; default: return false; } } function isCampSceneContext(context: FunctionAvailabilityContext) { return ( getWorldCampScenePreset(context.worldType)?.id === context.currentSceneId ); } export function getPlayerStateMode(inBattle: boolean): PlayerStateMode { return inBattle ? 'battle' : 'idle'; } export function getAllStateFunctionDefinitions( definitions: StateFunctionDefinition[] = ALL_FUNCTIONS, ) { return definitions; } export function getFunctionsForState( state: PlayerStateMode, definitions: StateFunctionDefinition[] = ALL_FUNCTIONS, ) { return definitions.filter((item) => item.state === state); } export function getFunctionById( functionId: string, definitions: StateFunctionDefinition[] = ALL_FUNCTIONS, ) { return definitions.find((item) => item.id === functionId) ?? null; } export function getExecutableFunctions( context: FunctionAvailabilityContext, definitions: StateFunctionDefinition[] = ALL_FUNCTIONS, ) { const state = getPlayerStateMode(context.inBattle); return getFunctionsForState(state, definitions) .filter((definition) => matchesCategory(definition, context)) .filter( (definition) => !( definition.id === 'idle_explore_forward' && isCampSceneContext(context) ), ) .sort((a, b) => { const scoreDiff = getFunctionPriority(b, context) - getFunctionPriority(a, context); if (scoreDiff !== 0) return scoreDiff; return a.id.localeCompare(b.id); }); } export function isFunctionExecutable( functionId: string, context: FunctionAvailabilityContext, definitions: StateFunctionDefinition[] = ALL_FUNCTIONS, ) { return getExecutableFunctions(context, definitions).some( (item) => item.id === functionId, ); } export function buildFunctionCatalogText( context: FunctionAvailabilityContext, definitions: StateFunctionDefinition[] = ALL_FUNCTIONS, ) { return getExecutableFunctions(context, definitions) .map( (item) => `- ${item.id}:${getFunctionPromptDescription(item.id, item.description)}`, ) .join('\n'); } export function resolveFunctionOption( functionId: string, context: FunctionAvailabilityContext, actionText?: string, definitions: StateFunctionDefinition[] = ALL_FUNCTIONS, ): StoryOption | null { const definition = getFunctionById(functionId, definitions); if (!definition || !isFunctionExecutable(functionId, context, definitions)) return null; const primaryMonster = context.monsters.find((monster) => monster.hp > 0) ?? context.monsters[0]; const monsterAction = definition.visual.monsterActionTemplate && primaryMonster ? definition.visual.monsterActionTemplate.replaceAll( '{monster}', primaryMonster.name, ) : '前方的气氛依旧紧绷'; const suggestedActionText = buildSuggestedActionText(definition, context); const shouldForceSuggestedActionText = definition.id === 'idle_explore_forward' || definition.id === 'idle_travel_next_scene'; const resolvedActionText = shouldForceSuggestedActionText ? suggestedActionText : actionText?.trim() || suggestedActionText; const detailText = buildOptionDetailText(definition, context); return { functionId: definition.id, actionText: resolvedActionText, text: resolvedActionText, detailText, priority: getStoryOptionPriority(definition.id), visuals: { playerAnimation: definition.visual.playerAnimation, playerMoveMeters: definition.visual.playerMoveMeters, playerOffsetY: definition.visual.playerOffsetY, playerFacing: definition.visual.playerFacing, scrollWorld: definition.visual.scrollWorld, monsterChanges: primaryMonster && definition.visual.monsterAnimation ? [ { id: primaryMonster.id, action: monsterAction, animation: definition.visual.monsterAnimation, moveMeters: definition.visual.monsterMoveMeters ?? 0, yOffset: 0, }, ] : [], }, }; } export function getDefaultFunctionIdsForContext( context: FunctionAvailabilityContext, definitions: StateFunctionDefinition[] = ALL_FUNCTIONS, ) { return getExecutableFunctions(context, definitions) .slice(0, 6) .map((item) => item.id); } export function getFunctionEffect( functionId: string, definitions: StateFunctionDefinition[] = ALL_FUNCTIONS, ) { return getFunctionById(functionId, definitions)?.effect ?? {}; } export function getFunctionSkillWeights( functionId: string, definitions: StateFunctionDefinition[] = ALL_FUNCTIONS, ) { return getFunctionById(functionId, definitions)?.effect.skillWeights ?? null; } export function getStoryOptionPriority(functionId: string) { return FUNCTION_OPTION_PRIORITIES[functionId] ?? 1; } export function sortStoryOptionsByPriority(options: StoryOption[]) { const normalizedOptions = options.map((option, index) => ({ option: { ...option, priority: getStoryOptionPriority(option.functionId), }, index, })); const lockedOptions = normalizedOptions .slice(0, MODEL_PRIORITY_LOCKED_OPTION_COUNT) .map((item) => item.option); const prioritizedOptions = normalizedOptions .slice(MODEL_PRIORITY_LOCKED_OPTION_COUNT) .sort((a, b) => { const priorityDiff = (b.option.priority ?? 1) - (a.option.priority ?? 1); if (priorityDiff !== 0) return priorityDiff; return a.index - b.index; }) .map((item) => item.option); return [...lockedOptions, ...prioritizedOptions]; }