Files
Genarrative/src/data/stateFunctions.ts
2026-04-28 19:36:39 +08:00

470 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
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,
STATE_FUNCTION_RUNTIME_SOURCES,
} 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<Record<SkillStyle, number>>;
escapeDurationMs?: number;
escapeDistance?: number;
monsterLagStart?: number;
monsterLagEnd?: number;
sceneShift?: number;
enterBattle?: boolean;
}
export type FunctionVisualConfig = Omit<SceneDirective, 'monsterChanges'> & {
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<StateFunctionDefinition, 'state' | 'category' | 'text' | 'description'>
> & {
visual?: Partial<FunctionVisualConfig>;
effect?: Partial<FunctionEffectConfig>;
};
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<string, number> = {
[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];
const STATE_FUNCTION_RUNTIME_SOURCE_MAP = new Map(
STATE_FUNCTION_RUNTIME_SOURCES.map((source) => [
source.definition.id,
source,
]),
);
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) => {
const runtime =
STATE_FUNCTION_RUNTIME_SOURCE_MAP.get(definition.id)?.runtime;
return runtime?.applyDefinitionAdjustments?.(definition) ?? 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 buildRuntimeMetrics(context: FunctionAvailabilityContext) {
return {
playerHpRatio: getPlayerHpRatio(context),
playerManaRatio: getPlayerManaRatio(context),
monsterHpRatio: getMonsterHpRatio(context),
};
}
function buildRuntimeEnvironment(context: FunctionAvailabilityContext) {
const monster = getPrimaryMonster(context);
const forwardScene = getForwardScenePreset(
context.worldType,
context.currentSceneId,
);
const travelScene = getTravelScenePreset(
context.worldType,
context.currentSceneId,
);
return {
sceneName: context.currentSceneName ?? '前路',
monsterName: monster?.name ?? '前方怪物',
hasForwardScene: Boolean(forwardScene),
travelSceneName: travelScene?.name ?? null,
travelSceneDescription: travelScene?.description ?? null,
};
}
function buildSuggestedActionText(
definition: StateFunctionDefinition,
context: FunctionAvailabilityContext,
) {
const runtime = STATE_FUNCTION_RUNTIME_SOURCE_MAP.get(definition.id)?.runtime;
return (
runtime?.buildSuggestedActionText?.({
definition,
metrics: buildRuntimeMetrics(context),
environment: buildRuntimeEnvironment(context),
}) ?? definition.text
);
}
function buildOptionDetailText(
definition: StateFunctionDefinition,
context: FunctionAvailabilityContext,
) {
const runtime = STATE_FUNCTION_RUNTIME_SOURCE_MAP.get(definition.id)?.runtime;
return runtime?.buildDetailText?.({
definition,
environment: buildRuntimeEnvironment(context),
});
}
function getFunctionPriority(
definition: StateFunctionDefinition,
context: FunctionAvailabilityContext,
) {
const runtime = STATE_FUNCTION_RUNTIME_SOURCE_MAP.get(definition.id)?.runtime;
return (
runtime?.getPriority?.({
definition,
metrics: buildRuntimeMetrics(context),
}) ?? 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];
}