Files
Genarrative/src/data/sceneEncounterPreviews.test.ts
高物 a9febe7678
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-28 10:57:40 +08:00

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 },
]);
});
});