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,381 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useMemo } from 'react';
import { afterEach, expect, test } from 'vitest';
import {
buildCustomWorldPlayableCharacters,
setRuntimeCharacterOverrides,
} from '../data/characterPresets';
import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary';
import { WorldType } from '../types';
import { useRpgSessionBootstrap } from './rpg-session';
function buildBackstoryReveal(label: string) {
return {
publicSummary: `${label}的公开背景`,
privateChatUnlockAffinity: 40,
chapters: [
{
id: `${label}-surface`,
title: '表层来意',
affinityRequired: 15,
teaser: `${label}先只肯说表面的来意。`,
content: `${label}表面上只愿意谈当前局势。`,
contextSnippet: `${label}表面上还在收着话。`,
},
{
id: `${label}-scar`,
title: '旧事裂痕',
affinityRequired: 30,
teaser: `${label}背后还有一段旧伤。`,
content: `${label}曾在旧案里留下无法轻易揭开的伤口。`,
contextSnippet: `${label}和旧案之间有一段没说开的裂痕。`,
},
{
id: `${label}-hidden`,
title: '隐藏执念',
affinityRequired: 60,
teaser: `${label}真正想追的不是表面那件事。`,
content: `${label}真正挂着的是旧案里还没结的那条线。`,
contextSnippet: `${label}真正执念指向旧案深处。`,
},
{
id: `${label}-final`,
title: '最终底牌',
affinityRequired: 90,
teaser: `${label}手里还压着最后一张牌。`,
content: `${label}手里还握着能直接证明真相的关键证据。`,
contextSnippet: `${label}最后的底牌足以改写局势。`,
},
],
};
}
function buildSavedProfile() {
const profile = normalizeCustomWorldProfileRecord({
id: 'saved-runtime-profile',
settingText: '被海雾吞没的旧航路群岛',
name: '回潮群岛',
subtitle: '旧灯塔与断续潮路',
summary: '围绕旧灯塔、假航灯和沉船旧案展开的结果页世界。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船夜与封航记录被改动的真相。',
templateWorldType: WorldType.WUXIA,
compatibilityTemplateWorldType: WorldType.WUXIA,
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['封航争夺', '沉船真相'],
attributeSchema: {
id: 'schema:test',
worldId: 'CUSTOM',
schemaVersion: 1,
schemaName: '测试',
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '回潮群岛',
settingSummary: '潮雾旧航路',
tone: '压抑',
conflictCore: '沉船真相',
},
slots: [],
},
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '旧航路引路人',
role: '关键同行者',
description: '最熟悉旧潮路的人。',
backstory: '他在沉船夜里带着半支船队逃出过假航灯。',
personality: '表面沉稳,心里一直在算退路。',
motivation: '想赶在封航前查清真相。',
combatStyle: '借潮路换位,先拉扯再压近。',
initialAffinity: 18,
relationshipHooks: ['旧友', '沉船旧案'],
tags: ['潮路', '引路'],
backstoryReveal: buildBackstoryReveal('沈砺'),
skills: [
{
id: 'skill-playable-1',
name: '潮行引路',
summary: '踩着旧潮阶切线前压,替队伍打开角度。',
style: '机动周旋',
},
{
id: 'skill-playable-2',
name: '回雾折返',
summary: '借海雾遮住身位,再从侧线拉开。',
style: '起手压制',
},
{
id: 'skill-playable-3',
name: '旧图定标',
summary: '用旧潮图锁定退路和突入口。',
style: '爆发终结',
},
],
initialItems: [
{
id: 'item-playable-1',
name: '旧潮短刃',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '专门在湿滑甲板上近身换位用的短刃。',
tags: ['潮路', '近战'],
},
{
id: 'item-playable-2',
name: '雾盐药包',
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '压住寒潮后遗症的随身药包。',
tags: ['补给'],
},
{
id: 'item-playable-3',
name: '旧潮图残页',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '足够指向沉船夜另一条线的残页。',
tags: ['线索', '真相'],
},
],
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
title: '守灯会值夜人',
role: '场景关键角色',
description: '夜里巡灯与封锁禁航区的人。',
backstory: '她在第一次海雾吞船那夜守到了最后一盏灯。',
personality: '冷静克制,但提到旧灯册会明显变调。',
motivation: '想守住灯塔记录,也想找到谁先改动了禁航信号。',
combatStyle: '借塔顶视角与风向压制,再用灯火错位扰乱。',
initialAffinity: 8,
relationshipHooks: ['禁航记录', '灯塔值夜'],
tags: ['守灯会', '灯塔'],
backstoryReveal: buildBackstoryReveal('顾潮音'),
skills: [
{
id: 'skill-story-1',
name: '夜潮灯语',
summary: '借灯语与潮声干扰对方判断。',
style: '起手压制',
},
{
id: 'skill-story-2',
name: '禁航暗潮',
summary: '封住错误航线,把人逼回她熟悉的区域。',
style: '机动周旋',
},
{
id: 'skill-story-3',
name: '回声巡线',
summary: '借塔顶回声迅速锁定异动方向。',
style: '爆发终结',
},
],
initialItems: [
{
id: 'item-story-1',
name: '值夜灯尺',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '兼作警械和测灯尺的长柄器具。',
tags: ['守灯会'],
},
],
narrativeProfile: {
publicMask: '守灯会值夜人,对外总像比别人更冷静一步。',
firstContactMask: '想进塔就按规矩来,今晚谁都别想乱闯禁航区。',
visibleLine: '她表面上只是在守灯和封线。',
hiddenLine: '她真正盯着的是那本被改过的原始灯册。',
contradiction: '越强调规矩,越像在掩住自己手里那份没上报的记录。',
debtOrBurden: '她扛着第一次海雾吞船夜里没能及时点亮备用灯的旧责。',
taboo: '最忌讳别人把那夜的失踪当成单纯天灾。',
immediatePressure: '新的封航令马上要落下来,她必须先把旧灯册转出去。',
relatedThreadIds: ['thread-visible-1'],
relatedScarIds: ['scar-1'],
reactionHooks: ['原始灯册', '封灯令'],
},
},
],
items: [],
camp: {
name: '回潮暂栖所',
description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。',
},
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。',
sceneNpcIds: ['story-1'],
connections: [
{
targetLandmarkId: 'landmark-2',
relativePosition: 'forward',
summary: '沿着旧潮阶继续前压到雾栈尽头。',
},
],
narrativeResidues: [
{
id: 'residue-1',
title: '潮痕',
visibleClue: '塔壁上有一圈不该出现在高处的潮痕。',
linkedFactIds: ['fact-1'],
linkedThreadIds: ['thread-visible-1'],
},
],
},
{
id: 'landmark-2',
name: '雾栈尽头',
description: '旧栈桥尽头只剩断索、潮板和被抹掉的船名。',
sceneNpcIds: [],
connections: [
{
targetLandmarkId: 'landmark-1',
relativePosition: 'back',
summary: '退回灯塔还能重新整理路线。',
},
],
},
],
scenarioPackId: 'scenario-pack:tide',
campaignPackId: 'campaign-pack:tide',
generationMode: 'full',
generationStatus: 'complete',
});
if (!profile) {
throw new Error('failed to build saved custom world profile');
}
return profile;
}
function readSnapshot() {
const raw = screen.getByTestId('state-snapshot').textContent ?? '{}';
return JSON.parse(raw) as {
worldType: string | null;
currentScene: string;
profileName: string | null;
activeScenarioPackId: string | null;
activeCampaignPackId: string | null;
currentScenePresetId: string | null;
currentScenePresetName: string | null;
currentSceneConnectedIds: string[];
firstLandmarkResidueTitle: string | null;
playerCharacterName: string | null;
playerInventoryNames: string[];
playerEquipment: {
weapon: string | null;
armor: string | null;
relic: string | null;
};
};
}
function GameFlowHarness() {
const profile = useMemo(() => buildSavedProfile(), []);
const playableCharacters = useMemo(
() => buildCustomWorldPlayableCharacters(profile),
[profile],
);
const selectedCharacter = playableCharacters[0] ?? null;
const { gameState, handleCustomWorldSelect, handleCharacterSelect } =
useRpgSessionBootstrap();
const snapshot = {
worldType: gameState.worldType,
currentScene: gameState.currentScene,
profileName: gameState.customWorldProfile?.name ?? null,
activeScenarioPackId: gameState.activeScenarioPackId ?? null,
activeCampaignPackId: gameState.activeCampaignPackId ?? null,
currentScenePresetId: gameState.currentScenePreset?.id ?? null,
currentScenePresetName: gameState.currentScenePreset?.name ?? null,
currentSceneConnectedIds: gameState.currentScenePreset?.connectedSceneIds ?? [],
firstLandmarkResidueTitle:
gameState.customWorldProfile?.landmarks[0]?.narrativeResidues?.[0]
?.title ?? null,
playerCharacterName: gameState.playerCharacter?.name ?? null,
playerInventoryNames: gameState.playerInventory.map((item) => item.name),
playerEquipment: {
weapon: gameState.playerEquipment.weapon?.name ?? null,
armor: gameState.playerEquipment.armor?.name ?? null,
relic: gameState.playerEquipment.relic?.name ?? null,
},
};
return (
<div>
<button
type="button"
onClick={() => handleCustomWorldSelect(profile)}
>
</button>
<button
type="button"
onClick={() => {
if (selectedCharacter) {
handleCharacterSelect(selectedCharacter);
}
}}
>
</button>
<pre data-testid="state-snapshot">{JSON.stringify(snapshot)}</pre>
</div>
);
}
afterEach(() => {
setRuntimeCustomWorldProfile(null);
setRuntimeCharacterOverrides(null);
});
test('saved custom world result settings flow into game state after entering the world', async () => {
const user = userEvent.setup();
render(<GameFlowHarness />);
await user.click(screen.getByRole('button', { name: '选择世界' }));
await waitFor(() => {
expect(readSnapshot().worldType).toBe(WorldType.CUSTOM);
});
expect(readSnapshot().profileName).toBe('回潮群岛');
expect(readSnapshot().currentScenePresetId).toBe('custom-scene-camp');
expect(readSnapshot().currentScenePresetName).toBe('回潮暂栖所');
expect(readSnapshot().currentSceneConnectedIds).toContain(
'custom-scene-landmark-1',
);
expect(readSnapshot().firstLandmarkResidueTitle).toBe('潮痕');
expect(readSnapshot().activeScenarioPackId).toBe('scenario-pack:tide');
expect(readSnapshot().activeCampaignPackId).toBe('campaign-pack:tide');
await user.click(screen.getByRole('button', { name: '确认角色' }));
await waitFor(() => {
expect(readSnapshot().currentScene).toBe('Story');
});
expect(readSnapshot().playerCharacterName).toBe('沈砺');
expect(readSnapshot().playerInventoryNames).toContain('旧潮短刃');
expect(readSnapshot().playerInventoryNames).toContain('旧潮图残页');
expect(readSnapshot().playerEquipment.weapon).toBe('旧潮短刃');
expect(readSnapshot().playerEquipment.relic).toBe('旧潮图残页');
expect(readSnapshot().playerEquipment.armor).toBeTruthy();
});