init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,749 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../services/aiService', () => ({
generateNextStep: vi.fn(),
}));
const {
isRpgRuntimeServerFunctionIdMock,
} = vi.hoisted(() => ({
isRpgRuntimeServerFunctionIdMock: vi.fn(() => false),
}));
vi.mock('../../services/rpg-runtime', () => ({
isRpgRuntimeServerFunctionId: isRpgRuntimeServerFunctionIdMock,
}));
import { generateNextStep } from '../../services/aiService';
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
import { createStoryChoiceActions } from './choiceActions';
function createTestCharacter(): Character {
return {
id: 'test-hero',
name: '测试主角',
title: '游侠',
description: '一名测试用主角',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero-portrait.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 10,
spirit: 10,
},
personality: 'calm',
skills: [
{
id: 'skill-basic',
name: '试探一击',
animation: AnimationState.ATTACK,
damage: 10,
manaCost: 0,
cooldownTurns: 1,
range: 1,
style: 'steady',
},
],
adventureOpenings: {},
};
}
function createBaseState(): GameState {
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: createTestCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: true,
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {
'npc-opponent': {
affinity: 0,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: 'npc-opponent',
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}
function createBattleOption(functionId = 'battle_all_in_crush'): StoryOption {
return {
functionId,
actionText: '挥刀抢攻',
text: '挥刀抢攻',
visuals: {
playerAnimation: AnimationState.ATTACK,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
};
}
function createFallbackStory(text = 'fallback'): StoryMoment {
return {
text,
options: [],
};
}
const neverNpcEncounter = (
encounter: GameState['currentEncounter'],
): encounter is Encounter => false;
describe('createStoryChoiceActions', () => {
beforeEach(() => {
isRpgRuntimeServerFunctionIdMock.mockReset();
isRpgRuntimeServerFunctionIdMock.mockReturnValue(false);
});
it('reveals deferred adventure options when story_continue_adventure is selected', async () => {
const state = {
...createBaseState(),
inBattle: false,
sceneHostileNpcs: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
};
const deferredOptions = [
{
functionId: 'idle_explore_forward',
actionText: '继续向前探索',
text: '继续向前探索',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right' as const,
scrollWorld: false,
monsterChanges: [],
},
},
] satisfies StoryOption[];
const continueOption: StoryOption = {
functionId: 'story_continue_adventure',
actionText: '查看后续',
text: '查看后续',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right' as const,
scrollWorld: false,
monsterChanges: [],
},
};
const currentStory: StoryMoment = {
text: '对话已经完成',
options: [continueOption],
deferredOptions,
};
const setCurrentStory = vi.fn();
const generateStoryForState = vi.fn();
const handleNpcInteraction = vi.fn();
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory,
isLoading: false,
setGameState: vi.fn(),
setCurrentStory,
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState: vi.fn(),
playResolvedChoice: vi.fn(),
buildStoryContextFromState: vi.fn(),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState,
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn(
(inputState: GameState) => inputState.sceneHostileNpcs,
),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction,
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(
(option: StoryOption) =>
option.functionId === 'story_continue_adventure',
),
isCampTravelHomeOption: vi.fn(() => false),
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(continueOption);
expect(setCurrentStory).toHaveBeenCalledWith({
...currentStory,
options: deferredOptions,
deferredOptions: undefined,
});
expect(generateStoryForState).not.toHaveBeenCalled();
expect(handleNpcInteraction).not.toHaveBeenCalled();
});
it('applies deferred runtime state when story_continue_adventure reveals the next act', async () => {
const state = {
...createBaseState(),
inBattle: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
};
const deferredOptions = [
{
functionId: 'idle_observe_signs',
actionText: '观察下一幕的线索',
text: '观察下一幕的线索',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right' as const,
scrollWorld: false,
monsterChanges: [],
},
},
] satisfies StoryOption[];
const continueOption: StoryOption = {
functionId: 'story_continue_adventure',
actionText: '继续冒险',
text: '继续冒险',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right' as const,
scrollWorld: false,
monsterChanges: [],
},
};
const currentStory: StoryMoment = {
text: '对话已经完成',
options: [continueOption],
deferredOptions,
deferredRuntimeState: {
storyEngineMemory: {
discoveredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
currentSceneActState: {
sceneId: 'scene-bridge',
chapterId: 'scene-bridge-chapter',
currentActId: 'scene-bridge-act-2',
currentActIndex: 1,
completedActIds: ['scene-bridge-act-1'],
visitedActIds: ['scene-bridge-act-1', 'scene-bridge-act-2'],
},
},
},
};
const setCurrentStory = vi.fn();
const setGameState = vi.fn();
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory,
isLoading: false,
setGameState,
setCurrentStory,
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState: vi.fn(),
playResolvedChoice: vi.fn(),
buildStoryContextFromState: vi.fn(),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn(
(inputState: GameState) => inputState.sceneHostileNpcs,
),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(),
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(
(option: StoryOption) =>
option.functionId === 'story_continue_adventure',
),
isCampTravelHomeOption: vi.fn(() => false),
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(continueOption);
expect(setGameState).toHaveBeenCalledWith(
expect.objectContaining({
storyEngineMemory: expect.objectContaining({
currentSceneActState: expect.objectContaining({
currentActId: 'scene-bridge-act-2',
}),
}),
}),
);
expect(setCurrentStory).toHaveBeenCalledWith({
...currentStory,
options: deferredOptions,
deferredOptions: undefined,
deferredRuntimeState: undefined,
});
});
it('keeps npc chat choices on the local UI path so chat mode can continue streaming locally', async () => {
const state = createBaseState();
const option = createBattleOption('npc_chat');
const setGameState = vi.fn();
const setCurrentStory = vi.fn();
const handleNpcInteraction = vi.fn(() => true);
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
const { handleChoice } = createStoryChoiceActions({
gameState: {
...state,
currentEncounter: {
id: 'npc-opponent',
kind: 'npc',
npcName: '山道客',
npcDescription: '拦路的陌生人',
npcAvatar: '/npc.png',
context: '山道相遇',
},
npcInteractionActive: true,
inBattle: false,
sceneHostileNpcs: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
},
currentStory: createFallbackStory('当前故事'),
isLoading: false,
setGameState,
setCurrentStory,
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState: vi.fn(),
playResolvedChoice: vi.fn(),
buildStoryContextFromState: vi.fn(),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction,
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isRegularNpcEncounter: (encounter): encounter is Encounter =>
Boolean(encounter?.kind === 'npc'),
isNpcEncounter: (encounter): encounter is Encounter =>
Boolean(encounter?.kind === 'npc'),
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(option);
expect(handleNpcInteraction).toHaveBeenCalledWith(
expect.objectContaining({
functionId: 'npc_chat',
}),
);
expect(setGameState).not.toHaveBeenCalled();
expect(setCurrentStory).not.toHaveBeenCalled();
});
it('keeps npc trade choices on the local UI path so the trade modal can collect payload', async () => {
const state: GameState = {
...createBaseState(),
currentEncounter: {
id: 'npc-merchant',
kind: 'npc' as const,
npcName: '梁伯',
npcDescription: '沿街商贩',
npcAvatar: '/npc.png',
context: '沿街商贩',
},
npcInteractionActive: true,
inBattle: false,
sceneHostileNpcs: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
};
const option: StoryOption = {
functionId: 'npc_trade',
actionText: '交易',
text: '交易',
interaction: {
kind: 'npc' as const,
npcId: 'npc-merchant',
action: 'trade' as const,
},
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right' as const,
scrollWorld: false,
monsterChanges: [],
},
};
const handleNpcInteraction = vi.fn(() => true);
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory: createFallbackStory('当前故事'),
isLoading: false,
setGameState: vi.fn(),
setCurrentStory: vi.fn(),
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState: vi.fn(),
playResolvedChoice: vi.fn(),
buildStoryContextFromState: vi.fn(),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction,
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isRegularNpcEncounter: (encounter): encounter is Encounter =>
Boolean(encounter?.kind === 'npc'),
isNpcEncounter: (encounter): encounter is Encounter =>
Boolean(encounter?.kind === 'npc'),
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(option);
expect(handleNpcInteraction).toHaveBeenCalledWith(option);
});
it('reopens npc chat instead of running generic follow-up after local npc victory', async () => {
const encounter: Encounter = {
id: 'npc-opponent',
kind: 'npc',
npcName: '山道客',
npcDescription: '拦路旧敌',
npcAvatar: '/npc.png',
context: '山道旧案',
};
const state = {
...createBaseState(),
currentEncounter: encounter,
npcInteractionActive: true,
};
const option = createBattleOption();
const afterSequence = {
...state,
inBattle: false,
sceneHostileNpcs: [],
currentNpcBattleOutcome: 'fight_victory' as const,
};
const generateStoryForState = vi.fn().mockResolvedValue(createFallbackStory('战后续写'));
const setCurrentStory = vi.fn();
const setGameState = vi.fn();
const handleNpcBattleConversationContinuation = vi.fn(() => true);
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory: createFallbackStory(),
isLoading: false,
setGameState,
setCurrentStory,
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState: vi.fn(() => ({
optionKind: 'battle' as const,
battlePlan: null,
afterSequence,
})),
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
buildStoryContextFromState: vi.fn(() => ({
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
})),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState,
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation,
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => ({
nextState: {
...afterSequence,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
inBattle: false,
},
resultText: '山道客已经败下阵来。胜利奖励:无战利品。',
})),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(option);
expect(handleNpcBattleConversationContinuation).toHaveBeenCalledWith(
expect.objectContaining({
nextState: expect.objectContaining({
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
}),
encounter,
actionText: '挥刀抢攻',
resultText: '山道客已经败下阵来。胜利奖励:无战利品。',
battleMode: 'fight',
}),
);
expect(generateStoryForState).not.toHaveBeenCalled();
expect(setCurrentStory).not.toHaveBeenCalledWith(
createFallbackStory('战后续写'),
);
});
it('injects an escape resolution into the immediate story context before ai continuation', async () => {
const mockedGenerateNextStep = vi.mocked(generateNextStep);
mockedGenerateNextStep.mockResolvedValue({
storyText: '你落到山道外侧,呼吸总算稳了下来。',
options: [],
});
const state = {
...createBaseState(),
currentBattleNpcId: null,
currentNpcBattleMode: null,
sceneHostileNpcs: [
{
id: 'wolf-1',
name: '山狼',
action: '低伏逼近',
description: '一头山狼',
animation: 'idle' as const,
xMeters: 3.2,
yOffset: 0,
facing: 'left' as const,
attackRange: 1.4,
speed: 7,
hp: 10,
maxHp: 10,
renderKind: 'npc' as const,
},
],
};
const option = createBattleOption('battle_escape_breakout');
const afterSequence = {
...state,
inBattle: false,
sceneHostileNpcs: [],
playerX: -1.2,
};
const setBattleReward = vi.fn();
const incrementRuntimeStats = vi.fn((inputState: GameState) => inputState);
const buildStoryContextFromState = vi.fn(() => ({
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
inBattle: false,
playerX: -1.2,
playerFacing: 'right' as const,
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
}));
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory: createFallbackStory(),
isLoading: false,
setGameState: vi.fn(),
setCurrentStory: vi.fn(),
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward,
buildResolvedChoiceState: vi.fn(() => ({
optionKind: 'escape' as const,
battlePlan: null,
afterSequence,
})),
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
buildStoryContextFromState,
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats,
getCampCompanionTravelScene: vi.fn(() => null),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(option);
expect(mockedGenerateNextStep).toHaveBeenCalledTimes(1);
const history = mockedGenerateNextStep.mock.calls[0]?.[3] as StoryMoment[];
expect(history.map((entry) => `${entry.historyRole}:${entry.text}`)).toEqual([
'action:挥刀抢攻',
'result:你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
]);
expect(buildStoryContextFromState).toHaveBeenCalledWith(
expect.objectContaining({
inBattle: false,
sceneHostileNpcs: [],
}),
expect.objectContaining({
lastFunctionId: 'battle_escape_breakout',
recentActionResult: '你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
}),
);
expect(setBattleReward).toHaveBeenCalledTimes(1);
expect(setBattleReward).toHaveBeenCalledWith(null);
expect(incrementRuntimeStats).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ hostileNpcsDefeated: 0 }),
);
});
});