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,424 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
}));
vi.mock('../apiClient', async () => {
const actual =
await vi.importActual<typeof import('../apiClient')>('../apiClient');
return {
...actual,
requestJson: requestJsonMock,
};
});
import { AnimationState } from '../../types';
import {
buildStoryMomentFromRuntimeOptions,
getRpgRuntimeClientVersion,
getRpgRuntimeSessionId,
getRpgRuntimeStoryState,
isRpgRuntimeServerFunctionId,
isRpgRuntimeTaskFunctionId,
resolveRpgRuntimeStoryAction,
resolveRpgRuntimeStoryMoment,
shouldUseRpgRuntimeServerOptions,
} from './rpgRuntimeStoryClient';
describe('rpgRuntimeStoryClient', () => {
beforeEach(() => {
requestJsonMock.mockReset();
});
it('builds runtime action requests against the dedicated story endpoint', async () => {
requestJsonMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 2,
viewModel: {},
presentation: {
actionText: '继续交谈',
resultText: '后端已结算',
storyText: '后端已结算',
options: [],
},
patches: [],
snapshot: {
version: 2,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {},
currentStory: null,
},
});
await resolveRpgRuntimeStoryAction({
sessionId: 'runtime-custom',
clientVersion: 9,
option: {
functionId: 'npc_chat',
actionText: '继续交谈',
},
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/story/actions/resolve',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-custom',
clientVersion: 9,
action: {
type: 'story_choice',
functionId: 'npc_chat',
targetId: undefined,
payload: {
optionText: '继续交谈',
},
},
snapshot: undefined,
}),
}),
'执行运行时动作失败',
expect.any(Object),
);
});
it('merges custom runtime payload fields into the action request body', async () => {
requestJsonMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 3,
viewModel: {},
presentation: {
actionText: '使用凝神灵液',
resultText: '后端已结算物品使用',
storyText: '后端已结算物品使用',
options: [],
},
patches: [],
snapshot: {
version: 3,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {},
currentStory: null,
},
});
await resolveRpgRuntimeStoryAction({
option: {
functionId: 'inventory_use',
actionText: '使用凝神灵液',
},
payload: {
itemId: 'focus-tonic',
},
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/story/actions/resolve',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
clientVersion: undefined,
action: {
type: 'story_choice',
functionId: 'inventory_use',
targetId: undefined,
payload: {
optionText: '使用凝神灵液',
itemId: 'focus-tonic',
},
},
snapshot: undefined,
}),
}),
'执行运行时动作失败',
expect.any(Object),
);
});
it('submits runtime state resolution with snapshot context to the server', async () => {
requestJsonMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 4,
viewModel: {
player: {
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
},
encounter: null,
companions: [],
availableOptions: [],
status: {
inBattle: false,
npcInteractionActive: false,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '',
resultText: '',
storyText: '服务端故事',
options: [],
},
patches: [],
snapshot: {
version: 2,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {},
currentStory: null,
},
});
await getRpgRuntimeStoryState({
sessionId: 'runtime-main',
clientVersion: 7,
snapshot: {
gameState: { currentScene: 'Story' } as never,
bottomTab: 'adventure',
currentStory: {
text: '本地故事',
options: [],
} as never,
},
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/story/state/resolve',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
clientVersion: 7,
snapshot: {
gameState: {
currentScene: 'Story',
},
bottomTab: 'adventure',
currentStory: {
text: '本地故事',
options: [],
},
},
}),
}),
'读取运行时故事状态失败',
expect.any(Object),
);
});
it('keeps disabled runtime options when rebuilding a story moment', () => {
const story = buildStoryMomentFromRuntimeOptions({
storyText: '服务端返回的新故事',
options: [
{
functionId: 'npc_chat',
actionText: '继续交谈',
scope: 'npc',
},
{
functionId: 'npc_recruit',
actionText: '邀请加入队伍',
scope: 'npc',
disabled: true,
reason: '队伍已满',
},
],
});
expect(story.text).toBe('服务端返回的新故事');
expect(story.options).toHaveLength(2);
expect(story.options[0]?.functionId).toBe('npc_chat');
expect(story.options[1]?.functionId).toBe('npc_recruit');
expect(story.options[1]?.disabled).toBe(true);
expect(story.options[1]?.disabledReason).toBe('队伍已满');
});
it('recognizes server-runtime option pools for server-side legality checks', () => {
expect(isRpgRuntimeTaskFunctionId('npc_chat')).toBe(true);
expect(isRpgRuntimeTaskFunctionId('battle_attack_basic')).toBe(true);
expect(isRpgRuntimeTaskFunctionId('npc_trade')).toBe(false);
expect(isRpgRuntimeServerFunctionId('npc_trade')).toBe(true);
expect(isRpgRuntimeServerFunctionId('unknown_action')).toBe(false);
expect(
shouldUseRpgRuntimeServerOptions([
{
functionId: 'npc_chat',
actionText: '继续交谈',
text: '继续交谈',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
]),
).toBe(true);
expect(
shouldUseRpgRuntimeServerOptions([
{
functionId: 'npc_trade',
actionText: '交易',
text: '交易',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
]),
).toBe(true);
expect(
shouldUseRpgRuntimeServerOptions([
{
functionId: 'unknown_action',
actionText: '未知动作',
text: '未知动作',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
]),
).toBe(false);
expect(getRpgRuntimeSessionId({ runtimeSessionId: '' })).toBe(
'runtime-main',
);
expect(getRpgRuntimeClientVersion({ runtimeActionVersion: 3 })).toBe(3);
});
it('preserves runtime option interaction metadata from the server response', () => {
const story = buildStoryMomentFromRuntimeOptions({
storyText: '服务端返回的新故事',
options: [
{
functionId: 'npc_trade',
actionText: '交易',
scope: 'npc',
interaction: {
kind: 'npc',
npcId: 'npc-merchant',
action: 'trade',
},
},
],
});
expect(story.options[0]?.interaction).toEqual({
kind: 'npc',
npcId: 'npc-merchant',
action: 'trade',
});
});
it('prefers the richer snapshot story when the server persisted dialogue mode', () => {
const story = resolveRpgRuntimeStoryMoment({
response: {
sessionId: 'runtime-main',
serverVersion: 4,
viewModel: {
player: { hp: 10, maxHp: 10, mana: 5, maxMana: 5 },
encounter: null,
companions: [],
availableOptions: [],
status: {
inBattle: false,
npcInteractionActive: true,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '继续交谈',
resultText: '后端已结算',
storyText: '普通文本',
options: [],
battle: null,
toast: null,
},
patches: [],
snapshot: {
version: 2,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {} as never,
currentStory: {
text: '你:先把话说开。\n梁伯那我就直说了。',
options: [],
displayMode: 'dialogue',
dialogue: [
{ speaker: 'player', text: '先把话说开。' },
{ speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' },
],
deferredOptions: [
{
functionId: 'npc_chat',
actionText: '继续交谈',
text: '继续交谈',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
],
},
} as never,
},
hydratedSnapshot: {
version: 2,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {} as never,
currentStory: {
text: '你:先把话说开。\n梁伯那我就直说了。',
options: [],
displayMode: 'dialogue',
dialogue: [
{ speaker: 'player', text: '先把话说开。' },
{ speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' },
],
deferredOptions: [
{
functionId: 'npc_chat',
actionText: '继续交谈',
text: '继续交谈',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
],
},
} as never,
fallbackStoryText: '普通文本',
});
expect(story.displayMode).toBe('dialogue');
expect(story.deferredOptions).toHaveLength(1);
expect(story.text).toContain('梁伯');
});
});