This commit is contained in:
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ export type BigFishWorkStatus = 'draft' | 'published';
|
||||
export interface BigFishWorkSummary {
|
||||
workId: string;
|
||||
sourceSessionId: string;
|
||||
ownerUserId: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
summary: string;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -144,6 +144,29 @@ pub async fn get_big_fish_works(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_big_fish_gallery(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
Path(session_id): Path<String>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -57,8 +57,28 @@ impl SpacetimeClient {
|
||||
&self,
|
||||
owner_user_id: String,
|
||||
) -> Result<Vec<BigFishWorkSummaryRecord>, 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<Vec<BigFishWorkSummaryRecord>, 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<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import { useState } from 'react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
executeBigFishCreationAction,
|
||||
getBigFishCreationSession,
|
||||
} from '../../services/big-fish-creation';
|
||||
import { listBigFishGallery } from '../../services/big-fish-gallery';
|
||||
import { startBigFishRuntimeRun } from '../../services/big-fish-runtime';
|
||||
import { listBigFishWorks } from '../../services/big-fish-works';
|
||||
import {
|
||||
createPuzzleAgentSession,
|
||||
@@ -145,6 +148,15 @@ vi.mock('../../services/big-fish-works', () => ({
|
||||
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;
|
||||
}) => (
|
||||
<div className="puzzle-agent-workspace-mock">
|
||||
<div>拼图工作区:{session?.sessionId ?? 'missing-session'}</div>
|
||||
{session?.messages.map((message) => (
|
||||
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
|
||||
))}
|
||||
<button type="button" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../puzzle-result/PuzzleResultView', () => ({
|
||||
PuzzleResultView: ({
|
||||
session,
|
||||
onBack,
|
||||
}: {
|
||||
session: { draft?: { levelName: string } | null };
|
||||
onBack: () => void;
|
||||
}) => (
|
||||
<div className="puzzle-result-view-mock">
|
||||
<div>拼图结果页</div>
|
||||
<label>
|
||||
关卡名
|
||||
<input readOnly value={session.draft?.levelName ?? ''} />
|
||||
</label>
|
||||
<button type="button" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../puzzle-gallery/PuzzleGalleryDetailView', () => ({
|
||||
PuzzleGalleryDetailView: ({
|
||||
item,
|
||||
onBack,
|
||||
onStartGame,
|
||||
}: {
|
||||
item: { levelName: string };
|
||||
onBack: () => void;
|
||||
onStartGame: () => void;
|
||||
}) => (
|
||||
<div className="puzzle-gallery-detail-view-mock">
|
||||
<div>{item.levelName}</div>
|
||||
<button type="button" onClick={onStartGame}>
|
||||
进入第 1 关
|
||||
</button>
|
||||
<button type="button" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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(<TestWrapper />);
|
||||
|
||||
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(<TestWrapper withAuth />);
|
||||
|
||||
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(<TestWrapper withAuth />);
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
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 }));
|
||||
|
||||
|
||||
@@ -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)]"
|
||||
/>
|
||||
<button
|
||||
@@ -665,9 +666,11 @@ function DesktopTrendingItem({
|
||||
))
|
||||
) : (
|
||||
<span className="platform-pill platform-pill--neutral px-2.5">
|
||||
{isPuzzleGalleryEntry(entry)
|
||||
? '拼图'
|
||||
: describePlatformThemeLabel(entry.themeMode)}
|
||||
{isBigFishGalleryEntry(entry)
|
||||
? '大鱼'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? '拼图'
|
||||
: describePlatformThemeLabel(entry.themeMode)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -714,13 +717,20 @@ function buildPublicCategoryGroups(
|
||||
}
|
||||
|
||||
function buildPublicGalleryCardKey(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 describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
|
||||
return isPuzzleGalleryEntry(entry)
|
||||
? '拼图'
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
return isBigFishGalleryEntry(entry)
|
||||
? '大鱼'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? '拼图'
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
}
|
||||
|
||||
function formatSnapshotTime(value: string | null | undefined) {
|
||||
|
||||
@@ -2,15 +2,20 @@ import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import {
|
||||
buildBigFishPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
} from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type PlatformWorldCardLike =
|
||||
| CustomWorldGalleryCard
|
||||
| CustomWorldLibraryEntry<CustomWorldProfile>
|
||||
| PlatformBigFishGalleryCard
|
||||
| PlatformPuzzleGalleryCard;
|
||||
|
||||
export type PlatformPuzzleGalleryCard = {
|
||||
@@ -30,8 +35,26 @@ export type PlatformPuzzleGalleryCard = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformBigFishGalleryCard = {
|
||||
sourceType: 'big-fish';
|
||||
workId: string;
|
||||
profileId: string;
|
||||
publicWorkCode: string;
|
||||
ownerUserId: string;
|
||||
authorDisplayName: string;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
themeTags: string[];
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformPublicGalleryCard =
|
||||
| CustomWorldGalleryCard
|
||||
| PlatformBigFishGalleryCard
|
||||
| PlatformPuzzleGalleryCard;
|
||||
|
||||
export function isLibraryWorldEntry(
|
||||
@@ -46,6 +69,12 @@ export function isPuzzleGalleryEntry(
|
||||
return 'sourceType' in entry && entry.sourceType === 'puzzle';
|
||||
}
|
||||
|
||||
export function isBigFishGalleryEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is PlatformBigFishGalleryCard {
|
||||
return 'sourceType' in entry && entry.sourceType === 'big-fish';
|
||||
}
|
||||
|
||||
export function mapPuzzleWorkToPlatformGalleryCard(
|
||||
work: PuzzleWorkSummary,
|
||||
): PlatformPuzzleGalleryCard {
|
||||
@@ -67,6 +96,27 @@ export function mapPuzzleWorkToPlatformGalleryCard(
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBigFishWorkToPlatformGalleryCard(
|
||||
work: BigFishWorkSummary,
|
||||
): PlatformBigFishGalleryCard {
|
||||
return {
|
||||
sourceType: 'big-fish',
|
||||
workId: work.workId,
|
||||
profileId: work.sourceSessionId,
|
||||
publicWorkCode: buildBigFishPublicWorkCode(work.sourceSessionId),
|
||||
ownerUserId: work.ownerUserId,
|
||||
authorDisplayName: '大鱼创作者',
|
||||
worldName: work.title,
|
||||
subtitle: work.subtitle || '大鱼吃小鱼',
|
||||
summaryText: work.summary,
|
||||
coverImageSrc: work.coverImageSrc,
|
||||
themeTags: ['大鱼', `${work.levelCount}级`],
|
||||
visibility: 'published',
|
||||
publishedAt: work.updatedAt,
|
||||
updatedAt: work.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
|
||||
if (entry.coverImageSrc) {
|
||||
return entry.coverImageSrc;
|
||||
@@ -88,6 +138,10 @@ export function resolvePlatformWorldLeadPortrait(entry: PlatformWorldCardLike) {
|
||||
}
|
||||
|
||||
export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['大鱼'];
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['拼图'];
|
||||
}
|
||||
@@ -128,6 +182,10 @@ export function formatPlatformWorldTime(value: string | null) {
|
||||
export function resolvePlatformPublicWorkCode(
|
||||
entry: PlatformWorldCardLike,
|
||||
): string | null {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const BIG_FISH_GALLERY_API_BASE = '/api/runtime/big-fish/gallery';
|
||||
const BIG_FISH_GALLERY_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取大鱼吃小鱼公开广场列表。
|
||||
*/
|
||||
export async function listBigFishGallery() {
|
||||
return requestJson<BigFishWorksResponse>(
|
||||
BIG_FISH_GALLERY_API_BASE,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取大鱼吃小鱼广场失败',
|
||||
{
|
||||
retry: BIG_FISH_GALLERY_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const bigFishGalleryClient = {
|
||||
list: listBigFishGallery,
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
bigFishGalleryClient,
|
||||
listBigFishGallery,
|
||||
} from './bigFishGalleryClient';
|
||||
@@ -13,6 +13,14 @@ export function buildPuzzlePublicWorkCode(profileId: string) {
|
||||
return `PZ-${suffix}`;
|
||||
}
|
||||
|
||||
export function buildBigFishPublicWorkCode(sessionId: string) {
|
||||
const normalized = normalizePublicCodeText(sessionId);
|
||||
const fallback = normalized || '00000000';
|
||||
const suffix = fallback.slice(-8).padStart(8, '0');
|
||||
|
||||
return `BF-${suffix}`;
|
||||
}
|
||||
|
||||
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
|
||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||
|
||||
@@ -22,3 +30,16 @@ export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
|
||||
normalizedKeyword === normalizePublicCodeText(profileId)
|
||||
);
|
||||
}
|
||||
|
||||
export function isSameBigFishPublicWorkCode(
|
||||
keyword: string,
|
||||
sessionId: string,
|
||||
) {
|
||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||
|
||||
return (
|
||||
normalizedKeyword ===
|
||||
normalizePublicCodeText(buildBigFishPublicWorkCode(sessionId)) ||
|
||||
normalizedKeyword === normalizePublicCodeText(sessionId)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user