Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-08 11:58:47 +08:00
parent 9d2fc9e4b8
commit bd9fdcbe31
170 changed files with 18259 additions and 1049 deletions

View File

@@ -1,12 +1,18 @@
import { HostileNpcAnimationConfig, HostileNpcSpriteConfig } from '../components/HostileNpcAnimator';
import { GameState, InventoryItem, MonsterLootEntry, RoleActionDefinition, SceneHostileNpc, WorldType } from '../types';
import {generateRuntimeItemAiIntents} from '../services/runtimeItemAiDirector';
import { GameState, InventoryItem, MonsterLootEntry, RoleActionDefinition, RuntimeItemPlan, SceneHostileNpc, WorldType } from '../types';
import { buildMonsterAttributeProfile } from './attributeProfileGenerator';
import { buildDefaultAxisVector } from './attributeResolver';
import {normalizeBuildTags} from './buildTags';
import { buildRuntimeCustomWorldInventoryItems, resolveRuleWorldType } from './customWorldRuntime';
import hostileNpcOverridesJson from './hostileNpcOverrides.json';
import { buildRuntimeItemGenerationContext } from './runtimeItemContext';
import { buildDirectedRuntimeReward } from './runtimeItemDirector';
import { flattenDirectedRuntimeRewardItems } from './runtimeItemNarrative';
import { generateDirectedRuntimeReward } from './runtimeItemDirector';
import {
applyRuntimeItemNarrativeToExistingItem,
buildRuntimeItemAiIntent,
flattenDirectedRuntimeRewardItems,
} from './runtimeItemNarrative';
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
export interface HostileNpcPreset extends HostileNpcSpriteConfig {
@@ -940,12 +946,15 @@ const ALL_HOSTILE_NPC_PRESETS = BASE_HOSTILE_NPC_PRESETS.map(basePreset => merge
export const HOSTILE_NPC_PRESETS_BY_WORLD: Record<WorldType, HostileNpcPreset[]> = {
[WorldType.WUXIA]: ALL_HOSTILE_NPC_PRESETS.filter(monster => monster.worldType === WorldType.WUXIA),
[WorldType.XIANXIA]: ALL_HOSTILE_NPC_PRESETS.filter(monster => monster.worldType === WorldType.XIANXIA),
[WorldType.CUSTOM]: ALL_HOSTILE_NPC_PRESETS.filter(monster => monster.worldType === WorldType.WUXIA),
[WorldType.CUSTOM]: [...ALL_HOSTILE_NPC_PRESETS],
};
export const MONSTER_PRESETS_BY_WORLD = HOSTILE_NPC_PRESETS_BY_WORLD;
export function getHostileNpcPresetById(worldType: WorldType, monsterId: string) {
if (worldType === WorldType.CUSTOM) {
return HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM].find(monster => monster.id === monsterId) ?? null;
}
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
return HOSTILE_NPC_PRESETS_BY_WORLD[resolvedWorldType].find(monster => monster.id === monsterId) ?? null;
}
@@ -953,6 +962,9 @@ export function getHostileNpcPresetById(worldType: WorldType, monsterId: string)
export const getMonsterPresetById = getHostileNpcPresetById;
export function getHostileNpcPresetsByWorld(worldType: WorldType) {
if (worldType === WorldType.CUSTOM) {
return HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM];
}
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
return HOSTILE_NPC_PRESETS_BY_WORLD[resolvedWorldType];
}
@@ -963,7 +975,100 @@ export function getHostileNpcPresetOverrideById(monsterId: string) {
return HOSTILE_NPC_OVERRIDES[monsterId] ?? null;
}
export function rollHostileNpcLoot(
function inferRuntimePlanFromLootItem(
item: InventoryItem,
context: ReturnType<typeof buildRuntimeItemGenerationContext>,
index: number,
): RuntimeItemPlan {
const normalizedBuildTags = normalizeBuildTags(item.tags, 3);
const targetBuildDirection = normalizedBuildTags.length > 0
? normalizedBuildTags
: normalizeBuildTags(context.playerBuildTags, 3);
return {
slot: item.rarity === 'epic' || item.rarity === 'legendary'
? 'primary'
: index === 0
? 'secondary'
: 'support',
itemKind: item.category === '武器' || item.category === '护甲'
? 'equipment'
: item.category === '消耗品' || item.tags.includes('consumable') || item.tags.includes('healing') || item.tags.includes('mana')
? 'consumable'
: item.category === '材料' || item.tags.includes('material')
? 'material'
: item.category === '专属品' || item.category === '专属物' || item.category === '专属物品'
? 'quest'
: 'relic',
permanence: item.category === '材料'
? 'resource'
: item.category === '消耗品'
? 'timed'
: 'permanent',
narrativeWeight: item.rarity === 'epic' || item.rarity === 'legendary' ? 'heavy' : 'medium',
targetBuildDirection: targetBuildDirection.length > 0 ? targetBuildDirection : ['均衡'],
relationAnchor: context.encounter?.monsterPresetId
? {
type: 'monster' as const,
monsterId: context.encounter.monsterPresetId,
monsterName: context.encounter.npcName,
}
: context.encounterNpcName
? {
type: 'npc' as const,
npcId: context.encounterNpcId ?? undefined,
npcName: context.encounterNpcName,
roleText: context.encounterContextText ?? undefined,
}
: {
type: 'scene' as const,
sceneId: context.sceneId ?? undefined,
sceneName: context.sceneName ?? '战场余烬',
},
} satisfies RuntimeItemPlan;
}
async function decoratePresetLootWithNarrative(
items: InventoryItem[],
context: ReturnType<typeof buildRuntimeItemGenerationContext>,
seedKeyPrefix: string,
) {
if (items.length <= 0) return items;
const plans = items.map((item, index) => inferRuntimePlanFromLootItem(item, context, index));
const fallbackIntents = plans.map(plan => buildRuntimeItemAiIntent(context, plan));
let intents = fallbackIntents;
try {
intents = await generateRuntimeItemAiIntents({
context,
plans,
});
} catch (error) {
console.warn('[HostileNpcPresets] preset loot narrative fallback', error);
}
return items.map((item, index) =>
applyRuntimeItemNarrativeToExistingItem({
item: {
...item,
runtimeMetadata: {
origin: 'procedural',
generationChannel: 'monster_drop',
relationAnchor: plans[index]!.relationAnchor,
seedKey: `${seedKeyPrefix}:preset:${index}`,
sourceReason: intents[index]!.reasonToAppear,
},
},
context,
plan: plans[index]!,
intent: intents[index]!,
preserveName: true,
}),
);
}
export async function rollHostileNpcLoot(
state: GameState,
defeatedHostileNpcs: Array<Pick<SceneHostileNpc, 'id' | 'name'>>,
) {
@@ -979,7 +1084,7 @@ export function rollHostileNpcLoot(
));
}
return defeatedHostileNpcs.flatMap(monster => {
const rewardBatches = await Promise.all(defeatedHostileNpcs.map(async monster => {
const preset = getHostileNpcPresetById(state.worldType!, monster.id);
const presetLoot = preset
? preset.lootTable
@@ -1001,13 +1106,19 @@ export function rollHostileNpcLoot(
monsterPresetId: monster.id,
},
});
const directedReward = buildDirectedRuntimeReward(context, {
seedKey: `monster-loot:${monster.id}:${monster.name}:${state.currentScenePreset?.id ?? 'scene'}`,
itemCount: 2,
fixedKinds: ['material', 'consumable'],
fixedPermanence: ['resource', 'timed'],
});
const seedKey = `monster-loot:${monster.id}:${monster.name}:${state.currentScenePreset?.id ?? 'scene'}`;
const [decoratedPresetLoot, directedReward] = await Promise.all([
decoratePresetLootWithNarrative(presetLoot, context, seedKey),
generateDirectedRuntimeReward(context, {
seedKey,
itemCount: 2,
fixedKinds: ['material', 'consumable'],
fixedPermanence: ['resource', 'timed'],
}),
]);
const runtimeItems = flattenDirectedRuntimeRewardItems(directedReward);
return [...presetLoot, ...runtimeItems];
});
return [...decoratedPresetLoot, ...runtimeItems];
}));
return rewardBatches.flat();
}