1
This commit is contained in:
@@ -38,9 +38,9 @@ import {
|
||||
ItemUseProfile,
|
||||
KnowledgeFact,
|
||||
RoleAttributeProfile,
|
||||
SceneNarrativeResidue,
|
||||
SceneActBlueprint,
|
||||
SceneChapterBlueprint,
|
||||
SceneNarrativeResidue,
|
||||
ThemePack,
|
||||
ThreadContract,
|
||||
WorldStoryGraph,
|
||||
@@ -990,7 +990,7 @@ function normalizeSceneActBlueprint(
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-scene-act-${sceneId}-${index + 1}`),
|
||||
sceneId,
|
||||
sceneId: toText(value.sceneId, sceneId),
|
||||
title: title || `第 ${index + 1} 幕`,
|
||||
summary: summary || title || `围绕${sceneId}继续推进`,
|
||||
stageCoverage:
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveActiveSceneActEncounterNpcIds } from '../services/customWorldSceneActRuntime';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type CustomWorldProfile,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type SceneNpc,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { getMonsterPresetsByWorld } from './hostileNpcPresets';
|
||||
import { createSceneHostileNpc } from './hostileNpcs';
|
||||
import { buildInitialNpcState } from './npcInteractions';
|
||||
import {
|
||||
createSceneEncounterPreview,
|
||||
hasAutoBattleSceneEncounter,
|
||||
resolveSceneEncounterPreview,
|
||||
} from './sceneEncounterPreviews';
|
||||
@@ -150,5 +154,345 @@ describe('sceneEncounterPreviews', () => {
|
||||
expect(monster?.encounter?.hostile).toBe(true);
|
||||
expect(monster?.encounter?.initialAffinity).toBe(-40);
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves active act npc ids when runtime scene id differs from landmark id', () => {
|
||||
const profile = {
|
||||
id: 'custom-profile',
|
||||
name: '测试世界',
|
||||
settingText: '',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
tone: '',
|
||||
playerGoal: '',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
attributes: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-raw-1',
|
||||
name: '旧桥',
|
||||
description: '旧桥',
|
||||
sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
sceneId: 'landmark-raw-1',
|
||||
title: '旧桥章节',
|
||||
summary: '',
|
||||
sceneTaskDescription: '',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: ['landmark-raw-1'],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
sceneId: 'landmark-raw-1',
|
||||
title: '第一幕',
|
||||
summary: '',
|
||||
stageCoverage: ['opening'],
|
||||
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
primaryNpcId: 'npc-front',
|
||||
oppositeNpcId: 'npc-front',
|
||||
eventDescription: '',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '',
|
||||
transitionHook: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as CustomWorldProfile;
|
||||
|
||||
expect(
|
||||
resolveActiveSceneActEncounterNpcIds({
|
||||
profile,
|
||||
sceneId: 'custom-scene-landmark-1',
|
||||
}),
|
||||
).toEqual(['npc-front', 'npc-back-1', 'npc-back-2']);
|
||||
});
|
||||
|
||||
it('resolves active act npc ids from act scene id even when chapter scene id is abstract', () => {
|
||||
const profile = {
|
||||
id: 'custom-profile',
|
||||
name: '测试世界',
|
||||
settingText: '',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
tone: '',
|
||||
playerGoal: '',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
attributes: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-raw-1',
|
||||
name: '旧桥',
|
||||
description: '旧桥',
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
sceneId: 'chapter-abstract-scene',
|
||||
title: '旧桥章节',
|
||||
summary: '',
|
||||
sceneTaskDescription: '',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
sceneId: 'landmark-raw-1',
|
||||
title: '第一幕',
|
||||
summary: '',
|
||||
stageCoverage: ['opening'],
|
||||
encounterNpcIds: [],
|
||||
primaryNpcId: '',
|
||||
oppositeNpcId: 'npc-front',
|
||||
eventDescription: '',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '',
|
||||
transitionHook: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as CustomWorldProfile;
|
||||
|
||||
expect(
|
||||
resolveActiveSceneActEncounterNpcIds({
|
||||
profile,
|
||||
sceneId: 'custom-scene-landmark-1',
|
||||
}),
|
||||
).toEqual(['npc-front']);
|
||||
});
|
||||
|
||||
it('uses the active act opposite npc as the formal scene encounter', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile: {
|
||||
id: 'custom-profile',
|
||||
name: '测试世界',
|
||||
settingText: '',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
tone: '',
|
||||
playerGoal: '',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
attributes: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-raw-1',
|
||||
name: '旧桥',
|
||||
description: '旧桥',
|
||||
sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
sceneId: 'landmark-raw-1',
|
||||
title: '旧桥章节',
|
||||
summary: '',
|
||||
sceneTaskDescription: '',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: ['landmark-raw-1'],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
sceneId: 'landmark-raw-1',
|
||||
title: '第一幕',
|
||||
summary: '',
|
||||
stageCoverage: ['opening'],
|
||||
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
primaryNpcId: 'npc-back-1',
|
||||
oppositeNpcId: 'npc-front',
|
||||
eventDescription: '',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '',
|
||||
transitionHook: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as CustomWorldProfile,
|
||||
currentEncounter: null,
|
||||
currentScenePreset: {
|
||||
id: 'custom-scene-landmark-1',
|
||||
name: '旧桥',
|
||||
description: '旧桥',
|
||||
imageSrc: '/bridge.png',
|
||||
connectedSceneIds: [],
|
||||
npcs: [
|
||||
{
|
||||
id: 'hostile-side',
|
||||
name: '旁路敌人',
|
||||
description: '旁路敌人',
|
||||
avatar: '敌',
|
||||
role: '敌对角色',
|
||||
monsterPresetId: 'monster-01',
|
||||
initialAffinity: -40,
|
||||
hostile: true,
|
||||
},
|
||||
{
|
||||
id: 'npc-back-1',
|
||||
name: '后排甲',
|
||||
description: '后排甲',
|
||||
avatar: '甲',
|
||||
role: '同幕角色',
|
||||
},
|
||||
{
|
||||
id: 'npc-front',
|
||||
name: '主角色',
|
||||
description: '主角色',
|
||||
avatar: '主',
|
||||
role: '主角色',
|
||||
},
|
||||
{
|
||||
id: 'npc-back-2',
|
||||
name: '后排乙',
|
||||
description: '后排乙',
|
||||
avatar: '乙',
|
||||
role: '同幕角色',
|
||||
},
|
||||
] satisfies SceneNpc[],
|
||||
treasureHints: [],
|
||||
},
|
||||
} satisfies GameState;
|
||||
|
||||
const preview = createSceneEncounterPreview(state);
|
||||
|
||||
expect(preview.currentEncounter?.id).toBe('npc-front');
|
||||
expect(preview.currentEncounter?.npcName).toBe('主角色');
|
||||
});
|
||||
|
||||
it('uses active act opposite npc even when that npc is hostile', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile: {
|
||||
id: 'custom-profile',
|
||||
name: '测试世界',
|
||||
settingText: '',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
tone: '',
|
||||
playerGoal: '',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
attributes: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
sceneId: 'custom-scene-camp',
|
||||
title: '开局章节',
|
||||
summary: '',
|
||||
sceneTaskDescription: '',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
sceneId: 'custom-scene-camp',
|
||||
title: '第一幕',
|
||||
summary: '',
|
||||
stageCoverage: ['opening'],
|
||||
encounterNpcIds: ['npc-hostile-opposite', 'npc-back'],
|
||||
primaryNpcId: 'npc-back',
|
||||
oppositeNpcId: 'npc-hostile-opposite',
|
||||
eventDescription: '',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '',
|
||||
transitionHook: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as CustomWorldProfile,
|
||||
currentEncounter: null,
|
||||
currentScenePreset: {
|
||||
id: 'custom-scene-camp',
|
||||
name: '营地',
|
||||
description: '营地',
|
||||
imageSrc: '/camp.png',
|
||||
connectedSceneIds: [],
|
||||
npcs: [
|
||||
{
|
||||
id: 'npc-hostile-opposite',
|
||||
name: '敌意对面角色',
|
||||
description: '第一幕先开口的敌意角色',
|
||||
avatar: '敌',
|
||||
role: '第一幕对面角色',
|
||||
initialAffinity: -20,
|
||||
hostile: true,
|
||||
},
|
||||
{
|
||||
id: 'npc-back',
|
||||
name: '后排角色',
|
||||
description: '同幕后排角色',
|
||||
avatar: '后',
|
||||
role: '同幕角色',
|
||||
},
|
||||
] satisfies SceneNpc[],
|
||||
treasureHints: [],
|
||||
},
|
||||
} satisfies GameState;
|
||||
|
||||
const preview = createSceneEncounterPreview(state);
|
||||
const resolved = resolveSceneEncounterPreview({
|
||||
...state,
|
||||
...preview,
|
||||
npcStates: {
|
||||
'npc-hostile-opposite': {
|
||||
...buildInitialNpcState(
|
||||
preview.currentEncounter!,
|
||||
WorldType.CUSTOM,
|
||||
state,
|
||||
),
|
||||
affinity: -20,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(preview.currentEncounter?.id).toBe('npc-hostile-opposite');
|
||||
expect(preview.currentEncounter?.npcName).toBe('敌意对面角色');
|
||||
expect(resolved.inBattle).toBe(false);
|
||||
expect(resolved.currentEncounter?.id).toBe('npc-hostile-opposite');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
canUseLimitedPrimaryNpcChat,
|
||||
resolveActiveSceneActEncounterFocusNpcId,
|
||||
resolveActiveSceneActEncounterNpcIds,
|
||||
} from '../services/customWorldSceneActRuntime';
|
||||
import { AnimationState, Encounter, GameState, SceneNpc, WorldType } from '../types';
|
||||
import { getRecruitedNpcIds } from './companionRoster';
|
||||
import {
|
||||
@@ -15,10 +20,6 @@ import {
|
||||
getSceneHostileNpcs,
|
||||
getWorldCampScenePreset,
|
||||
} from './scenePresets';
|
||||
import {
|
||||
canUseLimitedPrimaryNpcChat,
|
||||
resolveActiveSceneActEncounterNpcIds,
|
||||
} from '../services/customWorldSceneActRuntime';
|
||||
|
||||
export const EXPLORE_APPROACH_DURATION_MS = 4000;
|
||||
export const PREVIEW_ENTITY_X_METERS = 12;
|
||||
@@ -115,7 +116,11 @@ function getAvailableFriendlySceneNpcs(state: GameState) {
|
||||
const activeActNpcIdSet = new Set(activeActNpcIds);
|
||||
|
||||
return getSceneFriendlyNpcs(state.currentScenePreset)
|
||||
.filter(candidate => !isCampScene || Boolean(candidate.characterId))
|
||||
.filter(candidate =>
|
||||
!isCampScene ||
|
||||
Boolean(candidate.characterId) ||
|
||||
activeActNpcIdSet.has(candidate.id),
|
||||
)
|
||||
.filter(candidate => candidate.characterId !== state.playerCharacter?.id)
|
||||
.filter(candidate => !recruitedNpcIds.has(candidate.id))
|
||||
.filter(candidate =>
|
||||
@@ -126,6 +131,29 @@ function getAvailableFriendlySceneNpcs(state: GameState) {
|
||||
);
|
||||
}
|
||||
|
||||
function getAvailableActiveSceneActNpcs(state: GameState) {
|
||||
const recruitedNpcIds = getRecruitedNpcIds(state);
|
||||
const activeActNpcIds = resolveActiveSceneActEncounterNpcIds({
|
||||
profile: state.customWorldProfile,
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: state.storyEngineMemory,
|
||||
});
|
||||
const activeActNpcIdSet = new Set(activeActNpcIds);
|
||||
if (activeActNpcIdSet.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (state.currentScenePreset?.npcs ?? [])
|
||||
.filter(candidate => {
|
||||
const candidateIds = [candidate.id, candidate.characterId].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
return candidateIds.some(id => activeActNpcIdSet.has(id));
|
||||
})
|
||||
.filter(candidate => candidate.characterId !== state.playerCharacter?.id)
|
||||
.filter(candidate => !recruitedNpcIds.has(candidate.id));
|
||||
}
|
||||
|
||||
function getAvailableHostileSceneNpcs(state: GameState) {
|
||||
const recruitedNpcIds = getRecruitedNpcIds(state);
|
||||
|
||||
@@ -142,6 +170,54 @@ function pickEncounterHostileNpcs(hostileNpcs: Array<SceneNpc & { monsterPresetI
|
||||
return hostileNpcs.filter(npc => selectedMonsterIds.has(npc.monsterPresetId));
|
||||
}
|
||||
|
||||
function pickFriendlySceneNpcForActiveAct(state: GameState, npcs: SceneNpc[]) {
|
||||
const focusNpcId = resolveActiveSceneActEncounterFocusNpcId({
|
||||
profile: state.customWorldProfile,
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: state.storyEngineMemory,
|
||||
});
|
||||
|
||||
return (
|
||||
npcs.find(
|
||||
(npc) =>
|
||||
npc.id === focusNpcId ||
|
||||
(npc.characterId ? npc.characterId === focusNpcId : false),
|
||||
) ?? pickRandomItem(npcs)
|
||||
);
|
||||
}
|
||||
|
||||
function hasActiveSceneActEncounterTarget(state: GameState) {
|
||||
return resolveActiveSceneActEncounterNpcIds({
|
||||
profile: state.customWorldProfile,
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: state.storyEngineMemory,
|
||||
}).length > 0;
|
||||
}
|
||||
|
||||
function buildEmptyEncounterPreview() {
|
||||
return {
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildActiveSceneActNpcEncounter(
|
||||
state: GameState,
|
||||
availableNpcs: SceneNpc[],
|
||||
xMeters: number,
|
||||
) {
|
||||
const npc = pickFriendlySceneNpcForActiveAct(state, availableNpcs);
|
||||
|
||||
return {
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: npc ? buildFriendlyEncounter(npc, xMeters) : null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildHostileEncounterGroup(
|
||||
state: GameState,
|
||||
entryX: number,
|
||||
@@ -218,12 +294,15 @@ function buildResolvedHostileBattleState(state: GameState, hostileEncounters: En
|
||||
|
||||
export function createSceneEncounterPreview(state: GameState) {
|
||||
if (!state.worldType || !state.currentScenePreset) {
|
||||
return {
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
return buildEmptyEncounterPreview();
|
||||
}
|
||||
|
||||
if (hasActiveSceneActEncounterTarget(state)) {
|
||||
return buildActiveSceneActNpcEncounter(
|
||||
state,
|
||||
getAvailableActiveSceneActNpcs(state),
|
||||
PREVIEW_ENTITY_X_METERS,
|
||||
);
|
||||
}
|
||||
|
||||
const availableNpcs = getAvailableFriendlySceneNpcs(state);
|
||||
@@ -237,12 +316,7 @@ export function createSceneEncounterPreview(state: GameState) {
|
||||
|
||||
const kind = pickRandomItem(availableKinds);
|
||||
if (!kind) {
|
||||
return {
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
return buildEmptyEncounterPreview();
|
||||
}
|
||||
|
||||
if (kind === 'hostile') {
|
||||
@@ -255,7 +329,7 @@ export function createSceneEncounterPreview(state: GameState) {
|
||||
}
|
||||
|
||||
if (kind === 'npc') {
|
||||
const npc = pickRandomItem(availableNpcs);
|
||||
const npc = pickFriendlySceneNpcForActiveAct(state, availableNpcs);
|
||||
|
||||
return {
|
||||
sceneHostileNpcs: [],
|
||||
@@ -276,19 +350,22 @@ export function createSceneEncounterPreview(state: GameState) {
|
||||
|
||||
export function createSceneCallOutEncounter(state: GameState) {
|
||||
if (!state.worldType || !state.currentScenePreset) {
|
||||
return {
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
return buildEmptyEncounterPreview();
|
||||
}
|
||||
|
||||
if (hasActiveSceneActEncounterTarget(state)) {
|
||||
return buildActiveSceneActNpcEncounter(
|
||||
state,
|
||||
getAvailableActiveSceneActNpcs(state),
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
);
|
||||
}
|
||||
|
||||
const availableNpcs = getAvailableFriendlySceneNpcs(state);
|
||||
const availableKinds: Array<'hostile' | 'npc' | 'treasure'> = [];
|
||||
const availableHostiles = getAvailableHostileSceneNpcs(state);
|
||||
if (availableHostiles.length > 0) availableKinds.push('hostile');
|
||||
|
||||
const availableNpcs = getAvailableFriendlySceneNpcs(state);
|
||||
if (availableNpcs.length > 0) availableKinds.push('npc');
|
||||
if (TREASURE_ENCOUNTERS_ENABLED && (state.currentScenePreset.treasureHints?.length ?? 0) > 0) {
|
||||
availableKinds.push('treasure');
|
||||
@@ -305,7 +382,7 @@ export function createSceneCallOutEncounter(state: GameState) {
|
||||
}
|
||||
|
||||
if (kind === 'npc') {
|
||||
const npc = pickRandomItem(availableNpcs);
|
||||
const npc = pickFriendlySceneNpcForActiveAct(state, availableNpcs);
|
||||
return {
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: npc ? buildFriendlyEncounter(npc, CALL_OUT_ENTRY_X_METERS) : null,
|
||||
|
||||
@@ -381,6 +381,40 @@ function buildCustomSceneId(kind: 'camp' | 'landmark', index = 0) {
|
||||
return kind === 'camp' ? 'custom-scene-camp' : `custom-scene-landmark-${index + 1}`;
|
||||
}
|
||||
|
||||
function collectSceneActNpcIdsForScene(
|
||||
profile: CustomWorldProfile,
|
||||
sceneAliases: string[],
|
||||
) {
|
||||
const aliasSet = new Set(sceneAliases.map((alias) => alias.trim()).filter(Boolean));
|
||||
const npcIds: string[] = [];
|
||||
|
||||
const pushNpcId = (npcId: string | null | undefined) => {
|
||||
const normalizedNpcId = npcId?.trim() ?? '';
|
||||
if (normalizedNpcId && !npcIds.includes(normalizedNpcId)) {
|
||||
npcIds.push(normalizedNpcId);
|
||||
}
|
||||
};
|
||||
|
||||
profile.sceneChapterBlueprints?.forEach((chapter) => {
|
||||
const chapterSceneIds = [
|
||||
chapter.sceneId,
|
||||
...chapter.linkedLandmarkIds,
|
||||
...chapter.acts.map((act) => act.sceneId),
|
||||
].map((sceneId) => sceneId.trim()).filter(Boolean);
|
||||
if (!chapterSceneIds.some((sceneId) => aliasSet.has(sceneId))) {
|
||||
return;
|
||||
}
|
||||
|
||||
chapter.acts.forEach((act) => {
|
||||
pushNpcId(act.primaryNpcId);
|
||||
pushNpcId(act.oppositeNpcId);
|
||||
act.encounterNpcIds.forEach(pushNpcId);
|
||||
});
|
||||
});
|
||||
|
||||
return npcIds;
|
||||
}
|
||||
|
||||
function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
const campSceneProfile = resolveCustomWorldCampScene(profile);
|
||||
const landmarkImageMap = resolveCustomWorldLandmarkImageMap(profile);
|
||||
@@ -403,6 +437,37 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
const customStoryNpcById = new Map(
|
||||
profile.storyNpcs.map((npc) => [npc.id, npc]),
|
||||
);
|
||||
const buildCustomSceneNpcByRoleId = (roleId: string) => {
|
||||
const storyNpc = customStoryNpcById.get(roleId);
|
||||
if (storyNpc) {
|
||||
return buildCustomSceneNpc(storyNpc, profile);
|
||||
}
|
||||
|
||||
const playableNpc = profile.playableNpcs.find((npc) => npc.id === roleId);
|
||||
if (!playableNpc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildCharacterNpc(playableNpc.id, WorldType.CUSTOM, profile);
|
||||
};
|
||||
const pushUniqueSceneNpc = (sceneNpcs: SceneNpc[], npc: SceneNpc | null) => {
|
||||
if (!npc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidateIds = [npc.id, npc.characterId].filter(Boolean);
|
||||
if (
|
||||
sceneNpcs.some((sceneNpc) =>
|
||||
[sceneNpc.id, sceneNpc.characterId]
|
||||
.filter(Boolean)
|
||||
.some((sceneNpcId) => candidateIds.includes(sceneNpcId)),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
sceneNpcs.push(npc);
|
||||
};
|
||||
const campNpcs = playableCharacters.slice(1).map(character => {
|
||||
const npc = buildCharacterNpc(character.id, WorldType.CUSTOM, profile);
|
||||
return npc
|
||||
@@ -413,6 +478,12 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
}
|
||||
: null;
|
||||
}).filter(Boolean) as SceneNpc[];
|
||||
collectSceneActNpcIdsForScene(profile, [
|
||||
campSceneId,
|
||||
profile.camp?.id ?? '',
|
||||
]).forEach((npcId) =>
|
||||
pushUniqueSceneNpc(campNpcs, buildCustomSceneNpcByRoleId(npcId)),
|
||||
);
|
||||
|
||||
const campConnections = profile.landmarks
|
||||
.slice(0, 3)
|
||||
@@ -445,12 +516,20 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
npcs: campNpcs,
|
||||
},
|
||||
...profile.landmarks.map((landmark, index): ScenePreset => {
|
||||
const sceneNpcs = landmark.sceneNpcIds
|
||||
const runtimeSceneId = buildCustomSceneId('landmark', index);
|
||||
const sceneActNpcIds = collectSceneActNpcIdsForScene(profile, [
|
||||
landmark.id,
|
||||
runtimeSceneId,
|
||||
]);
|
||||
const sceneNpcs = [...sceneActNpcIds, ...landmark.sceneNpcIds]
|
||||
.map((npcId) => customStoryNpcById.get(npcId))
|
||||
.filter(Boolean)
|
||||
.map((npc) =>
|
||||
buildCustomSceneNpc(npc!, profile),
|
||||
);
|
||||
sceneActNpcIds.forEach((npcId) =>
|
||||
pushUniqueSceneNpc(sceneNpcs, buildCustomSceneNpcByRoleId(npcId)),
|
||||
);
|
||||
if (sceneNpcs.length < 3) {
|
||||
profile.storyNpcs
|
||||
.filter(
|
||||
@@ -499,12 +578,12 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
const monsterSliceStart = (index * 2) % Math.max(1, fallbackMonsterIds.length || 1);
|
||||
const seedMonsterIds: string[] = fallbackMonsterIds.slice(monsterSliceStart, monsterSliceStart + 2);
|
||||
const hostileNpcs = seedMonsterIds
|
||||
.map((monsterId: string) => buildHostileSceneNpc(buildCustomSceneId('landmark', index), WorldType.CUSTOM, monsterId))
|
||||
.map((monsterId: string) => buildHostileSceneNpc(runtimeSceneId, WorldType.CUSTOM, monsterId))
|
||||
.filter(Boolean) as SceneNpc[];
|
||||
const combinedNpcs = [...sceneNpcs, ...hostileNpcs];
|
||||
|
||||
return {
|
||||
id: buildCustomSceneId('landmark', index),
|
||||
id: runtimeSceneId,
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
worldType: WorldType.CUSTOM,
|
||||
@@ -521,7 +600,7 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
landmark.narrativeResidues && landmark.narrativeResidues.length > 0
|
||||
? landmark.narrativeResidues
|
||||
: buildSceneNarrativeResidues({
|
||||
sceneId: buildCustomSceneId('landmark', index),
|
||||
sceneId: runtimeSceneId,
|
||||
sceneName: landmark.name,
|
||||
profile,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user