Merge remote-tracking branch 'origin/master' into codex/ddd
# 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
This commit is contained in:
@@ -46,6 +46,7 @@ import {
|
||||
streamCharacterPanelChatReply,
|
||||
streamNpcRecruitDialogue,
|
||||
} from './ai';
|
||||
import { streamNpcChatTurn } from './aiService';
|
||||
import type { StoryGenerationContext } from './aiTypes';
|
||||
import type { CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
|
||||
|
||||
@@ -526,6 +527,50 @@ function createSseResponse(text: string) {
|
||||
} 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({
|
||||
@@ -535,6 +580,17 @@ describe('ai runtime client orchestration', () => {
|
||||
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[] = [];
|
||||
@@ -732,6 +788,86 @@ describe('ai runtime client orchestration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -60,6 +60,10 @@ function runtimeStoryMomentToAiResponse(
|
||||
};
|
||||
}
|
||||
|
||||
function getRuntimeSnapshotFromContext(context: StoryGenerationContext) {
|
||||
return context.runtimeSnapshot;
|
||||
}
|
||||
|
||||
async function requestPlainText(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
@@ -251,9 +255,11 @@ export async function generateCharacterPanelChatSuggestions(
|
||||
targetStatus: CharacterChatTargetStatus,
|
||||
) {
|
||||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||||
const snapshot = getRuntimeSnapshotFromContext(context);
|
||||
const payload = sessionId
|
||||
? ({
|
||||
sessionId,
|
||||
...(snapshot ? { snapshot } : {}),
|
||||
targetCharacter,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
@@ -289,9 +295,11 @@ export async function generateCharacterPanelChatSummary(
|
||||
targetStatus: CharacterChatTargetStatus,
|
||||
) {
|
||||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||||
const snapshot = getRuntimeSnapshotFromContext(context);
|
||||
const payload = sessionId
|
||||
? ({
|
||||
sessionId,
|
||||
...(snapshot ? { snapshot } : {}),
|
||||
targetCharacter,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
@@ -329,9 +337,11 @@ export async function streamCharacterPanelChatReply(
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||||
const snapshot = getRuntimeSnapshotFromContext(context);
|
||||
const payload = sessionId
|
||||
? ({
|
||||
sessionId,
|
||||
...(snapshot ? { snapshot } : {}),
|
||||
targetCharacter,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
@@ -370,9 +380,11 @@ export async function streamNpcChatDialogue(
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||||
const snapshot = getRuntimeSnapshotFromContext(context);
|
||||
const payload = sessionId
|
||||
? ({
|
||||
sessionId,
|
||||
...(snapshot ? { snapshot } : {}),
|
||||
encounter,
|
||||
topic,
|
||||
resultSummary,
|
||||
@@ -422,6 +434,7 @@ export async function streamNpcChatTurn(
|
||||
} = {},
|
||||
) {
|
||||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||||
const snapshot = getRuntimeSnapshotFromContext(context);
|
||||
const commonChatPayload = {
|
||||
encounter,
|
||||
conversationHistory: conversationHistory ?? [],
|
||||
@@ -440,15 +453,18 @@ export async function streamNpcChatTurn(
|
||||
chatDirective: options.chatDirective
|
||||
? {
|
||||
...options.chatDirective,
|
||||
functionOptions: options.chatDirective.functionOptions?.map((item) => ({
|
||||
...item,
|
||||
})),
|
||||
functionOptions: options.chatDirective.functionOptions?.map(
|
||||
(item) => ({
|
||||
...item,
|
||||
}),
|
||||
),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
const payload = sessionId
|
||||
? ({
|
||||
sessionId,
|
||||
...(snapshot ? { snapshot } : {}),
|
||||
...commonChatPayload,
|
||||
} satisfies NpcChatTurnRequest)
|
||||
: ({
|
||||
@@ -559,9 +575,11 @@ export async function streamNpcRecruitDialogue(
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||||
const snapshot = getRuntimeSnapshotFromContext(context);
|
||||
const payload = sessionId
|
||||
? ({
|
||||
sessionId,
|
||||
...(snapshot ? { snapshot } : {}),
|
||||
encounter,
|
||||
invitationText,
|
||||
recruitSummary,
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
EquipmentLoadout,
|
||||
FacingDirection,
|
||||
FactionTensionState,
|
||||
GameState,
|
||||
GoalStackState,
|
||||
InventoryItem,
|
||||
JourneyBeat,
|
||||
@@ -43,6 +44,7 @@ import type {
|
||||
WorldMutation,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import type { SavedGameSnapshotInput } from '../../packages/shared/src/contracts/runtime';
|
||||
import type { ConversationPressure, ConversationSituation } from '../types';
|
||||
|
||||
export interface StoryRequestOptions {
|
||||
@@ -91,6 +93,7 @@ export interface StoryGenerationContext {
|
||||
runtimeSessionId?: string | null;
|
||||
storySessionId?: string | null;
|
||||
runtimeActionVersion?: number;
|
||||
runtimeSnapshot?: SavedGameSnapshotInput<GameState, string, StoryMoment>;
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
playerMana: number;
|
||||
|
||||
@@ -457,4 +457,42 @@ describe('apiClient', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('uses api error details.message as ApiClientError message', async () => {
|
||||
setStoredAccessToken('details-message-token', { emit: false });
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 400,
|
||||
body: JSON.stringify({
|
||||
ok: false,
|
||||
data: null,
|
||||
error: {
|
||||
code: 'BAD_REQUEST',
|
||||
message: '请求参数不合法',
|
||||
details: {
|
||||
provider: 'dashscope',
|
||||
message: '拼图图片生成失败:请求参数不合法',
|
||||
},
|
||||
},
|
||||
meta: {},
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
requestJson('/api/runtime/puzzle/agent/sessions/test/actions', {
|
||||
method: 'POST',
|
||||
}, '执行拼图操作失败。'),
|
||||
).rejects.toMatchObject({
|
||||
message: '拼图图片生成失败:请求参数不合法',
|
||||
status: 400,
|
||||
code: 'BAD_REQUEST',
|
||||
details: {
|
||||
provider: 'dashscope',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,14 +26,16 @@ import {
|
||||
getAuthLoginOptions,
|
||||
getAuthRiskBlocks,
|
||||
getAuthSessions,
|
||||
getPublicAuthUserById,
|
||||
getCaptchaChallengeFromError,
|
||||
getCurrentAuthUser,
|
||||
getPublicAuthUserById,
|
||||
liftAuthRiskBlock,
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
redeemRegistrationInviteCode,
|
||||
sendPhoneLoginCode,
|
||||
startWechatLogin,
|
||||
updateAuthProfile,
|
||||
} from './authService';
|
||||
|
||||
function createLocalStorageMock() {
|
||||
@@ -87,10 +89,12 @@ describe('authService', () => {
|
||||
publicUserCode: 'SY-00000001',
|
||||
username: 'phone_00000001',
|
||||
displayName: '138****8000',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -115,6 +119,41 @@ describe('authService', () => {
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update profile trims nickname and posts avatar data url', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user_1',
|
||||
publicUserCode: 'SY-00000001',
|
||||
username: 'phone_00000001',
|
||||
displayName: '旅人甲',
|
||||
avatarUrl: 'data:image/png;base64,AAAA',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
const user = await updateAuthProfile({
|
||||
displayName: ' 旅人甲 ',
|
||||
avatarDataUrl: ' data:image/png;base64,AAAA ',
|
||||
});
|
||||
|
||||
expect(user.avatarUrl).toBe('data:image/png;base64,AAAA');
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/profile/me',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
displayName: '旅人甲',
|
||||
avatarDataUrl: 'data:image/png;base64,AAAA',
|
||||
}),
|
||||
}),
|
||||
'更新资料失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends phone login code through the auth endpoint', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -175,22 +214,29 @@ describe('authService', () => {
|
||||
publicUserCode: 'SY-00000004',
|
||||
username: '138****8000',
|
||||
displayName: '138****8000',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
const user = await loginWithPhoneCode('13800138000', '123456');
|
||||
const response = await loginWithPhoneCode(
|
||||
'13800138000',
|
||||
'123456',
|
||||
'spring-2026',
|
||||
);
|
||||
|
||||
expect(user.username).toBe('138****8000');
|
||||
expect(response.user.username).toBe('138****8000');
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/login',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
code: '123456',
|
||||
inviteCode: 'SPRING2026',
|
||||
}),
|
||||
}),
|
||||
'登录失败',
|
||||
@@ -203,6 +249,42 @@ describe('authService', () => {
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redeems registration invite code after authenticated new account login', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
center: {
|
||||
inviteCode: 'SY12345678',
|
||||
inviteLinkPath: '/?inviteCode=SY12345678',
|
||||
invitedCount: 1,
|
||||
rewardedInviteCount: 1,
|
||||
todayInviterRewardCount: 0,
|
||||
todayInviterRewardRemaining: 3,
|
||||
rewardPoints: 30,
|
||||
hasRedeemedCode: true,
|
||||
boundInviterUserId: 'user_inviter',
|
||||
boundAt: '2026-05-01T00:00:00Z',
|
||||
updatedAt: '2026-05-01T00:00:00Z',
|
||||
},
|
||||
inviteeRewardGranted: true,
|
||||
inviterRewardGranted: true,
|
||||
inviteeBalanceAfter: 30,
|
||||
inviterBalanceAfter: 30,
|
||||
});
|
||||
|
||||
const response = await redeemRegistrationInviteCode(' spring-2026 ');
|
||||
|
||||
expect(response.inviteeRewardGranted).toBe(true);
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/profile/referrals/redeem-code',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
inviteCode: 'SPRING2026',
|
||||
}),
|
||||
}),
|
||||
'填写邀请码失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('stores renewed access token after wechat bind activation', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
token: 'jwt-wechat-bind-token',
|
||||
@@ -211,10 +293,12 @@ describe('authService', () => {
|
||||
publicUserCode: 'SY-00000005',
|
||||
username: '138****8000',
|
||||
displayName: '138****8000',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'wechat',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: true,
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -232,10 +316,12 @@ describe('authService', () => {
|
||||
publicUserCode: 'SY-00000006',
|
||||
username: '139****9000',
|
||||
displayName: '139****9000',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: '139****9000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -13,17 +13,19 @@ import type {
|
||||
AuthPhoneChangeResponse,
|
||||
AuthPhoneLoginResponse,
|
||||
AuthPhoneSendCodeResponse,
|
||||
AuthProfileUpdateRequest,
|
||||
AuthProfileUpdateResponse,
|
||||
AuthRevokeSessionResponse,
|
||||
AuthRiskBlocksResponse,
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionsResponse,
|
||||
AuthSessionSummary,
|
||||
AuthUser,
|
||||
AuthWechatBindPhoneResponse,
|
||||
AuthWechatStartResponse,
|
||||
LogoutResponse,
|
||||
PublicUserSearchResponse,
|
||||
} from '../../packages/shared/src/contracts/auth';
|
||||
import type { RedeemProfileReferralInviteCodeResponse } from '../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
ApiClientError,
|
||||
type ApiRequestOptions,
|
||||
@@ -64,6 +66,13 @@ export function normalizePhoneInput(phoneInput: string) {
|
||||
return phoneInput.replace(/[^\d+]/gu, '').trim();
|
||||
}
|
||||
|
||||
export function normalizeInviteCodeInput(inviteCode: string | undefined) {
|
||||
return (inviteCode ?? '')
|
||||
.trim()
|
||||
.replace(/[^0-9a-z]/gi, '')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
export function getStoredLastLoginPhone() {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
@@ -144,7 +153,12 @@ export async function sendPhoneLoginCode(
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function loginWithPhoneCode(phone: string, code: string) {
|
||||
export async function loginWithPhoneCode(
|
||||
phone: string,
|
||||
code: string,
|
||||
inviteCode?: string,
|
||||
) {
|
||||
const normalizedInviteCode = normalizeInviteCodeInput(inviteCode);
|
||||
const response = await requestJson<AuthPhoneLoginResponse>(
|
||||
'/api/auth/phone/login',
|
||||
{
|
||||
@@ -153,6 +167,7 @@ export async function loginWithPhoneCode(phone: string, code: string) {
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
...(normalizedInviteCode ? { inviteCode: normalizedInviteCode } : {}),
|
||||
}),
|
||||
},
|
||||
'登录失败',
|
||||
@@ -160,7 +175,21 @@ export async function loginWithPhoneCode(phone: string, code: string) {
|
||||
);
|
||||
|
||||
setStoredAccessToken(response.token, { emit: false });
|
||||
return response.user;
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function redeemRegistrationInviteCode(inviteCode: string) {
|
||||
return requestJson<RedeemProfileReferralInviteCodeResponse>(
|
||||
'/api/profile/referrals/redeem-code',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
inviteCode: normalizeInviteCodeInput(inviteCode),
|
||||
}),
|
||||
},
|
||||
'填写邀请码失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function bindWechatPhone(phone: string, code: string) {
|
||||
@@ -261,6 +290,23 @@ export async function changePassword(
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function updateAuthProfile(payload: AuthProfileUpdateRequest) {
|
||||
const response = await requestJson<AuthProfileUpdateResponse>(
|
||||
'/api/profile/me',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
displayName: payload.displayName?.trim() || undefined,
|
||||
avatarDataUrl: payload.avatarDataUrl?.trim() || undefined,
|
||||
}),
|
||||
},
|
||||
'更新资料失败',
|
||||
);
|
||||
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function resetPassword(
|
||||
phone: string,
|
||||
code: string,
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock('../apiClient', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { listBigFishGallery } from './bigFishGalleryClient';
|
||||
import { likeBigFishGalleryWork, listBigFishGallery } from './bigFishGalleryClient';
|
||||
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
@@ -42,3 +42,15 @@ test('listBigFishGallery keeps non-gallery-read errors visible', async () => {
|
||||
|
||||
await expect(listBigFishGallery()).rejects.toBe(error);
|
||||
});
|
||||
|
||||
test('likeBigFishGalleryWork posts to authenticated like route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({ items: [] });
|
||||
|
||||
await likeBigFishGalleryWork('big-fish-session-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/big-fish/gallery/big-fish-session-1/like',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'点赞大鱼吃小鱼作品失败',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BigFishSessionResponse } from '../../../packages/shared/src/contracts/bigFish';
|
||||
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import { ApiClientError, type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
@@ -36,6 +37,34 @@ export async function listBigFishGallery() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将公开大鱼吃小鱼作品复制为当前用户草稿。
|
||||
*/
|
||||
export async function remixBigFishGalleryWork(sessionId: string) {
|
||||
return requestJson<BigFishSessionResponse>(
|
||||
`${BIG_FISH_GALLERY_API_BASE}/${encodeURIComponent(sessionId)}/remix`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'Remix 大鱼吃小鱼作品失败',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 点赞公开大鱼吃小鱼作品,后端按当前登录用户做幂等计数。
|
||||
*/
|
||||
export async function likeBigFishGalleryWork(sessionId: string) {
|
||||
return requestJson<BigFishWorksResponse>(
|
||||
`${BIG_FISH_GALLERY_API_BASE}/${encodeURIComponent(sessionId)}/like`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'点赞大鱼吃小鱼作品失败',
|
||||
);
|
||||
}
|
||||
|
||||
export const bigFishGalleryClient = {
|
||||
like: likeBigFishGalleryWork,
|
||||
list: listBigFishGallery,
|
||||
remix: remixBigFishGalleryWork,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export {
|
||||
bigFishGalleryClient,
|
||||
likeBigFishGalleryWork,
|
||||
listBigFishGallery,
|
||||
remixBigFishGalleryWork,
|
||||
} from './bigFishGalleryClient';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
createMatch3DCreationSession,
|
||||
executeMatch3DCreationAction,
|
||||
getMatch3DCreationSession,
|
||||
match3dCreationClient,
|
||||
streamMatch3DCreationMessage,
|
||||
} from './match3dCreationClient';
|
||||
@@ -0,0 +1,88 @@
|
||||
import type {
|
||||
CreateMatch3DSessionRequest,
|
||||
ExecuteMatch3DActionRequest,
|
||||
Match3DActionResponse,
|
||||
Match3DAgentSessionSnapshot,
|
||||
Match3DSessionResponse,
|
||||
SendMatch3DMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
import { createCreationAgentClient } from '../creation-agent';
|
||||
|
||||
const MATCH3D_AGENT_API_BASE = '/api/creation/match3d/sessions';
|
||||
|
||||
const match3dAgentHttpClient = createCreationAgentClient<
|
||||
CreateMatch3DSessionRequest,
|
||||
Match3DSessionResponse,
|
||||
Match3DSessionResponse,
|
||||
Match3DAgentSessionSnapshot,
|
||||
SendMatch3DMessageRequest,
|
||||
Match3DSessionResponse,
|
||||
ExecuteMatch3DActionRequest,
|
||||
Match3DActionResponse
|
||||
>({
|
||||
apiBase: MATCH3D_AGENT_API_BASE,
|
||||
messages: {
|
||||
createSession: '创建抓大鹅共创会话失败',
|
||||
getSession: '读取抓大鹅共创会话失败',
|
||||
sendMessage: '发送抓大鹅共创消息失败',
|
||||
streamIncomplete: '抓大鹅共创消息流式结果不完整',
|
||||
executeAction: '执行抓大鹅共创操作失败',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建抓大鹅 Agent 共创会话。
|
||||
* Q1 起前端只走 Axum facade,避免本地 mock 成为创作真相源。
|
||||
*/
|
||||
export function createMatch3DCreationSession(
|
||||
payload: CreateMatch3DSessionRequest = {},
|
||||
) {
|
||||
return match3dAgentHttpClient.createSession(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取抓大鹅 Agent 会话快照。
|
||||
*/
|
||||
export function getMatch3DCreationSession(sessionId: string) {
|
||||
return match3dAgentHttpClient.getSession(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 非流式发送抓大鹅 Agent 消息,保留为 SSE 降级入口。
|
||||
*/
|
||||
export function sendMatch3DCreationMessage(
|
||||
sessionId: string,
|
||||
payload: SendMatch3DMessageRequest,
|
||||
) {
|
||||
return match3dAgentHttpClient.sendMessage(sessionId, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式发送抓大鹅 Agent 消息。
|
||||
*/
|
||||
export function streamMatch3DCreationMessage(
|
||||
sessionId: string,
|
||||
payload: SendMatch3DMessageRequest,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
return match3dAgentHttpClient.streamMessage(sessionId, payload, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行抓大鹅创作操作,例如生成草稿作品。
|
||||
*/
|
||||
export function executeMatch3DCreationAction(
|
||||
sessionId: string,
|
||||
payload: ExecuteMatch3DActionRequest,
|
||||
) {
|
||||
return match3dAgentHttpClient.executeAction(sessionId, payload);
|
||||
}
|
||||
|
||||
export const match3dCreationClient = {
|
||||
createSession: createMatch3DCreationSession,
|
||||
getSession: getMatch3DCreationSession,
|
||||
sendMessage: sendMatch3DCreationMessage,
|
||||
streamMessage: streamMatch3DCreationMessage,
|
||||
executeAction: executeMatch3DCreationAction,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
export {
|
||||
buildLocalMatch3DOptimisticRun,
|
||||
confirmLocalMatch3DClick,
|
||||
MATCH3D_VISUAL_SEEDS,
|
||||
resolveLocalMatch3DTimer,
|
||||
startLocalMatch3DRun,
|
||||
stopLocalMatch3DRun,
|
||||
} from './match3dLocalRuntime';
|
||||
export {
|
||||
clickMatch3DItem,
|
||||
finishMatch3DTimeUp,
|
||||
getMatch3DRun,
|
||||
match3dRuntimeClient,
|
||||
restartMatch3DRun,
|
||||
startMatch3DRun,
|
||||
stopMatch3DRun,
|
||||
} from './match3dRuntimeClient';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,162 @@
|
||||
import type {
|
||||
Match3DClickConfirmation,
|
||||
Match3DClickItemRequest,
|
||||
Match3DClickItemResult,
|
||||
Match3DClickRejectReason,
|
||||
Match3DClickResponse,
|
||||
Match3DRunResponse,
|
||||
StopMatch3DRunRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const MATCH3D_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
const MATCH3D_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
function normalizeRejectStatus(reason?: Match3DClickRejectReason | null) {
|
||||
switch (reason) {
|
||||
case 'snapshot_version_mismatch':
|
||||
return 'VersionConflict';
|
||||
case 'tray_full':
|
||||
return 'RejectedTrayFull';
|
||||
case 'run_not_active':
|
||||
return 'RunFinished';
|
||||
case 'item_not_found':
|
||||
case 'item_not_in_board':
|
||||
return 'RejectedAlreadyMoved';
|
||||
case 'item_not_clickable':
|
||||
default:
|
||||
return 'RejectedNotClickable';
|
||||
}
|
||||
}
|
||||
|
||||
function mapClickConfirmation(
|
||||
request: Match3DClickItemRequest,
|
||||
confirmation: Match3DClickConfirmation,
|
||||
): Match3DClickItemResult {
|
||||
return {
|
||||
status: confirmation.accepted
|
||||
? 'Accepted'
|
||||
: normalizeRejectStatus(confirmation.rejectReason),
|
||||
run: confirmation.run,
|
||||
acceptedItemInstanceId: confirmation.accepted
|
||||
? request.itemInstanceId
|
||||
: undefined,
|
||||
clearedItemInstanceIds: confirmation.clearedItemInstanceIds,
|
||||
failureReason: confirmation.run.failureReason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于作品启动一局抓大鹅正式 run。
|
||||
*/
|
||||
export function startMatch3DRun(profileId: string) {
|
||||
return requestJson<Match3DRunResponse>(
|
||||
`/api/runtime/match3d/works/${encodeURIComponent(profileId)}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profileId }),
|
||||
},
|
||||
'启动抓大鹅玩法失败',
|
||||
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取抓大鹅运行态快照。
|
||||
*/
|
||||
export function getMatch3DRun(runId: string) {
|
||||
return requestJson<Match3DRunResponse>(
|
||||
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取抓大鹅运行快照失败',
|
||||
{ retry: MATCH3D_RUNTIME_READ_RETRY },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交一次点击,由后端做权威确认;返回值适配运行壳已实现的即时反馈语义。
|
||||
*/
|
||||
export async function clickMatch3DItem(
|
||||
runId: string,
|
||||
payload: Match3DClickItemRequest,
|
||||
) {
|
||||
const response = await requestJson<Match3DClickResponse>(
|
||||
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/click`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
runId: payload.runId ?? runId,
|
||||
}),
|
||||
},
|
||||
'确认抓大鹅点击失败',
|
||||
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
|
||||
return mapClickConfirmation(payload, response.confirmation);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止当前抓大鹅运行态。
|
||||
*/
|
||||
export function stopMatch3DRun(
|
||||
runId: string,
|
||||
payload: StopMatch3DRunRequest = {
|
||||
clientActionId: `match3d-stop-${Date.now()}`,
|
||||
},
|
||||
) {
|
||||
return requestJson<Match3DRunResponse>(
|
||||
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/stop`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'停止抓大鹅玩法失败',
|
||||
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于当前 run 重开一局。
|
||||
*/
|
||||
export function restartMatch3DRun(runId: string) {
|
||||
return requestJson<Match3DRunResponse>(
|
||||
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/restart`,
|
||||
{ method: 'POST' },
|
||||
'重新开始抓大鹅玩法失败',
|
||||
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端倒计时归零后通知后端确认失败状态。
|
||||
*/
|
||||
export function finishMatch3DTimeUp(runId: string) {
|
||||
return requestJson<Match3DRunResponse>(
|
||||
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/time-up`,
|
||||
{ method: 'POST' },
|
||||
'同步抓大鹅倒计时失败',
|
||||
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
}
|
||||
|
||||
export const match3dRuntimeClient = {
|
||||
clickItem: clickMatch3DItem,
|
||||
finishTimeUp: finishMatch3DTimeUp,
|
||||
getRun: getMatch3DRun,
|
||||
restartRun: restartMatch3DRun,
|
||||
startRun: startMatch3DRun,
|
||||
stopRun: stopMatch3DRun,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
deleteMatch3DWork,
|
||||
getMatch3DWorkDetail,
|
||||
listMatch3DGallery,
|
||||
listMatch3DWorks,
|
||||
match3dWorksClient,
|
||||
publishMatch3DWork,
|
||||
updateMatch3DWork,
|
||||
} from './match3dWorksClient';
|
||||
@@ -0,0 +1,113 @@
|
||||
import type {
|
||||
Match3DWorkDetailResponse,
|
||||
Match3DWorkMutationResponse,
|
||||
Match3DWorksResponse,
|
||||
PutMatch3DWorkRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const MATCH3D_WORKS_API_BASE = '/api/creation/match3d/works';
|
||||
const MATCH3D_GALLERY_API_BASE = '/api/runtime/match3d/gallery';
|
||||
const MATCH3D_WORKS_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
const MATCH3D_WORKS_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取当前用户的抓大鹅作品列表。
|
||||
*/
|
||||
export function listMatch3DWorks() {
|
||||
return requestJson<Match3DWorksResponse>(
|
||||
MATCH3D_WORKS_API_BASE,
|
||||
{ method: 'GET' },
|
||||
'读取抓大鹅作品列表失败',
|
||||
{ retry: MATCH3D_WORKS_READ_RETRY },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取公开抓大鹅作品列表。
|
||||
*/
|
||||
export function listMatch3DGallery() {
|
||||
return requestJson<Match3DWorksResponse>(
|
||||
MATCH3D_GALLERY_API_BASE,
|
||||
{ method: 'GET' },
|
||||
'读取抓大鹅广场失败',
|
||||
{
|
||||
retry: MATCH3D_WORKS_READ_RETRY,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取抓大鹅作品详情。
|
||||
*/
|
||||
export function getMatch3DWorkDetail(profileId: string) {
|
||||
return requestJson<Match3DWorkDetailResponse>(
|
||||
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取抓大鹅作品详情失败',
|
||||
{ retry: MATCH3D_WORKS_READ_RETRY },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存结果页可编辑字段。
|
||||
*/
|
||||
export function updateMatch3DWork(
|
||||
profileId: string,
|
||||
payload: PutMatch3DWorkRequest,
|
||||
) {
|
||||
return requestJson<Match3DWorkMutationResponse>(
|
||||
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'更新抓大鹅作品失败',
|
||||
{ retry: MATCH3D_WORKS_WRITE_RETRY },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布抓大鹅作品。发布门槛由后端最终确认。
|
||||
*/
|
||||
export function publishMatch3DWork(profileId: string) {
|
||||
return requestJson<Match3DWorkMutationResponse>(
|
||||
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}/publish`,
|
||||
{ method: 'POST' },
|
||||
'发布抓大鹅作品失败',
|
||||
{ retry: MATCH3D_WORKS_WRITE_RETRY },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除当前用户的抓大鹅作品,并返回删除后的列表。
|
||||
*/
|
||||
export function deleteMatch3DWork(profileId: string) {
|
||||
return requestJson<Match3DWorksResponse>(
|
||||
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除抓大鹅作品失败',
|
||||
{ retry: MATCH3D_WORKS_WRITE_RETRY },
|
||||
);
|
||||
}
|
||||
|
||||
export const match3dWorksClient = {
|
||||
delete: deleteMatch3DWork,
|
||||
getDetail: getMatch3DWorkDetail,
|
||||
listGallery: listMatch3DGallery,
|
||||
list: listMatch3DWorks,
|
||||
publish: publishMatch3DWork,
|
||||
update: updateMatch3DWork,
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildMiniGameDraftGenerationProgress,
|
||||
buildPuzzleGenerationAnchorEntries,
|
||||
type MiniGameDraftGenerationState,
|
||||
} from './miniGameDraftGenerationProgress';
|
||||
|
||||
@@ -63,4 +64,76 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
'玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。',
|
||||
);
|
||||
});
|
||||
|
||||
test('puzzle generation anchors expose form payload as the display source', () => {
|
||||
const entries = buildPuzzleGenerationAnchorEntries({
|
||||
sessionId: 'puzzle-session-1',
|
||||
currentTurn: 1,
|
||||
progressPercent: 0,
|
||||
stage: 'collecting_anchors',
|
||||
anchorPack: {
|
||||
themePromise: {
|
||||
key: 'themePromise',
|
||||
label: '题材承诺',
|
||||
value: '雨夜猫街',
|
||||
status: 'locked',
|
||||
},
|
||||
visualSubject: {
|
||||
key: 'visualSubject',
|
||||
label: '画面主体',
|
||||
value: '一只猫在雨夜灯牌下回头。',
|
||||
status: 'locked',
|
||||
},
|
||||
visualMood: {
|
||||
key: 'visualMood',
|
||||
label: '视觉气质',
|
||||
value: '清晰、适合拼图切块',
|
||||
status: 'inferred',
|
||||
},
|
||||
compositionHooks: {
|
||||
key: 'compositionHooks',
|
||||
label: '拼图记忆点',
|
||||
value: '主体轮廓、色块分区、局部细节',
|
||||
status: 'inferred',
|
||||
},
|
||||
tagsAndForbidden: {
|
||||
key: 'tagsAndForbidden',
|
||||
label: '标签与禁忌',
|
||||
value: '猫咪、雨夜、拼图;禁止标题字',
|
||||
status: 'inferred',
|
||||
},
|
||||
},
|
||||
draft: null,
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
}, {
|
||||
seedText: '表单作品名',
|
||||
workTitle: '暖灯猫街',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: null,
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 'puzzle-title',
|
||||
label: '作品名称',
|
||||
value: '暖灯猫街',
|
||||
},
|
||||
{
|
||||
id: 'work-description',
|
||||
label: '作品描述',
|
||||
value: '一套雨夜猫街主题拼图。',
|
||||
},
|
||||
{
|
||||
id: 'picture-description',
|
||||
label: '画面描述',
|
||||
value: '一只猫在雨夜灯牌下回头。',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { BigFishSessionSnapshotResponse } from '../../packages/shared/src/contracts/bigFish';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
CustomWorldGenerationStep,
|
||||
@@ -109,18 +112,15 @@ function buildMiniGameProgressSteps(
|
||||
) {
|
||||
return steps.map((step, index) => {
|
||||
const isCompleted = state.phase === 'ready' || index < activeStepIndex;
|
||||
const isActive = state.phase !== 'failed' && !isCompleted && index === activeStepIndex;
|
||||
const isActive =
|
||||
state.phase !== 'failed' && !isCompleted && index === activeStepIndex;
|
||||
const isAssetStep = step.id === state.phase && state.totalAssetCount > 0;
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
label: step.label,
|
||||
detail: step.detail,
|
||||
completed: isCompleted
|
||||
? 1
|
||||
: isAssetStep
|
||||
? state.completedAssetCount
|
||||
: 0,
|
||||
completed: isCompleted ? 1 : isAssetStep ? state.completedAssetCount : 0,
|
||||
total: isAssetStep ? state.totalAssetCount : 1,
|
||||
status: isCompleted ? 'completed' : isActive ? 'active' : 'pending',
|
||||
} satisfies CustomWorldGenerationStep;
|
||||
@@ -140,7 +140,9 @@ export function createMiniGameDraftGenerationState(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBigFishPhaseByElapsedMs(elapsedMs: number): MiniGameDraftGenerationPhase {
|
||||
function resolveBigFishPhaseByElapsedMs(
|
||||
elapsedMs: number,
|
||||
): MiniGameDraftGenerationPhase {
|
||||
if (elapsedMs >= 4_500) {
|
||||
return 'big-fish-runtime';
|
||||
}
|
||||
@@ -172,12 +174,18 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
const steps = getStepDefinitions(normalizedState.kind);
|
||||
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
|
||||
const completedWeight = steps
|
||||
.slice(0, normalizedState.phase === 'ready' ? steps.length : activeStepIndex)
|
||||
.slice(
|
||||
0,
|
||||
normalizedState.phase === 'ready' ? steps.length : activeStepIndex,
|
||||
)
|
||||
.reduce((sum, step) => sum + step.weight, 0);
|
||||
const activeStep = steps[activeStepIndex] ?? steps[0];
|
||||
const assetRatio =
|
||||
normalizedState.totalAssetCount > 0
|
||||
? Math.min(1, normalizedState.completedAssetCount / normalizedState.totalAssetCount)
|
||||
? Math.min(
|
||||
1,
|
||||
normalizedState.completedAssetCount / normalizedState.totalAssetCount,
|
||||
)
|
||||
: normalizedState.phase === 'ready'
|
||||
? 1
|
||||
: normalizedState.kind === 'big-fish'
|
||||
@@ -223,32 +231,38 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
|
||||
export function buildPuzzleGenerationAnchorEntries(
|
||||
session: PuzzleAgentSessionSnapshot | null | undefined,
|
||||
formPayload: CreatePuzzleAgentSessionRequest | null | undefined = null,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
if (!session) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const draft = session.draft;
|
||||
const entries: Array<MiniGameAnchorSource | null> = [
|
||||
session.anchorPack.themePromise,
|
||||
session.anchorPack.visualSubject,
|
||||
session.anchorPack.visualMood,
|
||||
session.anchorPack.compositionHooks,
|
||||
session.anchorPack.tagsAndForbidden,
|
||||
draft
|
||||
? {
|
||||
key: 'draft-summary',
|
||||
label: '草稿摘要',
|
||||
value: draft.summary,
|
||||
}
|
||||
: null,
|
||||
draft?.coverImageSrc
|
||||
? {
|
||||
key: 'cover-image',
|
||||
label: '正式图片',
|
||||
value: '已生成并应用',
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: 'puzzle-title',
|
||||
label: '作品名称',
|
||||
value:
|
||||
formPayload?.workTitle?.trim() ||
|
||||
formPayload?.seedText?.trim() ||
|
||||
session.draft?.workTitle ||
|
||||
session.anchorPack.themePromise.value,
|
||||
},
|
||||
{
|
||||
key: 'work-description',
|
||||
label: '作品描述',
|
||||
value:
|
||||
formPayload?.workDescription?.trim() ||
|
||||
session.draft?.workDescription ||
|
||||
'',
|
||||
},
|
||||
{
|
||||
key: 'picture-description',
|
||||
label: '画面描述',
|
||||
value:
|
||||
formPayload?.pictureDescription?.trim() ||
|
||||
session.draft?.levels?.[0]?.pictureDescription ||
|
||||
session.anchorPack.visualSubject.value,
|
||||
},
|
||||
];
|
||||
|
||||
return entries
|
||||
@@ -283,7 +297,10 @@ export function buildBigFishGenerationAnchorEntries(
|
||||
key: 'level-characters',
|
||||
label: '角色描述',
|
||||
value: draft.levels
|
||||
.map((level) => `Lv.${level.level} ${level.name}:${level.oneLineFantasy}`)
|
||||
.map(
|
||||
(level) =>
|
||||
`Lv.${level.level} ${level.name}:${level.oneLineFantasy}`,
|
||||
)
|
||||
.join('\n'),
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -21,6 +21,14 @@ export function buildBigFishPublicWorkCode(sessionId: string) {
|
||||
return `BF-${suffix}`;
|
||||
}
|
||||
|
||||
export function buildMatch3DPublicWorkCode(profileId: string) {
|
||||
const normalized = normalizePublicCodeText(profileId);
|
||||
const fallback = normalized || '00000000';
|
||||
const suffix = fallback.slice(-8).padStart(8, '0');
|
||||
|
||||
return `M3-${suffix}`;
|
||||
}
|
||||
|
||||
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
|
||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||
|
||||
@@ -43,3 +51,13 @@ export function isSameBigFishPublicWorkCode(
|
||||
normalizedKeyword === normalizePublicCodeText(sessionId)
|
||||
);
|
||||
}
|
||||
|
||||
export function isSameMatch3DPublicWorkCode(keyword: string, profileId: string) {
|
||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||
|
||||
return (
|
||||
normalizedKeyword ===
|
||||
normalizePublicCodeText(buildMatch3DPublicWorkCode(profileId)) ||
|
||||
normalizedKeyword === normalizePublicCodeText(profileId)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export {
|
||||
getPuzzleGalleryDetail,
|
||||
likePuzzleGalleryWork,
|
||||
listPuzzleGallery,
|
||||
puzzleGalleryClient,
|
||||
remixPuzzleGalleryWork,
|
||||
} from './puzzleGalleryClient';
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
getPuzzleGalleryDetail,
|
||||
likePuzzleGalleryWork,
|
||||
listPuzzleGallery,
|
||||
} from './puzzleGalleryClient';
|
||||
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ items: [] });
|
||||
});
|
||||
|
||||
test('listPuzzleGallery reads public gallery without auth refresh coupling', async () => {
|
||||
await listPuzzleGallery();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/puzzle/gallery',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取拼图广场失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('getPuzzleGalleryDetail reads public detail without auth refresh coupling', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
item: {
|
||||
profileId: 'puzzle-profile-1',
|
||||
},
|
||||
});
|
||||
|
||||
await getPuzzleGalleryDetail('puzzle-profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/puzzle/gallery/puzzle-profile-1',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取拼图广场详情失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('likePuzzleGalleryWork posts to authenticated like route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
item: {
|
||||
profileId: 'puzzle-profile-1',
|
||||
likeCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
await likePuzzleGalleryWork('puzzle-profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/puzzle/gallery/puzzle-profile-1/like',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'点赞拼图作品失败',
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
import type {
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type {
|
||||
PuzzleWorksResponse,
|
||||
PuzzleWorkSummary,
|
||||
@@ -23,6 +26,8 @@ export async function listPuzzleGallery() {
|
||||
'读取拼图广场失败',
|
||||
{
|
||||
retry: PUZZLE_GALLERY_READ_RETRY,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -39,11 +44,41 @@ export async function getPuzzleGalleryDetail(profileId: string) {
|
||||
'读取拼图广场详情失败',
|
||||
{
|
||||
retry: PUZZLE_GALLERY_READ_RETRY,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 点赞公开拼图作品,后端按当前登录用户做幂等计数。
|
||||
*/
|
||||
export async function likePuzzleGalleryWork(profileId: string) {
|
||||
return requestJson<{ item: PuzzleWorkSummary }>(
|
||||
`${PUZZLE_GALLERY_API_BASE}/${encodeURIComponent(profileId)}/like`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'点赞拼图作品失败',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将公开拼图作品复制为当前用户的草稿。
|
||||
*/
|
||||
export async function remixPuzzleGalleryWork(profileId: string) {
|
||||
return requestJson<{ session: PuzzleAgentSessionSnapshot }>(
|
||||
`${PUZZLE_GALLERY_API_BASE}/${encodeURIComponent(profileId)}/remix`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'Remix 拼图作品失败',
|
||||
);
|
||||
}
|
||||
|
||||
export const puzzleGalleryClient = {
|
||||
getDetail: getPuzzleGalleryDetail,
|
||||
like: likePuzzleGalleryWork,
|
||||
list: listPuzzleGallery,
|
||||
remix: remixPuzzleGalleryWork,
|
||||
};
|
||||
|
||||
@@ -6,4 +6,6 @@ export {
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
swapPuzzlePieces,
|
||||
updatePuzzleRunPause,
|
||||
usePuzzleRuntimeProp,
|
||||
} from './puzzleRuntimeClient';
|
||||
|
||||
@@ -3,9 +3,15 @@
|
||||
import type { PuzzlePieceState } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import {
|
||||
applyLocalPuzzleFreezeTime,
|
||||
advanceLocalPuzzleLevel,
|
||||
dragLocalPuzzlePiece,
|
||||
extendLocalPuzzleTime,
|
||||
isLocalPuzzleRun,
|
||||
refreshLocalPuzzleTimer,
|
||||
restartLocalPuzzleLevel,
|
||||
resolvePuzzleRestartLevelId,
|
||||
setLocalPuzzlePaused,
|
||||
startLocalPuzzleRun,
|
||||
submitLocalPuzzleLeaderboard,
|
||||
swapLocalPuzzlePieces,
|
||||
@@ -26,6 +32,7 @@ const baseWork: PuzzleWorkSummary = {
|
||||
updatedAt: '2026-04-25T00:00:00.000Z',
|
||||
publishedAt: '2026-04-25T00:00:00.000Z',
|
||||
playCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
};
|
||||
|
||||
@@ -80,6 +87,50 @@ function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
|
||||
}
|
||||
|
||||
describe('puzzleLocalRuntime', () => {
|
||||
test('本地关卡切割和倒计时按正式配置推进并循环', () => {
|
||||
let run = startLocalPuzzleRun(baseWork);
|
||||
const actual = [run];
|
||||
for (let index = 0; index < 15; index += 1) {
|
||||
run = advanceLocalPuzzleLevel({
|
||||
...run,
|
||||
clearedLevelCount: run.clearedLevelCount + 1,
|
||||
currentLevel: run.currentLevel
|
||||
? {
|
||||
...run.currentLevel,
|
||||
status: 'cleared',
|
||||
clearedAtMs: Date.now(),
|
||||
elapsedMs: 1_000,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
actual.push(run);
|
||||
}
|
||||
|
||||
expect(
|
||||
actual.map((item) => [
|
||||
item.currentLevel?.gridSize,
|
||||
item.currentLevel?.timeLimitMs,
|
||||
]),
|
||||
).toEqual([
|
||||
[3, 300_000],
|
||||
[4, 300_000],
|
||||
[5, 300_000],
|
||||
[5, 210_000],
|
||||
[5, 210_000],
|
||||
[6, 240_000],
|
||||
[5, 210_000],
|
||||
[7, 270_000],
|
||||
[5, 240_000],
|
||||
[7, 270_000],
|
||||
[5, 210_000],
|
||||
[6, 240_000],
|
||||
[5, 210_000],
|
||||
[7, 270_000],
|
||||
[5, 240_000],
|
||||
[7, 270_000],
|
||||
]);
|
||||
});
|
||||
|
||||
test('每次启动都会生成不同的初始打乱样式', async () => {
|
||||
const firstRun = startLocalPuzzleRun(baseWork);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
@@ -88,10 +139,9 @@ describe('puzzleLocalRuntime', () => {
|
||||
piece.currentRow,
|
||||
piece.currentCol,
|
||||
]);
|
||||
const secondPositions = secondRun.currentLevel?.board.pieces.map((piece) => [
|
||||
piece.currentRow,
|
||||
piece.currentCol,
|
||||
]);
|
||||
const secondPositions = secondRun.currentLevel?.board.pieces.map(
|
||||
(piece) => [piece.currentRow, piece.currentCol],
|
||||
);
|
||||
|
||||
expect(firstPositions).not.toEqual(secondPositions);
|
||||
});
|
||||
@@ -132,7 +182,10 @@ describe('puzzleLocalRuntime', () => {
|
||||
'piece-7': [1, 2],
|
||||
'piece-8': [2, 1],
|
||||
};
|
||||
const current = layout[piece.pieceId] ?? [piece.currentRow, piece.currentCol];
|
||||
const current = layout[piece.pieceId] ?? [
|
||||
piece.currentRow,
|
||||
piece.currentCol,
|
||||
];
|
||||
return {
|
||||
...piece,
|
||||
currentRow: current[0],
|
||||
@@ -265,7 +318,9 @@ describe('puzzleLocalRuntime', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const occupiedCells = nextBoard.pieces.map((piece) => `${piece.currentRow}:${piece.currentCol}`);
|
||||
const occupiedCells = nextBoard.pieces.map(
|
||||
(piece) => `${piece.currentRow}:${piece.currentCol}`,
|
||||
);
|
||||
expect(new Set(occupiedCells).size).toBe(nextBoard.pieces.length);
|
||||
expect(
|
||||
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-5'),
|
||||
@@ -288,7 +343,9 @@ describe('puzzleLocalRuntime', () => {
|
||||
const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork));
|
||||
|
||||
expect(clearedRun.currentLevel?.status).toBe('cleared');
|
||||
expect(clearedRun.recommendedNextProfileId).toBe('profile-1::local-level-2');
|
||||
expect(clearedRun.recommendedNextProfileId).toBeNull();
|
||||
expect(clearedRun.nextLevelMode).toBe('none');
|
||||
expect(clearedRun.recommendedNextWorks).toEqual([]);
|
||||
expect(clearedRun.currentLevel?.elapsedMs).toBeGreaterThan(0);
|
||||
expect(clearedRun.currentLevel?.leaderboardEntries).toEqual([]);
|
||||
expect(clearedRun.leaderboardEntries).toEqual([]);
|
||||
@@ -302,6 +359,8 @@ describe('puzzleLocalRuntime', () => {
|
||||
expect(nextRun.currentLevel?.elapsedMs).toBeNull();
|
||||
expect(nextRun.currentLevel?.leaderboardEntries).toEqual([]);
|
||||
expect(nextRun.recommendedNextProfileId).toBeNull();
|
||||
expect(nextRun.nextLevelMode).toBe('none');
|
||||
expect(nextRun.recommendedNextWorks).toEqual([]);
|
||||
});
|
||||
|
||||
test('连续推进下一关会重新打乱棋盘', () => {
|
||||
@@ -312,9 +371,15 @@ describe('puzzleLocalRuntime', () => {
|
||||
|
||||
expect(secondRun.currentLevelIndex).toBe(2);
|
||||
expect(thirdRun.currentLevelIndex).toBe(3);
|
||||
expect(boardPositionSignature(secondRun)).not.toBe(boardPositionSignature(thirdRun));
|
||||
expect(hasAnyOriginalNeighborPair(secondRun.currentLevel?.board.pieces ?? [])).toBe(false);
|
||||
expect(hasAnyOriginalNeighborPair(thirdRun.currentLevel?.board.pieces ?? [])).toBe(false);
|
||||
expect(boardPositionSignature(secondRun)).not.toBe(
|
||||
boardPositionSignature(thirdRun),
|
||||
);
|
||||
expect(
|
||||
hasAnyOriginalNeighborPair(secondRun.currentLevel?.board.pieces ?? []),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasAnyOriginalNeighborPair(thirdRun.currentLevel?.board.pieces ?? []),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('本地 run 通关后用本地排行榜兜底,不再依赖后端 runId', () => {
|
||||
@@ -337,4 +402,184 @@ describe('puzzleLocalRuntime', () => {
|
||||
leaderboardRun.leaderboardEntries,
|
||||
);
|
||||
});
|
||||
|
||||
test('本地倒计时超时后进入失败状态并拒绝继续移动', () => {
|
||||
const run = startLocalPuzzleRun(baseWork);
|
||||
const expiredRun = {
|
||||
...run,
|
||||
currentLevel: run.currentLevel
|
||||
? {
|
||||
...run.currentLevel,
|
||||
startedAtMs: Date.now() - run.currentLevel.timeLimitMs - 1_000,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
const timedRun = refreshLocalPuzzleTimer(expiredRun);
|
||||
const nextRun = dragLocalPuzzlePiece(timedRun, {
|
||||
pieceId: 'piece-0',
|
||||
targetRow: 0,
|
||||
targetCol: 0,
|
||||
});
|
||||
|
||||
expect(timedRun.currentLevel?.status).toBe('failed');
|
||||
expect(timedRun.currentLevel?.remainingMs).toBe(0);
|
||||
expect(nextRun).toBe(timedRun);
|
||||
});
|
||||
|
||||
test('本地失败关卡可以续时一分钟', () => {
|
||||
const run = startLocalPuzzleRun(baseWork);
|
||||
const failedRun = refreshLocalPuzzleTimer({
|
||||
...run,
|
||||
currentLevel: run.currentLevel
|
||||
? {
|
||||
...run.currentLevel,
|
||||
startedAtMs: Date.now() - run.currentLevel.timeLimitMs - 1_000,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
const extendedRun = extendLocalPuzzleTime(failedRun);
|
||||
|
||||
expect(extendedRun.currentLevel?.status).toBe('playing');
|
||||
expect(extendedRun.currentLevel?.remainingMs).toBe(60_000);
|
||||
expect(extendedRun.currentLevel?.elapsedMs).toBeNull();
|
||||
expect(extendedRun.currentLevel?.pauseStartedAtMs).toBeNull();
|
||||
expect(extendedRun.currentLevel?.freezeUntilMs).toBeNull();
|
||||
});
|
||||
|
||||
test('本地失败关卡重新开始会保留关卡索引并重建棋盘', () => {
|
||||
const run = startLocalPuzzleRun(baseWork);
|
||||
const failedRun = refreshLocalPuzzleTimer({
|
||||
...run,
|
||||
currentLevel: run.currentLevel
|
||||
? {
|
||||
...run.currentLevel,
|
||||
startedAtMs: Date.now() - run.currentLevel.timeLimitMs - 1_000,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
const restartedRun = restartLocalPuzzleLevel(failedRun);
|
||||
|
||||
expect(restartedRun.runId).not.toBe(failedRun.runId);
|
||||
expect(restartedRun.currentLevel?.status).toBe('playing');
|
||||
expect(restartedRun.currentLevel?.levelIndex).toBe(
|
||||
failedRun.currentLevel?.levelIndex,
|
||||
);
|
||||
expect(restartedRun.currentLevel?.remainingMs).toBe(
|
||||
restartedRun.currentLevel?.timeLimitMs,
|
||||
);
|
||||
expect(boardPositionSignature(restartedRun)).not.toBe(
|
||||
boardPositionSignature(failedRun),
|
||||
);
|
||||
});
|
||||
|
||||
test('失败重开优先使用当前关卡 id,旧快照缺失时按关卡序号兜底', () => {
|
||||
const workWithLevels: PuzzleWorkSummary = {
|
||||
...baseWork,
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '第一关',
|
||||
pictureDescription: '第一关画面',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: '/level-1.png',
|
||||
coverAssetId: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
{
|
||||
levelId: 'puzzle-level-2',
|
||||
levelName: '第二关',
|
||||
pictureDescription: '第二关画面',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: '/level-2.png',
|
||||
coverAssetId: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
};
|
||||
const run = startLocalPuzzleRun(workWithLevels);
|
||||
const secondLevelRun = {
|
||||
...run,
|
||||
currentLevelIndex: 2,
|
||||
currentLevel: run.currentLevel
|
||||
? {
|
||||
...run.currentLevel,
|
||||
levelIndex: 2,
|
||||
levelId: null,
|
||||
status: 'failed' as const,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
expect(resolvePuzzleRestartLevelId(secondLevelRun, workWithLevels)).toBe(
|
||||
'puzzle-level-2',
|
||||
);
|
||||
expect(
|
||||
resolvePuzzleRestartLevelId(
|
||||
{
|
||||
...secondLevelRun,
|
||||
currentLevel: secondLevelRun.currentLevel
|
||||
? {
|
||||
...secondLevelRun.currentLevel,
|
||||
levelId: 'explicit-level',
|
||||
}
|
||||
: null,
|
||||
},
|
||||
workWithLevels,
|
||||
),
|
||||
).toBe('explicit-level');
|
||||
});
|
||||
|
||||
test('暂停和冻结时间不会消耗本地倒计时', () => {
|
||||
const run = startLocalPuzzleRun(baseWork);
|
||||
const pausedRun = setLocalPuzzlePaused(
|
||||
{
|
||||
...run,
|
||||
currentLevel: run.currentLevel
|
||||
? {
|
||||
...run.currentLevel,
|
||||
startedAtMs: Date.now() - 5_000,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
true,
|
||||
);
|
||||
const pausedStartedAt =
|
||||
pausedRun.currentLevel?.pauseStartedAtMs ?? Date.now();
|
||||
const pausedAfterWait = refreshLocalPuzzleTimer({
|
||||
...pausedRun,
|
||||
currentLevel: pausedRun.currentLevel
|
||||
? {
|
||||
...pausedRun.currentLevel,
|
||||
startedAtMs: pausedRun.currentLevel.startedAtMs - 5_000,
|
||||
pauseStartedAtMs: pausedStartedAt - 5_000,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const frozenRun = applyLocalPuzzleFreezeTime(pausedAfterWait);
|
||||
const freezeStartedAt =
|
||||
frozenRun.currentLevel?.freezeStartedAtMs ?? Date.now();
|
||||
const frozenAfterWait = refreshLocalPuzzleTimer({
|
||||
...frozenRun,
|
||||
currentLevel: frozenRun.currentLevel
|
||||
? {
|
||||
...frozenRun.currentLevel,
|
||||
startedAtMs: frozenRun.currentLevel.startedAtMs - 5_000,
|
||||
freezeStartedAtMs: freezeStartedAt - 5_000,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
expect(pausedAfterWait.currentLevel?.remainingMs).toBe(
|
||||
pausedRun.currentLevel?.remainingMs,
|
||||
);
|
||||
expect(frozenAfterWait.currentLevel?.remainingMs).toBe(
|
||||
frozenRun.currentLevel?.remainingMs,
|
||||
);
|
||||
expect(frozenAfterWait.currentLevel?.pauseStartedAtMs).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user