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';
|
||||
|
||||
7
src/services/match3d-creation/index.ts
Normal file
7
src/services/match3d-creation/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
createMatch3DCreationSession,
|
||||
executeMatch3DCreationAction,
|
||||
getMatch3DCreationSession,
|
||||
match3dCreationClient,
|
||||
streamMatch3DCreationMessage,
|
||||
} from './match3dCreationClient';
|
||||
88
src/services/match3d-creation/match3dCreationClient.ts
Normal file
88
src/services/match3d-creation/match3dCreationClient.ts
Normal file
@@ -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
src/services/match3d-runtime/index.ts
Normal file
17
src/services/match3d-runtime/index.ts
Normal 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';
|
||||
510
src/services/match3d-runtime/match3dLocalRuntime.ts
Normal file
510
src/services/match3d-runtime/match3dLocalRuntime.ts
Normal file
@@ -0,0 +1,510 @@
|
||||
import type {
|
||||
Match3DClickItemRequest,
|
||||
Match3DClickItemResult,
|
||||
Match3DItemSnapshot,
|
||||
Match3DRunSnapshot,
|
||||
Match3DTraySlot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
|
||||
const MATCH3D_TRAY_SLOT_COUNT = 7;
|
||||
const MATCH3D_LOCAL_DURATION_MS = 600_000;
|
||||
|
||||
type Match3DVisualSeed = {
|
||||
itemTypeId: string;
|
||||
visualKey: string;
|
||||
colorClassName: string;
|
||||
label: string;
|
||||
sizeScale?: number;
|
||||
};
|
||||
|
||||
export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
|
||||
// 中文注释:水果题材内置视觉键要和后端 module-match3d 保持一致,避免不同物品被兜底成同一图案。
|
||||
{
|
||||
itemTypeId: 'watermelon',
|
||||
visualKey: 'watermelon-green',
|
||||
colorClassName: 'from-emerald-500 to-green-800',
|
||||
label: '西瓜',
|
||||
sizeScale: 1.24,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'apple-red',
|
||||
colorClassName: 'from-rose-400 to-red-600',
|
||||
label: '苹果',
|
||||
sizeScale: 1,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'banana',
|
||||
visualKey: 'banana-yellow',
|
||||
colorClassName: 'from-yellow-300 to-amber-500',
|
||||
label: '香蕉',
|
||||
sizeScale: 1.04,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'grape',
|
||||
visualKey: 'grape-purple',
|
||||
colorClassName: 'from-violet-400 to-purple-700',
|
||||
label: '葡萄',
|
||||
sizeScale: 0.78,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'melon',
|
||||
visualKey: 'melon-green',
|
||||
colorClassName: 'from-emerald-300 to-green-600',
|
||||
label: '甜瓜',
|
||||
sizeScale: 1.12,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'berry',
|
||||
visualKey: 'berry-blue',
|
||||
colorClassName: 'from-sky-300 to-blue-600',
|
||||
label: '蓝莓',
|
||||
sizeScale: 0.78,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'peach',
|
||||
visualKey: 'peach-pink',
|
||||
colorClassName: 'from-pink-300 to-orange-400',
|
||||
label: '桃子',
|
||||
sizeScale: 1,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'plum',
|
||||
visualKey: 'plum-indigo',
|
||||
colorClassName: 'from-indigo-300 to-indigo-700',
|
||||
label: '李子',
|
||||
sizeScale: 0.86,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'lime',
|
||||
visualKey: 'lime-lime',
|
||||
colorClassName: 'from-lime-300 to-lime-600',
|
||||
label: '青柠',
|
||||
sizeScale: 0.86,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'orange',
|
||||
visualKey: 'orange-orange',
|
||||
colorClassName: 'from-orange-300 to-orange-600',
|
||||
label: '橙子',
|
||||
sizeScale: 1,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'pear',
|
||||
visualKey: 'pear-cyan',
|
||||
colorClassName: 'from-cyan-300 to-teal-600',
|
||||
label: '梨',
|
||||
sizeScale: 1,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'red-circle',
|
||||
visualKey: 'red_circle',
|
||||
colorClassName: 'from-rose-400 to-red-600',
|
||||
label: '圆',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'yellow-triangle',
|
||||
visualKey: 'yellow_triangle',
|
||||
colorClassName: 'from-yellow-300 to-amber-500',
|
||||
label: '三',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'purple-diamond',
|
||||
visualKey: 'purple_diamond',
|
||||
colorClassName: 'from-violet-400 to-purple-700',
|
||||
label: '菱',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'green-square',
|
||||
visualKey: 'green_square',
|
||||
colorClassName: 'from-emerald-300 to-green-600',
|
||||
label: '方',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'blue-star',
|
||||
visualKey: 'blue_star',
|
||||
colorClassName: 'from-sky-300 to-blue-600',
|
||||
label: '星',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'orange-hexagon',
|
||||
visualKey: 'orange_hexagon',
|
||||
colorClassName: 'from-orange-300 to-orange-600',
|
||||
label: '六',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'cyan-capsule',
|
||||
visualKey: 'cyan_capsule',
|
||||
colorClassName: 'from-cyan-300 to-teal-600',
|
||||
label: '胶',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'pink-heart',
|
||||
visualKey: 'pink_heart',
|
||||
colorClassName: 'from-pink-300 to-rose-500',
|
||||
label: '心',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'lime-leaf',
|
||||
visualKey: 'lime_leaf',
|
||||
colorClassName: 'from-lime-300 to-lime-600',
|
||||
label: '叶',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'white-moon',
|
||||
visualKey: 'white_moon',
|
||||
colorClassName: 'from-slate-100 to-slate-400',
|
||||
label: '月',
|
||||
},
|
||||
];
|
||||
|
||||
function createEmptyTray(): Match3DTraySlot[] {
|
||||
return Array.from({ length: MATCH3D_TRAY_SLOT_COUNT }, (_, slotIndex) => ({
|
||||
slotIndex,
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeRemainingMs(run: Match3DRunSnapshot, nowMs = Date.now()) {
|
||||
if (run.status !== 'Running') {
|
||||
return run;
|
||||
}
|
||||
const elapsedMs = Math.max(0, nowMs - run.startedAtMs);
|
||||
const remainingMs = Math.max(0, run.durationLimitMs - elapsedMs);
|
||||
if (remainingMs > 0) {
|
||||
return {
|
||||
...run,
|
||||
serverNowMs: nowMs,
|
||||
remainingMs,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...run,
|
||||
status: 'Failed' as const,
|
||||
serverNowMs: nowMs,
|
||||
remainingMs: 0,
|
||||
failureReason: 'TimeUp' as const,
|
||||
snapshotVersion: run.snapshotVersion + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function buildItem(
|
||||
seed: Match3DVisualSeed,
|
||||
index: number,
|
||||
copyIndex: number,
|
||||
): Match3DItemSnapshot {
|
||||
const ring = Math.floor(index / 6);
|
||||
const angle = index * 0.86 + copyIndex * 0.22;
|
||||
const spread = 0.16 + (ring % 4) * 0.085;
|
||||
const x = 0.5 + Math.cos(angle) * spread + ((index % 3) - 1) * 0.026;
|
||||
const y =
|
||||
0.5 + Math.sin(angle * 1.13) * spread + ((copyIndex % 3) - 1) * 0.02;
|
||||
const baseRadius =
|
||||
0.072 - Math.min(0.018, ring * 0.004) + (copyIndex % 2) * 0.004;
|
||||
const radius = baseRadius * (seed.sizeScale ?? 1);
|
||||
return {
|
||||
itemInstanceId: `${seed.itemTypeId}-${copyIndex + 1}`,
|
||||
itemTypeId: seed.itemTypeId,
|
||||
visualKey: seed.visualKey,
|
||||
x: Math.max(0.18, Math.min(0.82, x)),
|
||||
y: Math.max(0.18, Math.min(0.82, y)),
|
||||
radius,
|
||||
layer: index + 1,
|
||||
state: 'InBoard',
|
||||
clickable: true,
|
||||
};
|
||||
}
|
||||
|
||||
function recomputeClickable(items: Match3DItemSnapshot[]) {
|
||||
const boardItems = items.filter((item) => item.state === 'InBoard');
|
||||
return items.map((item) => {
|
||||
if (item.state !== 'InBoard') {
|
||||
return {
|
||||
...item,
|
||||
clickable: false,
|
||||
};
|
||||
}
|
||||
const coveredByHigherLayer = boardItems.some((other) => {
|
||||
if (
|
||||
other.itemInstanceId === item.itemInstanceId ||
|
||||
other.layer <= item.layer
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const distance = Math.hypot(other.x - item.x, other.y - item.y);
|
||||
return distance < Math.min(item.radius, other.radius) * 0.78;
|
||||
});
|
||||
return {
|
||||
...item,
|
||||
clickable: !coveredByHigherLayer,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function findNextTrayIndex(traySlots: Match3DTraySlot[]) {
|
||||
return traySlots.find((slot) => !slot.itemInstanceId)?.slotIndex ?? -1;
|
||||
}
|
||||
|
||||
function countClearedItems(items: Match3DItemSnapshot[]) {
|
||||
return items.filter((item) => item.state === 'Cleared').length;
|
||||
}
|
||||
|
||||
function resolveRunStatus(run: Match3DRunSnapshot): Match3DRunSnapshot {
|
||||
const clearedItemCount = countClearedItems(run.items);
|
||||
if (clearedItemCount >= run.totalItemCount) {
|
||||
return {
|
||||
...run,
|
||||
status: 'Won',
|
||||
clearedItemCount,
|
||||
remainingMs: Math.max(0, run.remainingMs),
|
||||
};
|
||||
}
|
||||
const trayIsFull = run.traySlots.every((slot) =>
|
||||
Boolean(slot.itemInstanceId),
|
||||
);
|
||||
if (trayIsFull) {
|
||||
return {
|
||||
...run,
|
||||
status: 'Failed',
|
||||
clearedItemCount,
|
||||
failureReason: 'TrayFull',
|
||||
};
|
||||
}
|
||||
return {
|
||||
...run,
|
||||
status: 'Running',
|
||||
failureReason: undefined,
|
||||
clearedItemCount,
|
||||
};
|
||||
}
|
||||
|
||||
function settleMatchedTrayItems(run: Match3DRunSnapshot) {
|
||||
const slotsByType = new Map<string, Match3DTraySlot[]>();
|
||||
for (const slot of run.traySlots) {
|
||||
if (!slot.itemTypeId || !slot.itemInstanceId) {
|
||||
continue;
|
||||
}
|
||||
slotsByType.set(slot.itemTypeId, [
|
||||
...(slotsByType.get(slot.itemTypeId) ?? []),
|
||||
slot,
|
||||
]);
|
||||
}
|
||||
|
||||
const matchedSlots = [...slotsByType.values()].find(
|
||||
(slots) => slots.length >= 3,
|
||||
);
|
||||
if (!matchedSlots) {
|
||||
return {
|
||||
run,
|
||||
clearedItemInstanceIds: [] as string[],
|
||||
};
|
||||
}
|
||||
|
||||
const clearedItemInstanceIds = matchedSlots
|
||||
.slice(0, 3)
|
||||
.map((slot) => slot.itemInstanceId)
|
||||
.filter((itemInstanceId): itemInstanceId is string =>
|
||||
Boolean(itemInstanceId),
|
||||
);
|
||||
const clearedSet = new Set(clearedItemInstanceIds);
|
||||
const nextRun = {
|
||||
...run,
|
||||
traySlots: run.traySlots.map((slot) =>
|
||||
slot.itemInstanceId && clearedSet.has(slot.itemInstanceId)
|
||||
? { slotIndex: slot.slotIndex }
|
||||
: slot,
|
||||
),
|
||||
items: run.items.map((item) =>
|
||||
clearedSet.has(item.itemInstanceId)
|
||||
? {
|
||||
...item,
|
||||
state: 'Cleared' as const,
|
||||
clickable: false,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
run: nextRun,
|
||||
clearedItemInstanceIds,
|
||||
};
|
||||
}
|
||||
|
||||
export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot {
|
||||
const normalizedClearCount = Math.max(1, Math.round(clearCount));
|
||||
const typeCount = Math.min(10, normalizedClearCount);
|
||||
const items = Array.from({ length: normalizedClearCount }, (_, clearIndex) =>
|
||||
Array.from({ length: 3 }, (_, copyOffset) => {
|
||||
const seed =
|
||||
MATCH3D_VISUAL_SEEDS[clearIndex % typeCount] ??
|
||||
MATCH3D_VISUAL_SEEDS[0]!;
|
||||
return buildItem(
|
||||
seed,
|
||||
clearIndex * 3 + copyOffset,
|
||||
clearIndex * 3 + copyOffset,
|
||||
);
|
||||
}),
|
||||
).flat();
|
||||
const nowMs = Date.now();
|
||||
return {
|
||||
runId: `local-match3d-run-${nowMs}`,
|
||||
profileId: 'local-match3d-profile',
|
||||
status: 'Running',
|
||||
snapshotVersion: 1,
|
||||
startedAtMs: nowMs,
|
||||
durationLimitMs: MATCH3D_LOCAL_DURATION_MS,
|
||||
serverNowMs: nowMs,
|
||||
remainingMs: MATCH3D_LOCAL_DURATION_MS,
|
||||
clearCount: normalizedClearCount,
|
||||
totalItemCount: items.length,
|
||||
clearedItemCount: 0,
|
||||
traySlots: createEmptyTray(),
|
||||
items: recomputeClickable(items),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveLocalMatch3DTimer(run: Match3DRunSnapshot) {
|
||||
return normalizeRemainingMs(run);
|
||||
}
|
||||
|
||||
export function buildLocalMatch3DOptimisticRun(
|
||||
run: Match3DRunSnapshot,
|
||||
itemInstanceId: string,
|
||||
): Match3DRunSnapshot {
|
||||
const targetItem = run.items.find(
|
||||
(item) => item.itemInstanceId === itemInstanceId,
|
||||
);
|
||||
const nextTrayIndex = findNextTrayIndex(run.traySlots);
|
||||
if (!targetItem || targetItem.state !== 'InBoard' || nextTrayIndex < 0) {
|
||||
return run;
|
||||
}
|
||||
return {
|
||||
...run,
|
||||
items: run.items.map((item) =>
|
||||
item.itemInstanceId === itemInstanceId
|
||||
? {
|
||||
...item,
|
||||
state: 'Flying' as const,
|
||||
clickable: false,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
traySlots: run.traySlots.map((slot) =>
|
||||
slot.slotIndex === nextTrayIndex
|
||||
? {
|
||||
slotIndex: slot.slotIndex,
|
||||
itemInstanceId: targetItem.itemInstanceId,
|
||||
itemTypeId: targetItem.itemTypeId,
|
||||
visualKey: targetItem.visualKey,
|
||||
}
|
||||
: slot,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function confirmLocalMatch3DClick(
|
||||
run: Match3DRunSnapshot,
|
||||
request: Match3DClickItemRequest,
|
||||
): Promise<Match3DClickItemResult> {
|
||||
// 中文注释:F3 阶段用本地函数模拟后端权威确认,真实接口接入后保留同一结果语义。
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 180));
|
||||
const timedRun = normalizeRemainingMs(run);
|
||||
if (timedRun.status !== 'Running') {
|
||||
return {
|
||||
status: 'RunFinished',
|
||||
run: timedRun,
|
||||
clearedItemInstanceIds: [],
|
||||
failureReason: timedRun.failureReason,
|
||||
};
|
||||
}
|
||||
if (request.clientSnapshotVersion !== run.snapshotVersion) {
|
||||
return {
|
||||
status: 'VersionConflict',
|
||||
run: timedRun,
|
||||
clearedItemInstanceIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const targetItem = run.items.find(
|
||||
(item) => item.itemInstanceId === request.itemInstanceId,
|
||||
);
|
||||
if (!targetItem || targetItem.state !== 'InBoard') {
|
||||
return {
|
||||
status: 'RejectedAlreadyMoved',
|
||||
run: timedRun,
|
||||
clearedItemInstanceIds: [],
|
||||
};
|
||||
}
|
||||
if (!targetItem.clickable) {
|
||||
return {
|
||||
status: 'RejectedNotClickable',
|
||||
run: timedRun,
|
||||
clearedItemInstanceIds: [],
|
||||
};
|
||||
}
|
||||
const nextTrayIndex = findNextTrayIndex(run.traySlots);
|
||||
if (nextTrayIndex < 0) {
|
||||
const failedRun = {
|
||||
...timedRun,
|
||||
status: 'Failed' as const,
|
||||
failureReason: 'TrayFull' as const,
|
||||
snapshotVersion: run.snapshotVersion + 1,
|
||||
};
|
||||
return {
|
||||
status: 'RejectedTrayFull',
|
||||
run: failedRun,
|
||||
clearedItemInstanceIds: [],
|
||||
failureReason: 'TrayFull',
|
||||
};
|
||||
}
|
||||
|
||||
const movedRun: Match3DRunSnapshot = {
|
||||
...timedRun,
|
||||
snapshotVersion: run.snapshotVersion + 1,
|
||||
items: timedRun.items.map((item) =>
|
||||
item.itemInstanceId === targetItem.itemInstanceId
|
||||
? {
|
||||
...item,
|
||||
state: 'InTray' as const,
|
||||
clickable: false,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
traySlots: timedRun.traySlots.map((slot) =>
|
||||
slot.slotIndex === nextTrayIndex
|
||||
? {
|
||||
slotIndex: slot.slotIndex,
|
||||
itemInstanceId: targetItem.itemInstanceId,
|
||||
itemTypeId: targetItem.itemTypeId,
|
||||
visualKey: targetItem.visualKey,
|
||||
}
|
||||
: slot,
|
||||
),
|
||||
};
|
||||
const settled = settleMatchedTrayItems(movedRun);
|
||||
const nextRun = resolveRunStatus({
|
||||
...settled.run,
|
||||
items: recomputeClickable(settled.run.items),
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'Accepted',
|
||||
run: nextRun,
|
||||
acceptedItemInstanceId: targetItem.itemInstanceId,
|
||||
clearedItemInstanceIds: settled.clearedItemInstanceIds,
|
||||
failureReason: nextRun.failureReason,
|
||||
};
|
||||
}
|
||||
|
||||
export function stopLocalMatch3DRun(
|
||||
run: Match3DRunSnapshot,
|
||||
): Match3DRunSnapshot {
|
||||
if (run.status !== 'Running') {
|
||||
return run;
|
||||
}
|
||||
return {
|
||||
...run,
|
||||
status: 'Stopped',
|
||||
snapshotVersion: run.snapshotVersion + 1,
|
||||
};
|
||||
}
|
||||
162
src/services/match3d-runtime/match3dRuntimeClient.ts
Normal file
162
src/services/match3d-runtime/match3dRuntimeClient.ts
Normal file
@@ -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
src/services/match3d-works/index.ts
Normal file
9
src/services/match3d-works/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
deleteMatch3DWork,
|
||||
getMatch3DWorkDetail,
|
||||
listMatch3DGallery,
|
||||
listMatch3DWorks,
|
||||
match3dWorksClient,
|
||||
publishMatch3DWork,
|
||||
updateMatch3DWork,
|
||||
} from './match3dWorksClient';
|
||||
113
src/services/match3d-works/match3dWorksClient.ts
Normal file
113
src/services/match3d-works/match3dWorksClient.ts
Normal file
@@ -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';
|
||||
|
||||
73
src/services/puzzle-gallery/puzzleGalleryClient.test.ts
Normal file
73
src/services/puzzle-gallery/puzzleGalleryClient.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,18 +7,78 @@ import type {
|
||||
PuzzleMergedGroupState,
|
||||
PuzzlePieceState,
|
||||
PuzzleRunSnapshot,
|
||||
PuzzleRuntimeLevelSnapshot,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
|
||||
const LOCAL_PUZZLE_RUN_ID_PREFIX = 'local-puzzle-run-';
|
||||
const PUZZLE_FREEZE_TIME_DURATION_MS = 10_000;
|
||||
const PUZZLE_EXTEND_TIME_DURATION_MS = 60_000;
|
||||
let localPuzzleRunSequence = 0;
|
||||
type PuzzleLevelConfig = {
|
||||
gridSize: PuzzleGridSize;
|
||||
timeLimitMs: number;
|
||||
};
|
||||
|
||||
// 中文注释:本地兜底必须和后端按同一关卡序号解析切割规格与倒计时。
|
||||
function resolvePuzzleLevelConfig(levelIndex: number): PuzzleLevelConfig {
|
||||
const normalizedLevelIndex = Math.max(1, Math.floor(levelIndex || 1));
|
||||
switch (normalizedLevelIndex) {
|
||||
case 1:
|
||||
return { gridSize: 3, timeLimitMs: 300_000 };
|
||||
case 2:
|
||||
return { gridSize: 4, timeLimitMs: 300_000 };
|
||||
case 3:
|
||||
return { gridSize: 5, timeLimitMs: 300_000 };
|
||||
case 4:
|
||||
return { gridSize: 5, timeLimitMs: 210_000 };
|
||||
default: {
|
||||
const loopIndex = ((Math.max(5, normalizedLevelIndex) - 5) % 6) + 5;
|
||||
switch (loopIndex) {
|
||||
case 5:
|
||||
return { gridSize: 5, timeLimitMs: 210_000 };
|
||||
case 6:
|
||||
return { gridSize: 6, timeLimitMs: 240_000 };
|
||||
case 7:
|
||||
return { gridSize: 5, timeLimitMs: 210_000 };
|
||||
case 8:
|
||||
return { gridSize: 7, timeLimitMs: 270_000 };
|
||||
case 9:
|
||||
return { gridSize: 5, timeLimitMs: 240_000 };
|
||||
default:
|
||||
return { gridSize: 7, timeLimitMs: 270_000 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePuzzleGridSize(clearedLevelCount: number): PuzzleGridSize {
|
||||
return clearedLevelCount >= 3 ? 4 : 3;
|
||||
return resolvePuzzleLevelConfig(clearedLevelCount + 1).gridSize;
|
||||
}
|
||||
|
||||
const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS = 64;
|
||||
|
||||
function buildLocalPuzzleRunId(profileId: string) {
|
||||
localPuzzleRunSequence = (localPuzzleRunSequence + 1) % 1_000_000;
|
||||
return `${LOCAL_PUZZLE_RUN_ID_PREFIX}${profileId}-${Date.now()}-${localPuzzleRunSequence}`;
|
||||
}
|
||||
|
||||
export function resolvePuzzleRestartLevelId(
|
||||
run: PuzzleRunSnapshot,
|
||||
work: PuzzleWorkSummary | null | undefined,
|
||||
): string | null {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
currentLevel.levelId ??
|
||||
work?.levels?.[Math.max(0, currentLevel.levelIndex - 1)]?.levelId ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function buildShuffleSeed(...parts: Array<string | number>) {
|
||||
let hash = 0x811c9dc5;
|
||||
for (const part of parts.join('|')) {
|
||||
@@ -70,7 +130,11 @@ function buildInitialPositions(gridSize: PuzzleGridSize, seed: number) {
|
||||
row: Math.floor(index / gridSize),
|
||||
col: index % gridSize,
|
||||
}));
|
||||
for (let attempt = 0; attempt < PUZZLE_INITIAL_SHUFFLE_ATTEMPTS; attempt += 1) {
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < PUZZLE_INITIAL_SHUFFLE_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
const shuffled = shufflePositions(
|
||||
positions,
|
||||
(seed + Math.imul(attempt, 2654435761)) >>> 0,
|
||||
@@ -81,7 +145,11 @@ function buildInitialPositions(gridSize: PuzzleGridSize, seed: number) {
|
||||
return shuffled;
|
||||
}
|
||||
}
|
||||
return buildOriginalNeighborFreePositions(gridSize, seed) ?? positions;
|
||||
return (
|
||||
buildDeterministicNeighborFreePositions(gridSize, seed) ??
|
||||
buildOriginalNeighborFreePositions(gridSize, seed) ??
|
||||
positions
|
||||
);
|
||||
}
|
||||
|
||||
function boardCellKey(row: number, col: number) {
|
||||
@@ -92,6 +160,107 @@ function clampElapsedMs(value: number) {
|
||||
return Math.max(1_000, Math.round(value));
|
||||
}
|
||||
|
||||
function resolvePuzzleLevelTimeLimitMs(levelIndex: number) {
|
||||
return resolvePuzzleLevelConfig(levelIndex || 1).timeLimitMs;
|
||||
}
|
||||
|
||||
function resolveActiveFreezeElapsedMs(
|
||||
level: PuzzleRuntimeLevelSnapshot,
|
||||
nowMs: number,
|
||||
) {
|
||||
if (!level.freezeStartedAtMs || !level.freezeUntilMs) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(nowMs, level.freezeUntilMs) - level.freezeStartedAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveEffectiveElapsedMs(
|
||||
level: PuzzleRuntimeLevelSnapshot,
|
||||
nowMs: number,
|
||||
) {
|
||||
const pauseElapsedMs = level.pauseStartedAtMs
|
||||
? Math.max(0, nowMs - level.pauseStartedAtMs)
|
||||
: 0;
|
||||
return Math.max(
|
||||
0,
|
||||
nowMs -
|
||||
level.startedAtMs -
|
||||
level.pausedAccumulatedMs -
|
||||
pauseElapsedMs -
|
||||
level.freezeAccumulatedMs -
|
||||
resolveActiveFreezeElapsedMs(level, nowMs),
|
||||
);
|
||||
}
|
||||
|
||||
function settleExpiredFreeze(
|
||||
level: PuzzleRuntimeLevelSnapshot,
|
||||
nowMs: number,
|
||||
): PuzzleRuntimeLevelSnapshot {
|
||||
if (
|
||||
!level.freezeStartedAtMs ||
|
||||
!level.freezeUntilMs ||
|
||||
nowMs < level.freezeUntilMs
|
||||
) {
|
||||
return level;
|
||||
}
|
||||
return {
|
||||
...level,
|
||||
freezeAccumulatedMs:
|
||||
level.freezeAccumulatedMs +
|
||||
Math.max(0, level.freezeUntilMs - level.freezeStartedAtMs),
|
||||
freezeStartedAtMs: null,
|
||||
freezeUntilMs: null,
|
||||
};
|
||||
}
|
||||
|
||||
function withResolvedTimer(run: PuzzleRunSnapshot, nowMs = Date.now()) {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel || currentLevel.status !== 'playing') {
|
||||
return run;
|
||||
}
|
||||
const settledLevel = settleExpiredFreeze(currentLevel, nowMs);
|
||||
const remainingMs = Math.max(
|
||||
0,
|
||||
settledLevel.timeLimitMs - resolveEffectiveElapsedMs(settledLevel, nowMs),
|
||||
);
|
||||
return {
|
||||
...run,
|
||||
currentLevel: {
|
||||
...settledLevel,
|
||||
remainingMs,
|
||||
status: remainingMs <= 0 ? ('failed' as const) : settledLevel.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildLevelTimerFields(levelIndex: number) {
|
||||
const timeLimitMs = resolvePuzzleLevelTimeLimitMs(levelIndex);
|
||||
return {
|
||||
timeLimitMs,
|
||||
remainingMs: timeLimitMs,
|
||||
pausedAccumulatedMs: 0,
|
||||
pauseStartedAtMs: null,
|
||||
freezeAccumulatedMs: 0,
|
||||
freezeStartedAtMs: null,
|
||||
freezeUntilMs: null,
|
||||
};
|
||||
}
|
||||
|
||||
function closePauseForLevel(level: PuzzleRuntimeLevelSnapshot, nowMs: number) {
|
||||
if (!level.pauseStartedAtMs) {
|
||||
return level;
|
||||
}
|
||||
return {
|
||||
...level,
|
||||
pausedAccumulatedMs:
|
||||
level.pausedAccumulatedMs + Math.max(0, nowMs - level.pauseStartedAtMs),
|
||||
pauseStartedAtMs: null,
|
||||
};
|
||||
}
|
||||
|
||||
function neighborCells(row: number, col: number): PuzzleCellPosition[] {
|
||||
return [
|
||||
row > 0 ? { row: row - 1, col } : null,
|
||||
@@ -127,18 +296,6 @@ function buildPiecesFromPositions(
|
||||
}));
|
||||
}
|
||||
|
||||
function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
const piecesByCell = new Map(
|
||||
pieces.map((piece) => [boardCellKey(piece.currentRow, piece.currentCol), piece]),
|
||||
);
|
||||
return pieces.some((piece) =>
|
||||
neighborCells(piece.currentRow, piece.currentCol).some((neighbor) => {
|
||||
const neighborPiece = piecesByCell.get(boardCellKey(neighbor.row, neighbor.col));
|
||||
return Boolean(neighborPiece && areCorrectNeighbors(piece, neighborPiece));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function areOriginalNeighbors(left: PuzzlePieceState, right: PuzzlePieceState) {
|
||||
return (
|
||||
Math.abs(right.correctRow - left.correctRow) +
|
||||
@@ -149,12 +306,19 @@ function areOriginalNeighbors(left: PuzzlePieceState, right: PuzzlePieceState) {
|
||||
|
||||
function hasAnyOriginalNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
const piecesByCell = new Map(
|
||||
pieces.map((piece) => [boardCellKey(piece.currentRow, piece.currentCol), piece]),
|
||||
pieces.map((piece) => [
|
||||
boardCellKey(piece.currentRow, piece.currentCol),
|
||||
piece,
|
||||
]),
|
||||
);
|
||||
return pieces.some((piece) =>
|
||||
neighborCells(piece.currentRow, piece.currentCol).some((neighbor) => {
|
||||
const neighborPiece = piecesByCell.get(boardCellKey(neighbor.row, neighbor.col));
|
||||
return Boolean(neighborPiece && areOriginalNeighbors(piece, neighborPiece));
|
||||
const neighborPiece = piecesByCell.get(
|
||||
boardCellKey(neighbor.row, neighbor.col),
|
||||
);
|
||||
return Boolean(
|
||||
neighborPiece && areOriginalNeighbors(piece, neighborPiece),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -168,6 +332,127 @@ function seededOrderKey(seed: number, value: number) {
|
||||
return (state ^ (state >>> 16)) >>> 0;
|
||||
}
|
||||
|
||||
function buildDeterministicNeighborFreePositions(
|
||||
gridSize: PuzzleGridSize,
|
||||
seed: number,
|
||||
) {
|
||||
if (gridSize === 3) {
|
||||
return buildSeeded3x3NeighborFreePositions(seed);
|
||||
}
|
||||
if (gridSize === 4 || gridSize === 6) {
|
||||
return buildAffineNeighborFreePositions(gridSize, 1, 1, 2, 1, seed);
|
||||
}
|
||||
if (gridSize === 5 || gridSize === 7) {
|
||||
return buildAffineNeighborFreePositions(
|
||||
gridSize,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
gridSize - 1,
|
||||
seed,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildSeeded3x3NeighborFreePositions(seed: number) {
|
||||
const layouts: Array<Array<[number, number]>> = [
|
||||
[
|
||||
[0, 1],
|
||||
[1, 0],
|
||||
[1, 2],
|
||||
[2, 0],
|
||||
[0, 2],
|
||||
[2, 1],
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
[0, 0],
|
||||
],
|
||||
[
|
||||
[0, 1],
|
||||
[1, 0],
|
||||
[1, 2],
|
||||
[2, 0],
|
||||
[0, 2],
|
||||
[2, 1],
|
||||
[2, 2],
|
||||
[1, 1],
|
||||
[0, 0],
|
||||
],
|
||||
[
|
||||
[0, 1],
|
||||
[1, 0],
|
||||
[1, 2],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
[0, 2],
|
||||
[2, 1],
|
||||
],
|
||||
[
|
||||
[0, 1],
|
||||
[1, 0],
|
||||
[1, 2],
|
||||
[2, 1],
|
||||
[0, 2],
|
||||
[2, 0],
|
||||
[0, 0],
|
||||
[2, 2],
|
||||
[1, 1],
|
||||
],
|
||||
[
|
||||
[0, 1],
|
||||
[1, 0],
|
||||
[1, 2],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
[2, 1],
|
||||
[1, 1],
|
||||
[2, 0],
|
||||
[0, 0],
|
||||
],
|
||||
[
|
||||
[0, 1],
|
||||
[1, 0],
|
||||
[2, 1],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
[1, 2],
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
];
|
||||
const layout = layouts[Math.abs(seed) % layouts.length] ?? layouts[0];
|
||||
return (
|
||||
layout?.map(([row, col]) => ({
|
||||
row,
|
||||
col,
|
||||
})) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function buildAffineNeighborFreePositions(
|
||||
gridSize: PuzzleGridSize,
|
||||
rowFromRow: number,
|
||||
rowFromCol: number,
|
||||
colFromRow: number,
|
||||
colFromCol: number,
|
||||
seed: number,
|
||||
) {
|
||||
const rowOffset = seed % gridSize;
|
||||
const colOffset = Math.floor(seed / gridSize) % gridSize;
|
||||
return Array.from({ length: gridSize * gridSize }, (_, index) => {
|
||||
const row = Math.floor(index / gridSize);
|
||||
const col = index % gridSize;
|
||||
return {
|
||||
row: (rowFromRow * row + rowFromCol * col + rowOffset) % gridSize,
|
||||
col: (colFromRow * row + colFromCol * col + colOffset) % gridSize,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildOriginalNeighborFreePositions(
|
||||
gridSize: PuzzleGridSize,
|
||||
seed: number,
|
||||
@@ -242,11 +527,14 @@ function violatesOriginalNeighborFreeRule(
|
||||
return false;
|
||||
}
|
||||
const originalNeighbors =
|
||||
Math.abs(Math.floor(pieceIndex / gridSize) - Math.floor(placedIndex / gridSize)) +
|
||||
Math.abs(
|
||||
Math.floor(pieceIndex / gridSize) - Math.floor(placedIndex / gridSize),
|
||||
) +
|
||||
Math.abs((pieceIndex % gridSize) - (placedIndex % gridSize)) ===
|
||||
1;
|
||||
const currentNeighbors =
|
||||
Math.abs(cell.row - placedCell.row) + Math.abs(cell.col - placedCell.col) ===
|
||||
Math.abs(cell.row - placedCell.row) +
|
||||
Math.abs(cell.col - placedCell.col) ===
|
||||
1;
|
||||
return originalNeighbors && currentNeighbors;
|
||||
});
|
||||
@@ -256,7 +544,10 @@ function resolveMergedGroups(
|
||||
pieces: PuzzlePieceState[],
|
||||
): PuzzleMergedGroupState[] {
|
||||
const piecesByCell = new Map(
|
||||
pieces.map((piece) => [boardCellKey(piece.currentRow, piece.currentCol), piece]),
|
||||
pieces.map((piece) => [
|
||||
boardCellKey(piece.currentRow, piece.currentCol),
|
||||
piece,
|
||||
]),
|
||||
);
|
||||
const piecesById = new Map(pieces.map((piece) => [piece.pieceId, piece]));
|
||||
const visited = new Set<string>();
|
||||
@@ -285,7 +576,9 @@ function resolveMergedGroups(
|
||||
currentPiece.currentRow,
|
||||
currentPiece.currentCol,
|
||||
)) {
|
||||
const neighborPiece = piecesByCell.get(boardCellKey(neighbor.row, neighbor.col));
|
||||
const neighborPiece = piecesByCell.get(
|
||||
boardCellKey(neighbor.row, neighbor.col),
|
||||
);
|
||||
if (neighborPiece && areCorrectNeighbors(currentPiece, neighborPiece)) {
|
||||
queue.push(neighborPiece.pieceId);
|
||||
}
|
||||
@@ -332,7 +625,8 @@ function rebuildBoardSnapshot(
|
||||
piece.currentCol === piece.correctCol,
|
||||
);
|
||||
const allPiecesMergedIntoOneGroup = mergedGroups.some(
|
||||
(group) => group.pieceIds.length === nextPieces.length && nextPieces.length > 1,
|
||||
(group) =>
|
||||
group.pieceIds.length === nextPieces.length && nextPieces.length > 1,
|
||||
);
|
||||
const allTilesResolved =
|
||||
allPiecesInCorrectCells || allPiecesMergedIntoOneGroup;
|
||||
@@ -365,36 +659,44 @@ function applyNextBoard(
|
||||
run: PuzzleRunSnapshot,
|
||||
nextBoard: PuzzleBoardSnapshot,
|
||||
): PuzzleRunSnapshot {
|
||||
if (!run.currentLevel) {
|
||||
const timedRun = withResolvedTimer(run);
|
||||
if (!timedRun.currentLevel || timedRun.currentLevel.status === 'failed') {
|
||||
return run;
|
||||
}
|
||||
const status = nextBoard.allTilesResolved ? 'cleared' : 'playing';
|
||||
const nextClearedLevelCount =
|
||||
status === 'cleared' && run.currentLevel.status !== 'cleared'
|
||||
? run.clearedLevelCount + 1
|
||||
: run.clearedLevelCount;
|
||||
const justCleared = status === 'cleared' && run.currentLevel.status !== 'cleared';
|
||||
status === 'cleared' && timedRun.currentLevel.status !== 'cleared'
|
||||
? timedRun.clearedLevelCount + 1
|
||||
: timedRun.clearedLevelCount;
|
||||
const justCleared =
|
||||
status === 'cleared' && timedRun.currentLevel.status !== 'cleared';
|
||||
const nowMs = Date.now();
|
||||
const clearedAtMs = justCleared ? nowMs : (run.currentLevel.clearedAtMs ?? null);
|
||||
const clearedAtMs = justCleared
|
||||
? nowMs
|
||||
: (timedRun.currentLevel.clearedAtMs ?? null);
|
||||
const elapsedMs = justCleared
|
||||
? clampElapsedMs(nowMs - run.currentLevel.startedAtMs)
|
||||
: (run.currentLevel.elapsedMs ?? null);
|
||||
? clampElapsedMs(resolveEffectiveElapsedMs(timedRun.currentLevel, nowMs))
|
||||
: (timedRun.currentLevel.elapsedMs ?? null);
|
||||
return {
|
||||
...run,
|
||||
...timedRun,
|
||||
clearedLevelCount: nextClearedLevelCount,
|
||||
currentLevel: {
|
||||
...run.currentLevel,
|
||||
...timedRun.currentLevel,
|
||||
board: nextBoard,
|
||||
status,
|
||||
clearedAtMs,
|
||||
elapsedMs,
|
||||
leaderboardEntries: justCleared ? [] : run.currentLevel.leaderboardEntries,
|
||||
remainingMs: justCleared ? 0 : timedRun.currentLevel.remainingMs,
|
||||
leaderboardEntries: justCleared
|
||||
? []
|
||||
: timedRun.currentLevel.leaderboardEntries,
|
||||
},
|
||||
leaderboardEntries: justCleared ? [] : run.leaderboardEntries,
|
||||
recommendedNextProfileId:
|
||||
status === 'cleared'
|
||||
? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1)
|
||||
: run.recommendedNextProfileId,
|
||||
recommendedNextProfileId: run.recommendedNextProfileId,
|
||||
nextLevelMode: run.nextLevelMode ?? 'none',
|
||||
nextLevelProfileId: run.nextLevelProfileId ?? null,
|
||||
nextLevelId: run.nextLevelId ?? null,
|
||||
recommendedNextWorks: run.recommendedNextWorks ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -447,25 +749,42 @@ function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
...currentLevel,
|
||||
runId: run.runId,
|
||||
levelIndex: nextLevelIndex,
|
||||
levelId: null,
|
||||
gridSize,
|
||||
profileId: nextProfileId,
|
||||
levelName: buildLocalLevelName(currentLevel.levelName, nextLevelIndex),
|
||||
board: buildInitialBoard(gridSize, run.runId, nextProfileId, nextLevelIndex),
|
||||
board: buildInitialBoard(
|
||||
gridSize,
|
||||
run.runId,
|
||||
nextProfileId,
|
||||
nextLevelIndex,
|
||||
),
|
||||
status: 'playing',
|
||||
startedAtMs,
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
...buildLevelTimerFields(nextLevelIndex),
|
||||
leaderboardEntries: [],
|
||||
},
|
||||
recommendedNextProfileId: null,
|
||||
nextLevelMode: 'none',
|
||||
nextLevelProfileId: null,
|
||||
nextLevelId: null,
|
||||
recommendedNextWorks: [],
|
||||
leaderboardEntries: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot {
|
||||
export function startLocalPuzzleRun(
|
||||
item: PuzzleWorkSummary,
|
||||
): PuzzleRunSnapshot {
|
||||
const gridSize = resolvePuzzleGridSize(0);
|
||||
const runId = `${LOCAL_PUZZLE_RUN_ID_PREFIX}${item.profileId}-${Date.now()}`;
|
||||
const runId = buildLocalPuzzleRunId(item.profileId);
|
||||
const startedAtMs = Date.now();
|
||||
const firstLevel = item.levels?.[0] ?? null;
|
||||
const firstLevelName = firstLevel?.levelName || item.levelName;
|
||||
const firstCoverImageSrc = firstLevel?.coverImageSrc ?? item.coverImageSrc;
|
||||
const secondLevel = item.levels?.[1] ?? null;
|
||||
return {
|
||||
runId,
|
||||
entryProfileId: item.profileId,
|
||||
@@ -477,20 +796,26 @@ export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot
|
||||
currentLevel: {
|
||||
runId,
|
||||
levelIndex: 1,
|
||||
levelId: firstLevel?.levelId ?? null,
|
||||
gridSize,
|
||||
profileId: item.profileId,
|
||||
levelName: item.levelName,
|
||||
levelName: firstLevelName,
|
||||
authorDisplayName: item.authorDisplayName,
|
||||
themeTags: item.themeTags,
|
||||
coverImageSrc: item.coverImageSrc,
|
||||
coverImageSrc: firstCoverImageSrc,
|
||||
board: buildInitialBoard(gridSize, runId, item.profileId, 1),
|
||||
status: 'playing',
|
||||
startedAtMs,
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
...buildLevelTimerFields(1),
|
||||
leaderboardEntries: [],
|
||||
},
|
||||
recommendedNextProfileId: null,
|
||||
nextLevelMode: secondLevel ? 'sameWork' : 'none',
|
||||
nextLevelProfileId: secondLevel ? item.profileId : null,
|
||||
nextLevelId: secondLevel?.levelId ?? null,
|
||||
recommendedNextWorks: [],
|
||||
leaderboardEntries: [],
|
||||
};
|
||||
}
|
||||
@@ -499,15 +824,18 @@ export function swapLocalPuzzlePieces(
|
||||
run: PuzzleRunSnapshot,
|
||||
payload: SwapPuzzlePiecesRequest,
|
||||
): PuzzleRunSnapshot {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel || currentLevel.status === 'cleared') {
|
||||
return run;
|
||||
const timedRun = withResolvedTimer(run);
|
||||
const currentLevel = timedRun.currentLevel;
|
||||
if (!currentLevel || currentLevel.status !== 'playing') {
|
||||
return timedRun;
|
||||
}
|
||||
const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece }));
|
||||
const first = pieces.find((piece) => piece.pieceId === payload.firstPieceId);
|
||||
const second = pieces.find((piece) => piece.pieceId === payload.secondPieceId);
|
||||
const second = pieces.find(
|
||||
(piece) => piece.pieceId === payload.secondPieceId,
|
||||
);
|
||||
if (!first || !second) {
|
||||
return run;
|
||||
return timedRun;
|
||||
}
|
||||
const firstPosition = { row: first.currentRow, col: first.currentCol };
|
||||
first.currentRow = second.currentRow;
|
||||
@@ -515,7 +843,10 @@ export function swapLocalPuzzlePieces(
|
||||
second.currentRow = firstPosition.row;
|
||||
second.currentCol = firstPosition.col;
|
||||
|
||||
return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
|
||||
return applyNextBoard(
|
||||
timedRun,
|
||||
rebuildBoardSnapshot(currentLevel.gridSize, pieces),
|
||||
);
|
||||
}
|
||||
|
||||
function dragSinglePiece(
|
||||
@@ -591,7 +922,8 @@ function dragGroup(
|
||||
col: piece.currentCol,
|
||||
}))
|
||||
.filter(
|
||||
(position) => !targetCellKeys.has(boardCellKey(position.row, position.col)),
|
||||
(position) =>
|
||||
!targetCellKeys.has(boardCellKey(position.row, position.col)),
|
||||
)
|
||||
.sort((left, right) => left.row - right.row || left.col - right.col);
|
||||
const occupyingPieces = targetPositions
|
||||
@@ -607,7 +939,8 @@ function dragGroup(
|
||||
.filter((piece): piece is PuzzlePieceState => Boolean(piece))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.currentRow - right.currentRow || left.currentCol - right.currentCol,
|
||||
left.currentRow - right.currentRow ||
|
||||
left.currentCol - right.currentCol,
|
||||
);
|
||||
|
||||
if (occupyingPieces.length !== vacatedPositions.length) {
|
||||
@@ -636,9 +969,10 @@ export function dragLocalPuzzlePiece(
|
||||
run: PuzzleRunSnapshot,
|
||||
payload: DragPuzzlePieceRequest,
|
||||
): PuzzleRunSnapshot {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel || currentLevel.status === 'cleared') {
|
||||
return run;
|
||||
const timedRun = withResolvedTimer(run);
|
||||
const currentLevel = timedRun.currentLevel;
|
||||
if (!currentLevel || currentLevel.status !== 'playing') {
|
||||
return timedRun;
|
||||
}
|
||||
if (
|
||||
payload.targetRow < 0 ||
|
||||
@@ -646,12 +980,12 @@ export function dragLocalPuzzlePiece(
|
||||
payload.targetRow >= currentLevel.gridSize ||
|
||||
payload.targetCol >= currentLevel.gridSize
|
||||
) {
|
||||
return run;
|
||||
return timedRun;
|
||||
}
|
||||
const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece }));
|
||||
const moving = pieces.find((piece) => piece.pieceId === payload.pieceId);
|
||||
if (!moving) {
|
||||
return run;
|
||||
return timedRun;
|
||||
}
|
||||
|
||||
if (moving.mergedGroupId) {
|
||||
@@ -663,19 +997,57 @@ export function dragLocalPuzzlePiece(
|
||||
currentLevel.gridSize,
|
||||
);
|
||||
if (!moved) {
|
||||
return run;
|
||||
return timedRun;
|
||||
}
|
||||
} else {
|
||||
dragSinglePiece(pieces, moving, payload.targetRow, payload.targetCol);
|
||||
}
|
||||
|
||||
return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
|
||||
return applyNextBoard(
|
||||
timedRun,
|
||||
rebuildBoardSnapshot(currentLevel.gridSize, pieces),
|
||||
);
|
||||
}
|
||||
|
||||
export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
export function advanceLocalPuzzleLevel(
|
||||
run: PuzzleRunSnapshot,
|
||||
): PuzzleRunSnapshot {
|
||||
return buildFallbackLocalLevel(run);
|
||||
}
|
||||
|
||||
export function restartLocalPuzzleLevel(
|
||||
run: PuzzleRunSnapshot,
|
||||
): PuzzleRunSnapshot {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel) {
|
||||
return run;
|
||||
}
|
||||
|
||||
const runId = buildLocalPuzzleRunId(currentLevel.profileId);
|
||||
const startedAtMs = Date.now();
|
||||
return {
|
||||
...run,
|
||||
runId,
|
||||
leaderboardEntries: [],
|
||||
currentLevel: {
|
||||
...currentLevel,
|
||||
runId,
|
||||
board: buildInitialBoard(
|
||||
currentLevel.gridSize,
|
||||
runId,
|
||||
currentLevel.profileId,
|
||||
currentLevel.levelIndex,
|
||||
),
|
||||
status: 'playing',
|
||||
startedAtMs,
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
...buildLevelTimerFields(currentLevel.levelIndex),
|
||||
leaderboardEntries: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前拼图运行态是否为前端本地兜底 run。
|
||||
* 这类 run 没有后端持久化记录,不能再调用依赖真实 runId 的排行榜接口。
|
||||
@@ -717,3 +1089,90 @@ export function submitLocalPuzzleLeaderboard(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function refreshLocalPuzzleTimer(
|
||||
run: PuzzleRunSnapshot,
|
||||
): PuzzleRunSnapshot {
|
||||
return withResolvedTimer(run);
|
||||
}
|
||||
|
||||
export function setLocalPuzzlePaused(
|
||||
run: PuzzleRunSnapshot,
|
||||
paused: boolean,
|
||||
): PuzzleRunSnapshot {
|
||||
const timedRun = withResolvedTimer(run);
|
||||
const currentLevel = timedRun.currentLevel;
|
||||
if (!currentLevel || currentLevel.status !== 'playing') {
|
||||
return timedRun;
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
if (paused) {
|
||||
return {
|
||||
...timedRun,
|
||||
currentLevel: {
|
||||
...currentLevel,
|
||||
pauseStartedAtMs: currentLevel.pauseStartedAtMs ?? nowMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...timedRun,
|
||||
currentLevel: closePauseForLevel(currentLevel, nowMs),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyLocalPuzzleFreezeTime(
|
||||
run: PuzzleRunSnapshot,
|
||||
): PuzzleRunSnapshot {
|
||||
const timedRun = withResolvedTimer(run);
|
||||
const currentLevel = timedRun.currentLevel;
|
||||
if (!currentLevel || currentLevel.status !== 'playing') {
|
||||
return timedRun;
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
const activeLevel = closePauseForLevel(currentLevel, nowMs);
|
||||
return {
|
||||
...timedRun,
|
||||
currentLevel: {
|
||||
...activeLevel,
|
||||
freezeStartedAtMs: nowMs,
|
||||
freezeUntilMs: nowMs + PUZZLE_FREEZE_TIME_DURATION_MS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function extendLocalPuzzleTime(
|
||||
run: PuzzleRunSnapshot,
|
||||
): PuzzleRunSnapshot {
|
||||
const timedRun = withResolvedTimer(run);
|
||||
const currentLevel = timedRun.currentLevel;
|
||||
if (!currentLevel || currentLevel.status !== 'failed') {
|
||||
return timedRun;
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
const consumedBeforeExtend = Math.max(
|
||||
0,
|
||||
currentLevel.timeLimitMs - PUZZLE_EXTEND_TIME_DURATION_MS,
|
||||
);
|
||||
|
||||
return {
|
||||
...timedRun,
|
||||
currentLevel: {
|
||||
...currentLevel,
|
||||
status: 'playing',
|
||||
startedAtMs: nowMs - consumedBeforeExtend,
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
remainingMs: PUZZLE_EXTEND_TIME_DURATION_MS,
|
||||
pausedAccumulatedMs: 0,
|
||||
pauseStartedAtMs: null,
|
||||
freezeAccumulatedMs: 0,
|
||||
freezeStartedAtMs: null,
|
||||
freezeUntilMs: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
StartPuzzleRunRequest,
|
||||
SubmitPuzzleLeaderboardRequest,
|
||||
SwapPuzzlePiecesRequest,
|
||||
UpdatePuzzleRuntimePauseRequest,
|
||||
UsePuzzleRuntimePropRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
@@ -75,27 +77,6 @@ export async function swapPuzzlePieces(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交单块或合并块拖动请求。
|
||||
*/
|
||||
export async function dragPuzzlePieceOrGroup(
|
||||
runId: string,
|
||||
payload: DragPuzzlePieceRequest,
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/drag`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'拖动拼图块失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入推荐出的下一关。
|
||||
*/
|
||||
@@ -133,6 +114,48 @@ export async function submitPuzzleLeaderboard(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停或恢复正式拼图运行态计时。
|
||||
*/
|
||||
export async function updatePuzzleRunPause(
|
||||
runId: string,
|
||||
payload: UpdatePuzzleRuntimePauseRequest,
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/pause`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'更新拼图计时状态失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用正式拼图道具,服务端负责扣除光点并更新运行态。
|
||||
*/
|
||||
export async function usePuzzleRuntimeProp(
|
||||
runId: string,
|
||||
payload: UsePuzzleRuntimePropRequest,
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/props`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'使用拼图道具失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const puzzleRuntimeClient = {
|
||||
advanceNextLevel: advancePuzzleNextLevel,
|
||||
drag: dragPuzzlePieceOrGroup,
|
||||
@@ -140,4 +163,6 @@ export const puzzleRuntimeClient = {
|
||||
submitLeaderboard: submitPuzzleLeaderboard,
|
||||
startRun: startPuzzleRun,
|
||||
swap: swapPuzzlePieces,
|
||||
updatePause: updatePuzzleRunPause,
|
||||
useProp: usePuzzleRuntimeProp,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
getPuzzleWorkDetail,
|
||||
claimPuzzleWorkPointIncentive,
|
||||
deletePuzzleWork,
|
||||
getPuzzleWorkDetail,
|
||||
listPuzzleWorks,
|
||||
puzzleWorksClient,
|
||||
updatePuzzleWork,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type {
|
||||
PuzzleWorkDetailResponse,
|
||||
PuzzleWorkMutationResponse,
|
||||
@@ -52,16 +53,19 @@ export async function getPuzzleWorkDetail(profileId: string) {
|
||||
|
||||
/**
|
||||
* 更新已发布或草稿态拼图作品的轻量字段。
|
||||
* 只覆盖结果页约定的标题、摘要、标签与正式图。
|
||||
* 只覆盖结果页约定的作品信息、首关摘要、标签、正式图与关卡列表。
|
||||
*/
|
||||
export async function updatePuzzleWork(
|
||||
profileId: string,
|
||||
payload: {
|
||||
workTitle?: string;
|
||||
workDescription?: string;
|
||||
levelName: string;
|
||||
summary: string;
|
||||
themeTags: string[];
|
||||
coverImageSrc?: string | null;
|
||||
coverAssetId?: string | null;
|
||||
levels: PuzzleDraftLevel[];
|
||||
},
|
||||
) {
|
||||
return requestJson<PuzzleWorkMutationResponse>(
|
||||
@@ -94,7 +98,24 @@ export async function deletePuzzleWork(profileId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 领取当前用户名下拼图作品的整数光点激励。
|
||||
*/
|
||||
export async function claimPuzzleWorkPointIncentive(profileId: string) {
|
||||
return requestJson<PuzzleWorkMutationResponse>(
|
||||
`${PUZZLE_WORKS_API_BASE}/${encodeURIComponent(profileId)}/point-incentive/claim`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'领取拼图积分激励失败',
|
||||
{
|
||||
retry: PUZZLE_WORKS_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const puzzleWorksClient = {
|
||||
claimPointIncentive: claimPuzzleWorkPointIncentive,
|
||||
delete: deletePuzzleWork,
|
||||
getDetail: getPuzzleWorkDetail,
|
||||
list: listPuzzleWorks,
|
||||
|
||||
117
src/services/puzzleReferenceImage.test.ts
Normal file
117
src/services/puzzleReferenceImage.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH,
|
||||
readPuzzleReferenceImageAsDataUrl,
|
||||
} from './puzzleReferenceImage';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function stubFileReader(dataUrl: string) {
|
||||
class MockFileReader {
|
||||
result: string | null = null;
|
||||
error: Error | null = null;
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | (() => void) = null;
|
||||
|
||||
readAsDataURL() {
|
||||
this.result = dataUrl;
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
|
||||
}
|
||||
|
||||
function stubImage(width = 4096, height = 3072) {
|
||||
class MockImage {
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | (() => void) = null;
|
||||
naturalWidth = width;
|
||||
naturalHeight = height;
|
||||
width = width;
|
||||
height = height;
|
||||
|
||||
set src(_value: string) {
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('Image', MockImage as unknown as typeof Image);
|
||||
}
|
||||
|
||||
function stubCanvas(dataUrls: string[]) {
|
||||
const drawImage = vi.fn();
|
||||
const toDataURL = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
() => dataUrls.shift() ?? 'data:image/jpeg;base64,small',
|
||||
);
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
|
||||
if (tagName !== 'canvas') {
|
||||
return originalCreateElement(tagName);
|
||||
}
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => ({
|
||||
drawImage,
|
||||
fillRect: vi.fn(),
|
||||
fillStyle: '',
|
||||
imageSmoothingEnabled: false,
|
||||
imageSmoothingQuality: 'low',
|
||||
}),
|
||||
toDataURL,
|
||||
} as unknown as HTMLCanvasElement;
|
||||
});
|
||||
return { drawImage, toDataURL };
|
||||
}
|
||||
|
||||
describe('readPuzzleReferenceImageAsDataUrl', () => {
|
||||
test('compresses large puzzle reference images before JSON upload', async () => {
|
||||
stubFileReader(`data:image/png;base64,${'A'.repeat(3 * 1024 * 1024)}`);
|
||||
stubImage();
|
||||
const { drawImage, toDataURL } = stubCanvas([
|
||||
`data:image/jpeg;base64,${'B'.repeat(1200)}`,
|
||||
`data:image/jpeg;base64,${'C'.repeat(1000)}`,
|
||||
`data:image/jpeg;base64,${'D'.repeat(1400)}`,
|
||||
]);
|
||||
|
||||
const file = new File(['x'.repeat(2 * 1024 * 1024)], 'reference.png', {
|
||||
type: 'image/png',
|
||||
});
|
||||
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
|
||||
|
||||
expect(dataUrl).toBe(`data:image/jpeg;base64,${'C'.repeat(1000)}`);
|
||||
expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 1536, 1152);
|
||||
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.84);
|
||||
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.76);
|
||||
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.68);
|
||||
});
|
||||
|
||||
test('rejects reference images that still exceed the upload budget', async () => {
|
||||
stubFileReader(
|
||||
`data:image/png;base64,${'A'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH + 1)}`,
|
||||
);
|
||||
stubImage();
|
||||
stubCanvas([
|
||||
`data:image/jpeg;base64,${'B'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH + 1)}`,
|
||||
`data:image/jpeg;base64,${'C'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH + 2)}`,
|
||||
`data:image/jpeg;base64,${'D'.repeat(PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH + 3)}`,
|
||||
]);
|
||||
|
||||
const file = new File(['x'.repeat(2 * 1024 * 1024)], 'reference.png', {
|
||||
type: 'image/png',
|
||||
});
|
||||
|
||||
await expect(readPuzzleReferenceImageAsDataUrl(file)).rejects.toThrow(
|
||||
'参考图过大,请换一张尺寸更小的图片。',
|
||||
);
|
||||
});
|
||||
});
|
||||
117
src/services/puzzleReferenceImage.ts
Normal file
117
src/services/puzzleReferenceImage.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
const PUZZLE_REFERENCE_IMAGE_MAX_EDGE = 1536;
|
||||
const PUZZLE_REFERENCE_IMAGE_COMPRESS_TRIGGER_BYTES = 1536 * 1024;
|
||||
export const PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH = 10 * 1024 * 1024;
|
||||
|
||||
type PuzzleReferenceImageSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
function readFileAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('参考图读取失败,请重试。'));
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== 'string') {
|
||||
reject(new Error('参考图读取失败,请重试。'));
|
||||
return;
|
||||
}
|
||||
resolve(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureReferenceImageWithinLimit(dataUrl: string) {
|
||||
if (dataUrl.length > PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH) {
|
||||
throw new Error('参考图过大,请换一张尺寸更小的图片。');
|
||||
}
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
function loadReferenceImage(dataUrl: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('参考图读取失败,请重试。'));
|
||||
image.src = dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCompressedImageSize(
|
||||
image: HTMLImageElement,
|
||||
): PuzzleReferenceImageSize {
|
||||
const sourceWidth = image.naturalWidth || image.width;
|
||||
const sourceHeight = image.naturalHeight || image.height;
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
||||
throw new Error('参考图读取失败,请重试。');
|
||||
}
|
||||
|
||||
const scale = Math.min(
|
||||
1,
|
||||
PUZZLE_REFERENCE_IMAGE_MAX_EDGE / Math.max(sourceWidth, sourceHeight),
|
||||
);
|
||||
return {
|
||||
width: Math.max(1, Math.round(sourceWidth * scale)),
|
||||
height: Math.max(1, Math.round(sourceHeight * scale)),
|
||||
};
|
||||
}
|
||||
|
||||
function shouldCompressReferenceImage(file: File, dataUrl: string) {
|
||||
return (
|
||||
file.size > PUZZLE_REFERENCE_IMAGE_COMPRESS_TRIGGER_BYTES ||
|
||||
dataUrl.length > PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
async function compressReferenceImageDataUrl(file: File, dataUrl: string) {
|
||||
if (
|
||||
typeof document === 'undefined' ||
|
||||
typeof Image === 'undefined' ||
|
||||
!shouldCompressReferenceImage(file, dataUrl)
|
||||
) {
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
const image = await loadReferenceImage(dataUrl);
|
||||
const size = resolveCompressedImageSize(image);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size.width;
|
||||
canvas.height = size.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
// 中文注释:参考图只作为生成提示,不需要保留手机原图体积;压到单边 1536 内给 JSON body 留余量。
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.fillStyle = '#ffffff';
|
||||
context.fillRect(0, 0, size.width, size.height);
|
||||
context.drawImage(image, 0, 0, size.width, size.height);
|
||||
|
||||
const candidates = [0.84, 0.76, 0.68].map((quality) =>
|
||||
canvas.toDataURL('image/jpeg', quality),
|
||||
);
|
||||
return candidates.reduce((best, current) =>
|
||||
current.length < best.length ? current : best,
|
||||
);
|
||||
}
|
||||
|
||||
export async function readPuzzleReferenceImageAsDataUrl(file: File) {
|
||||
const dataUrl = await readFileAsDataUrl(file);
|
||||
try {
|
||||
const compressedDataUrl = await compressReferenceImageDataUrl(
|
||||
file,
|
||||
dataUrl,
|
||||
);
|
||||
return ensureReferenceImageWithinLimit(
|
||||
compressedDataUrl.length < dataUrl.length ? compressedDataUrl : dataUrl,
|
||||
);
|
||||
} catch (error) {
|
||||
if (dataUrl.length <= PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH) {
|
||||
return dataUrl;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
export {
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetail,
|
||||
getRpgEntryWorldLibraryDetail,
|
||||
listRpgEntryWorldGallery,
|
||||
listRpgEntryWorldLibrary,
|
||||
likeRpgEntryWorldGallery,
|
||||
publishRpgEntryWorldProfile,
|
||||
recordRpgEntryWorldGalleryPlay,
|
||||
remixRpgEntryWorldGallery,
|
||||
rpgEntryLibraryClient,
|
||||
type RuntimeRequestOptions,
|
||||
unpublishRpgEntryWorldProfile,
|
||||
|
||||
@@ -152,6 +152,26 @@ describe('rpgEntry public custom world gallery routes', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('likes public gallery detail through the authenticated mutation route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
likeCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const { likeRpgEntryWorldGallery } = await import('./rpgEntryLibraryClient');
|
||||
await likeRpgEntryWorldGallery('user-1', 'profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-gallery/user-1/profile-1/like',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'点赞作品失败',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rpgEntry save archive routes', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ const { requestJsonMock } = vi.hoisted(() => ({
|
||||
import {
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetail,
|
||||
getRpgEntryWorldLibraryDetail,
|
||||
listRpgEntryWorldGallery,
|
||||
listRpgEntryWorldLibrary,
|
||||
publishRpgEntryWorldProfile,
|
||||
@@ -72,6 +73,26 @@ describe('rpgEntryLibraryClient world library routes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reads owned library detail from the runtime entry route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
ownerUserId: 'owner-1',
|
||||
profileId: 'profile-1',
|
||||
},
|
||||
});
|
||||
|
||||
await getRpgEntryWorldLibraryDetail('profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-library/profile-1',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取作品详情失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes world profile through the runtime entry route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
|
||||
@@ -78,6 +78,76 @@ export async function getRpgEntryWorldGalleryDetailByCode(
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
export async function remixRpgEntryWorldGallery(
|
||||
ownerUserId: string,
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}/remix`,
|
||||
{ method: 'POST' },
|
||||
'Remix 作品失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function recordRpgEntryWorldGalleryPlay(
|
||||
ownerUserId: string,
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldGalleryDetailResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}/play`,
|
||||
{ method: 'POST' },
|
||||
'记录作品游玩失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
export async function likeRpgEntryWorldGallery(
|
||||
ownerUserId: string,
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldGalleryDetailResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}/like`,
|
||||
{ method: 'POST' },
|
||||
'点赞作品失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
export async function getRpgEntryWorldLibraryDetail(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldGalleryDetailResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取作品详情失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
export async function upsertRpgEntryWorldProfile(
|
||||
profile: CustomWorldProfile,
|
||||
options: RuntimeRequestOptions = {},
|
||||
@@ -162,6 +232,10 @@ export const rpgEntryLibraryClient = {
|
||||
listWorldGallery: listRpgEntryWorldGallery,
|
||||
getWorldGalleryDetail: getRpgEntryWorldGalleryDetail,
|
||||
getWorldGalleryDetailByCode: getRpgEntryWorldGalleryDetailByCode,
|
||||
getWorldLibraryDetail: getRpgEntryWorldLibraryDetail,
|
||||
remixWorldGallery: remixRpgEntryWorldGallery,
|
||||
recordWorldGalleryPlay: recordRpgEntryWorldGalleryPlay,
|
||||
likeWorldGallery: likeRpgEntryWorldGallery,
|
||||
upsertWorldProfile: upsertRpgEntryWorldProfile,
|
||||
deleteWorldProfile: deleteRpgEntryWorldProfile,
|
||||
publishWorldProfile: publishRpgEntryWorldProfile,
|
||||
|
||||
Reference in New Issue
Block a user