Simplify custom world result editing controls

This commit is contained in:
2026-04-08 19:07:46 +08:00
parent bd9fdcbe31
commit a02f7b6414
125 changed files with 8804 additions and 1462 deletions

View File

@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it } from 'vitest';
import { buildExpandedCustomWorldProfile } from '../services/customWorldBuilder';
import { AnimationState } from '../types';
import {
buildCustomWorldRuntimeCharacters,
getCharacterById,
@@ -136,6 +137,28 @@ describe('characterPresets custom world runtime characters', () => {
relationshipHooks: ['断桥旧案', '夜港潮路'],
tags: ['码头', '潮路', '短刀'],
imageSrc: '/custom/npcs/shenwu.png',
generatedVisualAssetId: 'visual-custom-shenwu',
generatedAnimationSetId: 'animation-set-custom-shenwu',
animationMap: {
[AnimationState.IDLE]: {
folder: 'idle',
prefix: 'frame',
frames: 8,
startFrame: 1,
extension: 'png',
basePath:
'/generated-animations/custom-shenwu/animation-set-custom-shenwu/idle',
},
[AnimationState.ATTACK]: {
folder: 'attack',
prefix: 'frame',
frames: 8,
startFrame: 1,
extension: 'png',
basePath:
'/generated-animations/custom-shenwu/animation-set-custom-shenwu/attack',
},
},
visual: {
race: 'human',
bodyColor: 'blue',
@@ -217,6 +240,15 @@ describe('characterPresets custom world runtime characters', () => {
expect(storyCharacter?.backstory).toContain('断桥坠潮夜');
expect(storyCharacter?.skills[0]?.name).toBe('技能11-1');
expect(storyCharacter?.portrait).toBe('/custom/npcs/shenwu.png');
expect(storyCharacter?.generatedVisualAssetId).toBe(
'visual-custom-shenwu',
);
expect(storyCharacter?.generatedAnimationSetId).toBe(
'animation-set-custom-shenwu',
);
expect(storyCharacter?.animationMap?.[AnimationState.IDLE]?.basePath).toBe(
'/generated-animations/custom-shenwu/animation-set-custom-shenwu/idle',
);
expect(storyCharacter?.visual).toEqual(storyRole?.visual);
expect(storyCharacter?.groundOffsetY).toBe(22);

View File

@@ -1,4 +1,5 @@
import { buildThemedSkillName } from '../services/customWorldPresentation';
import { resolveRoleTemplateCharacterIdFromReferenceProfile } from '../services/customWorldReferenceSignals';
import { detectCustomWorldThemeMode } from '../services/customWorldTheme';
import {
AnimationState,
@@ -1625,7 +1626,15 @@ function buildCustomWorldRoleCharacter(
description: role.description,
backstory: role.backstory,
backstoryReveal: role.backstoryReveal,
portrait: ('imageSrc' in role && role.imageSrc?.trim()) || baseCharacter.portrait,
portrait: role.imageSrc?.trim() || baseCharacter.portrait,
generatedVisualAssetId: role.generatedVisualAssetId,
generatedAnimationSetId: role.generatedAnimationSetId,
animationMap: role.animationMap
? {
...(baseCharacter.animationMap ?? {}),
...role.animationMap,
}
: baseCharacter.animationMap,
visual: 'visual' in role ? role.visual : undefined,
groundOffsetY: 'visual' in role && role.visual ? 22 : baseCharacter.groundOffsetY,
personality: role.personality,
@@ -1657,6 +1666,7 @@ function buildCustomWorldRoleCharacter(
function pickCustomWorldRoleTemplateCharacter(
role: CustomWorldRuntimeRole,
fallbackIndex: number,
profile?: CustomWorldProfile | null,
) {
const fallbackTemplateCharacter = PRESET_CHARACTERS[
fallbackIndex % Math.max(1, PRESET_CHARACTERS.length)
@@ -1672,6 +1682,28 @@ function pickCustomWorldRoleTemplateCharacter(
return explicitTemplateCharacter;
}
const referenceTemplateCharacterId = resolveRoleTemplateCharacterIdFromReferenceProfile(
profile ?? null,
{
id: role.id,
name: role.name,
title: role.title,
role: role.role,
description: role.description,
personality: role.personality,
combatStyle: role.combatStyle,
tags: role.tags,
},
);
const referenceTemplateCharacter = referenceTemplateCharacterId
? PRESET_CHARACTERS.find(
(character) => character.id === referenceTemplateCharacterId,
) ?? null
: null;
if (referenceTemplateCharacter) {
return referenceTemplateCharacter;
}
const heuristicTemplateCharacter = PRESET_CHARACTERS.find(
character =>
character.id === resolveFallbackRecruitTemplateCharacterId([
@@ -1698,7 +1730,11 @@ export function buildCustomWorldPlayableCharacters(profile: CustomWorldProfile |
}
return profile.playableNpcs.map((role, index) => {
const templateCharacter = pickCustomWorldRoleTemplateCharacter(role, index);
const templateCharacter = pickCustomWorldRoleTemplateCharacter(
role,
index,
profile,
);
return buildCustomWorldRoleCharacter(
templateCharacter,
@@ -1721,6 +1757,7 @@ export function buildCustomWorldRuntimeCharacters(profile: CustomWorldProfile |
const templateCharacter = pickCustomWorldRoleTemplateCharacter(
role,
profile.playableNpcs.length + index,
profile,
);
return buildCustomWorldRoleCharacter(

View File

@@ -7,7 +7,10 @@ import {
normalizeCustomWorldCreatorIntent,
normalizeCustomWorldLockState,
} from '../services/customWorldCreatorIntent';
import { normalizeCustomWorldOwnedSettingLayers } from '../services/customWorldOwnedSettingLayers';
import {
AnimationState,
CharacterAnimationConfig,
CharacterBackstoryChapter,
CharacterBackstoryRevealConfig,
CustomWorldAnchorPack,
@@ -47,6 +50,7 @@ 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 ANIMATION_STATES = new Set<AnimationState>(Object.values(AnimationState));
const CUSTOM_WORLD_NPC_VISUAL_RACES = new Set<CustomWorldNpcVisualRace>(['human', 'elf', 'orc', 'goblin']);
const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES = new Set<CustomWorldNpcVisualGearType>(['cloth', 'leather', 'metal', 'melee', 'magic', 'ranged']);
const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = new Set([
@@ -361,6 +365,54 @@ function normalizeCustomWorldNpcVisual(value: unknown): CustomWorldNpcVisual | u
};
}
function normalizeCharacterAnimationConfig(
value: unknown,
): CharacterAnimationConfig | null {
if (!isRecord(value)) return null;
const folder = toText(value.folder);
const prefix = toText(value.prefix);
const frames = Math.max(1, toOptionalInteger(value.frames) ?? 0);
if (!folder || !prefix || frames <= 0) {
return null;
}
const startFrame = toOptionalInteger(value.startFrame);
const extension = toText(value.extension);
const file = toText(value.file);
const basePath = toText(value.basePath);
return {
folder,
prefix,
frames,
...(startFrame ? { startFrame: Math.max(1, startFrame) } : {}),
...(extension ? { extension } : {}),
...(file ? { file } : {}),
...(basePath ? { basePath } : {}),
};
}
function normalizeGeneratedAnimationMap(value: unknown) {
if (!isRecord(value)) return undefined;
const entries = Object.entries(value).flatMap(([key, rawConfig]) => {
if (!ANIMATION_STATES.has(key as AnimationState)) {
return [];
}
const config = normalizeCharacterAnimationConfig(rawConfig);
return config ? [[key as AnimationState, config] as const] : [];
});
return entries.length > 0
? Object.fromEntries(entries) as Partial<
Record<AnimationState, CharacterAnimationConfig>
>
: undefined;
}
function normalizeItemStatProfile(value: unknown): ItemStatProfile | null {
if (!isRecord(value)) return null;
@@ -408,9 +460,9 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
} satisfies CustomWorldRoleFallbackSource;
return {
id: toText(value.id, `saved-playable-${index + 1}`),
name,
return {
id: toText(value.id, `saved-playable-${index + 1}`),
name,
title,
role,
description: fallbackSource.description,
@@ -419,14 +471,18 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
motivation: fallbackSource.motivation,
combatStyle: fallbackSource.combatStyle,
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_PLAYABLE_INITIAL_AFFINITY),
relationshipHooks: fallbackSource.relationshipHooks,
tags: fallbackSource.tags,
backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource),
skills: normalizeRoleSkills(value.skills, fallbackSource),
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
templateCharacterId: toText(value.templateCharacterId) || undefined,
};
}
relationshipHooks: fallbackSource.relationshipHooks,
tags: fallbackSource.tags,
backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource),
skills: normalizeRoleSkills(value.skills, fallbackSource),
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
imageSrc: toText(value.imageSrc) || undefined,
generatedVisualAssetId: toText(value.generatedVisualAssetId) || undefined,
generatedAnimationSetId: toText(value.generatedAnimationSetId) || undefined,
animationMap: normalizeGeneratedAnimationMap(value.animationMap),
templateCharacterId: toText(value.templateCharacterId) || undefined,
};
}
function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null {
if (!isRecord(value)) return null;
@@ -450,9 +506,9 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
} satisfies CustomWorldRoleFallbackSource;
return {
id: toText(value.id, `saved-story-${index + 1}`),
name,
return {
id: toText(value.id, `saved-story-${index + 1}`),
name,
title,
role,
description: fallbackSource.description,
@@ -461,15 +517,18 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
motivation: fallbackSource.motivation,
combatStyle: fallbackSource.combatStyle,
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_STORY_NPC_INITIAL_AFFINITY),
relationshipHooks: fallbackSource.relationshipHooks,
tags: fallbackSource.tags,
backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource),
skills: normalizeRoleSkills(value.skills, fallbackSource),
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
imageSrc: toText(value.imageSrc) || undefined,
visual: normalizeCustomWorldNpcVisual(value.visual),
};
}
relationshipHooks: fallbackSource.relationshipHooks,
tags: fallbackSource.tags,
backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource),
skills: normalizeRoleSkills(value.skills, fallbackSource),
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
imageSrc: toText(value.imageSrc) || undefined,
generatedVisualAssetId: toText(value.generatedVisualAssetId) || undefined,
generatedAnimationSetId: toText(value.generatedAnimationSetId) || undefined,
animationMap: normalizeGeneratedAnimationMap(value.animationMap),
visual: normalizeCustomWorldNpcVisual(value.visual),
};
}
function normalizeItem(value: unknown, index: number): CustomWorldItem | null {
if (!isRecord(value)) return null;
@@ -619,7 +678,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
.filter((entry): entry is CustomWorldLandmarkDraft => Boolean(entry))
: [];
return {
const normalizedProfile = {
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
settingText,
name,
@@ -670,6 +729,14 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
value.generationStatus === 'key_only' || value.generationStatus === 'complete'
? value.generationStatus
: 'complete',
} satisfies CustomWorldProfile;
return {
...normalizedProfile,
ownedSettingLayers: normalizeCustomWorldOwnedSettingLayers(
value.ownedSettingLayers,
normalizedProfile,
),
};
}

View File

@@ -1,4 +1,13 @@
import { type CustomWorldNpc, type CustomWorldPlayableNpc, WorldType } from '../types';
import {
collectCreatureArchetypeSignals,
resolveCreatureArchetypeForSource,
} from '../services/customWorldReferenceSignals';
import {
type CustomWorldNpc,
type CustomWorldPlayableNpc,
type CustomWorldProfile,
WorldType,
} from '../types';
import {
getMonsterPresetsByWorld,
type HostileNpcPreset,
@@ -64,6 +73,10 @@ function getMonsterPresetPool(worldType?: WorldType | null) {
});
}
function getAllMonsterPresets() {
return getMonsterPresetPool(null);
}
function uniqueText(values: Array<string | null | undefined>) {
return [
...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)),
@@ -118,9 +131,99 @@ function scoreMonsterPreset(preset: HostileNpcPreset, sourceText: string) {
return score;
}
function scoreMonsterPresetWithArchetype(
preset: HostileNpcPreset,
sourceText: string,
options: {
archetypeSignals?: ReturnType<typeof collectCreatureArchetypeSignals> | null;
preferredWorldType?: WorldType | null;
} = {},
) {
let score = scoreMonsterPreset(preset, sourceText);
const { archetypeSignals, preferredWorldType } = options;
if (archetypeSignals) {
archetypeSignals.keywords.forEach((keyword) => {
if (!keyword) {
return;
}
if (
preset.name.includes(keyword)
|| preset.habitatTags.some((tag) => tag.includes(keyword) || keyword.includes(tag))
|| preset.combatTags.some((tag) => tag.includes(keyword) || keyword.includes(tag))
) {
score += keyword.length >= 3 ? 6 : 4;
}
});
archetypeSignals.combatTags.forEach((tag) => {
if (preset.combatTags.includes(tag)) {
score += 8;
}
});
archetypeSignals.habitatTags.forEach((tag) => {
if (preset.habitatTags.includes(tag)) {
score += 6;
}
});
}
if (
preferredWorldType
&& preferredWorldType !== WorldType.CUSTOM
&& preset.worldType === preferredWorldType
) {
score += 3;
}
return score;
}
export function getCustomWorldMonsterPresetPool(
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
) {
const presets = getAllMonsterPresets();
const creatureArchetypes =
profile?.ownedSettingLayers?.referenceProfile.creatureArchetypes ?? [];
if (creatureArchetypes.length === 0) {
return presets;
}
const preferredWorldType = profile?.templateWorldType ?? null;
const scoredPresets = presets
.map((preset) => {
const archetypeScore = creatureArchetypes.reduce((bestScore, archetype) => {
const nextScore = scoreMonsterPresetWithArchetype(
preset,
preset.name,
{
archetypeSignals: collectCreatureArchetypeSignals(archetype),
preferredWorldType,
},
);
return Math.max(bestScore, nextScore);
}, 0);
return {
preset,
score: archetypeScore,
};
})
.sort((left, right) => right.score - left.score);
const filtered = scoredPresets
.filter((entry) => entry.score > 0)
.map((entry) => entry.preset);
return filtered.length > 0 ? filtered : presets;
}
export function resolveCustomWorldNpcMonsterPreset(
npc: CustomWorldMonsterSource,
worldType?: WorldType | null,
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
) {
const sourceText = buildMonsterSourceText(npc);
if (!sourceText || !MONSTER_SIGNAL_PATTERN.test(sourceText)) {
@@ -132,7 +235,18 @@ export function resolveCustomWorldNpcMonsterPreset(
return null;
}
const candidates = getMonsterPresetPool(worldType);
const preferredWorldType = profile?.templateWorldType ?? worldType ?? null;
const referenceArchetype = resolveCreatureArchetypeForSource(
profile as CustomWorldProfile | null | undefined,
npc,
);
const archetypeSignals = referenceArchetype
? collectCreatureArchetypeSignals(referenceArchetype)
: null;
const candidates =
profile && profile.ownedSettingLayers?.referenceProfile.creatureArchetypes.length
? getCustomWorldMonsterPresetPool(profile)
: getMonsterPresetPool(worldType);
if (candidates.length === 0) {
return null;
}
@@ -140,7 +254,10 @@ export function resolveCustomWorldNpcMonsterPreset(
const scoredCandidates = candidates
.map((candidate) => ({
candidate,
score: scoreMonsterPreset(candidate, sourceText),
score: scoreMonsterPresetWithArchetype(candidate, sourceText, {
archetypeSignals,
preferredWorldType,
}),
}))
.sort((left, right) => right.score - left.score);
@@ -154,6 +271,7 @@ export function resolveCustomWorldNpcMonsterPreset(
export function resolveCustomWorldNpcMonsterPresetId(
npc: CustomWorldMonsterSource,
worldType?: WorldType | null,
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
) {
return resolveCustomWorldNpcMonsterPreset(npc, worldType)?.id ?? null;
return resolveCustomWorldNpcMonsterPreset(npc, worldType, profile)?.id ?? null;
}

View File

@@ -1,4 +1,8 @@
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import {
collectSceneBucketSignalKeywords,
resolveSceneBucketForLandmark,
} from '../services/customWorldReferenceSignals';
import { detectCustomWorldThemeMode } from '../services/customWorldTheme';
import {
type CustomWorldLandmark,
@@ -197,6 +201,7 @@ type CustomWorldSceneImageMatchOptions = {
| 'settingText'
| 'templateWorldType'
| 'camp'
| 'ownedSettingLayers'
> | null;
landmark?: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'dangerLevel'> | null;
usedImageSrcs?: Iterable<string>;
@@ -262,6 +267,36 @@ function buildSceneReferencePool(worldType: WorldTemplateType) {
}));
}
function buildOwnedSceneReferencePool(
profile: Pick<
CustomWorldProfile,
'id' | 'name' | 'ownedSettingLayers'
>,
) {
const sceneBuckets =
profile.ownedSettingLayers?.referenceProfile.sceneBuckets ?? [];
if (sceneBuckets.length === 0) {
return [];
}
const pool = getAllCustomWorldSceneImages();
if (pool.length === 0) {
return [];
}
return sceneBuckets.map((bucket, index) => {
const offset =
hashText(`${profile.id || profile.name}:${bucket.id}:${bucket.label}`)
% pool.length;
return {
name: bucket.label,
keywords: collectSceneBucketSignalKeywords(bucket),
imageSrc: pool[(offset + index) % pool.length] ?? '',
};
});
}
function buildSourceText(
seedKey: string,
index: number,
@@ -369,7 +404,13 @@ export function getDefaultCustomWorldSceneImage(
worldType: WorldTemplateType,
options: CustomWorldSceneImageMatchOptions = {},
) {
const pool = collectWorldSceneImagePool(worldType);
const ownedReferencePool = options.profile
? buildOwnedSceneReferencePool(options.profile)
: [];
const pool =
ownedReferencePool.length > 0
? getAllCustomWorldSceneImages()
: collectWorldSceneImagePool(worldType);
if (pool.length === 0) {
return worldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png';
}
@@ -379,12 +420,34 @@ export function getDefaultCustomWorldSceneImage(
.map((value) => normalizeOptionalImageSrc(value))
.filter((value): value is string => Boolean(value)),
);
const sourceText = buildSourceText(seedKey, index, worldType, options);
const referencePool = buildSceneReferencePool(worldType);
const preferredSceneBucket =
options.profile && options.landmark
? resolveSceneBucketForLandmark(
options.profile as CustomWorldProfile,
options.landmark,
)
: null;
const sourceText = [
buildSourceText(seedKey, index, worldType, options),
preferredSceneBucket?.label ?? '',
...(preferredSceneBucket
? collectSceneBucketSignalKeywords(preferredSceneBucket)
: []),
].join(' ');
const referencePool =
ownedReferencePool.length > 0
? ownedReferencePool
: buildSceneReferencePool(worldType);
const scoredReferences = referencePool
.map((reference, referenceIndex) => ({
imageSrc: reference.imageSrc,
score: scoreSceneReference(reference, sourceText),
score:
scoreSceneReference(reference, sourceText)
+ (
preferredSceneBucket && reference.name === preferredSceneBucket.label
? 28
: 0
),
tieBreaker: hashText(`${seedKey}:${reference.name}:${referenceIndex}`),
}))
.sort((left, right) => {
@@ -418,7 +481,14 @@ export function getDefaultCustomWorldSceneImage(
export function resolveCustomWorldLandmarkImage(
profile: Pick<
CustomWorldProfile,
'id' | 'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
| 'id'
| 'name'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
| 'ownedSettingLayers'
>,
landmark: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'dangerLevel' | 'imageSrc'>,
index: number,
@@ -453,6 +523,7 @@ export function resolveCustomWorldLandmarkImageMap(
| 'templateWorldType'
| 'landmarks'
| 'camp'
| 'ownedSettingLayers'
>,
) {
const usedImageSrcs = new Set(
@@ -490,6 +561,7 @@ export function resolveCustomWorldCampSceneImage(
| 'templateWorldType'
| 'landmarks'
| 'camp'
| 'ownedSettingLayers'
>,
) {
const campScene = resolveCustomWorldCampScene(profile);

View File

@@ -1,4 +1,6 @@
import { InventoryItem, WorldType } from '../types';
import { resolveCustomWorldRuleProfile } from '../services/customWorldOwnedSettingLayers';
import { CustomWorldProfile, InventoryItem, WorldType } from '../types';
import { getRuntimeCustomWorldProfile } from './customWorldRuntime';
const RARITY_BASE_VALUES: Record<InventoryItem['rarity'], number> = {
common: 12,
@@ -8,13 +10,40 @@ const RARITY_BASE_VALUES: Record<InventoryItem['rarity'], number> = {
legendary: 168,
};
export function getCurrencyName(worldType: WorldType | null) {
function resolveEconomyProfile(
worldType: WorldType | null,
customWorldProfile?: CustomWorldProfile | null,
) {
const profile =
customWorldProfile ??
(worldType === WorldType.CUSTOM ? getRuntimeCustomWorldProfile() : null);
return resolveCustomWorldRuleProfile(profile);
}
export function getCurrencyName(
worldType: WorldType | null,
customWorldProfile?: CustomWorldProfile | null,
) {
const ruleProfile = resolveEconomyProfile(worldType, customWorldProfile);
if (ruleProfile) {
return ruleProfile.resourceLabels.currency;
}
if (worldType === WorldType.XIANXIA) return '灵石';
if (worldType === WorldType.WUXIA) return '铜钱';
return '钱币';
}
export function getInitialPlayerCurrency(worldType: WorldType | null) {
export function getInitialPlayerCurrency(
worldType: WorldType | null,
customWorldProfile?: CustomWorldProfile | null,
) {
const ruleProfile = resolveEconomyProfile(worldType, customWorldProfile);
if (ruleProfile) {
return ruleProfile.economyProfile.initialCurrency;
}
return worldType === WorldType.XIANXIA ? 140 : 160;
}
@@ -55,6 +84,10 @@ export function getNpcBuybackPrice(item: InventoryItem, affinity: number) {
return Math.max(4, Math.round(getInventoryItemValue(item) * buybackMultiplier));
}
export function formatCurrency(value: number, worldType: WorldType | null) {
return `${value} ${getCurrencyName(worldType)}`;
export function formatCurrency(
value: number,
worldType: WorldType | null,
customWorldProfile?: CustomWorldProfile | null,
) {
return `${value} ${getCurrencyName(worldType, customWorldProfile)}`;
}

View File

@@ -186,6 +186,30 @@ describe('npcInteractions', () => {
expect(story.options.some((option) => option.functionId === 'npc_gift')).toBe(false);
});
it('uses ai-first copy for quest offers instead of prebuilding a fallback quest preview', () => {
const encounter = createEncounter();
const story = buildNpcEncounterStoryMoment({
encounter,
npcState: buildInitialNpcState(encounter, WorldType.WUXIA),
playerCharacter: createCharacter(),
playerInventory: [],
activeQuests: [],
scene: {
id: 'scene-ruins',
name: '遗迹外缘',
npcs: [],
treasureHints: ['半截封泥'],
},
worldType: WorldType.WUXIA,
partySize: 0,
});
const questOption = story.options.find((option) => option.functionId === 'npc_quest_accept');
expect(questOption).toBeTruthy();
expect(questOption?.detailText).toContain('AI 剧情引擎');
expect(questOption?.detailText).not.toContain('完成后可获得');
});
it('builds concrete trade action text for story continuation', () => {
const encounter = createEncounter();

View File

@@ -68,9 +68,8 @@ import {
type GiftAffinityInsight,
} from './npcAttributeInsights';
import {
buildQuestAcceptDetail,
buildQuestForEncounter,
buildQuestTurnInDetail,
evaluateQuestOpportunity,
getQuestForIssuer,
} from './questFlow';
import {
@@ -1379,6 +1378,30 @@ function buildNpcOption(
} as StoryOption;
}
function buildQuestAcceptOpportunityDetail(params: {
issuerNpcId: string;
issuerNpcName: string;
roleText: string;
scene: Pick<ScenePresetInfo, 'id' | 'name' | 'npcs' | 'treasureHints'> | null;
worldType: WorldType | null;
currentQuests: QuestLogEntry[];
}) {
const opportunity = evaluateQuestOpportunity({
issuerNpcId: params.issuerNpcId,
issuerNpcName: params.issuerNpcName,
roleText: params.roleText,
scene: params.scene,
worldType: params.worldType,
currentQuests: params.currentQuests,
});
if (!opportunity.shouldOffer) {
return null;
}
return `${opportunity.reason} 接取后将由 AI 剧情引擎根据当前局势生成具体目标、步骤与奖励。`;
}
function getPlayerBenefitScore(item: InventoryItem, character: Character) {
let score = getInventoryItemValue(item);
const customWorldProfile = getRuntimeCustomWorldProfile();
@@ -1967,13 +1990,14 @@ export function buildNpcEncounterStoryMoment({
})
: null;
const activeQuest = getQuestForIssuer(activeQuests, npcId);
const generatedQuest = buildQuestForEncounter({
const questAcceptDetail = !activeQuest ? buildQuestAcceptOpportunityDetail({
issuerNpcId: npcId,
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene,
worldType,
});
currentQuests: activeQuests,
}) : null;
const options: StoryOption[] = [];
const isHostileEncounter =
npcState.affinity < 0 ||
@@ -2098,15 +2122,14 @@ export function buildNpcEncounterStoryMoment({
activeQuest.id,
),
);
} else if (!activeQuest && generatedQuest) {
} else if (!activeQuest && questAcceptDetail) {
options.push(
buildNpcOption(
NPC_QUEST_ACCEPT_FUNCTION.id,
`接下${encounter.npcName}的委托`,
buildQuestAcceptDetail(generatedQuest),
questAcceptDetail,
npcId,
'quest_accept',
generatedQuest.id,
),
);
}

View File

@@ -5,6 +5,7 @@ import {WorldType} from '../types';
import {
applyQuestProgressFromHostileNpcDefeat,
applyQuestProgressFromNpcTalk,
buildChapterQuestForScene,
buildQuestForEncounter,
isQuestReadyToClaim,
normalizeQuestLogEntries,
@@ -29,6 +30,62 @@ const TEST_SCENE = {
treasureHints: [],
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>;
const CHAPTER_SCENE = {
id: 'palace_court',
name: '宫苑内庭',
description: '回廊深处静得过分,花木修得齐整,却处处像埋着王庭旧案。',
npcs: [
{
id: 'npc-maid',
name: '旧宫侍女',
description: '她总知道哪条回廊最近不该过去。',
avatar: '侍',
role: '宫人',
initialAffinity: 8,
hostile: false,
},
{
id: 'hostile-guard',
name: '旧宫戍影',
description: '巡行在回廊深处的敌影。',
avatar: '戍',
role: '敌对角色',
monsterPresetId: 'monster-11',
initialAffinity: -40,
hostile: true,
},
],
treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'],
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>;
const OVERRIDDEN_SCENE = {
id: 'wuxia-palace-court',
name: '宫苑内庭',
description: '回廊深处静得过分,花木修得齐整,却处处像埋着王庭旧案。',
npcs: [
{
id: 'wuxia-npc-maid',
name: '旧宫侍女',
description: '嘴上说得少,却总知道哪条回廊最近不该过去。',
avatar: '侍',
role: '宫人',
initialAffinity: 8,
hostile: false,
},
{
id: 'hostile-guard',
name: '旧宫戍影',
description: '巡行在回廊深处的敌影。',
avatar: '戍',
role: '敌对角色',
monsterPresetId: 'monster-11',
initialAffinity: -40,
hostile: true,
},
],
treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'],
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>;
function requireStep(quest: QuestLogEntry, stepId: string): QuestStep {
const step = quest.steps?.find(item => item.id === stepId);
expect(step).toBeTruthy();
@@ -111,5 +168,55 @@ describe('questFlow', () => {
expect(normalized?.status).toBe('completed');
expect(normalized?.progress).toBe(1);
});
});
it('builds a scene chapter quest that reuses staged quest steps', () => {
const quest = buildChapterQuestForScene({
scene: CHAPTER_SCENE,
worldType: WorldType.WUXIA,
});
expect(quest).toBeTruthy();
expect(quest?.chapterId).toBe('chapter:scene:palace_court');
expect(quest?.sceneId).toBe('palace_court');
expect(quest?.steps?.map((step) => step.kind)).toEqual([
'talk_to_npc',
'defeat_hostile_npc',
'talk_to_npc',
]);
});
it('lets scene chapter quests advance through npc talk and scene pressure steps', () => {
const quest = buildChapterQuestForScene({
scene: CHAPTER_SCENE,
worldType: WorldType.WUXIA,
});
expect(quest).toBeTruthy();
const afterOpeningTalk = applyQuestProgressFromNpcTalk([quest!], 'npc-maid')[0];
expect(afterOpeningTalk?.objective.kind).toBe('defeat_hostile_npc');
const afterPressure = applyQuestProgressFromHostileNpcDefeat(
[afterOpeningTalk!],
CHAPTER_SCENE.id,
['monster-11'],
)[0];
expect(afterPressure?.objective.kind).toBe('talk_to_npc');
const afterTurningTalk = applyQuestProgressFromNpcTalk([afterPressure!], 'npc-maid')[0];
expect(afterTurningTalk?.status).toBe('ready_to_turn_in');
expect(isQuestReadyToClaim(afterTurningTalk!)).toBe(true);
});
it('uses scene chapter overrides to prefer investigation beats on key scenes', () => {
const quest = buildChapterQuestForScene({
scene: OVERRIDDEN_SCENE,
worldType: WorldType.WUXIA,
});
expect(quest).toBeTruthy();
expect(quest?.title).toBe('查清内庭旧痕');
expect(requireStep(quest!, 'step_scene_pressure').kind).toBe('inspect_treasure');
expect(requireStep(quest!, 'step_scene_pressure').title).toBe('调查回廊暗格');
expect(requireStep(quest!, 'step_scene_turning').title).toBe('拿旧金牌去对问侍女');
});
});

View File

@@ -28,7 +28,7 @@ import {
} from './runtimeItemContext';
import {buildDirectedRuntimeReward} from './runtimeItemDirector';
import {flattenDirectedRuntimeRewardItems} from './runtimeItemNarrative';
import {getSceneHostileNpcs} from './scenePresets';
import {getSceneFriendlyNpcs, getSceneHostileNpcs} from './scenePresets';
const REWARD_READY_STATUSES: QuestStatus[] = ['ready_to_turn_in', 'completed'];
const TERMINAL_QUEST_STATUSES: QuestStatus[] = ['turned_in', 'failed', 'expired'];
@@ -52,6 +52,112 @@ type SceneQuestThreat =
suggestedThreatType: 'relationship';
};
type SceneChapterOverride = {
title?: string;
description?: string;
summary?: string;
preferredObjectiveKind?: QuestObjectiveKind;
openingTalk?: Partial<Pick<QuestStep, 'title' | 'revealText' | 'completeText'>>;
pressureStep?: Partial<Pick<QuestStep, 'title' | 'revealText' | 'completeText'>>;
turningTalk?: Partial<Pick<QuestStep, 'title' | 'revealText' | 'completeText'>>;
};
const SCENE_CHAPTER_OVERRIDES: Record<string, SceneChapterOverride> = {
'wuxia-palace-court': {
title: '查清内庭旧痕',
description: '旧宫侍女显然知道宫苑内庭近来的异动不只是一条禁行回廊那么简单,你需要顺着残痕把这一章真正翻开。',
summary: '在宫苑内庭查清旧案残痕,并逼出侍女压着没说的那一层旧事',
preferredObjectiveKind: 'inspect_treasure',
openingTalk: {
title: '追问禁行回廊',
revealText: '先问清旧宫侍女为什么总拦着那条回廊,这一章的开口多半就藏在她的口风里。',
completeText: '旧宫侍女已经把最表层的理由说出来了,但真正的旧事还压在更深处。',
},
pressureStep: {
title: '调查回廊暗格',
revealText: '先把回廊暗格里的香囊翻出来,确认内庭异动究竟是在遮人,还是在遮旧案。',
completeText: '回廊暗格已经给出了回应,内庭这章也开始逼近改判前的节点。',
},
turningTalk: {
title: '拿旧金牌去对问侍女',
revealText: '把你查到的旧金牌和暗格痕迹带回去,和旧宫侍女把这层旧事对清楚。',
completeText: '旧宫侍女已经接住你的追问,这章也被你真正推到了收束前夜。',
},
},
'wuxia-rain-street': {
title: '追索雨街账册',
description: '夜灯摊主见过太多不该见的人,雨夜长街的异常更像一条被水汽和灯影压着的旧账,你得先把线索翻出来。',
summary: '在雨夜长街查出湿布包和账册残页背后到底是谁在追索谁',
preferredObjectiveKind: 'inspect_treasure',
openingTalk: {
title: '向摊主问清夜街异样',
revealText: '先和夜灯摊主把这条街最近不对劲的地方问清,别让这章一开始就被人带偏。',
},
pressureStep: {
title: '翻出灯下残页',
revealText: '顺着浸湿的布包和账册残页查下去,这条长街真正压着的旧账就会冒头。',
},
turningTalk: {
title: '拿账册回去对灯摊主',
revealText: '把你翻到的账册残页拿回去,逼夜灯摊主把没说透的那半句话补完。',
},
},
'wuxia-forge-works': {
title: '追索失落兵谱',
description: '老铸匠一眼就认出你身上的杀气来源,铸坊工场里压着的旧兵谱和铁匣显然不只是废料,你得先把这章的火候逼出来。',
summary: '在铸坊工场追出失落兵谱的去向,并问清是谁把旧匣压在风箱后面',
preferredObjectiveKind: 'inspect_treasure',
openingTalk: {
title: '追问兵器缺口来路',
revealText: '先让老铸匠把兵器缺口看清楚,他多半已经从上面认出了这章的来路。',
},
pressureStep: {
title: '翻出风箱后兵谱',
revealText: '先把风箱后压着的旧兵谱和铁匣找出来,铸坊这章的真正火候就在那附近。',
},
turningTalk: {
title: '拿兵谱回去问铸匠',
revealText: '把你翻到的兵谱拿回去,对着老铸匠把来历和去向一并问透。',
},
},
'xianxia-cloud-gate': {
title: '查明仙门符匣异动',
description: '守门灵官一直像在等一份迟迟未到的回报,云海仙门的符匣和玉牌显然牵着更深的禁制线,你得先把这章的入口找准。',
summary: '在云海仙门查清符匣和门阙阴影背后的异常,确认谁在借仙门遮掩旧事',
preferredObjectiveKind: 'inspect_treasure',
openingTalk: {
title: '向灵官问清门阙异象',
revealText: '先让守门灵官把云海仙门最近的异象说清楚,这章的入口多半就藏在他守着不放的话头里。',
},
pressureStep: {
title: '调查云阶符匣',
revealText: '顺着云阶尽头的灵符匣查下去,把仙门这一章真正的异常先钉住。',
},
turningTalk: {
title: '带着符匣回去问灵官',
revealText: '把你查到的符匣线索带回去,逼守门灵官把没说完的禁制旧事补全。',
},
},
'xianxia-star-vessel': {
title: '追索星图旧航线',
description: '星舟舵手守着旧航线图不肯松手,甲板上压着的星图匣和灵罗盘像在等人把残缺航线拼起来,这一章更适合从调查切进去。',
summary: '在星舟甲板拼出失落航线的缺口,并问清是谁把旧坐标压在高空风压里',
preferredObjectiveKind: 'inspect_treasure',
openingTalk: {
title: '追问失落航线',
revealText: '先和星舟舵手把旧航线的缺口问清楚,别让甲板上的风声把真正的方向吹散。',
},
pressureStep: {
title: '调查舵台后星图匣',
revealText: '把舵台后的星图匣和灵罗盘先翻出来,这章的方向才会真正落到你手里。',
},
turningTalk: {
title: '带着航线回去问舵手',
revealText: '把你拼出来的航线缺口带回去,逼星舟舵手把这段旧路说到底。',
},
},
};
function resolveQuestRewardRuntimeConfig(params: {
roleText: string;
rewardTheme: QuestIntent['rewardTheme'];
@@ -244,6 +350,10 @@ function buildQuestId(issuerNpcId: string, kind: QuestObjectiveKind, targetKey:
return `quest:${issuerNpcId}:${kind}:${targetKey}`;
}
export function buildSceneChapterId(sceneId: string) {
return `chapter:scene:${sceneId}`;
}
function isRewardReadyStatus(status: QuestStatus) {
return REWARD_READY_STATUSES.includes(status);
}
@@ -505,6 +615,214 @@ function buildTalkBackStep(issuerNpcId: string, issuerNpcName: string): QuestSte
};
}
function buildSceneOpeningTalkStep(params: {
issuerNpcId: string;
issuerNpcName: string;
sceneName: string;
override?: SceneChapterOverride | null;
}) {
const {issuerNpcId, issuerNpcName, sceneName, override} = params;
const title = override?.openingTalk?.title ?? `${issuerNpcName} 打听异动`;
return {
id: 'step_scene_opening',
kind: 'talk_to_npc',
targetNpcId: issuerNpcId,
requiredCount: 1,
progress: 0,
title,
revealText: override?.openingTalk?.revealText
?? `${issuerNpcName} 明显知道 ${sceneName} 最近不对劲,先和她把眼前局势问清楚。`,
completeText: override?.openingTalk?.completeText
?? `${issuerNpcName} 的回应已经把 ${sceneName} 这一章真正带进了正题。`,
} satisfies QuestStep;
}
function buildSceneTurningTalkStep(params: {
issuerNpcId: string;
issuerNpcName: string;
sceneName: string;
override?: SceneChapterOverride | null;
}) {
const {issuerNpcId, issuerNpcName, sceneName, override} = params;
const title = override?.turningTalk?.title ?? `回去与 ${issuerNpcName} 对证`;
return {
id: 'step_scene_turning',
kind: 'talk_to_npc',
targetNpcId: issuerNpcId,
requiredCount: 1,
progress: 0,
title,
revealText: override?.turningTalk?.revealText
?? `把你在 ${sceneName} 查到的情况带回去,和 ${issuerNpcName} 把这一层旧事对清楚。`,
completeText: override?.turningTalk?.completeText
?? `${issuerNpcName} 已经接住你的回报,这一章也逼近最后的收束。`,
} satisfies QuestStep;
}
function resolveSceneChapterIssuer(scene: QuestSceneSnapshot | null) {
const friendlyNpc = getSceneFriendlyNpcs(scene)[0] ?? null;
if (!friendlyNpc) {
return {
issuerNpcId: scene?.id ? `scene-chapter:${scene.id}` : 'scene-chapter:unknown',
issuerNpcName: scene?.name ?? '当前区域',
roleText: scene?.description ?? scene?.name ?? '场景章节',
hasGuideNpc: false,
};
}
return {
issuerNpcId: friendlyNpc.id,
issuerNpcName: friendlyNpc.name,
roleText: friendlyNpc.role || friendlyNpc.description || scene?.description || friendlyNpc.name,
hasGuideNpc: true,
};
}
function buildSceneChapterPrimaryStep(params: {
scene: QuestSceneSnapshot;
worldType: WorldType | null;
issuerNpcId: string;
issuerNpcName: string;
hasGuideNpc: boolean;
override?: SceneChapterOverride | null;
}) {
const {scene, worldType, issuerNpcId, issuerNpcName, hasGuideNpc, override} = params;
const threat = getScenePrimaryThreat(scene, worldType);
const preferredObjectiveKind = override?.preferredObjectiveKind ?? null;
if (preferredObjectiveKind === 'inspect_treasure' && (scene.treasureHints?.length ?? 0) > 0) {
const clueLabel = scene.treasureHints?.[0] ?? scene.name;
return {
id: 'step_scene_pressure',
kind: 'inspect_treasure',
targetSceneId: scene.id,
requiredCount: 1,
progress: 0,
title: override?.pressureStep?.title ?? `调查 ${clueLabel}`,
revealText: override?.pressureStep?.revealText ?? (
hasGuideNpc
? `${issuerNpcName} 提到的异样多半就藏在 ${clueLabel} 附近,先去把这一层旧痕翻出来。`
: `先把 ${clueLabel} 这条线索看清,${scene.name} 这一章才会真正往前走。`
),
completeText: override?.pressureStep?.completeText
?? `${clueLabel} 已经给出了回应,${scene.name} 这一章开始进入改判前的阶段。`,
} satisfies QuestStep;
}
if ((preferredObjectiveKind === 'defeat_hostile_npc' || !preferredObjectiveKind) && threat?.kind === 'defeat_hostile_npc') {
const hostileNpcName = threat.targetHostileNpcName;
return {
id: 'step_scene_pressure',
kind: 'defeat_hostile_npc',
targetHostileNpcId: threat.targetHostileNpcId,
targetSceneId: threat.targetSceneId,
requiredCount: 1,
progress: 0,
title: override?.pressureStep?.title ?? `压制 ${hostileNpcName}`,
revealText: override?.pressureStep?.revealText ?? (
hasGuideNpc
? `${issuerNpcName} 要你先压制 ${hostileNpcName},再回来确认 ${scene.name} 里的异动究竟是谁在推动。`
: `先压下 ${hostileNpcName} 带来的压力,才能把 ${scene.name} 这一章继续往下推。`
),
completeText: override?.pressureStep?.completeText
?? `${hostileNpcName} 已被压制,${scene.name} 这一章的核心压力开始松动。`,
} satisfies QuestStep;
}
if ((scene.treasureHints?.length ?? 0) > 0) {
const clueLabel = scene.treasureHints?.[0] ?? scene.name;
return {
id: 'step_scene_pressure',
kind: 'inspect_treasure',
targetSceneId: scene.id,
requiredCount: 1,
progress: 0,
title: override?.pressureStep?.title ?? `调查 ${clueLabel}`,
revealText: override?.pressureStep?.revealText ?? (
hasGuideNpc
? `${issuerNpcName} 提到的异样多半就藏在 ${clueLabel} 附近,先去把这一层旧痕翻出来。`
: `先把 ${clueLabel} 这条线索看清,${scene.name} 这一章才会真正往前走。`
),
completeText: override?.pressureStep?.completeText
?? `${clueLabel} 已经给出了回应,${scene.name} 这一章开始进入改判前的阶段。`,
} satisfies QuestStep;
}
return {
id: 'step_scene_pressure',
kind: 'talk_to_npc',
targetNpcId: issuerNpcId,
requiredCount: 1,
progress: 0,
title: override?.pressureStep?.title ?? `继续逼问 ${issuerNpcName}`,
revealText: override?.pressureStep?.revealText ?? `${issuerNpcName} 还压着一层没说透的话,把这章的中段压力继续顶上去。`,
completeText: override?.pressureStep?.completeText ?? `${issuerNpcName} 的口风终于松了一层,这章也开始逼近转折。`,
} satisfies QuestStep;
}
function buildSceneChapterSteps(params: {
scene: QuestSceneSnapshot;
worldType: WorldType | null;
issuerNpcId: string;
issuerNpcName: string;
hasGuideNpc: boolean;
override?: SceneChapterOverride | null;
}) {
const {scene, worldType, issuerNpcId, issuerNpcName, hasGuideNpc, override} = params;
const steps: QuestStep[] = [];
if (hasGuideNpc) {
steps.push(buildSceneOpeningTalkStep({
issuerNpcId,
issuerNpcName,
sceneName: scene.name,
override,
}));
}
steps.push(buildSceneChapterPrimaryStep({
scene,
worldType,
issuerNpcId,
issuerNpcName,
hasGuideNpc,
override,
}));
if (hasGuideNpc) {
steps.push(buildSceneTurningTalkStep({
issuerNpcId,
issuerNpcName,
sceneName: scene.name,
override,
}));
}
return steps;
}
function resolveSceneChapterNarrativeType(scene: QuestSceneSnapshot, worldType: WorldType | null) {
const threat = getScenePrimaryThreat(scene, worldType);
if (threat?.kind === 'defeat_hostile_npc') {
return 'bounty' as const;
}
if ((scene.treasureHints?.length ?? 0) > 0) {
return 'investigation' as const;
}
return 'relationship' as const;
}
function resolveSceneChapterRewardTheme(scene: QuestSceneSnapshot, worldType: WorldType | null) {
const threat = getScenePrimaryThreat(scene, worldType);
if ((scene.treasureHints?.length ?? 0) > 0) {
return 'intel' as const;
}
if (threat?.kind === 'defeat_hostile_npc') {
return 'resource' as const;
}
return 'relationship' as const;
}
function deriveObjectiveFromStep(step: QuestStep | null, issuerNpcId: string): QuestObjective {
if (!step) {
return {
@@ -609,6 +927,7 @@ export function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry {
const normalizedQuest: QuestLogEntry = {
...quest,
chapterId: quest.chapterId ?? null,
objective,
progress,
status,
@@ -713,6 +1032,18 @@ export function getQuestForIssuer(quests: QuestLogEntry[], issuerNpcId: string)
return quests.find(quest => quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in') ?? null;
}
export function getChapterQuestForScene(quests: QuestLogEntry[], sceneId: string | null | undefined) {
if (!sceneId) {
return null;
}
const chapterId = buildSceneChapterId(sceneId);
return quests.find((quest) =>
quest.chapterId === chapterId
&& !isTerminalStatus(quest.status),
) ?? null;
}
export function evaluateQuestOpportunity(params: QuestPreviewRequest): QuestOpportunity {
const {issuerNpcId, scene, currentQuests = []} = params;
if (!scene) {
@@ -927,6 +1258,109 @@ export function buildQuestForEncounter(params: QuestPreviewRequest): QuestLogEnt
);
}
export function buildChapterQuestForScene(params: {
scene: QuestSceneSnapshot | null;
worldType: WorldType | null;
context?: QuestGenerationContext;
}) {
const {scene, worldType, context} = params;
if (!scene) {
return null;
}
const {
issuerNpcId,
issuerNpcName,
roleText,
hasGuideNpc,
} = resolveSceneChapterIssuer(scene);
const override = SCENE_CHAPTER_OVERRIDES[scene.id] ?? null;
const steps = buildSceneChapterSteps({
scene,
worldType,
issuerNpcId,
issuerNpcName,
hasGuideNpc,
override,
});
if (steps.length <= 0) {
return null;
}
const narrativeType = resolveSceneChapterNarrativeType(scene, worldType);
const rewardTheme = resolveSceneChapterRewardTheme(scene, worldType);
const reward = buildQuestReward({
issuerNpcId,
issuerNpcName,
worldType,
roleText,
rewardTheme,
narrativeType,
scene,
context,
});
const rewardText = buildRewardText(reward, worldType);
const threadContract = resolveQuestThreadContract({
context,
issuerNpcId,
scene,
});
const chapterId = buildSceneChapterId(scene.id);
const threat = getScenePrimaryThreat(scene, worldType);
const title = normalizeQuestTitle(
override?.title ?? `${compactQuestLabel(scene.name, 6)}异动`,
`查明${compactQuestLabel(scene.name, 6)}`,
);
return normalizeQuestLogEntry({
id: `quest:chapter:${scene.id}`,
issuerNpcId,
issuerNpcName,
sceneId: scene.id,
chapterId,
actId: context?.actState?.id ?? null,
threadId: threadContract?.threadId ?? null,
contractId: threadContract?.id ?? null,
title,
description: override?.description ?? (
hasGuideNpc
? `${issuerNpcName} 认为 ${scene.name} 这一带的异动并不简单,希望你把眼前的线索与压力真正查清。`
: `${scene.name} 当前的局势还没有收束,你需要把这一章的线索和压力真正接住。`
),
summary: override?.summary ?? `${scene.name} 接住这一章的线索并完成收束`,
objective: deriveObjectiveFromStep(steps[0] ?? null, issuerNpcId),
progress: 0,
status: 'active',
completionNotified: false,
reward,
rewardText,
narrativeBinding: {
origin: 'fallback_builder',
narrativeType,
dramaticNeed: hasGuideNpc
? `${issuerNpcName} 明显知道 ${scene.name} 的局势正在失衡,但还没把真正的问题说透。`
: `${scene.name} 的异常正在把这段局势往前推,你需要先把现场的主压力接住。`,
issuerGoal: hasGuideNpc
? `查清 ${scene.name} 的异动到底是谁、哪件旧事或哪层残痕在推动。`
: `${scene.name} 当前未收束的压力和线索梳理清楚。`,
playerHook: `你已经进入 ${scene.name},这一章现在就落在你面前。`,
worldReason: threat?.kind === 'defeat_hostile_npc'
? `${scene.name} 的敌对压力已经摆到了台前,不先处理就很难继续推进。`
: `${scene.name} 的线索和残痕已经堆到足以独立成章的程度。`,
followupHooks: [
`${scene.name} 的这一章收束后,下一段 lead 会开始变得更明确。`,
],
},
steps,
activeStepId: steps[0]?.id ?? null,
visibleStage: 0,
hiddenFlags: [],
discoveredFactIds: [],
relatedCarrierIds: [],
consequenceIds: [],
});
}
export function buildQuestAcceptDetail(quest: QuestLogEntry) {
const normalizedQuest = withNormalizedQuest(quest);
const activeStep = getQuestActiveStep(normalizedQuest);

View File

@@ -25,12 +25,13 @@ import {
PRESET_CHARACTERS,
} from './characterPresets';
import { resolveCustomWorldNpcMonsterPreset } from './customWorldNpcMonsters';
import { getCustomWorldMonsterPresetPool } from './customWorldNpcMonsters';
import { getRuntimeCustomWorldProfile, resolveRuleWorldType } from './customWorldRuntime';
import {
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImageMap,
} from './customWorldVisuals';
import { getMonsterPresetById, getMonsterPresetsByWorld } from './hostileNpcPresets';
import { getMonsterPresetById } from './hostileNpcPresets';
import sceneNpcOverridesJson from './sceneNpcOverrides.json';
import sceneOverridesJson from './sceneOverrides.json';
@@ -307,7 +308,7 @@ function buildCustomSceneNpc(
);
const monsterPreset =
npc.initialAffinity < 0
? resolveCustomWorldNpcMonsterPreset(npc)
? resolveCustomWorldNpcMonsterPreset(npc, WorldType.CUSTOM, profile)
: null;
const hostile = npc.initialAffinity < 0 || Boolean(monsterPreset);
const attributeProfile = monsterPreset?.attributeProfile
@@ -378,7 +379,7 @@ function buildCustomSceneId(kind: 'camp' | 'landmark', index = 0) {
function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
const campSceneProfile = resolveCustomWorldCampScene(profile);
const landmarkImageMap = resolveCustomWorldLandmarkImageMap(profile);
const baseMonsterPool: string[] = getMonsterPresetsByWorld(WorldType.CUSTOM)
const baseMonsterPool: string[] = getCustomWorldMonsterPresetPool(profile)
.map((monster) => monster.id)
.filter((monsterId: string, index: number, array: string[]) => array.indexOf(monsterId) === index);
const fallbackMonsterIds: string[] = baseMonsterPool.length > 0 ? baseMonsterPool : [];

View File

@@ -1,3 +1,4 @@
import { resolveCustomWorldRuleProfile } from '../services/customWorldOwnedSettingLayers';
import type {CustomWorldProfile, WorldAttributeSchema} from '../types';
import {WorldType} from '../types';
@@ -162,8 +163,11 @@ export function getWorldAttributeSchema(
worldType: WorldType | null | undefined,
customWorldProfile?: CustomWorldProfile | null,
) {
if (worldType === WorldType.CUSTOM && customWorldProfile?.attributeSchema) {
return customWorldProfile.attributeSchema;
if (worldType === WorldType.CUSTOM && customWorldProfile) {
return (
resolveCustomWorldRuleProfile(customWorldProfile)?.attributeSchema
?? customWorldProfile.attributeSchema
);
}
if (worldType === WorldType.XIANXIA) {