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,425 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
rollHostileNpcLootMock,
resolveServerRuntimeChoiceMock,
} = vi.hoisted(() => ({
rollHostileNpcLootMock: vi.fn(),
resolveServerRuntimeChoiceMock: vi.fn(),
}));
vi.mock('../../data/hostileNpcPresets', async () => {
const actual =
await vi.importActual<typeof import('../../data/hostileNpcPresets')>(
'../../data/hostileNpcPresets',
);
return {
...actual,
rollHostileNpcLoot: rollHostileNpcLootMock,
};
});
vi.mock('.', () => ({
resolveRpgRuntimeChoice: resolveServerRuntimeChoiceMock,
}));
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
import {
buildCombatResolutionContextText,
buildHostileNpcBattleReward,
buildReasonedOptionCatalog,
runServerRuntimeChoiceAction,
shouldOpenLocalRuntimeNpcModal,
} from './storyChoiceRuntime';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '测试角色',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 10,
spirit: 10,
},
personality: 'calm',
skills: [],
adventureOpenings: {},
} as unknown as Character;
}
function createStory(text: string): StoryMoment {
return {
text,
options: [],
};
}
function createOption(
functionId: string,
interaction?: StoryOption['interaction'],
): StoryOption {
return {
functionId,
actionText: functionId,
text: functionId,
interaction,
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} as StoryOption;
}
function createState(overrides: Partial<GameState> = {}): GameState {
return {
worldType: 'WUXIA',
customWorldProfile: null,
playerCharacter: createCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: 'idle',
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
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: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
...overrides,
} as GameState;
}
describe('storyChoiceRuntime', () => {
beforeEach(() => {
rollHostileNpcLootMock.mockReset();
resolveServerRuntimeChoiceMock.mockReset();
});
it('deduplicates option catalogs by function id for post-battle recovery', () => {
const options = buildReasonedOptionCatalog([
createOption('npc_chat'),
createOption('npc_chat'),
createOption('npc_help'),
]);
expect(options.map((option) => option.functionId)).toEqual([
'npc_chat',
'npc_help',
]);
});
it('keeps npc chat, trade and gift on the local runtime npc interaction path', () => {
expect(
shouldOpenLocalRuntimeNpcModal(
createOption('npc_chat', {
kind: 'npc',
npcId: 'npc-friend',
action: 'chat',
}),
),
).toBe(true);
expect(
shouldOpenLocalRuntimeNpcModal(
createOption('npc_trade', {
kind: 'npc',
npcId: 'npc-merchant',
action: 'trade',
}),
),
).toBe(true);
expect(
shouldOpenLocalRuntimeNpcModal(
createOption('npc_gift', {
kind: 'npc',
npcId: 'npc-friend',
action: 'gift',
}),
),
).toBe(true);
expect(
shouldOpenLocalRuntimeNpcModal(createOption('idle_explore_forward')),
).toBe(false);
});
it('builds escape and victory context text for local battle resolution', () => {
const baseState = createState({
inBattle: true,
sceneHostileNpcs: [
{ id: 'wolf', name: '山狼' },
] as GameState['sceneHostileNpcs'],
});
expect(
buildCombatResolutionContextText({
baseState,
afterSequence: {
...baseState,
inBattle: false,
sceneHostileNpcs: [],
},
optionKind: 'escape',
projectedBattleReward: null,
getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs,
}),
).toContain('你已成功逃脱');
expect(
buildCombatResolutionContextText({
baseState: {
...baseState,
currentBattleNpcId: null,
},
afterSequence: {
...baseState,
inBattle: false,
sceneHostileNpcs: [],
},
optionKind: 'battle',
projectedBattleReward: {
id: 'reward-1',
defeatedHostileNpcs: [{ id: 'wolf', name: '山狼' }],
items: [
{ id: 'loot-1', category: '材料', name: '狼牙', quantity: 1, rarity: 'common', tags: [] },
],
},
getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs,
}),
).toContain('战利品:狼牙。');
});
it('builds defeated hostile rewards from locally resolved battle states', async () => {
rollHostileNpcLootMock.mockResolvedValue([
{
id: 'loot-1',
category: '材料',
name: '狼牙',
quantity: 1,
rarity: 'common',
tags: [],
},
]);
const reward = await buildHostileNpcBattleReward(
createState({
inBattle: true,
sceneHostileNpcs: [
{ id: 'wolf', name: '山狼' },
] as GameState['sceneHostileNpcs'],
currentBattleNpcId: null,
}),
createState({
inBattle: false,
sceneHostileNpcs: [],
}),
'battle',
(state) => state.sceneHostileNpcs,
);
expect(rollHostileNpcLootMock).toHaveBeenCalledTimes(1);
expect(reward?.items[0]).toEqual(
expect.objectContaining({
name: '狼牙',
}),
);
});
it('applies server runtime responses and falls back locally when the request fails', async () => {
const gameState = createState();
const currentStory = createStory('当前故事');
const setBattleReward = vi.fn();
const setAiError = vi.fn();
const setIsLoading = vi.fn();
const setGameState = vi.fn();
const setCurrentStory = vi.fn();
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
hydratedSnapshot: {
gameState: {
...gameState,
runtimeActionVersion: 3,
},
},
nextStory: createStory('服务端故事'),
});
await runServerRuntimeChoiceAction({
gameState,
currentStory,
option: createOption('npc_chat'),
character: createCharacter(),
setBattleReward,
setAiError,
setIsLoading,
setGameState,
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
buildFallbackStoryForState: () => createStory('fallback'),
});
expect(setGameState).toHaveBeenCalledWith(
expect.objectContaining({
runtimeActionVersion: 3,
}),
);
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: '服务端故事',
}),
);
resolveServerRuntimeChoiceMock.mockRejectedValueOnce(new Error('boom'));
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
await runServerRuntimeChoiceAction({
gameState,
currentStory: null,
option: createOption('npc_chat'),
character: createCharacter(),
setBattleReward,
setAiError,
setIsLoading,
setGameState,
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
buildFallbackStoryForState: () => createStory('fallback'),
});
} finally {
consoleErrorSpy.mockRestore();
}
expect(setAiError).toHaveBeenCalledWith('boom');
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: 'fallback',
}),
);
});
it('plays server battle presentation before committing the hydrated snapshot', async () => {
const gameState = createState({
inBattle: true,
playerHp: 30,
playerMana: 10,
sceneHostileNpcs: [
{
id: 'wolf',
name: '山狼',
action: '逼近',
description: '山狼',
animation: 'idle',
xMeters: 3,
yOffset: 0,
facing: 'left',
attackRange: 1,
speed: 1,
hp: 18,
maxHp: 18,
},
],
});
const finalState = createState({
...gameState,
inBattle: false,
playerHp: 26,
sceneHostileNpcs: [],
});
const setGameState = vi.fn();
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
response: {
presentation: {
battle: {
targetId: 'wolf',
damageDealt: 18,
damageTaken: 4,
outcome: 'victory',
},
resultText: '山狼被你压制下去。',
},
},
hydratedSnapshot: {
gameState: finalState,
},
nextStory: createStory('服务端故事'),
});
await runServerRuntimeChoiceAction({
gameState,
currentStory: createStory('当前故事'),
option: createOption('battle_attack_basic'),
character: createCharacter(),
setBattleReward: vi.fn(),
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setGameState,
setCurrentStory: vi.fn() as (story: StoryMoment) => void,
buildFallbackStoryForState: () => createStory('fallback'),
turnVisualMs: 1,
});
expect(setGameState).toHaveBeenCalledWith(
expect.objectContaining({
animationState: 'idle',
playerHp: 26,
sceneHostileNpcs: expect.arrayContaining([
expect.objectContaining({
id: 'wolf',
hp: 0,
animation: 'die',
}),
]),
}),
);
expect(setGameState).toHaveBeenLastCalledWith(finalState);
});
});