868 lines
24 KiB
TypeScript
868 lines
24 KiB
TypeScript
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 {
|
|
buildNpcBattleFormationFromEncounter,
|
|
createSceneEncounterPreview,
|
|
hasAutoBattleSceneEncounter,
|
|
resolveSceneEncounterPreview,
|
|
} from './sceneEncounterPreviews';
|
|
|
|
function createCharacter(): Character {
|
|
return {
|
|
id: 'hero',
|
|
name: 'Hero',
|
|
title: 'Wanderer',
|
|
description: 'A reliable test hero.',
|
|
backstory: 'Travels the land.',
|
|
avatar: '/hero.png',
|
|
portrait: '/hero-portrait.png',
|
|
assetFolder: 'hero',
|
|
assetVariant: 'default',
|
|
attributes: {
|
|
strength: 10,
|
|
agility: 9,
|
|
intelligence: 8,
|
|
spirit: 7,
|
|
},
|
|
personality: 'steady',
|
|
skills: [],
|
|
adventureOpenings: {},
|
|
};
|
|
}
|
|
|
|
function createEncounter(): Encounter {
|
|
return {
|
|
id: 'npc-trader',
|
|
kind: 'npc',
|
|
npcName: 'Trader Lin',
|
|
npcDescription: 'A traveling merchant.',
|
|
npcAvatar: 'T',
|
|
context: 'merchant',
|
|
initialAffinity: 12,
|
|
hostile: false,
|
|
};
|
|
}
|
|
|
|
function createBaseState(): GameState {
|
|
const encounter = createEncounter();
|
|
|
|
return {
|
|
worldType: WorldType.WUXIA,
|
|
customWorldProfile: null,
|
|
playerCharacter: createCharacter(),
|
|
runtimeStats: {
|
|
playTimeMs: 0,
|
|
lastPlayTickAt: null,
|
|
hostileNpcsDefeated: 0,
|
|
questsAccepted: 0,
|
|
itemsUsed: 0,
|
|
scenesTraveled: 0,
|
|
},
|
|
currentScene: 'Story',
|
|
storyHistory: [],
|
|
characterChats: {},
|
|
animationState: AnimationState.IDLE,
|
|
currentEncounter: encounter,
|
|
npcInteractionActive: false,
|
|
currentScenePreset: {
|
|
id: 'scene-1',
|
|
name: 'Trail',
|
|
description: 'A mountain trail.',
|
|
imageSrc: '/trail.png',
|
|
connectedSceneIds: [],
|
|
npcs: [],
|
|
treasureHints: [],
|
|
},
|
|
sceneHostileNpcs: [],
|
|
playerX: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
playerActionMode: 'idle',
|
|
scrollWorld: false,
|
|
inBattle: false,
|
|
playerHp: 100,
|
|
playerMaxHp: 100,
|
|
playerMana: 20,
|
|
playerMaxMana: 20,
|
|
playerSkillCooldowns: {},
|
|
activeCombatEffects: [],
|
|
playerCurrency: 0,
|
|
playerInventory: [],
|
|
playerEquipment: {
|
|
weapon: null,
|
|
armor: null,
|
|
relic: null,
|
|
},
|
|
npcStates: {
|
|
[encounter.id!]: {
|
|
...buildInitialNpcState(encounter, WorldType.WUXIA),
|
|
affinity: -5,
|
|
},
|
|
},
|
|
quests: [],
|
|
roster: [],
|
|
companions: [],
|
|
currentBattleNpcId: null,
|
|
currentNpcBattleMode: null,
|
|
currentNpcBattleOutcome: null,
|
|
sparReturnEncounter: null,
|
|
sparPlayerHpBefore: null,
|
|
sparPlayerMaxHpBefore: null,
|
|
sparStoryHistoryBefore: null,
|
|
};
|
|
}
|
|
|
|
describe('sceneEncounterPreviews', () => {
|
|
it('treats negative-affinity npc encounters as immediate battles', () => {
|
|
const state = createBaseState();
|
|
|
|
expect(hasAutoBattleSceneEncounter(state)).toBe(true);
|
|
|
|
const resolved = resolveSceneEncounterPreview(state);
|
|
|
|
expect(resolved.inBattle).toBe(true);
|
|
expect(resolved.currentEncounter).toBeNull();
|
|
expect(resolved.currentBattleNpcId).toBe('npc-trader');
|
|
expect(resolved.currentNpcBattleMode).toBe('fight');
|
|
expect(resolved.sparReturnEncounter).toEqual(state.currentEncounter);
|
|
expect(resolved.sceneHostileNpcs).toHaveLength(1);
|
|
expect(resolved.sceneHostileNpcs[0]?.encounter?.npcName).toBe('Trader Lin');
|
|
});
|
|
|
|
it('attaches npc encounter metadata to regular monsters', () => {
|
|
const monsterId = getMonsterPresetsByWorld(WorldType.WUXIA)[0]?.id;
|
|
if (!monsterId) {
|
|
throw new Error('Expected at least one monster preset');
|
|
}
|
|
|
|
const monster = createSceneHostileNpc(WorldType.WUXIA, monsterId);
|
|
|
|
expect(monster).not.toBeNull();
|
|
expect(monster?.encounter?.kind).toBe('npc');
|
|
expect(monster?.encounter?.monsterPresetId).toBe(monsterId);
|
|
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');
|
|
});
|
|
|
|
it('builds active act npc battle formations with stable back-row slots', () => {
|
|
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: [
|
|
{
|
|
id: 'npc-front',
|
|
name: '正面对手',
|
|
title: '刀客',
|
|
description: '正面对手',
|
|
initialAffinity: -30,
|
|
imageSrc: '',
|
|
role: '敌对角色',
|
|
backstory: '',
|
|
personality: '',
|
|
motivation: '',
|
|
combatStyle: '',
|
|
relationshipHooks: [],
|
|
tags: [],
|
|
skills: [],
|
|
initialItems: [],
|
|
},
|
|
{
|
|
id: 'npc-back-1',
|
|
name: '后排甲',
|
|
title: '弓手',
|
|
description: '后排甲',
|
|
initialAffinity: -25,
|
|
imageSrc: '',
|
|
role: '敌对角色',
|
|
backstory: '',
|
|
personality: '',
|
|
motivation: '',
|
|
combatStyle: '',
|
|
relationshipHooks: [],
|
|
tags: [],
|
|
skills: [],
|
|
initialItems: [],
|
|
},
|
|
{
|
|
id: 'npc-back-2',
|
|
name: '后排乙',
|
|
title: '术士',
|
|
description: '后排乙',
|
|
initialAffinity: -20,
|
|
imageSrc: '',
|
|
role: '敌对角色',
|
|
backstory: '',
|
|
personality: '',
|
|
motivation: '',
|
|
combatStyle: '',
|
|
relationshipHooks: [],
|
|
tags: [],
|
|
skills: [],
|
|
initialItems: [],
|
|
},
|
|
],
|
|
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,
|
|
currentScenePreset: {
|
|
id: 'landmark-raw-1',
|
|
name: '旧桥',
|
|
description: '旧桥',
|
|
imageSrc: '/bridge.png',
|
|
connectedSceneIds: [],
|
|
treasureHints: [],
|
|
npcs: [
|
|
{
|
|
id: 'npc-front',
|
|
name: '正面对手',
|
|
description: '正面对手',
|
|
avatar: '正',
|
|
role: '敌对角色',
|
|
initialAffinity: -30,
|
|
hostile: true,
|
|
attributeProfile: {
|
|
attributes: {},
|
|
combat: {
|
|
maxHp: 96,
|
|
attack: 12,
|
|
defense: 8,
|
|
speed: 10,
|
|
},
|
|
} as SceneNpc['attributeProfile'],
|
|
},
|
|
{
|
|
id: 'npc-back-1',
|
|
name: '后排甲',
|
|
description: '后排甲',
|
|
avatar: '甲',
|
|
role: '敌对角色',
|
|
initialAffinity: -25,
|
|
hostile: true,
|
|
attributeProfile: {
|
|
attributes: {},
|
|
combat: {
|
|
maxHp: 82,
|
|
attack: 10,
|
|
defense: 6,
|
|
speed: 9,
|
|
},
|
|
} as SceneNpc['attributeProfile'],
|
|
},
|
|
{
|
|
id: 'npc-back-2',
|
|
name: '后排乙',
|
|
description: '后排乙',
|
|
avatar: '乙',
|
|
role: '敌对角色',
|
|
initialAffinity: -20,
|
|
hostile: true,
|
|
attributeProfile: {
|
|
attributes: {},
|
|
combat: {
|
|
maxHp: 78,
|
|
attack: 9,
|
|
defense: 5,
|
|
speed: 11,
|
|
},
|
|
} as SceneNpc['attributeProfile'],
|
|
},
|
|
] satisfies SceneNpc[],
|
|
},
|
|
currentEncounter: {
|
|
id: 'npc-front',
|
|
kind: 'npc',
|
|
npcName: '正面对手',
|
|
npcDescription: '正面对手',
|
|
npcAvatar: '正',
|
|
context: '敌对角色',
|
|
hostile: true,
|
|
initialAffinity: -30,
|
|
xMeters: 3.2,
|
|
},
|
|
npcStates: {
|
|
'npc-front': {
|
|
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
|
|
affinity: -30,
|
|
},
|
|
'npc-back-1': {
|
|
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
|
|
affinity: -25,
|
|
},
|
|
'npc-back-2': {
|
|
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
|
|
affinity: -20,
|
|
},
|
|
},
|
|
} satisfies GameState;
|
|
|
|
const formation = buildNpcBattleFormationFromEncounter({
|
|
state,
|
|
encounter: state.currentEncounter!,
|
|
});
|
|
|
|
expect(formation).toHaveLength(3);
|
|
expect(formation.map((monster) => monster.encounter?.id)).toEqual([
|
|
'npc-front',
|
|
'npc-back-1',
|
|
'npc-back-2',
|
|
]);
|
|
expect(
|
|
formation.map((monster) => ({
|
|
id: monster.encounter?.id,
|
|
xMeters: monster.xMeters,
|
|
yOffset: monster.yOffset,
|
|
})),
|
|
).toEqual([
|
|
{ id: 'npc-front', xMeters: 3.2, yOffset: 0 },
|
|
{ id: 'npc-back-1', xMeters: 4.28, yOffset: 62 },
|
|
{ id: 'npc-back-2', xMeters: 4.28, yOffset: -46 },
|
|
]);
|
|
});
|
|
|
|
it('keeps scene-act formation order even when the clicked encounter comes from the back row', () => {
|
|
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-front',
|
|
oppositeNpcId: 'npc-front',
|
|
eventDescription: '',
|
|
linkedThreadIds: [],
|
|
advanceRule: 'after_primary_contact',
|
|
actGoal: '',
|
|
transitionHook: '',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
} as CustomWorldProfile,
|
|
currentScenePreset: {
|
|
id: 'landmark-raw-1',
|
|
name: '海底遗址',
|
|
description: '海底遗址',
|
|
imageSrc: '/underwater.png',
|
|
connectedSceneIds: [],
|
|
treasureHints: [],
|
|
npcs: [
|
|
{
|
|
id: 'npc-front',
|
|
name: '珊瑚祭司',
|
|
description: '前排祭司',
|
|
avatar: '祭',
|
|
role: '敌对角色',
|
|
initialAffinity: -20,
|
|
hostile: true,
|
|
},
|
|
{
|
|
id: 'npc-back-1',
|
|
name: '赤发护卫',
|
|
description: '后排护卫',
|
|
avatar: '卫',
|
|
role: '敌对角色',
|
|
initialAffinity: -20,
|
|
hostile: true,
|
|
},
|
|
{
|
|
id: 'npc-back-2',
|
|
name: '潮歌侍从',
|
|
description: '后排侍从',
|
|
avatar: '侍',
|
|
role: '敌对角色',
|
|
initialAffinity: -20,
|
|
hostile: true,
|
|
},
|
|
] satisfies SceneNpc[],
|
|
},
|
|
currentEncounter: {
|
|
id: 'npc-back-1',
|
|
kind: 'npc',
|
|
npcName: '赤发护卫',
|
|
npcDescription: '后排护卫',
|
|
npcAvatar: '卫',
|
|
context: '敌对角色',
|
|
hostile: true,
|
|
initialAffinity: -20,
|
|
xMeters: 4.28,
|
|
},
|
|
npcStates: {
|
|
'npc-front': {
|
|
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
|
|
affinity: -20,
|
|
},
|
|
'npc-back-1': {
|
|
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
|
|
affinity: -20,
|
|
},
|
|
'npc-back-2': {
|
|
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
|
|
affinity: -20,
|
|
},
|
|
},
|
|
} satisfies GameState;
|
|
|
|
const formation = buildNpcBattleFormationFromEncounter({
|
|
state,
|
|
encounter: state.currentEncounter!,
|
|
});
|
|
|
|
expect(
|
|
formation.map((monster) => ({
|
|
id: monster.encounter?.id,
|
|
xMeters: monster.xMeters,
|
|
yOffset: monster.yOffset,
|
|
})),
|
|
).toEqual([
|
|
{ id: 'npc-front', xMeters: 3.2, yOffset: 0 },
|
|
{ id: 'npc-back-1', xMeters: 4.28, yOffset: 62 },
|
|
{ id: 'npc-back-2', xMeters: 4.28, yOffset: -46 },
|
|
]);
|
|
});
|
|
});
|