This commit is contained in:
2026-04-27 14:23:19 +08:00
parent 09d3fe59b3
commit fa2dbb310b
75 changed files with 7363 additions and 1487 deletions

View File

@@ -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');
});
});