Files
Genarrative/src/data/sceneEncounterPreviews.test.ts
2026-04-27 14:23:19 +08:00

499 lines
14 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 {
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.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');
});
});