From 615d828add64e5a1de7571498c41d436537545bb Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 27 Apr 2026 00:09:09 +0800 Subject: [PATCH] fix: show published big fish works in gallery --- ...NTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md | 14 +- .../src/contracts/bigFishWorkSummary.ts | 1 + server-rs/crates/api-server/src/app.rs | 5 +- server-rs/crates/api-server/src/big_fish.rs | 24 ++ server-rs/crates/module-big-fish/src/lib.rs | 5 + .../shared-contracts/src/big_fish_works.rs | 1 + .../crates/spacetime-client/src/big_fish.rs | 22 +- .../crates/spacetime-client/src/mapper.rs | 1 + .../big_fish_works_list_input_type.rs | 1 + .../spacetime-module/src/big_fish/runtime.rs | 12 +- .../spacetime-module/src/big_fish/session.rs | 6 + .../PlatformEntryFlowShellImpl.tsx | 322 +++++++++++++----- ...gEntryFlowShell.agent.interaction.test.tsx | 209 +++++++++++- .../RpgEntryHomeView.recharge.test.tsx | 14 +- src/components/rpg-entry/RpgEntryHomeView.tsx | 26 +- .../rpg-entry/rpgEntryWorldPresentation.ts | 60 +++- .../big-fish-gallery/bigFishGalleryClient.ts | 29 ++ src/services/big-fish-gallery/index.ts | 4 + src/services/publicWorkCode.ts | 21 ++ 19 files changed, 663 insertions(+), 114 deletions(-) create mode 100644 src/services/big-fish-gallery/bigFishGalleryClient.ts create mode 100644 src/services/big-fish-gallery/index.ts diff --git a/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md b/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md index 78329042..00377a5b 100644 --- a/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md +++ b/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md @@ -147,7 +147,7 @@ ## 6. HTTP contract -所有接口挂在 `/api/runtime/big-fish/*`,全部需要 Bearer 鉴权。 +所有接口挂在 `/api/runtime/big-fish/*`。创作、私有作品列表、删除、运行态启动与输入推进需要 Bearer 鉴权;公开广场读取接口不要求登录,只返回已发布作品。 开发态本地链路补充约定: @@ -191,12 +191,24 @@ 2. `GET /api/runtime/big-fish/runs/{runId}` 3. `POST /api/runtime/big-fish/runs/{runId}/input` +运行态启动规则: + +1. 当前用户启动自己未发布草稿时,`session.owner_user_id` 必须等于当前登录用户。 +2. 当前用户启动别人作品时,只允许启动 `stage = published` 的公开作品。 +3. 新建的 `big_fish_runtime_run.owner_user_id` 始终写入当前游玩用户,不能写入作品作者,后续 run 查询与输入推进仍按游玩用户隔离。 + ### 6.3 作品列表 1. `GET /api/runtime/big-fish/works` 开发态 Vite 必须把该同源接口代理到 Rust `api-server`;前端作品页只调用同源 `/api/runtime/big-fish/works`,不得直连 Rust 端口或回退到 `server-node`。 +### 6.4 公开广场 + +1. `GET /api/runtime/big-fish/gallery` + +公开广场只返回 `status = published` 的大鱼吃小鱼作品。响应复用 `BigFishWorksResponse`,每个条目必须包含 `ownerUserId`,供前端生成稳定广场卡片 key 与后续运行态权限判断。发布动作完成后,前端必须同时刷新私有作品列表和公开广场列表,保证发布结果能立即出现在首页与分类页。 + `input` 请求体: ```json diff --git a/packages/shared/src/contracts/bigFishWorkSummary.ts b/packages/shared/src/contracts/bigFishWorkSummary.ts index d8cd49eb..30ec15f1 100644 --- a/packages/shared/src/contracts/bigFishWorkSummary.ts +++ b/packages/shared/src/contracts/bigFishWorkSummary.ts @@ -3,6 +3,7 @@ export type BigFishWorkStatus = 'draft' | 'published'; export interface BigFishWorkSummary { workId: string; sourceSessionId: string; + ownerUserId: string; title: string; subtitle: string; summary: string; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 4323ea3b..89e1035f 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -34,8 +34,8 @@ use crate::{ auth_sessions::auth_sessions, big_fish::{ create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run, - get_big_fish_session, get_big_fish_works, start_big_fish_run, stream_big_fish_message, - submit_big_fish_input, submit_big_fish_message, + get_big_fish_session, get_big_fish_works, list_big_fish_gallery, start_big_fish_run, + stream_big_fish_message, submit_big_fish_input, submit_big_fish_message, }, character_animation_assets::{ generate_character_animation, get_character_animation_job, get_character_workflow_cache, @@ -562,6 +562,7 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route("/api/runtime/big-fish/gallery", get(list_big_fish_gallery)) .route( "/api/runtime/big-fish/works/{session_id}", delete(delete_big_fish_work).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index 300c539a..a8e70277 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -144,6 +144,29 @@ pub async fn get_big_fish_works( )) } +pub async fn list_big_fish_gallery( + State(state): State, + Extension(request_context): Extension, +) -> Result, Response> { + let items = state + .spacetime_client() + .list_big_fish_gallery() + .await + .map_err(|error| { + big_fish_error_response(&request_context, map_big_fish_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + BigFishWorksResponse { + items: items + .into_iter() + .map(map_big_fish_work_summary_response) + .collect(), + }, + )) +} + pub async fn delete_big_fish_work( State(state): State, Path(session_id): Path, @@ -917,6 +940,7 @@ fn map_big_fish_work_summary_response( BigFishWorkSummaryResponse { work_id: item.work_id, source_session_id: item.source_session_id, + owner_user_id: item.owner_user_id, title: item.title, subtitle: item.subtitle, summary: item.summary, diff --git a/server-rs/crates/module-big-fish/src/lib.rs b/server-rs/crates/module-big-fish/src/lib.rs index c71d8166..1dbb594f 100644 --- a/server-rs/crates/module-big-fish/src/lib.rs +++ b/server-rs/crates/module-big-fish/src/lib.rs @@ -257,6 +257,7 @@ pub struct BigFishSessionProcedureResult { pub struct BigFishWorkSummarySnapshot { pub work_id: String, pub source_session_id: String, + pub owner_user_id: String, pub title: String, pub subtitle: String, pub summary: String, @@ -274,6 +275,7 @@ pub struct BigFishWorkSummarySnapshot { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishWorksListInput { pub owner_user_id: String, + pub published_only: bool, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -747,6 +749,9 @@ pub fn validate_session_get_input(input: &BigFishSessionGetInput) -> Result<(), } pub fn validate_works_list_input(input: &BigFishWorksListInput) -> Result<(), BigFishFieldError> { + if input.published_only { + return Ok(()); + } if normalize_required_string(&input.owner_user_id).is_none() { return Err(BigFishFieldError::MissingOwnerUserId); } diff --git a/server-rs/crates/shared-contracts/src/big_fish_works.rs b/server-rs/crates/shared-contracts/src/big_fish_works.rs index acddc497..1f876bf7 100644 --- a/server-rs/crates/shared-contracts/src/big_fish_works.rs +++ b/server-rs/crates/shared-contracts/src/big_fish_works.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; pub struct BigFishWorkSummaryResponse { pub work_id: String, pub source_session_id: String, + pub owner_user_id: String, pub title: String, pub subtitle: String, pub summary: String, diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs index a7931249..168d9c48 100644 --- a/server-rs/crates/spacetime-client/src/big_fish.rs +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -57,8 +57,28 @@ impl SpacetimeClient { &self, owner_user_id: String, ) -> Result, SpacetimeClientError> { - let procedure_input = BigFishWorksListInput { owner_user_id }; + let procedure_input = BigFishWorksListInput { + owner_user_id, + published_only: false, + }; + self.list_big_fish_works_with_input(procedure_input).await + } + + pub async fn list_big_fish_gallery( + &self, + ) -> Result, SpacetimeClientError> { + self.list_big_fish_works_with_input(BigFishWorksListInput { + owner_user_id: String::new(), + published_only: true, + }) + .await + } + + async fn list_big_fish_works_with_input( + &self, + procedure_input: BigFishWorksListInput, + ) -> Result, SpacetimeClientError> { self.call_after_connect(move |connection, sender| { connection .procedures() diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index c0213eb7..fc57f93a 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -4590,6 +4590,7 @@ pub struct BigFishSessionRecord { pub struct BigFishWorkSummaryRecord { pub work_id: String, pub source_session_id: String, + pub owner_user_id: String, pub title: String, pub subtitle: String, pub summary: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_works_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_works_list_input_type.rs index 240ce46a..9b23133a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_works_list_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_works_list_input_type.rs @@ -8,6 +8,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; #[sats(crate = __lib)] pub struct BigFishWorksListInput { pub owner_user_id: String, + pub published_only: bool, } impl __sdk::InModule for BigFishWorksListInput { diff --git a/server-rs/crates/spacetime-module/src/big_fish/runtime.rs b/server-rs/crates/spacetime-module/src/big_fish/runtime.rs index 67b80056..d794b653 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/runtime.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/runtime.rs @@ -77,8 +77,12 @@ fn start_big_fish_run_tx( .big_fish_creation_session() .session_id() .find(&input.session_id) - .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; + if session.owner_user_id != input.owner_user_id + && session.stage != BigFishCreationStage::Published + { + return Err("big_fish_creation_session 不存在".to_string()); + } let draft = session .draft_json .as_deref() @@ -124,8 +128,12 @@ fn submit_big_fish_input_tx( .big_fish_creation_session() .session_id() .find(&run.session_id) - .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; + if session.owner_user_id != input.owner_user_id + && session.stage != BigFishCreationStage::Published + { + return Err("big_fish_creation_session 不存在".to_string()); + } let draft = session .draft_json .as_deref() diff --git a/server-rs/crates/spacetime-module/src/big_fish/session.rs b/server-rs/crates/spacetime-module/src/big_fish/session.rs index 50a4c435..81f2c5d8 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -239,6 +239,10 @@ pub(crate) fn list_big_fish_works_tx( .big_fish_creation_session() .iter() .filter(|row| { + if input.published_only { + return row.stage == BigFishCreationStage::Published; + } + row.owner_user_id == input.owner_user_id && should_include_big_fish_work(ctx, row) }) .map(|row| build_big_fish_work_summary(ctx, &row)) @@ -330,6 +334,7 @@ pub(crate) fn delete_big_fish_work_tx( ctx, BigFishWorksListInput { owner_user_id: input.owner_user_id, + published_only: false, }, ) } @@ -642,6 +647,7 @@ pub(crate) fn build_big_fish_work_summary( Ok(BigFishWorkSummarySnapshot { work_id: format!("big-fish-work-{}", row.session_id), source_session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), title, subtitle, summary, diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index acedecd6..a8a812f5 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -48,6 +48,7 @@ import { startBigFishRuntimeRun, submitBigFishRuntimeInput, } from '../../services/big-fish-runtime'; +import { listBigFishGallery } from '../../services/big-fish-gallery'; import { deleteBigFishWork, listBigFishWorks, @@ -64,7 +65,10 @@ import { type MiniGameDraftGenerationState, } from '../../services/miniGameDraftGenerationProgress'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; -import { isSamePuzzlePublicWorkCode } from '../../services/publicWorkCode'; +import { + isSameBigFishPublicWorkCode, + isSamePuzzlePublicWorkCode, +} from '../../services/publicWorkCode'; import { createPuzzleAgentSession, executePuzzleAgentAction, @@ -91,7 +95,9 @@ import { import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; import { + isBigFishGalleryEntry, isPuzzleGalleryEntry, + mapBigFishWorkToPlatformGalleryCard, mapPuzzleWorkToPlatformGalleryCard, type PlatformPublicGalleryCard, } from '../rpg-entry/rpgEntryWorldPresentation'; @@ -146,7 +152,12 @@ function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) { } function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) { - return `${isPuzzleGalleryEntry(entry) ? 'puzzle' : 'rpg'}:${entry.ownerUserId}:${entry.profileId}`; + const kind = isBigFishGalleryEntry(entry) + ? 'big-fish' + : isPuzzleGalleryEntry(entry) + ? 'puzzle' + : 'rpg'; + return `${kind}:${entry.ownerUserId}:${entry.profileId}`; } function mergePlatformPublicGalleryEntries( @@ -393,6 +404,9 @@ export function PlatformEntryFlowShellImpl({ const [selectedDetailEntry, setSelectedDetailEntry] = useState | null>(null); const [bigFishWorks, setBigFishWorks] = useState([]); + const [bigFishGalleryEntries, setBigFishGalleryEntries] = useState< + BigFishWorkSummary[] + >([]); const [bigFishRun, setBigFishRun] = useState(null); const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false); @@ -460,6 +474,64 @@ export function PlatformEntryFlowShellImpl({ [], ); + const refreshBigFishShelf = useCallback(async () => { + setIsBigFishLoadingLibrary(true); + + try { + const worksResponse = await listBigFishWorks(); + setBigFishWorks(worksResponse.items); + setBigFishError(null); + } catch (error) { + setBigFishError( + resolveBigFishErrorMessage(error, '读取大鱼吃小鱼作品列表失败。'), + ); + } finally { + setIsBigFishLoadingLibrary(false); + } + }, [resolveBigFishErrorMessage]); + + const refreshBigFishGallery = useCallback(async () => { + try { + const galleryResponse = await listBigFishGallery(); + setBigFishGalleryEntries(galleryResponse.items); + return galleryResponse.items; + } catch (error) { + setBigFishGalleryEntries([]); + setBigFishError( + resolveBigFishErrorMessage(error, '读取大鱼吃小鱼广场失败。'), + ); + return []; + } + }, [resolveBigFishErrorMessage]); + + const refreshPuzzleShelf = useCallback(async () => { + setIsPuzzleLoadingLibrary(true); + + try { + const worksResponse = await listPuzzleWorks(); + setPuzzleWorks(worksResponse.items); + setPuzzleError(null); + } catch (error) { + setPuzzleError( + resolvePuzzleErrorMessage(error, '读取拼图作品列表失败。'), + ); + } finally { + setIsPuzzleLoadingLibrary(false); + } + }, [resolvePuzzleErrorMessage]); + + const refreshPuzzleGallery = useCallback(async () => { + try { + const galleryResponse = await listPuzzleGallery(); + setPuzzleGalleryEntries(galleryResponse.items); + return galleryResponse.items; + } catch (error) { + setPuzzleGalleryEntries([]); + setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图广场失败。')); + return []; + } + }, [resolvePuzzleErrorMessage]); + const sessionController = useRpgCreationSessionController({ userId: authUi?.user?.id, openLoginModal: authUi?.openLoginModal, @@ -552,6 +624,7 @@ export function PlatformEntryFlowShellImpl({ await Promise.allSettled([ platformBootstrap.refreshPublishedGallery(), platformBootstrap.refreshCustomWorldWorks(), + refreshBigFishGallery(), refreshPuzzleGallery(), ]); return latestSession; @@ -606,21 +679,35 @@ export function PlatformEntryFlowShellImpl({ }, [agentResultPreview]); const featuredGalleryEntries = useMemo(() => { + const bigFishPublicEntries = bigFishGalleryEntries.map( + mapBigFishWorkToPlatformGalleryCard, + ); const puzzlePublicEntries = puzzleGalleryEntries.map( mapPuzzleWorkToPlatformGalleryCard, ); return mergePlatformPublicGalleryEntries( platformBootstrap.publishedGalleryEntries, - puzzlePublicEntries, + [...bigFishPublicEntries, ...puzzlePublicEntries], ).slice(0, 6); - }, [platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries]); + }, [ + bigFishGalleryEntries, + platformBootstrap.publishedGalleryEntries, + puzzleGalleryEntries, + ]); const latestGalleryEntries = useMemo( () => mergePlatformPublicGalleryEntries( platformBootstrap.publishedGalleryEntries, - puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard), + [ + ...bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard), + ...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard), + ], ), - [platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries], + [ + bigFishGalleryEntries, + platformBootstrap.publishedGalleryEntries, + puzzleGalleryEntries, + ], ); const creationHubItems = @@ -680,50 +767,6 @@ export function PlatformEntryFlowShellImpl({ setShowCreationTypeModal(true); }, [prepareCreationLaunch]); - const refreshBigFishShelf = useCallback(async () => { - setIsBigFishLoadingLibrary(true); - - try { - const worksResponse = await listBigFishWorks(); - setBigFishWorks(worksResponse.items); - setBigFishError(null); - } catch (error) { - setBigFishError( - resolveBigFishErrorMessage(error, '读取大鱼吃小鱼作品列表失败。'), - ); - } finally { - setIsBigFishLoadingLibrary(false); - } - }, [resolveBigFishErrorMessage]); - - const refreshPuzzleShelf = useCallback(async () => { - setIsPuzzleLoadingLibrary(true); - - try { - const worksResponse = await listPuzzleWorks(); - setPuzzleWorks(worksResponse.items); - setPuzzleError(null); - } catch (error) { - setPuzzleError( - resolvePuzzleErrorMessage(error, '读取拼图作品列表失败。'), - ); - } finally { - setIsPuzzleLoadingLibrary(false); - } - }, [resolvePuzzleErrorMessage]); - - const refreshPuzzleGallery = useCallback(async () => { - try { - const galleryResponse = await listPuzzleGallery(); - setPuzzleGalleryEntries(galleryResponse.items); - return galleryResponse.items; - } catch (error) { - setPuzzleGalleryEntries([]); - setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图广场失败。')); - return []; - } - }, [resolvePuzzleErrorMessage]); - const bigFishFlow = usePlatformCreationAgentFlowController< BigFishSessionSnapshotResponse, Record, @@ -761,6 +804,7 @@ export function PlatformEntryFlowShellImpl({ setSession(response.session); if (payload.action === 'big_fish_publish_game') { void refreshBigFishShelf(); + void refreshBigFishGallery(); } if (payload.action !== 'big_fish_compile_draft') { return; @@ -1081,6 +1125,34 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage, ]); + const restartBigFishRun = useCallback(async () => { + const sessionId = bigFishSession?.sessionId ?? bigFishRun?.sessionId; + if (!sessionId || isBigFishBusy) { + return; + } + + setIsBigFishBusy(true); + setBigFishError(null); + + try { + const { run } = await startBigFishRuntimeRun(sessionId); + setBigFishRun(run); + setSelectionStage('big-fish-runtime'); + } catch (error) { + setBigFishError( + resolveBigFishErrorMessage(error, '重新开始大鱼吃小鱼玩法失败。'), + ); + } finally { + setIsBigFishBusy(false); + } + }, [ + bigFishRun?.sessionId, + bigFishSession?.sessionId, + isBigFishBusy, + resolveBigFishErrorMessage, + setSelectionStage, + ]); + const startPuzzleRunFromProfile = useCallback( async (profileId: string) => { if (isPuzzleBusy) { @@ -1390,8 +1462,9 @@ export function PlatformEntryFlowShellImpl({ setBigFishError(null); void deleteBigFishWork(work.sourceSessionId) - .then((response) => { + .then(async (response) => { setBigFishWorks(response.items); + await refreshBigFishGallery().catch(() => []); }) .catch((error) => { setBigFishError( @@ -1403,7 +1476,12 @@ export function PlatformEntryFlowShellImpl({ }); }); }, - [deletingCreationWorkId, resolveBigFishErrorMessage, runProtectedAction], + [ + deletingCreationWorkId, + refreshBigFishGallery, + resolveBigFishErrorMessage, + runProtectedAction, + ], ); const handleDeletePuzzleWork = useCallback( @@ -1499,6 +1577,33 @@ export function PlatformEntryFlowShellImpl({ [openPuzzleDetail, puzzleFlow, refreshPuzzleShelf, setPuzzleError], ); + const startBigFishRunFromWork = useCallback( + async (item: BigFishWorkSummary) => { + const sessionId = item.sourceSessionId?.trim(); + if (!sessionId) { + setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。'); + return; + } + + setIsBigFishBusy(true); + setBigFishError(null); + + try { + const { run } = await startBigFishRuntimeRun(sessionId); + bigFishFlow.setSession(null); + setBigFishRun(run); + setSelectionStage('big-fish-runtime'); + } catch (error) { + setBigFishError( + resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。'), + ); + } finally { + setIsBigFishBusy(false); + } + }, + [bigFishFlow, resolveBigFishErrorMessage, setSelectionStage], + ); + const handlePublicCodeSearch = useCallback( async (keyword: string) => { const normalizedKeyword = keyword.trim(); @@ -1514,15 +1619,19 @@ export function PlatformEntryFlowShellImpl({ const shouldSearchUserIdFirst = /^user[_-][a-z0-9_-]+$/iu.test( normalizedKeyword, ); + const shouldSearchBigFishFirst = upperKeyword.startsWith('BF'); const shouldSearchPuzzleFirst = upperKeyword.startsWith('PZ'); const shouldSearchWorkFirst = !shouldSearchUserIdFirst && + !shouldSearchBigFishFirst && !shouldSearchPuzzleFirst && (upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword)); const shouldSearchUserFirst = shouldSearchUserIdFirst || upperKeyword.startsWith('SY') || - (!shouldSearchWorkFirst && !shouldSearchPuzzleFirst); + (!shouldSearchWorkFirst && + !shouldSearchBigFishFirst && + !shouldSearchPuzzleFirst); const tryOpenGalleryEntry = async () => { const entry = @@ -1562,6 +1671,21 @@ export function PlatformEntryFlowShellImpl({ tab: platformBootstrap.platformTab, }); }; + const tryOpenBigFishGalleryEntry = async () => { + const entries = + bigFishGalleryEntries.length > 0 + ? bigFishGalleryEntries + : await refreshBigFishGallery(); + const matchedEntry = entries.find((entry) => + isSameBigFishPublicWorkCode(normalizedKeyword, entry.sourceSessionId), + ); + + if (!matchedEntry) { + throw new Error('未找到大鱼吃小鱼作品。'); + } + + await startBigFishRunFromWork(matchedEntry); + }; try { if (shouldSearchUserIdFirst) { @@ -1575,6 +1699,11 @@ export function PlatformEntryFlowShellImpl({ return; } + if (shouldSearchBigFishFirst) { + await tryOpenBigFishGalleryEntry(); + return; + } + if (shouldSearchWorkFirst) { try { await tryOpenGalleryEntry(); @@ -1611,10 +1740,13 @@ export function PlatformEntryFlowShellImpl({ }, [ detailNavigation, + bigFishGalleryEntries, openPuzzleDetail, platformBootstrap.platformTab, puzzleGalleryEntries, + refreshBigFishGallery, refreshPuzzleGallery, + startBigFishRunFromWork, ], ); @@ -1631,39 +1763,12 @@ export function PlatformEntryFlowShellImpl({ [bigFishFlow, refreshBigFishShelf], ); - const startBigFishRunFromWork = useCallback( - async (item: BigFishWorkSummary) => { - const sessionId = item.sourceSessionId?.trim(); - if (!sessionId) { - setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。'); - return; - } - - setIsBigFishBusy(true); - setBigFishError(null); - - try { - const { session } = await getBigFishCreationSession(sessionId); - const { run } = await startBigFishRuntimeRun(sessionId); - bigFishFlow.setSession(session); - setBigFishRun(run); - setSelectionStage('big-fish-runtime'); - } catch (error) { - setBigFishError( - resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。'), - ); - } finally { - setIsBigFishBusy(false); - } - }, - [bigFishFlow, resolveBigFishErrorMessage, setSelectionStage], - ); - useEffect(() => { if (selectionStage === 'platform') { + void refreshBigFishGallery(); void refreshPuzzleGallery(); } - }, [refreshPuzzleGallery, selectionStage]); + }, [refreshBigFishGallery, refreshPuzzleGallery, selectionStage]); useEffect(() => { if ( @@ -1836,6 +1941,33 @@ export function PlatformEntryFlowShellImpl({ onOpenCreateWorld={openCreationTypePicker} onOpenCreateTypePicker={openCreationTypePicker} onOpenGalleryDetail={(entry) => { + if (isBigFishGalleryEntry(entry)) { + runProtectedAction(() => { + void startBigFishRunFromWork({ + workId: entry.workId, + sourceSessionId: entry.profileId, + ownerUserId: entry.ownerUserId, + title: entry.worldName, + subtitle: entry.subtitle, + summary: entry.summaryText, + coverImageSrc: entry.coverImageSrc, + status: 'published', + updatedAt: entry.updatedAt, + publishReady: true, + levelCount: Number.parseInt( + entry.themeTags + .find((tag) => /^\d+级$/u.test(tag)) + ?.replace('级', '') ?? '0', + 10, + ), + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: Boolean(entry.coverImageSrc), + }); + }); + return; + } + if (isPuzzleGalleryEntry(entry)) { void openPuzzleDetail(entry.profileId, { tab: platformBootstrap.platformTab, @@ -2109,10 +2241,12 @@ export function PlatformEntryFlowShellImpl({ isBusy={isBigFishBusy} error={bigFishError} onBack={() => { - setSelectionStage('big-fish-result'); + setSelectionStage( + bigFishSession ? 'big-fish-result' : 'platform', + ); }} onRestart={() => { - void startBigFishRun(); + void restartBigFishRun(); }} onSubmitInput={submitBigFishInput} /> @@ -2128,7 +2262,9 @@ export function PlatformEntryFlowShellImpl({ exit={{ opacity: 0, y: -12 }} className="flex h-full min-h-0 flex-col" > - }> + } + > - }> + } + > - }> + } + > { - void startPuzzleRunFromProfile(selectedPuzzleDetail.profileId); + void startPuzzleRunFromProfile( + selectedPuzzleDetail.profileId, + ); }} /> @@ -2264,7 +2406,9 @@ export function PlatformEntryFlowShellImpl({ exit={{ opacity: 0 }} className="fixed inset-0 z-[100]" > - }> + } + > ({ listBigFishWorks: vi.fn(), })); +vi.mock('../../services/big-fish-gallery', () => ({ + listBigFishGallery: vi.fn(), +})); + +vi.mock('../../services/big-fish-runtime', () => ({ + startBigFishRuntimeRun: vi.fn(), + submitBigFishRuntimeInput: vi.fn(), +})); + vi.mock('../../services/puzzle-agent', () => ({ createPuzzleAgentSession: vi.fn(), executePuzzleAgentAction: vi.fn(), @@ -152,6 +164,69 @@ vi.mock('../../services/puzzle-agent', () => ({ streamPuzzleAgentMessage: vi.fn(), })); +vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({ + PuzzleAgentWorkspace: ({ + session, + onBack, + }: { + session: { sessionId: string; messages: Array<{ text: string }> } | null; + onBack: () => void; + }) => ( +
+
拼图工作区:{session?.sessionId ?? 'missing-session'}
+ {session?.messages.map((message) => ( +
{message.text}
+ ))} + +
+ ), +})); + +vi.mock('../puzzle-result/PuzzleResultView', () => ({ + PuzzleResultView: ({ + session, + onBack, + }: { + session: { draft?: { levelName: string } | null }; + onBack: () => void; + }) => ( +
+
拼图结果页
+ + +
+ ), +})); + +vi.mock('../puzzle-gallery/PuzzleGalleryDetailView', () => ({ + PuzzleGalleryDetailView: ({ + item, + onBack, + onStartGame, + }: { + item: { levelName: string }; + onBack: () => void; + onStartGame: () => void; + }) => ( +
+
{item.levelName}
+ + +
+ ), +})); + vi.mock('../big-fish-creation/BigFishAgentWorkspace', () => ({ BigFishAgentWorkspace: ({ session, @@ -232,8 +307,7 @@ const mockSession: CustomWorldAgentSessionSnapshot = { '玩家以返乡守灯人继承者身份切入,回港首夜撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。', coreConflict: '守灯会与航运公会争夺航路解释权,有人在借假航灯持续清洗旧案证据,玩家返乡当夜就被卷进封航冲突。', - keyRelationships: - '玩家与沈砺旧友互疑,沈砺知道沉船夜的另一半真相。', + keyRelationships: '玩家与沈砺旧友互疑,沈砺知道沉船夜的另一半真相。', hiddenLines: '沉船夜与假航灯骗局属于同一操盘链条,表面像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。', iconicElements: @@ -956,6 +1030,34 @@ beforeEach(() => { vi.mocked(listBigFishWorks).mockResolvedValue({ items: [], }); + vi.mocked(listBigFishGallery).mockResolvedValue({ + items: [], + }); + vi.mocked(startBigFishRuntimeRun).mockResolvedValue({ + run: { + runId: 'big-fish-run-1', + sessionId: 'big-fish-session-public-1', + status: 'running', + tick: 0, + playerLevel: 1, + winLevel: 8, + leaderEntityId: 'owned-1', + ownedEntities: [ + { + entityId: 'owned-1', + level: 1, + position: { x: 0, y: 0 }, + radius: 12, + offscreenSeconds: 0, + }, + ], + wildEntities: [], + cameraCenter: { x: 0, y: 0 }, + lastInput: { x: 0, y: 0 }, + eventLog: ['机械鱼群开始巡游。'], + updatedAt: '2026-04-25T12:12:00.000Z', + }, + }); vi.mocked(listPuzzleWorks).mockResolvedValue({ items: [], }); @@ -1329,6 +1431,7 @@ test('creation hub clears all private work shelves immediately after logout stat { workId: 'big-fish-logout-cache-1', sourceSessionId: 'big-fish-logout-cache-session', + ownerUserId: 'user-1', title: '大鱼退出缓存作品', subtitle: '登出后不应继续可见', summary: '这条大鱼私有作品只能在登录态展示。', @@ -1427,6 +1530,48 @@ test('published puzzle works appear on home and category public shelves', async ).toBeGreaterThan(0); }); +test('published big fish works appear on home and category public shelves', async () => { + const user = userEvent.setup(); + const publishedBigFishWork: BigFishWorkSummary = { + workId: 'big-fish-work-public-1', + sourceSessionId: 'big-fish-session-public-1', + ownerUserId: 'user-2', + title: '机械深海 大鱼吃小鱼', + subtitle: '机械微生物吞并进化', + summary: '从微光孢子一路吞并成长到深海巨鲲。', + coverImageSrc: null, + status: 'published', + updatedAt: '2026-04-25T10:30:00.000Z', + publishReady: true, + levelCount: 8, + levelMainImageReadyCount: 8, + levelMotionReadyCount: 16, + backgroundReady: true, + }; + + vi.mocked(listBigFishGallery).mockResolvedValue({ + items: [publishedBigFishWork], + }); + + render(); + + await waitFor(() => { + expect(screen.getAllByText('机械深海 大鱼吃小鱼').length).toBeGreaterThan( + 0, + ); + }); + + await user.click(screen.getByRole('button', { name: '分类' })); + + const categoryPanel = getPlatformTabPanel('category'); + expect( + within(categoryPanel).getAllByText('机械深海 大鱼吃小鱼').length, + ).toBeGreaterThan(0); + expect( + within(categoryPanel).getAllByRole('button', { name: /大鱼/u }).length, + ).toBeGreaterThan(0); +}); + test('published puzzle detail returns to the source platform tab', async () => { const user = userEvent.setup(); const publishedPuzzleWork = { @@ -1797,8 +1942,9 @@ test('public code search opens a published puzzle by PZ code', async () => { render(); - const searchInput = - await screen.findByPlaceholderText('输入 SY / CW / PZ 编号'); + const searchInput = await screen.findByPlaceholderText( + '输入 SY / CW / BF / PZ 编号', + ); await user.type(searchInput, 'PZ-EPUBLIC1'); await user.click(screen.getByRole('button', { name: '搜索' })); @@ -1812,6 +1958,49 @@ test('public code search opens a published puzzle by PZ code', async () => { expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled(); }); +test('public code search opens a published big fish work by BF code', async () => { + const user = userEvent.setup(); + const bigFishWork: BigFishWorkSummary = { + workId: 'big-fish-work-public-1', + sourceSessionId: 'big-fish-session-public-1', + ownerUserId: 'user-2', + title: '机械深海 大鱼吃小鱼', + subtitle: '机械微生物吞并进化', + summary: '从微光孢子一路吞并成长到深海巨鲲。', + coverImageSrc: null, + status: 'published', + updatedAt: '2026-04-25T10:30:00.000Z', + publishReady: true, + levelCount: 8, + levelMainImageReadyCount: 8, + levelMotionReadyCount: 16, + backgroundReady: true, + }; + + vi.mocked(listBigFishGallery).mockResolvedValue({ + items: [bigFishWork], + }); + + render(); + + const searchInput = await screen.findByPlaceholderText( + '输入 SY / CW / BF / PZ 编号', + ); + await user.type(searchInput, 'BF-NPUBLIC1'); + await user.click(screen.getByRole('button', { name: '搜索' })); + + await waitFor(() => { + expect(startBigFishRuntimeRun).toHaveBeenCalledWith( + 'big-fish-session-public-1', + ); + }); + expect(await screen.findByText('Lv.1/8 · 进行中')).toBeTruthy(); + expect(getBigFishCreationSession).not.toHaveBeenCalledWith( + 'big-fish-session-public-1', + ); + expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled(); +}); + test('big fish draft card restores the bound agent session and opens the result view', async () => { const user = userEvent.setup(); @@ -1820,6 +2009,7 @@ test('big fish draft card restores the bound agent session and opens the result { workId: 'big-fish-work-big-fish-session-1', sourceSessionId: 'big-fish-session-1', + ownerUserId: 'user-1', title: '机械深海 大鱼吃小鱼', subtitle: '机械微生物吞并进化 · 偏爽快节奏', summary: '机械微生物吞并进化', @@ -1867,10 +2057,12 @@ test('big fish draft card restores the bound agent session and opens the result test('big fish result publish action refreshes creation works', async () => { const user = userEvent.setup(); - const baseBigFishSession = (await getBigFishCreationSession('big-fish-session-1')) - .session; + const baseBigFishSession = ( + await getBigFishCreationSession('big-fish-session-1') + ).session; vi.mocked(getBigFishCreationSession).mockClear(); vi.mocked(listBigFishWorks).mockClear(); + vi.mocked(listBigFishGallery).mockClear(); const publishedBigFishSession = { ...baseBigFishSession, stage: 'published', @@ -1894,6 +2086,7 @@ test('big fish result publish action refreshes creation works', async () => { { workId: 'big-fish-work-big-fish-session-1', sourceSessionId: 'big-fish-session-1', + ownerUserId: 'user-1', title: '机械深海 大鱼吃小鱼', subtitle: '机械微生物吞并进化 · 偏爽快节奏', summary: '机械微生物吞并进化', @@ -1913,6 +2106,7 @@ test('big fish result publish action refreshes creation works', async () => { { workId: 'big-fish-work-big-fish-session-1', sourceSessionId: 'big-fish-session-1', + ownerUserId: 'user-1', title: '机械深海 大鱼吃小鱼', subtitle: '机械微生物吞并进化 · 偏爽快节奏', summary: '机械微生物吞并进化', @@ -1959,6 +2153,9 @@ test('big fish result publish action refreshes creation works', async () => { await waitFor(() => { expect(listBigFishWorks).toHaveBeenCalled(); }); + await waitFor(() => { + expect(listBigFishGallery).toHaveBeenCalled(); + }); }); test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index dad02e7c..7e3204f8 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -5,7 +5,10 @@ import userEvent from '@testing-library/user-event'; import { afterEach, expect, test, vi } from 'vitest'; import { AuthUiContext } from '../auth/AuthUiContext'; -import { RpgEntryHomeView, type RpgEntryHomeViewProps } from './RpgEntryHomeView'; +import { + RpgEntryHomeView, + type RpgEntryHomeViewProps, +} from './RpgEntryHomeView'; import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation'; vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({ @@ -343,7 +346,9 @@ test('mobile home search submits public work code', async () => { , ); - const searchInput = screen.getByPlaceholderText('输入 SY / CW / PZ 编号'); + const searchInput = screen.getByPlaceholderText( + '输入 SY / CW / BF / PZ 编号', + ); await user.type(searchInput, 'PZ-PROFILE1{enter}'); expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1'); @@ -359,8 +364,9 @@ test('public gallery cards hide work code until detail is opened', async () => { }); expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull(); - expect(screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' })) - .toBeNull(); + expect( + screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }), + ).toBeNull(); await user.click(screen.getByRole('button', { name: /查看作品/u })); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 6a6ff596..5b9d370c 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -56,6 +56,7 @@ import { buildPlatformWorldTags, describePlatformThemeLabel, formatPlatformWorldTime, + isBigFishGalleryEntry, isPuzzleGalleryEntry, type PlatformPublicGalleryCard, type PlatformWorldCardLike, @@ -223,7 +224,7 @@ function PublicCodeSearchBar({ onSubmit(); } }} - placeholder="输入 SY / CW / PZ 编号" + placeholder="输入 SY / CW / BF / PZ 编号" className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]" />