# Conflicts: # docs/technical/README.md # docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md # docs/technical/SPACETIMEDB_TABLE_CATALOG.md # scripts/generate-spacetime-bindings.mjs # server-rs/crates/api-server/src/app.rs # server-rs/crates/api-server/src/assets.rs # server-rs/crates/api-server/src/big_fish.rs # server-rs/crates/api-server/src/custom_world_ai.rs # server-rs/crates/api-server/src/llm.rs # server-rs/crates/api-server/src/main.rs # server-rs/crates/api-server/src/puzzle.rs # server-rs/crates/api-server/src/runtime_profile.rs # server-rs/crates/api-server/src/runtime_story/compat/ai.rs # server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs # server-rs/crates/api-server/src/runtime_story/compat/presentation.rs # server-rs/crates/api-server/src/runtime_story/compat/tests.rs # server-rs/crates/api-server/src/state.rs # server-rs/crates/module-auth/src/lib.rs # server-rs/crates/module-big-fish/src/lib.rs # server-rs/crates/module-custom-world/src/lib.rs # server-rs/crates/module-puzzle/src/lib.rs # server-rs/crates/module-runtime/src/lib.rs # server-rs/crates/spacetime-client/src/big_fish.rs # server-rs/crates/spacetime-client/src/lib.rs # server-rs/crates/spacetime-client/src/mapper.rs # server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/advance_puzzle_next_level_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/authorize_database_migration_operator_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/big_fish_runtime_run_type.rs # server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/clear_platform_browse_history_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/compile_big_fish_draft_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/compile_custom_world_published_profile_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/compile_puzzle_agent_draft_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/continue_story_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_big_fish_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_custom_world_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_profile_recharge_order_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/create_puzzle_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_big_fish_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_puzzle_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/delete_runtime_snapshot_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/drag_puzzle_piece_or_group_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/execute_custom_world_agent_action_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_snapshot_from_tables_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/finalize_big_fish_agent_message_turn_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/finalize_custom_world_agent_message_turn_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/finalize_puzzle_agent_message_turn_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/generate_big_fish_asset_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_auth_store_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_battle_state_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_big_fish_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_chapter_progression_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_card_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_by_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_library_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_player_progression_or_default_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_play_stats_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_center_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_profile_referral_invite_center_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_agent_session_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_gallery_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_run_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_work_detail_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_runtime_inventory_state_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_runtime_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/get_story_session_state_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_file_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_file_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_asset_history_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_big_fish_works_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_gallery_entries_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_profiles_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_works_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_platform_browse_history_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_profile_save_archives_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_gallery_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_works_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/mod.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_big_fish_game_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_world_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/publish_puzzle_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_referral_invite_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/resume_profile_save_archive_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/revoke_database_migration_operator_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/save_puzzle_generated_images_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/select_puzzle_cover_image_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/start_puzzle_run_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_big_fish_message_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_custom_world_agent_message_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_agent_message_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_leaderboard_entry_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/swap_puzzle_pieces_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/update_puzzle_work_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_auth_store_snapshot_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_agent_operation_progress_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_platform_browse_history_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs # server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_snapshot_and_return_procedure.rs # server-rs/crates/spacetime-module/src/auth/procedures.rs # server-rs/crates/spacetime-module/src/custom_world/mod.rs # server-rs/crates/spacetime-module/src/lib.rs # server-rs/crates/spacetime-module/src/migration.rs # server-rs/crates/spacetime-module/src/puzzle.rs # server-rs/crates/spacetime-module/src/runtime/profile.rs # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx # src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx # src/services/aiService.ts # src/services/puzzle-runtime/puzzleRuntimeClient.ts
1273 lines
38 KiB
TypeScript
1273 lines
38 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
|
||
import { getScenePresetsByWorld } from '../data/scenePresets';
|
||
import type {
|
||
Character,
|
||
Encounter,
|
||
SceneHostileNpc,
|
||
StoryMoment,
|
||
StoryOption,
|
||
} from '../types';
|
||
import { AnimationState, WorldType } from '../types';
|
||
import type { StoryRuntimeProjectionResponse } from '../../packages/shared/src/contracts/story';
|
||
|
||
const {
|
||
connectivityError,
|
||
fetchMock,
|
||
requestChatMessageContentMock,
|
||
requestPlainTextCompletionMock,
|
||
streamPlainTextCompletionMock,
|
||
timeoutError,
|
||
} = vi.hoisted(() => ({
|
||
connectivityError: new Error('LLM unavailable'),
|
||
fetchMock: vi.fn(),
|
||
requestChatMessageContentMock: vi.fn(),
|
||
requestPlainTextCompletionMock: vi.fn(),
|
||
streamPlainTextCompletionMock: vi.fn(),
|
||
timeoutError: new Error('LLM timed out'),
|
||
}));
|
||
|
||
vi.mock('./llmClient', () => ({
|
||
CUSTOM_WORLD_REQUEST_TIMEOUT_MS: 120000,
|
||
isLlmConnectivityError: (error: unknown) => error === connectivityError,
|
||
isLlmTimeoutError: (error: unknown) => error === timeoutError,
|
||
requestChatMessageContent: requestChatMessageContentMock,
|
||
requestPlainTextCompletion: requestPlainTextCompletionMock,
|
||
streamPlainTextCompletion: streamPlainTextCompletionMock,
|
||
}));
|
||
|
||
import {
|
||
generateCharacterPanelChatSuggestions,
|
||
generateCustomWorldProfile,
|
||
generateCustomWorldSceneImage,
|
||
generateInitialStory,
|
||
generateNextStep,
|
||
streamCharacterPanelChatReply,
|
||
streamNpcRecruitDialogue,
|
||
} from './ai';
|
||
import { streamNpcChatTurn } from './aiService';
|
||
import type { StoryGenerationContext } from './aiTypes';
|
||
import type { CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
|
||
|
||
const [
|
||
BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||
BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||
BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||
BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
|
||
|
||
function createCharacter(overrides: Partial<Character> = {}): Character {
|
||
return {
|
||
id: 'hero',
|
||
name: 'Lin',
|
||
title: 'Wanderer',
|
||
description: 'A cautious traveler.',
|
||
backstory: 'Walked out of the northern mountains.',
|
||
avatar: '/avatars/lin.png',
|
||
portrait: '/portraits/lin.png',
|
||
assetFolder: 'lin',
|
||
assetVariant: 'default',
|
||
attributes: {
|
||
strength: 10,
|
||
agility: 8,
|
||
intelligence: 7,
|
||
spirit: 9,
|
||
},
|
||
personality: 'Calm, observant, and steady.',
|
||
skills: [],
|
||
adventureOpenings: {},
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function createStoryOption(overrides: Partial<StoryOption> = {}): StoryOption {
|
||
return {
|
||
functionId: 'rest',
|
||
actionText: 'Pause and recover.',
|
||
text: 'Pause and recover.',
|
||
visuals: {
|
||
playerAnimation: AnimationState.IDLE,
|
||
playerMoveMeters: 0,
|
||
playerOffsetY: 0,
|
||
playerFacing: 'right',
|
||
scrollWorld: false,
|
||
monsterChanges: [],
|
||
},
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function createContext(
|
||
overrides: Partial<StoryGenerationContext> = {},
|
||
): StoryGenerationContext {
|
||
return {
|
||
runtimeSessionId: 'runtime-main',
|
||
storySessionId: 'storysess-main',
|
||
runtimeActionVersion: 3,
|
||
playerHp: 30,
|
||
playerMaxHp: 40,
|
||
playerMana: 12,
|
||
playerMaxMana: 20,
|
||
inBattle: false,
|
||
playerX: 0,
|
||
playerFacing: 'right',
|
||
playerAnimation: AnimationState.IDLE,
|
||
skillCooldowns: {},
|
||
sceneId: null,
|
||
sceneName: 'Forest Trail',
|
||
sceneDescription: 'A quiet mountain path.',
|
||
pendingSceneEncounter: false,
|
||
observeSignsRequested: false,
|
||
recentActionResult: null,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function createTargetStatus(
|
||
overrides: Partial<CharacterChatTargetStatus> = {},
|
||
): CharacterChatTargetStatus {
|
||
return {
|
||
roleLabel: 'Companion',
|
||
hp: 18,
|
||
maxHp: 20,
|
||
mana: 9,
|
||
maxMana: 12,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||
return {
|
||
npcName: 'Lan',
|
||
npcDescription: 'A sharp-eyed scout.',
|
||
npcAvatar: '/avatars/lan.png',
|
||
context: 'Campfire',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function createPlayableNpc(index: number) {
|
||
return {
|
||
name: `角色${index + 1}`,
|
||
title: `身份${index + 1}`,
|
||
role: `世界职责${index + 1}`,
|
||
description: `角色描述${index + 1}`,
|
||
backstory: `角色背景${index + 1}`,
|
||
personality: `角色性格${index + 1}`,
|
||
motivation: `角色动机${index + 1}`,
|
||
combatStyle: `战斗风格${index + 1}`,
|
||
initialAffinity: 18,
|
||
relationshipHooks: [`接触点${index + 1}`],
|
||
tags: [`标签${index + 1}`],
|
||
backstoryReveal: {
|
||
publicSummary: `公开背景${index + 1}`,
|
||
chapters: [
|
||
{
|
||
id: `surface-${index + 1}`,
|
||
title: '表层来意',
|
||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||
teaser: `提示${index + 1}-1`,
|
||
content: `内容${index + 1}-1`,
|
||
contextSnippet: `摘要${index + 1}-1`,
|
||
},
|
||
{
|
||
id: `scar-${index + 1}`,
|
||
title: '旧事裂痕',
|
||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||
teaser: `提示${index + 1}-2`,
|
||
content: `内容${index + 1}-2`,
|
||
contextSnippet: `摘要${index + 1}-2`,
|
||
},
|
||
{
|
||
id: `hidden-${index + 1}`,
|
||
title: '隐藏执念',
|
||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||
teaser: `提示${index + 1}-3`,
|
||
content: `内容${index + 1}-3`,
|
||
contextSnippet: `摘要${index + 1}-3`,
|
||
},
|
||
{
|
||
id: `final-${index + 1}`,
|
||
title: '最终底牌',
|
||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||
teaser: `提示${index + 1}-4`,
|
||
content: `内容${index + 1}-4`,
|
||
contextSnippet: `摘要${index + 1}-4`,
|
||
},
|
||
],
|
||
},
|
||
skills: [
|
||
{ name: `技能${index + 1}-1`, summary: '技能说明1', style: '起手压制' },
|
||
{ name: `技能${index + 1}-2`, summary: '技能说明2', style: '机动周旋' },
|
||
{ name: `技能${index + 1}-3`, summary: '技能说明3', style: '爆发终结' },
|
||
],
|
||
initialItems: [
|
||
{
|
||
name: `物品${index + 1}-1`,
|
||
category: '武器',
|
||
quantity: 1,
|
||
rarity: 'rare',
|
||
description: '物品说明1',
|
||
tags: ['物品标签1'],
|
||
},
|
||
{
|
||
name: `物品${index + 1}-2`,
|
||
category: '消耗品',
|
||
quantity: 2,
|
||
rarity: 'uncommon',
|
||
description: '物品说明2',
|
||
tags: ['物品标签2'],
|
||
},
|
||
{
|
||
name: `物品${index + 1}-3`,
|
||
category: '专属物品',
|
||
quantity: 1,
|
||
rarity: 'rare',
|
||
description: '物品说明3',
|
||
tags: ['物品标签3'],
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
function createStoryNpc(index: number) {
|
||
return {
|
||
name: `世界NPC${index + 1}`,
|
||
title: `头衔${index + 1}`,
|
||
role: `职责${index + 1}`,
|
||
description: `世界NPC描述${index + 1}`,
|
||
backstory: `世界NPC背景${index + 1}`,
|
||
personality: `世界NPC性格${index + 1}`,
|
||
motivation: `世界NPC动机${index + 1}`,
|
||
combatStyle: `世界NPC战斗风格${index + 1}`,
|
||
initialAffinity: index % 4 === 0 ? -10 : 6,
|
||
relationshipHooks: [`关系${index + 1}`],
|
||
tags: [`线索${index + 1}`],
|
||
backstoryReveal: {
|
||
publicSummary: `世界公开背景${index + 1}`,
|
||
chapters: [
|
||
{
|
||
id: `surface-story-${index + 1}`,
|
||
title: '表层来意',
|
||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||
teaser: `提示${index + 1}-1`,
|
||
content: `内容${index + 1}-1`,
|
||
contextSnippet: `摘要${index + 1}-1`,
|
||
},
|
||
{
|
||
id: `scar-story-${index + 1}`,
|
||
title: '旧事裂痕',
|
||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||
teaser: `提示${index + 1}-2`,
|
||
content: `内容${index + 1}-2`,
|
||
contextSnippet: `摘要${index + 1}-2`,
|
||
},
|
||
{
|
||
id: `hidden-story-${index + 1}`,
|
||
title: '隐藏执念',
|
||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||
teaser: `提示${index + 1}-3`,
|
||
content: `内容${index + 1}-3`,
|
||
contextSnippet: `摘要${index + 1}-3`,
|
||
},
|
||
{
|
||
id: `final-story-${index + 1}`,
|
||
title: '最终底牌',
|
||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||
teaser: `提示${index + 1}-4`,
|
||
content: `内容${index + 1}-4`,
|
||
contextSnippet: `摘要${index + 1}-4`,
|
||
},
|
||
],
|
||
},
|
||
skills: [
|
||
{
|
||
name: `世界技能${index + 1}-1`,
|
||
summary: '技能说明1',
|
||
style: '起手压制',
|
||
},
|
||
{
|
||
name: `世界技能${index + 1}-2`,
|
||
summary: '技能说明2',
|
||
style: '机动周旋',
|
||
},
|
||
{
|
||
name: `世界技能${index + 1}-3`,
|
||
summary: '技能说明3',
|
||
style: '爆发终结',
|
||
},
|
||
],
|
||
initialItems: [
|
||
{
|
||
name: `世界物品${index + 1}-1`,
|
||
category: '武器',
|
||
quantity: 1,
|
||
rarity: 'rare',
|
||
description: '物品说明1',
|
||
tags: ['物品标签1'],
|
||
},
|
||
{
|
||
name: `世界物品${index + 1}-2`,
|
||
category: '消耗品',
|
||
quantity: 2,
|
||
rarity: 'uncommon',
|
||
description: '物品说明2',
|
||
tags: ['物品标签2'],
|
||
},
|
||
{
|
||
name: `世界物品${index + 1}-3`,
|
||
category: '专属物品',
|
||
quantity: 1,
|
||
rarity: 'rare',
|
||
description: '物品说明3',
|
||
tags: ['物品标签3'],
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
function createLandmark(
|
||
index: number,
|
||
options?: {
|
||
storyNpcNames?: string[];
|
||
landmarkCount?: number;
|
||
},
|
||
) {
|
||
const landmarkCount = options?.landmarkCount ?? 10;
|
||
const nextName = `场景${((index + 1) % landmarkCount) + 1}`;
|
||
const prevName = `场景${((index - 1 + landmarkCount) % landmarkCount) + 1}`;
|
||
|
||
return {
|
||
name: `场景${index + 1}`,
|
||
description: `场景描述${index + 1}`,
|
||
actNPCNames: options?.storyNpcNames ?? [
|
||
`世界NPC${index + 1}`,
|
||
`世界NPC${index + 2}`,
|
||
`世界NPC${index + 3}`,
|
||
],
|
||
connections:
|
||
landmarkCount > 1
|
||
? [
|
||
{
|
||
targetLandmarkName: nextName,
|
||
relativePosition: 'forward',
|
||
summary: `沿主路可到${nextName}`,
|
||
},
|
||
{
|
||
targetLandmarkName: prevName,
|
||
relativePosition: 'back',
|
||
summary: `回身可返${prevName}`,
|
||
},
|
||
]
|
||
: [],
|
||
};
|
||
}
|
||
|
||
function createCustomWorldResponse(
|
||
overrides: Partial<{
|
||
name: string;
|
||
subtitle: string;
|
||
summary: string;
|
||
tone: string;
|
||
playerGoal: string;
|
||
templateWorldType: 'WUXIA' | 'XIANXIA';
|
||
playableNpcs: ReturnType<typeof createPlayableNpc>[];
|
||
storyNpcs: ReturnType<typeof createStoryNpc>[];
|
||
landmarks: ReturnType<typeof createLandmark>[];
|
||
items: Array<Record<string, unknown>>;
|
||
}> = {},
|
||
) {
|
||
const storyNpcs =
|
||
overrides.storyNpcs ??
|
||
Array.from({ length: 25 }, (_, index) => createStoryNpc(index));
|
||
const landmarks =
|
||
overrides.landmarks ??
|
||
Array.from({ length: 10 }, (_, index) =>
|
||
createLandmark(index, {
|
||
landmarkCount: 10,
|
||
storyNpcNames: [
|
||
storyNpcs[index % storyNpcs.length]?.name ?? `世界NPC${index + 1}`,
|
||
storyNpcs[(index + 1) % storyNpcs.length]?.name ??
|
||
`世界NPC${index + 2}`,
|
||
storyNpcs[(index + 2) % storyNpcs.length]?.name ??
|
||
`世界NPC${index + 3}`,
|
||
],
|
||
}),
|
||
);
|
||
|
||
return {
|
||
name: '测试世界',
|
||
subtitle: '副标题',
|
||
summary: '概述',
|
||
tone: '基调',
|
||
playerGoal: '目标',
|
||
templateWorldType: 'WUXIA' as const,
|
||
playableNpcs: Array.from({ length: 5 }, (_, index) =>
|
||
createPlayableNpc(index),
|
||
),
|
||
storyNpcs,
|
||
landmarks,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function createApiEnvelopeResponse(data: unknown) {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
headers: new Headers(),
|
||
text: async () =>
|
||
JSON.stringify({
|
||
ok: true,
|
||
data,
|
||
error: null,
|
||
meta: {
|
||
apiVersion: '2026-04-08',
|
||
},
|
||
}),
|
||
} as Response;
|
||
}
|
||
|
||
type RuntimeProjectionOverrides = Omit<
|
||
Partial<StoryRuntimeProjectionResponse>,
|
||
'storySession'
|
||
> & {
|
||
storySession?: Partial<StoryRuntimeProjectionResponse['storySession']>;
|
||
};
|
||
|
||
function createRuntimeProjection(
|
||
overrides: RuntimeProjectionOverrides = {},
|
||
): StoryRuntimeProjectionResponse {
|
||
const storySession = {
|
||
storySessionId: 'storysess-main',
|
||
runtimeSessionId: 'runtime-main',
|
||
actorUserId: 'user-main',
|
||
worldProfileId: 'profile-main',
|
||
initialPrompt: '进入山路',
|
||
openingSummary: null,
|
||
latestNarrativeText: '山路尽头传来新的动静。',
|
||
latestChoiceFunctionId: null,
|
||
status: 'active',
|
||
version: 3,
|
||
createdAt: '2026-04-08T00:00:00.000Z',
|
||
updatedAt: '2026-04-08T00:00:01.000Z',
|
||
...(overrides.storySession ?? {}),
|
||
} satisfies StoryRuntimeProjectionResponse['storySession'];
|
||
|
||
return {
|
||
storySession,
|
||
storyEvents: overrides.storyEvents ?? [],
|
||
serverVersion: overrides.serverVersion ?? storySession.version,
|
||
gameState: {
|
||
runtimeSessionId: storySession.runtimeSessionId,
|
||
storySessionId: storySession.storySessionId,
|
||
runtimeActionVersion: overrides.serverVersion ?? storySession.version,
|
||
currentScene: 'Story',
|
||
playerEquipment: { weapon: null, armor: null, relic: null },
|
||
...(overrides.gameState ?? {}),
|
||
},
|
||
actor: overrides.actor ?? {
|
||
hp: 30,
|
||
maxHp: 40,
|
||
mana: 12,
|
||
maxMana: 20,
|
||
currency: 0,
|
||
currencyText: '0 铜钱',
|
||
},
|
||
inventory: overrides.inventory ?? {
|
||
backpackItems: [],
|
||
equipmentSlots: [],
|
||
forgeRecipes: [],
|
||
},
|
||
options: overrides.options ?? [],
|
||
status: overrides.status ?? {
|
||
inBattle: false,
|
||
npcInteractionActive: false,
|
||
currentNpcBattleMode: null,
|
||
currentNpcBattleOutcome: null,
|
||
},
|
||
currentNarrativeText:
|
||
overrides.currentNarrativeText ?? storySession.latestNarrativeText,
|
||
actionResultText: overrides.actionResultText ?? null,
|
||
toast: overrides.toast ?? null,
|
||
};
|
||
}
|
||
|
||
function createSseResponse(text: string) {
|
||
const encoder = new TextEncoder();
|
||
const chunks = [
|
||
encoder.encode(
|
||
`data: ${JSON.stringify({
|
||
choices: [{ delta: { content: text } }],
|
||
})}\n\n`,
|
||
),
|
||
];
|
||
let index = 0;
|
||
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
headers: new Headers(),
|
||
body: {
|
||
getReader() {
|
||
return {
|
||
async read() {
|
||
if (index >= chunks.length) {
|
||
return { done: true, value: undefined };
|
||
}
|
||
const value = chunks[index];
|
||
index += 1;
|
||
return { done: false, value };
|
||
},
|
||
};
|
||
},
|
||
},
|
||
text: async () => '',
|
||
} as Response;
|
||
}
|
||
|
||
function createNpcChatTurnSseResponse(reply: string) {
|
||
const encoder = new TextEncoder();
|
||
const completePayload = {
|
||
npcReply: reply,
|
||
affinityDelta: 0,
|
||
affinityText: '这轮对话暂时没有带来明显关系变化。',
|
||
suggestions: [],
|
||
functionSuggestions: [],
|
||
pendingQuestOffer: null,
|
||
chatDirective: null,
|
||
};
|
||
const chunks = [
|
||
encoder.encode(
|
||
`event: reply_delta\ndata: ${JSON.stringify({ text: reply })}\n\n`,
|
||
),
|
||
encoder.encode(
|
||
`event: complete\ndata: ${JSON.stringify(completePayload)}\n\n`,
|
||
),
|
||
encoder.encode('data: [DONE]\n\n'),
|
||
];
|
||
let index = 0;
|
||
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
headers: new Headers(),
|
||
body: {
|
||
getReader() {
|
||
return {
|
||
async read() {
|
||
if (index >= chunks.length) {
|
||
return { done: true, value: undefined };
|
||
}
|
||
const value = chunks[index];
|
||
index += 1;
|
||
return { done: false, value };
|
||
},
|
||
};
|
||
},
|
||
},
|
||
text: async () => '',
|
||
} as Response;
|
||
}
|
||
|
||
describe('ai runtime client orchestration', () => {
|
||
const playerCharacter = createCharacter();
|
||
const targetCharacter = createCharacter({
|
||
id: 'ally',
|
||
name: 'Lan',
|
||
title: 'Scout',
|
||
personality: 'Dry, practical, and quietly protective.',
|
||
});
|
||
const context = createContext();
|
||
const transientSnapshot: NonNullable<
|
||
StoryGenerationContext['runtimeSnapshot']
|
||
> = {
|
||
bottomTab: 'adventure',
|
||
gameState: {
|
||
worldType: WorldType.WUXIA,
|
||
runtimeSessionId: 'runtime-preview',
|
||
runtimePersistenceDisabled: true,
|
||
} as NonNullable<StoryGenerationContext['runtimeSnapshot']>['gameState'],
|
||
currentStory: null,
|
||
};
|
||
const targetStatus = createTargetStatus();
|
||
const monsters: SceneHostileNpc[] = [];
|
||
const storyHistory: StoryMoment[] = [];
|
||
|
||
beforeEach(() => {
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
fetchMock.mockReset();
|
||
requestChatMessageContentMock.mockReset();
|
||
requestPlainTextCompletionMock.mockReset();
|
||
streamPlainTextCompletionMock.mockReset();
|
||
});
|
||
|
||
it('requests initial story from the story session projection', async () => {
|
||
const availableOptions = [createStoryOption()];
|
||
fetchMock.mockResolvedValue(
|
||
createApiEnvelopeResponse(
|
||
createRuntimeProjection({
|
||
options: availableOptions.map((option) => ({
|
||
functionId: option.functionId,
|
||
actionText: option.actionText,
|
||
detailText: null,
|
||
scope: 'story',
|
||
payload: null,
|
||
enabled: true,
|
||
reason: null,
|
||
})),
|
||
currentNarrativeText: '山路尽头传来新的动静。',
|
||
storySession: {
|
||
latestNarrativeText: '山路尽头传来新的动静。',
|
||
},
|
||
}),
|
||
),
|
||
);
|
||
|
||
const response = await generateInitialStory(
|
||
WorldType.WUXIA,
|
||
playerCharacter,
|
||
monsters,
|
||
context,
|
||
{ availableOptions },
|
||
);
|
||
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
'/api/story/sessions/storysess-main/runtime-projection',
|
||
expect.objectContaining({
|
||
method: 'GET',
|
||
}),
|
||
);
|
||
expect(response.storyText).toBe('山路尽头传来新的动静。');
|
||
expect(response.options).toEqual(availableOptions);
|
||
});
|
||
|
||
it('requests next story step from the story session action endpoint', async () => {
|
||
const availableOptions = [
|
||
createStoryOption({
|
||
functionId: 'idle_explore_forward',
|
||
actionText: '继续沿山道探路。',
|
||
text: '继续沿山道探路。',
|
||
}),
|
||
];
|
||
fetchMock.mockResolvedValue(
|
||
createApiEnvelopeResponse({
|
||
projection: createRuntimeProjection({
|
||
serverVersion: 4,
|
||
currentNarrativeText: '林间重新安静下来,你听见远处的风声。',
|
||
storySession: {
|
||
latestNarrativeText: '林间重新安静下来,你听见远处的风声。',
|
||
latestChoiceFunctionId: 'idle_explore_forward',
|
||
version: 4,
|
||
},
|
||
options: availableOptions.map((option) => ({
|
||
functionId: option.functionId,
|
||
actionText: option.actionText,
|
||
detailText: null,
|
||
scope: 'story',
|
||
payload: null,
|
||
enabled: true,
|
||
reason: null,
|
||
})),
|
||
}),
|
||
}),
|
||
);
|
||
|
||
const response = await generateNextStep(
|
||
WorldType.WUXIA,
|
||
playerCharacter,
|
||
monsters,
|
||
storyHistory,
|
||
'继续向前',
|
||
{
|
||
...context,
|
||
lastFunctionId: 'idle_explore_forward',
|
||
},
|
||
{ availableOptions },
|
||
);
|
||
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
'/api/story/sessions/storysess-main/actions/resolve',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
storySessionId: 'storysess-main',
|
||
clientVersion: 3,
|
||
functionId: 'idle_explore_forward',
|
||
actionText: '继续向前',
|
||
payload: {
|
||
optionText: '继续向前',
|
||
observeSignsRequested: false,
|
||
recentActionResult: null,
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
expect(response.storyText).toBe('林间重新安静下来,你听见远处的风声。');
|
||
expect(response.options).toEqual(availableOptions);
|
||
});
|
||
|
||
it('requests character chat suggestions from the runtime api server', async () => {
|
||
fetchMock.mockResolvedValue(
|
||
createApiEnvelopeResponse({
|
||
text: '先说你真正担心的事。\n这件事你还瞒了我什么?\n先别急,我们慢慢说。',
|
||
}),
|
||
);
|
||
|
||
const suggestions = await generateCharacterPanelChatSuggestions(
|
||
WorldType.WUXIA,
|
||
playerCharacter,
|
||
targetCharacter,
|
||
storyHistory,
|
||
context,
|
||
[],
|
||
'',
|
||
targetStatus,
|
||
);
|
||
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
'/api/runtime/chat/character/suggestions',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
sessionId: 'runtime-main',
|
||
targetCharacter,
|
||
conversationHistory: [],
|
||
conversationSummary: '',
|
||
targetStatus,
|
||
}),
|
||
}),
|
||
);
|
||
expect(suggestions).toEqual([
|
||
'先说你真正担心的事。',
|
||
'这件事你还瞒了我什么?',
|
||
'先别急,我们慢慢说。',
|
||
]);
|
||
});
|
||
|
||
it('streams character chat reply from the runtime api server', async () => {
|
||
const onUpdate = vi.fn();
|
||
const playerMessage = 'Tell me what you are really worried about.';
|
||
const conversationSummary = 'Lan has started to trust the player more.';
|
||
fetchMock.mockResolvedValue(
|
||
createSseResponse('我会认真回答你,但这件事没你想得那么简单。'),
|
||
);
|
||
|
||
const reply = await streamCharacterPanelChatReply(
|
||
WorldType.WUXIA,
|
||
playerCharacter,
|
||
targetCharacter,
|
||
storyHistory,
|
||
context,
|
||
[],
|
||
conversationSummary,
|
||
playerMessage,
|
||
targetStatus,
|
||
{ onUpdate },
|
||
);
|
||
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
'/api/runtime/chat/character/reply/stream',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
sessionId: 'runtime-main',
|
||
targetCharacter,
|
||
conversationHistory: [],
|
||
conversationSummary,
|
||
playerMessage,
|
||
targetStatus,
|
||
}),
|
||
}),
|
||
);
|
||
expect(reply).toBe('我会认真回答你,但这件事没你想得那么简单。');
|
||
expect(onUpdate).toHaveBeenCalledOnce();
|
||
expect(onUpdate).toHaveBeenCalledWith(
|
||
'我会认真回答你,但这件事没你想得那么简单。',
|
||
);
|
||
});
|
||
|
||
it('attaches transient snapshot to session based chat requests only when provided', async () => {
|
||
fetchMock.mockResolvedValue(
|
||
createApiEnvelopeResponse({
|
||
text: '先确认眼下的局势。\n问清对方的真实目的。\n保持距离继续观察。',
|
||
}),
|
||
);
|
||
|
||
await generateCharacterPanelChatSuggestions(
|
||
WorldType.WUXIA,
|
||
playerCharacter,
|
||
targetCharacter,
|
||
storyHistory,
|
||
createContext({
|
||
runtimeSessionId: 'runtime-preview',
|
||
runtimeSnapshot: transientSnapshot,
|
||
}),
|
||
[],
|
||
'',
|
||
targetStatus,
|
||
);
|
||
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
'/api/runtime/chat/character/suggestions',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
sessionId: 'runtime-preview',
|
||
snapshot: transientSnapshot,
|
||
targetCharacter,
|
||
conversationHistory: [],
|
||
conversationSummary: '',
|
||
targetStatus,
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('attaches transient snapshot to npc chat turn session requests', async () => {
|
||
const encounter = createEncounter();
|
||
fetchMock.mockResolvedValue(
|
||
createNpcChatTurnSseResponse('先把眼前的事说清楚。'),
|
||
);
|
||
|
||
const result = await streamNpcChatTurn(
|
||
WorldType.WUXIA,
|
||
playerCharacter,
|
||
encounter,
|
||
monsters,
|
||
storyHistory,
|
||
createContext({
|
||
runtimeSessionId: 'runtime-preview',
|
||
runtimeSnapshot: transientSnapshot,
|
||
}),
|
||
[],
|
||
'你刚才看见了什么?',
|
||
{ chattedCount: 0 },
|
||
);
|
||
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
'/api/runtime/chat/npc/turn/stream',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
sessionId: 'runtime-preview',
|
||
snapshot: transientSnapshot,
|
||
encounter,
|
||
conversationHistory: [],
|
||
dialogue: [],
|
||
playerMessage: '你刚才看见了什么?',
|
||
npcState: { chattedCount: 0 },
|
||
npcInitiatesConversation: false,
|
||
questOfferContext: null,
|
||
combatContext: null,
|
||
chatDirective: null,
|
||
}),
|
||
}),
|
||
);
|
||
expect(result.npcReply).toBe('先把眼前的事说清楚。');
|
||
});
|
||
|
||
it('streams npc recruit dialogue from the runtime api server', async () => {
|
||
const onUpdate = vi.fn();
|
||
const encounter = createEncounter();
|
||
fetchMock.mockResolvedValue(
|
||
createSseResponse('你:和我一起走下去吧。\nLan:好,我答应你。'),
|
||
);
|
||
|
||
const reply = await streamNpcRecruitDialogue(
|
||
WorldType.WUXIA,
|
||
playerCharacter,
|
||
encounter,
|
||
monsters,
|
||
storyHistory,
|
||
context,
|
||
'Join us.',
|
||
'The party is ready to travel together.',
|
||
{ onUpdate },
|
||
);
|
||
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
'/api/runtime/chat/npc/recruit/stream',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
sessionId: 'runtime-main',
|
||
encounter,
|
||
invitationText: 'Join us.',
|
||
recruitSummary: 'The party is ready to travel together.',
|
||
}),
|
||
}),
|
||
);
|
||
expect(reply).toBe('你:和我一起走下去吧。\nLan:好,我答应你。');
|
||
expect(onUpdate).toHaveBeenCalledOnce();
|
||
expect(onUpdate).toHaveBeenCalledWith(
|
||
'你:和我一起走下去吧。\nLan:好,我答应你。',
|
||
);
|
||
});
|
||
|
||
it('rejects custom world output when the model does not generate enough NPCs and scenes', async () => {
|
||
requestPlainTextCompletionMock.mockResolvedValue(
|
||
JSON.stringify(
|
||
createCustomWorldResponse({
|
||
storyNpcs: Array.from({ length: 10 }, (_, index) =>
|
||
createStoryNpc(index),
|
||
),
|
||
landmarks: Array.from({ length: 4 }, (_, index) =>
|
||
createLandmark(index, { landmarkCount: 4 }),
|
||
),
|
||
}),
|
||
),
|
||
);
|
||
|
||
await expect(
|
||
generateCustomWorldProfile('一个需要很多角色和场景的世界'),
|
||
).rejects.toThrow(
|
||
/requires at least 10 generated scenes|至少产出 10 个场景|至少需要 10 个场景/i,
|
||
);
|
||
});
|
||
|
||
it('keeps the generated custom world dossier item-free when the model output is valid', async () => {
|
||
requestPlainTextCompletionMock.mockResolvedValue(
|
||
JSON.stringify(
|
||
createCustomWorldResponse({
|
||
items: [
|
||
{
|
||
name: '不应保留的物品',
|
||
category: '材料',
|
||
rarity: 'rare',
|
||
description: '这个字段应该被清空',
|
||
tags: ['测试'],
|
||
},
|
||
],
|
||
}),
|
||
),
|
||
);
|
||
|
||
const profile =
|
||
await generateCustomWorldProfile('一个需要很多角色和场景的世界');
|
||
|
||
expect(profile.playableNpcs).toHaveLength(5);
|
||
expect(profile.storyNpcs).toHaveLength(25);
|
||
expect(profile.landmarks).toHaveLength(10);
|
||
expect(
|
||
profile.landmarks.every((landmark) => landmark.sceneNpcIds.length >= 3),
|
||
).toBe(true);
|
||
expect(
|
||
profile.landmarks.every((landmark) => landmark.connections.length > 0),
|
||
).toBe(true);
|
||
expect(profile.items).toEqual([]);
|
||
});
|
||
|
||
it('generates custom worlds through a framework stage plus segmented narrative and dossier batches', async () => {
|
||
requestPlainTextCompletionMock.mockResolvedValue(
|
||
JSON.stringify(createCustomWorldResponse()),
|
||
);
|
||
|
||
await generateCustomWorldProfile('一个需要拆分生成的世界');
|
||
|
||
const debugLabels = requestPlainTextCompletionMock.mock.calls.map(
|
||
(call) => (call[2] as { debugLabel?: string } | undefined)?.debugLabel,
|
||
);
|
||
|
||
expect(debugLabels).toContain('custom-world-framework');
|
||
expect(debugLabels).toContain('custom-world-playable-outline-batch-1');
|
||
expect(debugLabels).toContain('custom-world-story-outline-batch-1');
|
||
expect(debugLabels).toContain('custom-world-landmark-seed-batch-1');
|
||
expect(debugLabels).not.toContain('custom-world-landmark-network-batch-1');
|
||
expect(debugLabels).toContain('custom-world-playable-narrative-batch-1');
|
||
expect(debugLabels).toContain('custom-world-playable-dossier-batch-1');
|
||
expect(debugLabels).toContain('custom-world-story-narrative-batch-1');
|
||
expect(debugLabels).toContain('custom-world-story-dossier-batch-1');
|
||
});
|
||
|
||
it('reports staged progress while generating a custom world', async () => {
|
||
requestPlainTextCompletionMock.mockResolvedValue(
|
||
JSON.stringify(createCustomWorldResponse()),
|
||
);
|
||
const onProgress = vi.fn();
|
||
|
||
await generateCustomWorldProfile('一个需要展示真实进度的世界', {
|
||
onProgress,
|
||
});
|
||
|
||
const phaseIds = onProgress.mock.calls.map(
|
||
(call) =>
|
||
(call[0] as { phaseId?: string; overallProgress?: number }).phaseId,
|
||
);
|
||
const lastProgress = onProgress.mock.calls.at(-1)?.[0] as
|
||
| { overallProgress?: number; estimatedRemainingMs?: number | null }
|
||
| undefined;
|
||
|
||
expect(phaseIds).toContain('framework');
|
||
expect(phaseIds).toContain('playable-outline');
|
||
expect(phaseIds).toContain('story-outline');
|
||
expect(phaseIds).toContain('landmark-seed');
|
||
expect(phaseIds).not.toContain('landmark-network');
|
||
expect(phaseIds).toContain('playable-narrative');
|
||
expect(phaseIds).toContain('playable-dossier');
|
||
expect(phaseIds).toContain('story-narrative');
|
||
expect(phaseIds).toContain('story-dossier');
|
||
expect(phaseIds).toContain('finalize');
|
||
expect(lastProgress?.overallProgress).toBe(100);
|
||
expect(lastProgress?.estimatedRemainingMs).toBe(0);
|
||
});
|
||
|
||
it('passes abort signals through custom world generation and rejects when interrupted', async () => {
|
||
requestPlainTextCompletionMock.mockImplementation(
|
||
(_system: string, _user: string, options?: { signal?: AbortSignal }) =>
|
||
new Promise((_resolve, reject) => {
|
||
options?.signal?.addEventListener(
|
||
'abort',
|
||
() =>
|
||
reject(options.signal?.reason ?? new Error('世界生成已中断。')),
|
||
{ once: true },
|
||
);
|
||
}),
|
||
);
|
||
const abortController = new AbortController();
|
||
const generation = generateCustomWorldProfile('一个会被中断的世界', {
|
||
signal: abortController.signal,
|
||
});
|
||
|
||
abortController.abort(new Error('手动中断生成'));
|
||
|
||
await expect(generation).rejects.toThrow('手动中断生成');
|
||
expect(requestPlainTextCompletionMock).toHaveBeenCalledWith(
|
||
expect.any(String),
|
||
expect.any(String),
|
||
expect.objectContaining({
|
||
signal: abortController.signal,
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('retries custom world generation with a longer timeout after the first timeout attempt', async () => {
|
||
requestPlainTextCompletionMock
|
||
.mockRejectedValueOnce(timeoutError)
|
||
.mockResolvedValue(
|
||
JSON.stringify(
|
||
createCustomWorldResponse({
|
||
name: '重试世界',
|
||
}),
|
||
),
|
||
);
|
||
|
||
const profile = await generateCustomWorldProfile('一个生成很慢的世界');
|
||
|
||
expect(profile.name).toBe('重试世界');
|
||
expect(requestPlainTextCompletionMock).toHaveBeenNthCalledWith(
|
||
1,
|
||
expect.any(String),
|
||
expect.any(String),
|
||
expect.objectContaining({
|
||
timeoutMs: 120000,
|
||
debugLabel: 'custom-world-framework',
|
||
}),
|
||
);
|
||
expect(requestPlainTextCompletionMock).toHaveBeenNthCalledWith(
|
||
2,
|
||
expect.any(String),
|
||
expect.any(String),
|
||
expect.objectContaining({
|
||
timeoutMs: 180000,
|
||
debugLabel: 'custom-world-framework-retry-2',
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('repairs invalid custom world json through a follow-up formatting request', async () => {
|
||
requestPlainTextCompletionMock
|
||
.mockResolvedValueOnce(
|
||
`{
|
||
"name": "修复世界",
|
||
"subtitle": "副标题",
|
||
"summary": "概述",
|
||
"tone": "基调",
|
||
"playerGoal": "目标",
|
||
"templateWorldType": "WUXIA",
|
||
"playableNpcs": [{ name: "角色1" }],
|
||
"storyNpcs": [],
|
||
"landmarks": []
|
||
}`,
|
||
)
|
||
.mockResolvedValue(
|
||
JSON.stringify(
|
||
createCustomWorldResponse({
|
||
name: '修复世界',
|
||
}),
|
||
),
|
||
);
|
||
|
||
const profile = await generateCustomWorldProfile('一个格式容易损坏的世界');
|
||
|
||
expect(profile.name).toBe('修复世界');
|
||
expect(profile.playableNpcs).toHaveLength(5);
|
||
expect(requestPlainTextCompletionMock).toHaveBeenNthCalledWith(
|
||
2,
|
||
expect.stringContaining('你是 JSON 修复器'),
|
||
expect.stringContaining(
|
||
'不要输出 playableNpcs、storyNpcs、landmarks、items',
|
||
),
|
||
expect.objectContaining({
|
||
debugLabel: 'custom-world-framework-json-repair',
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('attaches creator intent and anchor pack when generating from creator cards', async () => {
|
||
requestPlainTextCompletionMock.mockResolvedValue(
|
||
JSON.stringify(
|
||
createCustomWorldResponse({
|
||
name: '锚点世界',
|
||
}),
|
||
),
|
||
);
|
||
|
||
const profile = await generateCustomWorldProfile({
|
||
settingText: '世界一句话:一个被灵潮反复改写地形的边境世界。',
|
||
creatorIntent: {
|
||
sourceMode: 'card',
|
||
rawSettingText: '',
|
||
worldHook: '一个被灵潮反复改写地形的边境世界。',
|
||
themeKeywords: ['边境', '灵潮'],
|
||
toneDirectives: ['紧张', '潮湿'],
|
||
playerPremise: '玩家是前巡夜人。',
|
||
openingSituation: '刚进城就卷入旧案。',
|
||
coreConflicts: ['旧案名单再次出现'],
|
||
keyFactions: [],
|
||
keyCharacters: [
|
||
{
|
||
id: 'creator-character-1',
|
||
name: '沈砺',
|
||
role: '灰炬向导',
|
||
publicMask: '看起来只是个带路人',
|
||
hiddenHook: '一直在查旧撤离线',
|
||
relationToPlayer: '会先怀疑玩家身份',
|
||
notes: '',
|
||
locked: true,
|
||
},
|
||
],
|
||
keyLandmarks: [],
|
||
iconicElements: ['裂潮灯塔'],
|
||
forbiddenDirectives: ['不要出现现代枪械'],
|
||
},
|
||
});
|
||
|
||
expect(profile.name).toBe('锚点世界');
|
||
expect(profile.creatorIntent?.sourceMode).toBe('card');
|
||
expect(profile.creatorIntent?.keyCharacters[0]?.name).toBe('沈砺');
|
||
expect(profile.anchorPack?.keyCharacterAnchors[0]?.name).toBe('沈砺');
|
||
expect(profile.anchorPack?.lockedAnchorIds).toContain(
|
||
'creator-character-1',
|
||
);
|
||
});
|
||
|
||
it('generates a custom world scene image through the local proxy and returns the saved asset path', async () => {
|
||
fetchMock.mockResolvedValue({
|
||
ok: true,
|
||
text: async () =>
|
||
JSON.stringify({
|
||
ok: true,
|
||
data: {
|
||
ok: true,
|
||
imageSrc: '/generated-custom-world-scenes/world/landmark/scene.png',
|
||
assetId: 'custom-scene-1',
|
||
model: 'wan2.7-image',
|
||
size: '1280*720',
|
||
taskId: 'task-123',
|
||
prompt: '系统整理后的提示词',
|
||
actualPrompt: '扩写后的提示词',
|
||
},
|
||
error: null,
|
||
meta: {
|
||
apiVersion: '2026-04-08',
|
||
},
|
||
}),
|
||
} as Response);
|
||
|
||
const result = await generateCustomWorldSceneImage({
|
||
profile: {
|
||
id: 'custom-world-1',
|
||
name: '测试世界',
|
||
subtitle: '副标题',
|
||
summary: '世界概述',
|
||
tone: '世界基调',
|
||
playerGoal: '核心目标',
|
||
settingText: '原始设定',
|
||
},
|
||
landmark: {
|
||
id: 'landmark-1',
|
||
name: '雾潮码头',
|
||
description: '被潮雾与旧升降机包围的码头。',
|
||
},
|
||
userPrompt: '雨夜的栈桥横跨黑色海沟,塔楼灯火被潮雾吞没。',
|
||
size: '1280*720',
|
||
referenceImageSrc: '/scene_bg/reference-layout.png',
|
||
});
|
||
|
||
const sceneImageCalls = fetchMock.mock.calls.filter(
|
||
([url]) => url === '/api/runtime/custom-world/scene-image',
|
||
);
|
||
expect(sceneImageCalls).toHaveLength(1);
|
||
expect(sceneImageCalls[0]).toEqual([
|
||
'/api/runtime/custom-world/scene-image',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
headers: expect.objectContaining({
|
||
'Content-Type': 'application/json',
|
||
}),
|
||
}),
|
||
]);
|
||
const [, request] = sceneImageCalls[0] as [string, RequestInit];
|
||
const requestBody = JSON.parse(String(request.body)) as {
|
||
userPrompt: string;
|
||
referenceImageSrc?: string;
|
||
};
|
||
expect(requestBody.referenceImageSrc).toBe(
|
||
'/scene_bg/reference-layout.png',
|
||
);
|
||
expect(requestBody.userPrompt).toContain('雨夜的栈桥横跨黑色海沟');
|
||
expect(result).toEqual({
|
||
imageSrc: '/generated-custom-world-scenes/world/landmark/scene.png',
|
||
assetId: 'custom-scene-1',
|
||
model: 'wan2.7-image',
|
||
size: '1280*720',
|
||
taskId: 'task-123',
|
||
prompt: '系统整理后的提示词',
|
||
actualPrompt: '扩写后的提示词',
|
||
});
|
||
});
|
||
|
||
it('surfaces proxy error messages when scene image generation fails', async () => {
|
||
fetchMock.mockResolvedValue({
|
||
ok: false,
|
||
text: async () =>
|
||
JSON.stringify({
|
||
error: {
|
||
message: 'DashScope API key 无效。',
|
||
},
|
||
}),
|
||
} as Response);
|
||
|
||
await expect(
|
||
generateCustomWorldSceneImage({
|
||
profile: {
|
||
id: 'custom-world-1',
|
||
name: '测试世界',
|
||
subtitle: '副标题',
|
||
summary: '世界概述',
|
||
tone: '世界基调',
|
||
playerGoal: '核心目标',
|
||
settingText: '原始设定',
|
||
},
|
||
landmark: {
|
||
id: 'landmark-1',
|
||
name: '雾潮码头',
|
||
description: '被潮雾与旧升降机包围的码头。',
|
||
},
|
||
}),
|
||
).rejects.toThrow('DashScope API key 无效。');
|
||
});
|
||
});
|