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:
kdletters
2026-05-02 03:35:59 +08:00
513 changed files with 52813 additions and 6013 deletions
+136
View File
@@ -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();
+21 -3
View File
@@ -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,
+3
View File
@@ -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;
+38
View File
@@ -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',
},
});
});
});
+89 -3
View File
@@ -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',
},
});
+49 -3
View File
@@ -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,
};
+2
View File
@@ -1,4 +1,6 @@
export {
bigFishGalleryClient,
likeBigFishGalleryWork,
listBigFishGallery,
remixBigFishGalleryWork,
} from './bigFishGalleryClient';
+7
View File
@@ -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,
};
+17
View File
@@ -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,
};
+9
View File
@@ -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: '一只猫在雨夜灯牌下回头。',
},
]);
});
});
+48 -31
View File
@@ -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,
+18
View File
@@ -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)
);
}
+2
View File
@@ -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,
};
+2
View File
@@ -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