diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 9c860439..8290da85 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -37,6 +37,12 @@ module.exports = { 'simple-import-sort/exports': 'off', }, }, + { + files: ['src/components/match3d-runtime/Match3DPhysicsBoard.tsx'], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, ], plugins: [ '@typescript-eslint', diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index ce5249ab..8443338b 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -24,6 +24,14 @@ - 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live` 应能写出默认背景文件。 - 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`、`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`。 +## 2026-05-10 方洞挑战从创作页入口和作品架隐藏 + +- 背景:运营节奏要求创作页完全隐藏方洞挑战,不能只隐藏新建入口后仍从创作页作品架暴露已有方洞草稿或已发布作品。 +- 决策:SpacetimeDB `creation_entry_type_config` 中 `square-hole.visible=false` 作为创作页统一开关;创作 Tab 模板入口、旧选择弹层、创作 Hub 卡带和创作页作品架都基于该开关隐藏方洞挑战。既有方洞详情、作品号、广场和运行态链路暂不删除,api-server 路由熔断只按 `open=false` 禁用玩法 API。 +- 影响范围:SpacetimeDB 入口配置默认种子、`platformEntryCreationTypes`、`CustomWorldCreationHub`、`PlatformEntryFlowShellImpl` 以及创作入口相关文档和回归测试。 +- 验证方式:执行入口配置、创作 Hub 和平台入口交互定向测试,确认看不到“方洞挑战” Tab、按钮和作品架条目。 +- 关联文档:`docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md`、`docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`。 + ## 2026-05-10 运行态输入设备抽象层全项目通用化 - 背景:拼图运行态接入 mocap 后,鼠标/触控和 mocap 各自维护输入逻辑会导致合并大块、拖拽语义和取消会话行为不一致;后续其他玩法也需要复用体感、摇杆、键盘等设备输入。 @@ -110,7 +118,7 @@ - 背景:平台计划新增 2048 游戏玩法模板,需要同时适配前端 stage、HTTP 路由、Rust 模块、SpacetimeDB 表和公开作品号;裸 `2048` 不适合作为模块或文件命名前缀。 - 决策:面向用户展示名保持 `2048`,工程玩法 ID 固定为 `twenty-forty-eight`,Rust 模块与表前缀使用 `twenty_forty_eight`,公开作品号前缀使用 `TF-`;玩法按完整闭环设计,包含 Agent 创作、结果页、试玩、发布、公开运行、后端棋盘裁决、排行榜和作品架 / 广场接入。 -- 影响范围:后续 `src/config/newWorkEntryConfig.ts`、平台 `SelectionStage`、前端 `twenty-forty-eight-*` 组件与 service、`module-twenty-forty-eight`、`shared-contracts`、`spacetime-module` 表、`spacetime-client` facade、`api-server` 路由、作品号和 PRD 索引。 +- 影响范围:后续 SpacetimeDB 创作入口配置、平台 `SelectionStage`、前端 `twenty-forty-eight-*` 组件与 service、`module-twenty-forty-eight`、`shared-contracts`、`spacetime-module` 表、`spacetime-client` facade、`api-server` 路由、作品号和 PRD 索引。 - 验证方式:后续落地时确认用户可见标题为 `2048`,代码、路由和表统一使用 `twenty-forty-eight` / `twenty_forty_eight`;移动、合并、生成新方块、目标达成、失败和榜单成绩由后端正式裁决,前端不伪造分数或目标达成。 - 关联文档:`docs/prd/AI_NATIVE_2048_GAMEPLAY_TEMPLATE_PRD_2026-05-05.md`。 @@ -213,10 +221,26 @@ ## 2026-05-10 视觉小说入口收敛为单句创作 + 画风选择 - 背景:视觉小说入口页要对齐抓大鹅式的线性创作入口,只保留最小可用输入,避免再暴露文档 / 空白 / 对话式工作台。 -- 决策:入口页只展示一句话创作输入框和横向视觉画风卡片;画风通过 `seedText` 追加 `视觉画风` 和 `画风要求` 两行透传给既有创作链路;点击生成后先进入 `visual-novel-generating` 过程页,再自动进入 `visual-novel-result`。 -- 影响范围:`VisualNovelAgentWorkspace`、`PlatformEntryFlowShellImpl`、`platformEntryTypes`、视觉小说 PRD;不新增后端字段或数据库结构。 -- 验证方式:视觉小说工作台单测通过,`npm run check:encoding` 通过;`npm run typecheck` 仍受仓库里 `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` 的既有类型错误影响。 -- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`。 +- 决策:入口页只展示一句话创作输入框和横向视觉画风卡片;画风通过 `seedText` 追加 `视觉画风` 和 `画风要求` 两行透传给既有创作链路;点击生成后先进入 `visual-novel-generating` 过程页,再自动进入 `visual-novel-result`。画风卡片主视觉固定消费 `public/visual-novel-style-references/` 下由 VectorEngine `gpt-image-2-all` 生成的静态参考图,不在前端运行时现场调用生图接口。 +- 影响范围:`VisualNovelAgentWorkspace`、`visualNovelEntryGeneration`、`PlatformEntryFlowShellImpl`、视觉小说 PRD 和创作 Tab 设计文档;不新增后端字段或数据库结构。 +- 验证方式:执行 `npm run test -- VisualNovelAgentWorkspace`、视觉小说工作台相关 ESLint、`npx prettier --check` 和 `npm run check:encoding`;`npm run typecheck` 若失败需先区分是否来自无关 Match3D / RPG 既有改动。 +- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`。 + +## 2026-05-10 用户标签只做后端白名单投影 + +- 背景:运营邀请码需要给账号打标签,但标签默认不能暴露到前端通用用户资料;拼图排行榜仅需展示特定标签。 +- 决策:`user_account.user_tags` 保存账号标签,数据库默认 `None`,业务按空数组读取;后台预置邀请码使用后授予的标签不再使用独立列,统一存放并解析自 `profile_invite_code.metadata_json.userTags`,兼容读取 `user_tags`。通用登录态和个人资料不返回原始标签。首版只在拼图排行榜 `visibleTags` 中白名单投影 `北科`。 +- 影响范围:用户认证表、邀请码后台、邀请兑换事务、拼图排行榜响应和 UI。 +- 验证方式:表结构变更需同步 `migration.rs`、`SPACETIMEDB_TABLE_CATALOG.md` 和 SpacetimeDB bindings;后端运行 `cargo check -p api-server`,后台运行 `npm run admin-web:typecheck`。 +- 关联文档:`docs/technical/USER_TAG_INVITE_AND_PUZZLE_LEADERBOARD_2026-05-10.md`。 + +## 2026-05-10 抓大鹅草稿元信息由 gpt-4o 生成 + +- 背景:抓大鹅草稿生成需要基于入口题材设定生成作品名称,结果页作品信息要对齐拼图草稿,不再把封面和作品名称拆成两个模块。 +- 决策:`match3d_compile_draft` 使用 `gpt-4o` 生成 `gameName` 与 3 到 6 个标签;`summary` 默认保持空字符串;标签可由结果页 `作品信息` Tab 手动编辑或再次 AI 生成。草稿生成会产出 3 个物品图片并立即调用 Rodin 生成 GLB,图片和模型一起写入 `generated_item_assets_json`,运行态必须优先消费 `generatedItemAssets[].modelSrc` / `modelObjectKey`,默认积木只做兜底。 +- 影响范围:`api-server` Match3D 编译、Match3D works 标签接口、结果页 `作品信息` 与 `3D素材` Tab、运行态 `Match3DRuntimeShell` / `Match3DPhysicsBoard`、生成进度和 Match3D 技术文档。 +- 验证方式:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts`、`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`,并用 `npm run api-server` 检查 `/healthz`。 +- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`、`docs/technical/MATCH3D_RODIN_ASSET_TAB_2026-05-10.md`。 ## 2026-05-07 移动端整页缩放由入口统一锁定 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index edb0fdf5..4f55c772 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -147,6 +147,14 @@ - 验证:重新发布日志应显示创建新的数据库,而不是更新旧数据库;若仍显示更新或继续 `401`,继续检查 root-dir、库名和 CLI 身份。 - 关联:`docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`、`docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md`。 +## SpacetimeDB 模块 publish 报 `wasm-bindgen detected` + +- 现象:`spacetime publish` 已经完成 Rust 编译,但随后报 `wasm-bindgen detected`,提示依赖树里有面向 Web 平台的 wasm-bindgen。 +- 原因:SpacetimeDB 模块是数据库内 WASM,不允许拉入 Web/HTTP client 链路;常见误因是 `spacetime-module -> module-* -> shared-contracts -> platform-* -> reqwest -> wasm-bindgen` 这类反向依赖。 +- 处理:执行 `cargo tree -i wasm-bindgen --manifest-path server-rs/Cargo.toml -p spacetime-module --target wasm32-unknown-unknown` 找到链路;把平台实现类型从 `shared-contracts` 或 `module-*` 中移除,只保留公开 DTO,平台响应到 DTO 的转换放回 `api-server` 等 adapter 层。 +- 验证:上述 `cargo tree` 输出 `warning: nothing to print`;`cargo check -p shared-contracts`、`cargo check -p api-server` 通过;重新 `spacetime publish ... --module-path server-rs/crates/spacetime-module` 不再报 wasm-bindgen。 +- 关联:`docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md`、`server-rs/crates/shared-contracts/src/assets.rs`、`server-rs/crates/api-server/src/assets.rs`。 + ## Vite SPA fallback 吞掉 API 请求 - 现象:本地请求 `/api/profile/*` 等接口时返回 HTML,被前端当 JSON 解析报错。 @@ -429,13 +437,21 @@ - 验证:`cargo test -p api-server accepts_opaque_subscription_key_without_length_cap --manifest-path server-rs/Cargo.toml`。 - 关联:`server-rs/crates/api-server/src/hyper3d_generation.rs`、`docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md`。 -## 抓大鹅草稿生成不要阻塞在 Rodin 模型下载 +## 抓大鹅草稿生成恢复 Rodin 后要并行生成模型、同步长超时和 GLB 私有读取 -- 现象:抓大鹅草稿生成时 Hyper3D 状态已完成,但下载列表为空或没有可用模型文件,`/api/creation/match3d/sessions/{sessionId}/actions` 返回 `502 Bad Gateway`,前端提示 `Hyper3D 已完成但未返回可下载模型文件`。 -- 原因:草稿生成链路曾在切割图片后立即并行调用 Rodin 图生模型,并把模型下载成功作为草稿完成前置条件;上游完成态和可下载文件列表不是强一致,容易把本来可用的图片草稿卡死。 -- 处理:草稿阶段只生成物品名、素材图、切割独立图片并上传 OSS,返回 `status = image_ready`;Rodin 3D 模型生成留到结果页 `3D素材` Tab 手动触发。 -- 验证:草稿响应中的 `generatedItemAssets[].imageSrc` 有值、`modelSrc` 为空、状态为 `image_ready`;结果页显示 `图片已就绪` 和 `0 文件`,不会自动请求 Hyper3D 下载。 -- 关联:`server-rs/crates/api-server/src/match3d.rs`、`src/components/match3d-result/Match3DResultView.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 +- 现象:抓大鹅草稿生成重新接回 Rodin 后,前端可能在模型轮询和 GLB 转存完成前超时;或结果页把 `/generated-match3d-assets/.../model.glb` 直接当浏览器 URL 加载导致私有 OSS/CORS 读取失败。 +- 原因:`match3d_compile_draft` 会完成作品元信息、素材图、切图上传、3 件 Rodin 图生模型、GLB 下载和 OSS 转存,耗时远长于普通 Agent action;如果 3 件 Rodin 模型逐个提交和轮询,等待时间会线性叠加;同时 generated 私有资产不能被 Three.js 直接 `fetch`。 +- 处理:切图和图片入库后,所有 Rodin 图生模型任务必须并行提交、并行轮询、并行下载转存;Match3D creation client 的 `executeAction` 必须给长超时,当前为 20 分钟;生成进度页要包含 `生成3D模型` 阶段;结果页模型预览、场内运行态和备选栏预览都必须通过 `/api/assets/read-bytes` 读取 GLB 字节后交给 Three.js GLTFLoader,不要直接请求裸 generated 路径。 +- 验证:`npm run test -- src\services\miniGameDraftGenerationProgress.test.ts src\components\match3d-result\Match3DResultView.test.tsx src\components\match3d-runtime\Match3DRuntimeShell.test.tsx`、`npm run typecheck`;真实联调需配置 `VECTOR_ENGINE_API_KEY`、`HYPER3D_API_KEY` 和 OSS 变量。 +- 关联:`src/services/match3d-creation/match3dCreationClient.ts`、`src/services/creation-agent/creationAgentClientFactory.ts`、`src/components/match3d-result/Match3DModelPreview.tsx`、`src/components/match3d-runtime/Match3DPhysicsBoard.tsx`、`server-rs/crates/api-server/src/match3d.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 + +## Rodin 完成态后下载列表可能延迟或字段漂移 + +- 现象:抓大鹅草稿生成时 Rodin 状态已完成,但 `/api/creation/match3d/sessions/{sessionId}/actions` 返回 502,提示 `{物品名} 3D 模型已完成但未返回可下载模型文件:{taskUuid}`。 +- 原因:Hyper3D Rodin 官方示例要求看 `jobs` 列表,只有所有 job 都 `Done` 才能进入下载;旧聚合若只看 root status 或第一个 job,可能在 preview job 完成但模型 job 仍在生成时提前下载。另外任务完成和下载列表文件发布不是强同步的;上游下载结果还可能使用 `fileUrl`、`signedUrl`、`presignedUrl`、`fileName` 等字段别名,旧解析器只识别 `url/downloadUrl/name/file_name/filename` 时会得到空列表。 +- 处理:`query_task_status` 聚合状态必须以 `jobs` 为准:任一 failed 即 failed,全部 done 才 done;`match3d_compile_draft` 在状态完成后对 `query_downloads` 继续轮询;下载解析兼容常见 URL 和文件名字段别名;模型选择优先 `.glb`,可兜底到非图片下载文件,但只有 preview/png/jpg/webp 这类预览图时必须继续失败,不能伪装成 GLB。 +- 验证:`cargo test -p api-server match3d_model_download --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server extracts_download_files --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server hyper3d --manifest-path server-rs/Cargo.toml`。 +- 关联:`server-rs/crates/api-server/src/match3d.rs`、`server-rs/crates/api-server/src/hyper3d_generation.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 ## 抓大鹅切图路径不能只用中文物品名 @@ -452,3 +468,19 @@ - 处理:compile 成功时把独立物品图片列表序列化写入 `match3d_work_profile.generated_item_assets_json`;`update_match3d_work` / `publish_match3d_work` 保留该字段;API work summary/detail 映射反序列化为 `generatedItemAssets`。前端保持“本次 draft 优先,重进 profile 兜底”的读取顺序。 - 验证:`cargo test -p spacetime-client match3d --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`。 - 关联:`server-rs/crates/spacetime-module/src/match3d/*`、`server-rs/crates/spacetime-client/src/mapper.rs`、`server-rs/crates/api-server/src/match3d.rs`、`src/components/match3d-result/Match3DResultView.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 + +## 抓大鹅标签清洗不要把 `3D素材` 当编号剥掉 + +- 现象:AI 或兜底生成的 `3D素材` 标签在后端规范化后变成 `D素材`。 +- 原因:标签清洗在去掉编号列表前缀后,又无条件剥离开头数字和标点,把合法标签中的 `3D` 当成列表编号处理。 +- 处理:只移除明确的编号列表前缀,例如 `1. 标签`、`1、标签`、`1) 标签`;不要对普通标签开头数字做二次剥离。 +- 验证:`cargo test -p api-server match3d_tag_normalization --manifest-path server-rs/Cargo.toml`,并保留 `normalize_match3d_tag("3D素材") == "3D素材"` 的单测。 +- 关联:`server-rs/crates/api-server/src/match3d.rs`。 + +## 用户标签不要直接外显,SpacetimeDB Vec 字段不要写 default 宏 + +- 现象:给 `user_account.user_tags` 或邀请码独立标签列写 `#[default(Vec::::new())]` 时,SpacetimeDB WASM 构建报 `destructor of Vec cannot be evaluated at compile-time`。 +- 原因:SpacetimeDB 的 table default 宏会走编译期常量求值,不能直接使用有析构逻辑的堆分配类型默认值。 +- 处理:`user_account.user_tags` 使用 `Option>` + `#[default(None::>)]` 表达数据库默认空,业务层统一把 `None` 归一化为空数组;邀请码授予标签复用 `metadata_json.userTags` 存储和解析,不再新增独立 Vec 列。用户标签原始值不得进入登录态、个人资料等通用响应,只能在明确业务白名单里投影,例如拼图排行榜 `visibleTags` 首版仅允许 `北科`。 +- 验证:`npm run spacetime:generate -- --rust-only` 能通过;`user_account` 旧迁移 JSON 缺字段时能导入,`profile_invite_code` 缺 `metadata_json` 时按 `{}` 兼容。 +- 关联:`docs/technical/USER_TAG_INVITE_AND_PUZZLE_LEADERBOARD_2026-05-10.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。 diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 6d37d80e..013d36f4 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -182,7 +182,6 @@ export interface AdminUpsertProfileRedeemCodeRequest { export interface AdminUpsertProfileInviteCodeRequest { inviteCode: string; metadata?: Record; - grantedUserTags: string[]; startsAt?: string | null; expiresAt?: string | null; } @@ -229,7 +228,6 @@ export interface ProfileInviteCodeAdminResponse { userId: string; inviteCode: string; metadata: Record; - grantedUserTags: string[]; startsAt?: string | null; expiresAt?: string | null; status: 'pending' | 'active' | 'expired'; diff --git a/apps/admin-web/src/pages/AdminInviteCodePage.tsx b/apps/admin-web/src/pages/AdminInviteCodePage.tsx index 48f7c3c4..5952d042 100644 --- a/apps/admin-web/src/pages/AdminInviteCodePage.tsx +++ b/apps/admin-web/src/pages/AdminInviteCodePage.tsx @@ -78,10 +78,13 @@ export function AdminInviteCodePage({ setIsSaving(true); try { + const metadata = withMetadataUserTags( + parseMetadata(metadataText), + parseUserTags(grantedTagsText), + ); const payload: AdminUpsertProfileInviteCodeRequest = { inviteCode: inviteCode.trim(), - metadata: parseMetadata(metadataText), - grantedUserTags: parseUserTags(grantedTagsText), + metadata, startsAt: startsAt ? toIsoDateTime(startsAt) : null, expiresAt: expiresAt ? toIsoDateTime(expiresAt) : null, }; @@ -117,7 +120,7 @@ export function AdminInviteCodePage({ setInviteCode(entry.inviteCode); setStartsAt(toDateTimeLocalValue(entry.startsAt)); setExpiresAt(toDateTimeLocalValue(entry.expiresAt)); - setGrantedTagsText(entry.grantedUserTags.join('、')); + setGrantedTagsText(metadataUserTags(entry.metadata).join('、')); setMetadataText(JSON.stringify(entry.metadata, null, 2)); } @@ -252,7 +255,7 @@ export function AdminInviteCodePage({ - + @@ -291,7 +294,7 @@ export function AdminInviteCodePage({
标签
- +
@@ -338,6 +341,29 @@ function TagList({tags}: {tags: string[]}) { ); } +function metadataUserTags(metadata: Record) { + const raw = metadata.userTags ?? metadata.user_tags; + if (!Array.isArray(raw)) { + return []; + } + + return parseUserTags(raw.filter((value): value is string => typeof value === 'string').join('、')); +} + +function withMetadataUserTags( + metadata: Record, + tags: string[], +): Record { + const next = {...metadata}; + delete next.user_tags; + if (tags.length) { + next.userTags = tags; + } else { + delete next.userTags; + } + return next; +} + function parseUserTags(value: string) { const tags: string[] = []; for (const raw of value.split(/[\n,,;;、]+/)) { diff --git a/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md b/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md index 1738becb..c0c29fcb 100644 --- a/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md +++ b/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md @@ -7,7 +7,7 @@ 创作 Tab 恢复为模板选择入口,但不回到旧的大卡片选择面板: 1. 首屏保留现有创作页布局骨架,顶部标题固定为“10分钟创作一个精品互动玩法”。 -2. 选择模板入口改为横向 Tab,数据来自 `src/config/newWorkEntryConfig.ts` 的可见玩法配置。 +2. 选择模板入口改为横向 Tab,数据来自 `GET /api/creation-entry/config` 返回的可见玩法配置。 3. 默认选中“拼图”模板,并在创作 Tab 内直接展示拼图创作表单。 4. 智能创作入口从可见模板中隐藏,保留既有 `creative-agent` 运行链路用于后续内部恢复或草稿目标打开。 5. 草稿、发现、我的等一级 Tab 职责不变,作品管理仍在草稿 Tab。 @@ -18,7 +18,7 @@ ```text 标题:10分钟创作一个精品互动玩法 -模板 Tab:拼图 / 方洞挑战 / 视觉小说 / AIRP +模板 Tab:拼图 / 抓大鹅 / 视觉小说 / AIRP 默认内容:拼图创作表单 ``` @@ -36,19 +36,22 @@ 3. 拼图表单内的模板按钮使用 `tablist / tab` 语义,点击后只填充画面描述。 4. 点击非拼图且已开放的模板 Tab 时,进入该玩法既有工作台;未开放模板保持禁用。 5. `creative-agent` 不出现在模板 Tab 和选择弹层中,不再作为创作 Tab 首屏入口。 +6. 方洞挑战暂时从创作页完全隐藏,不出现在模板 Tab、旧选择弹层和创作 Hub 卡片中;既有作品链路继续保留。 ## 4. 验收 1. 点击“创作”后首屏出现“10分钟创作一个精品互动玩法”。 2. 顶部选择模板入口为 Tab,拼图 Tab 默认 `aria-selected=true`。 3. 创作 Tab 默认显示拼图创作表单内容,且不显示旧“Hi, 朋友”、输入框或智能创作快捷按钮。 -4. 隐藏的智能创作类型不出现在模板 Tab、旧选择弹层和创作 Hub 卡片中。 +4. 隐藏的智能创作类型与方洞挑战不出现在模板 Tab、旧选择弹层和创作 Hub 卡片中。 5. 草稿页返回创作页后仍回到同一模板入口,并可保留拼图表单草稿内容。 ## 5. 嵌入式表单 UI 细节 2026-05-10 补充:抓大鹅与视觉小说作为创作 Tab 内嵌表单时,风格类横滑选择器应统一使用浅底卡片、柔和玫瑰色选中态和小圆点确认标记。不要使用大面积黑色渐变、黑底胶囊标签或高饱和红色外框,以免在输入框下方误读为错误提示。 +2026-05-10 追加:视觉小说画风选项已改为使用 `public/visual-novel-style-references/` 下由 VectorEngine `gpt-image-2-all` 生成的参考图,作为横向卡片主视觉。 + 嵌入式表单控件保持以下口径: 1. 大文本输入框使用白底、低饱和边框和轻量 focus ring。 diff --git a/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md b/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md index 43bcdebf..eeba4d8a 100644 --- a/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md +++ b/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md @@ -124,7 +124,7 @@ Match3D 必须形成独立玩法域,后续技术方案至少需要覆盖: 3. 不做排行榜正式展示。 4. 不做道具,但需要预留功能口。 5. 不做洗牌、重置、旋转、放大等局内操作。 -6. 不做真实 3D 模型。 +6. 不做多批次真实 3D 模型生成;当前草稿生成只固定产出 `3` 个 GLB 模型并写入 OSS。 7. 不做真实 3D 物理遮挡。 8. 不做真实物理碰撞结算。 9. 不做必须试玩通关才能发布的门槛。 @@ -161,7 +161,7 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的 题材决定后续生成或选择物品素材的方向。用户可以自定义主题,例如水果、玩具、食物、符号等。 -首版 demo 不接入真实图片生成。当前运行态可消除物统一使用题材方向的 25 个积木件类型表现,不使用透明气泡,也不在图案上放文字标识。前端首版用差异化颜色、积木造型和 3D 程序化模型表现可消除物,避免玩家在堆叠状态下难以辨认。 +当前抓大鹅草稿生成会固定生成 `3` 个题材物品:素材图切割出的独立图片会作为 Rodin 图生 3D 参考图,生成出的 GLB 模型必须转存 OSS,并随作品 profile 的 `generatedItemAssets` 持久化。运行态优先使用这些生成模型;只有模型缺失、加载失败或未进入 3D 渲染模式时,才回退到 25 个默认积木件视觉键。默认素材不使用透明气泡,也不在图案上放文字标识。 可消除物尺寸使用五档相对体积规则:XL 型相对体积为 `1.60~2.30`,L 型为 `1.25~1.60`,M 型为 `1.00`,XS 型为 `0.65~0.85`,S 型为 `0.35~0.50`。单局中 XL / L / M / XS / S 按本局使用的消除物类型数的 `20% / 30% / 30% / 15% / 5%` 分配;非整数配额按最大余数补齐,确保总数等于本局使用类型数量。同一关卡内同一个颜色和造型的物品只能对应一个尺寸档位;可存在同尺寸但不同颜色和造型的物品。后端运行态通过 `radius` 下发权威尺寸,前端只按快照表现。 @@ -195,7 +195,7 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的 ## 6.3 参考图片 -抓大鹅入口页不展示参考图片上传。题材表现先由题材文本和草稿切割图片链路承接;后续需要 3D 模型时,在结果页 `3D素材` Tab 以切割图片作为图生模型参考图手动触发。 +抓大鹅入口页不展示参考图片上传。题材表现由题材文本和草稿切割图片链路承接;草稿生成阶段会直接以切割图片作为 Rodin 图生模型参考图生成首批 GLB。结果页 `3D素材` Tab 仍可对单个素材触发重新生成。 --- @@ -222,9 +222,9 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的 ## 7.3 素材生成边界 -抓大鹅草稿生成链路会生成首批 `3` 个题材物品素材:文本模型生成物品名,VectorEngine 生成 `2*2` 素材图并切割独立图片。入口页选择的 `assetStylePrompt` 必须写入素材图提示词;结果页手动 Rodin 图生模型时,继续以该物品图片和默认提示词作为起点。 +抓大鹅草稿生成链路会生成首批 `3` 个题材物品素材:文本模型生成物品名,VectorEngine 生成 `2*2` 素材图并切割独立图片,再以每张独立图片调用 Rodin 图生 3D,下载 `.glb` 并转存 OSS。入口页选择的 `assetStylePrompt` 必须写入素材图提示词;结果页手动 Rodin 图生模型时,继续以该物品图片和默认提示词作为起点。 -生成出的独立图片先作为草稿页 `3D素材` Tab 的预览资产返回,状态为 `image_ready`,模型文件为空。正式平台资产绑定、Rodin 生成模型转存和二次编辑流程以后续技术方案为准。 +生成出的独立图片与 GLB 模型都必须作为草稿页 `3D素材` Tab 的预览资产返回。模型生成成功时 `generatedItemAssets[].status = model_ready`,并携带 `modelSrc`、`modelObjectKey`、`modelFileName`、`taskUuid` 和 `subscriptionKey`;正式平台资产绑定和更完整的二次编辑流程以后续技术方案为准。 ## 7.4 发布前试玩 @@ -297,12 +297,14 @@ itemTypeCount = clearCount <= 25 ? clearCount : 25 ## 8.5 物品资产 -首版 demo 使用 2D 图案素材。 +当前 demo 使用生成 GLB 优先、默认积木兜底的物品资产策略。 -1. demo 至少提供 `25` 种彼此不同的颜色与几何造型组合素材,支撑 `clearCount > 25` 时的类型上限。 -2. 当前 demo 使用 25 个积木件视觉键作为默认素材池;前端首版必须把这些视觉键映射为无文字的纯色 2D 图标和程序化 3D 积木模型,不能显示为透明气泡或文字标记。 -3. 后续可以尝试替换为伪 3D 或 3D 模型。 -4. 用户题材主题后续会映射为符合常识预期的物品集合。 +1. demo 至少提供 `25` 种彼此不同的颜色与几何造型组合默认素材,支撑 `clearCount > 25` 时的类型上限和 GLB 缺失兜底。 +2. 有 `generatedItemAssets[].modelSrc` 或 `modelObjectKey` 时,运行态与备选栏必须优先读取该 GLB;默认积木件只作为加载失败、模型缺失或 2D 回退时的兜底素材池。 +3. 前端读取生成模型必须通过 `/api/assets/read-bytes` 获取私有 OSS 字节,再交给 Three.js `GLTFLoader` 解析;不得直接把 `/generated-match3d-assets/...` 当裸 URL 请求。 +4. 当前固定 `clearCount = 3` 的生成草稿中,运行态 `match3d-type-01/02/03` 按类型编号顺序映射到生成出的 `3` 个模型;后续恢复更大生成数量时,模型列表顺序必须继续与类型编号稳定对应。 +5. 默认积木视觉键仍需映射为无文字的纯色 2D 图标和程序化 3D 积木模型,不能显示为透明气泡或文字标记。 +6. 用户题材主题后续会映射为符合常识预期的物品集合。 示例:水果题材可以对应红色苹果、黄色香蕉、紫色葡萄等。 @@ -706,10 +708,10 @@ GET /api/runtime/match3d/runs/:runId 3. 入口页不展示参考图上传。 4. 内置风格显示画风参考图,自定义风格通过独立面板填写并进入提交 payload。 5. 移动端入口页所有内容一屏展示,不产生纵向滚动。 -6. 系统可生成待发布结果页,并在草稿中返回首批切割图片素材预览。 +6. 系统可生成待发布结果页,并在草稿中返回首批切割图片与 OSS GLB 模型素材预览。 7. 用户可编辑游戏名称、标签、封面图等基础信息。 8. 用户可发布前试玩,且试玩失败不阻断发布。 -9. 运行态能展示圆形空间、倒计时、物品和 `7` 格备选栏。 +9. 运行态能展示圆形空间、倒计时、物品和 `7` 格备选栏;存在 `generatedItemAssets` 模型时必须优先展示生成 GLB,而不是默认积木素材。 10. 物品可重叠、遮挡、堆叠。 11. 被完全遮挡物品不可点击,露出可点击区域的物品可点击。 12. 点击通过后物品飞入备选栏。 diff --git a/docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md b/docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md index 18a413c7..842ba96f 100644 --- a/docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md +++ b/docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md @@ -50,18 +50,18 @@ 外部仓库文件只作为玩法语义参考,不能作为目录结构迁入依据: -| 外部参考 | 可参考内容 | Genarrative 落点 | -| --- | --- | --- | -| `interface/routes/visual.js` | 视觉小说创作和运行时路由语义 | `server-rs/crates/api-server/src/visual_novel.rs` | -| `interface/handlers/visual/sendActionStream.js` | 流式动作推进事件 | Axum SSE handler + `shared-contracts` typed envelope | -| `interface/handlers/visual/getHistory.js` | 历史记录读取 | 平台 runtime history API | -| `interface/handlers/visual/saves.js` | 存档语义 | 平台统一 `profile/save-archives` | -| `services/visual/gameLogic.js` | step 解析、会话状态推进 | `server-rs/crates/module-visual-novel` | -| `services/visual/storyGeneration.js` | 创作底稿生成语义 | `platform-agent` / `platform-llm` + visual novel Tool | -| `prompts/visual.gm.system/1.0.1/body.js` | 视觉小说 GM 输出结构经验 | 新 prompt 必须适配 Genarrative 契约,不原样照搬平台规则 | -| `src/page/Galgame.tsx` | 运行时界面结构 | `src/components/visual-novel-runtime/VisualNovelRuntimeShell.tsx` | -| `src/hooks/galgame/useGalgameController.ts` | 前端运行时状态组织 | visual novel runtime hooks | -| `src/page/GameSettingsEditor.tsx` | 创作编辑器模块划分 | visual novel result / workspace 组件 | +| 外部参考 | 可参考内容 | Genarrative 落点 | +| ----------------------------------------------- | ---------------------------- | ----------------------------------------------------------------- | +| `interface/routes/visual.js` | 视觉小说创作和运行时路由语义 | `server-rs/crates/api-server/src/visual_novel.rs` | +| `interface/handlers/visual/sendActionStream.js` | 流式动作推进事件 | Axum SSE handler + `shared-contracts` typed envelope | +| `interface/handlers/visual/getHistory.js` | 历史记录读取 | 平台 runtime history API | +| `interface/handlers/visual/saves.js` | 存档语义 | 平台统一 `profile/save-archives` | +| `services/visual/gameLogic.js` | step 解析、会话状态推进 | `server-rs/crates/module-visual-novel` | +| `services/visual/storyGeneration.js` | 创作底稿生成语义 | `platform-agent` / `platform-llm` + visual novel Tool | +| `prompts/visual.gm.system/1.0.1/body.js` | 视觉小说 GM 输出结构经验 | 新 prompt 必须适配 Genarrative 契约,不原样照搬平台规则 | +| `src/page/Galgame.tsx` | 运行时界面结构 | `src/components/visual-novel-runtime/VisualNovelRuntimeShell.tsx` | +| `src/hooks/galgame/useGalgameController.ts` | 前端运行时状态组织 | visual novel runtime hooks | +| `src/page/GameSettingsEditor.tsx` | 创作编辑器模块划分 | visual novel result / workspace 组件 | --- @@ -160,8 +160,8 @@ 5. 玩家身份。 6. 3 到 6 个主要角色。 7. 3 到 8 个可用场景。 -7. 3 到 6 个剧情阶段。 -8. 初始场景、初始旁白和第一轮可选行动。 +8. 3 到 6 个剧情阶段。 +9. 初始场景、初始旁白和第一轮可选行动。 ### 5.2 文档创建 `document` @@ -399,7 +399,15 @@ export type VisualNovelAgentActionKind = ```ts export type VisualNovelAgentStreamEvent = | { type: 'start'; sessionId: string } - | { type: 'phase'; phase: 'perception' | 'reasoning' | 'drafting' | 'reflection' | 'finalizing' } + | { + type: 'phase'; + phase: + | 'perception' + | 'reasoning' + | 'drafting' + | 'reflection' + | 'finalizing'; + } | { type: 'text_delta'; text: string } | { type: 'draft_patch'; patch: VisualNovelDraftPatch } | { type: 'action_required'; action: VisualNovelAgentPendingAction } @@ -558,35 +566,35 @@ export type VisualNovelRuntimeStreamEvent = ### 9.1 创作 session -| 方法 | 路由 | 用途 | -| --- | --- | --- | -| `POST` | `/api/creation/visual-novel/sessions` | 创建视觉小说创作 session | -| `GET` | `/api/creation/visual-novel/sessions/{session_id}` | 读取 session snapshot | -| `POST` | `/api/creation/visual-novel/sessions/{session_id}/messages` | 非流式发送创作消息 | -| `POST` | `/api/creation/visual-novel/sessions/{session_id}/messages/stream` | 流式发送创作消息 | -| `POST` | `/api/creation/visual-novel/sessions/{session_id}/actions` | 执行结构化创作 action | -| `POST` | `/api/creation/visual-novel/sessions/{session_id}/compile` | 编译为 work profile 草稿 | +| 方法 | 路由 | 用途 | +| ------ | ------------------------------------------------------------------ | ------------------------ | +| `POST` | `/api/creation/visual-novel/sessions` | 创建视觉小说创作 session | +| `GET` | `/api/creation/visual-novel/sessions/{session_id}` | 读取 session snapshot | +| `POST` | `/api/creation/visual-novel/sessions/{session_id}/messages` | 非流式发送创作消息 | +| `POST` | `/api/creation/visual-novel/sessions/{session_id}/messages/stream` | 流式发送创作消息 | +| `POST` | `/api/creation/visual-novel/sessions/{session_id}/actions` | 执行结构化创作 action | +| `POST` | `/api/creation/visual-novel/sessions/{session_id}/compile` | 编译为 work profile 草稿 | ### 9.2 作品草稿与发布 -| 方法 | 路由 | 用途 | -| --- | --- | --- | -| `GET` | `/api/creation/visual-novel/works` | 读取当前用户视觉小说作品草稿列表 | -| `GET` | `/api/creation/visual-novel/works/{profile_id}` | 读取作品详情 | -| `PUT/PATCH` | `/api/creation/visual-novel/works/{profile_id}` | 更新作品草稿 | -| `DELETE` | `/api/creation/visual-novel/works/{profile_id}` | 删除未发布草稿或用户自有作品 | -| `POST` | `/api/creation/visual-novel/works/{profile_id}/publish` | 发布到平台作品体系 | +| 方法 | 路由 | 用途 | +| ----------- | ------------------------------------------------------- | -------------------------------- | +| `GET` | `/api/creation/visual-novel/works` | 读取当前用户视觉小说作品草稿列表 | +| `GET` | `/api/creation/visual-novel/works/{profile_id}` | 读取作品详情 | +| `PUT/PATCH` | `/api/creation/visual-novel/works/{profile_id}` | 更新作品草稿 | +| `DELETE` | `/api/creation/visual-novel/works/{profile_id}` | 删除未发布草稿或用户自有作品 | +| `POST` | `/api/creation/visual-novel/works/{profile_id}/publish` | 发布到平台作品体系 | ### 9.3 运行时 -| 方法 | 路由 | 用途 | -| --- | --- | --- | -| `GET` | `/api/runtime/visual-novel/gallery` | 读取平台聚合后的视觉小说公开作品列表 | -| `POST` | `/api/runtime/visual-novel/works/{profile_id}/runs` | 创建测试或正式 run | -| `GET` | `/api/runtime/visual-novel/runs/{run_id}` | 读取 run snapshot | -| `POST` | `/api/runtime/visual-novel/runs/{run_id}/actions/stream` | 提交选择 / 自由行动并流式推进 | -| `GET` | `/api/runtime/visual-novel/runs/{run_id}/history` | 读取当前 run 历史 | -| `POST` | `/api/runtime/visual-novel/runs/{run_id}/regenerate` | 从历史节点重生成 | +| 方法 | 路由 | 用途 | +| ------ | -------------------------------------------------------- | ------------------------------------ | +| `GET` | `/api/runtime/visual-novel/gallery` | 读取平台聚合后的视觉小说公开作品列表 | +| `POST` | `/api/runtime/visual-novel/works/{profile_id}/runs` | 创建测试或正式 run | +| `GET` | `/api/runtime/visual-novel/runs/{run_id}` | 读取 run snapshot | +| `POST` | `/api/runtime/visual-novel/runs/{run_id}/actions/stream` | 提交选择 / 自由行动并流式推进 | +| `GET` | `/api/runtime/visual-novel/runs/{run_id}/history` | 读取当前 run 历史 | +| `POST` | `/api/runtime/visual-novel/runs/{run_id}/regenerate` | 从历史节点重生成 | ### 9.4 存档 @@ -627,16 +635,16 @@ GET /api/runtime/profile/save-archives ### 10.1 crate 职责 -| crate / 层 | 职责 | -| --- | --- | +| crate / 层 | 职责 | +| -------------------------------------- | ------------------------------------------------------------- | | `server-rs/crates/module-visual-novel` | 纯领域规则:草稿校验、step 解析、run 状态推进、历史重生成边界 | -| `server-rs/crates/shared-contracts` | DTO、请求响应、SSE envelope、草稿和运行时契约 | -| `server-rs/crates/spacetime-module` | 表、reducer、procedure、migration 和事务编排 | -| `server-rs/crates/spacetime-client` | api-server 到 SpacetimeDB 的 typed facade | -| `server-rs/crates/api-server` | Axum 路由、鉴权、SSE、LLM 编排、资产和钱包 facade 调用 | -| `server-rs/crates/platform-llm` | 视觉小说创作和运行时 GM 的模型调用 | -| `server-rs/crates/platform-oss` | 文档、图片、音乐等资产对象读写 | -| `server-rs/crates/platform-agent` | 如复用创意 Agent,负责 Agent 编排和工具注册 | +| `server-rs/crates/shared-contracts` | DTO、请求响应、SSE envelope、草稿和运行时契约 | +| `server-rs/crates/spacetime-module` | 表、reducer、procedure、migration 和事务编排 | +| `server-rs/crates/spacetime-client` | api-server 到 SpacetimeDB 的 typed facade | +| `server-rs/crates/api-server` | Axum 路由、鉴权、SSE、LLM 编排、资产和钱包 facade 调用 | +| `server-rs/crates/platform-llm` | 视觉小说创作和运行时 GM 的模型调用 | +| `server-rs/crates/platform-oss` | 文档、图片、音乐等资产对象读写 | +| `server-rs/crates/platform-agent` | 如复用创意 Agent,负责 Agent 编排和工具注册 | ### 10.2 领域规则 @@ -654,14 +662,14 @@ GET /api/runtime/profile/save-archives 新增表必须同步 `migration.rs`、表目录和 bindings: -| 表 | 用途 | -| --- | --- | -| `visual_novel_agent_session` | 创作 session 主表 | -| `visual_novel_agent_message` | 创作消息和模型回复 | -| `visual_novel_work_profile` | 视觉小说作品草稿 / 发布 profile | -| `visual_novel_runtime_run` | 玩家或测试 run | -| `visual_novel_runtime_history_entry` | 运行时历史 | -| `visual_novel_runtime_event` | 可审计事件,不用于回放 | +| 表 | 用途 | +| ------------------------------------ | ------------------------------- | +| `visual_novel_agent_session` | 创作 session 主表 | +| `visual_novel_agent_message` | 创作消息和模型回复 | +| `visual_novel_work_profile` | 视觉小说作品草稿 / 发布 profile | +| `visual_novel_runtime_run` | 玩家或测试 run | +| `visual_novel_runtime_history_entry` | 运行时历史 | +| `visual_novel_runtime_event` | 可审计事件,不用于回放 | 不得新增: @@ -781,6 +789,8 @@ service client 要复用现有请求封装、鉴权和错误提示风格,不 3. 生成草稿按钮。 4. 错误、忙碌与禁用态。 +视觉画风卡片使用 `public/visual-novel-style-references/` 下的 `gpt-image-2-all` 参考图,分别对应映画动画、水彩绘本、像素霓虹、水墨幻想、柔彩校园和暗色哥特。卡片只承载图像和标签,不额外展示说明文案。 + 点击生成草稿后进入 `visual-novel-generating` 过程页,过程页复用平台已有生成进度面板,展示一句话输入、画风和草稿生成阶段;完成后自动进入 `visual-novel-result` 草稿页。 不展示: @@ -975,13 +985,13 @@ V1 默认提供平台统一存档能力;如果平台存档 UI 当前按槽位 ### 16.1 并行批次总览 -| 批次 | 可并行任务 | 进入条件 | 汇合点 | -| --- | --- | --- | --- | -| Batch 0 | `VN-00` | PRD 已冻结 | 所有人以本文为唯一口径 | -| Batch 1 | `VN-01`、`VN-02`、`VN-03`、`VN-04` | `VN-00` 完成 | 契约、领域、UI 骨架、Prompt 口径可对齐 | -| Batch 2 | `VN-05`、`VN-06`、`VN-07`、`VN-08` | `VN-01` 产出契约初稿;`VN-02` 产出表草案 | 后端 API、前端创作、前端运行时可联调 | -| Batch 3 | `VN-09`、`VN-10`、`VN-11` | `VN-05`、`VN-06`、`VN-07`、`VN-08` 主链路可跑 | 发布、存档、广场、负向扫描收口 | -| Batch 4 | `VN-12`、`VN-13` | 主链路完成 | 全链路验收和文档同步 | +| 批次 | 可并行任务 | 进入条件 | 汇合点 | +| ------- | ---------------------------------- | --------------------------------------------- | -------------------------------------- | +| Batch 0 | `VN-00` | PRD 已冻结 | 所有人以本文为唯一口径 | +| Batch 1 | `VN-01`、`VN-02`、`VN-03`、`VN-04` | `VN-00` 完成 | 契约、领域、UI 骨架、Prompt 口径可对齐 | +| Batch 2 | `VN-05`、`VN-06`、`VN-07`、`VN-08` | `VN-01` 产出契约初稿;`VN-02` 产出表草案 | 后端 API、前端创作、前端运行时可联调 | +| Batch 3 | `VN-09`、`VN-10`、`VN-11` | `VN-05`、`VN-06`、`VN-07`、`VN-08` 主链路可跑 | 发布、存档、广场、负向扫描收口 | +| Batch 4 | `VN-12`、`VN-13` | 主链路完成 | 全链路验收和文档同步 | 并行规则: @@ -992,22 +1002,22 @@ V1 默认提供平台统一存档能力;如果平台存档 UI 当前按槽位 ### 16.2 并行任务具体要求速查表 -| 任务 | 可并行窗口 | 输入依赖 | 必须完成 | 禁止事项 | 验收口径 | -| --- | --- | --- | --- | --- | --- | -| `VN-00` 口径冻结 | Batch 0 | 本 PRD、旧 TXT 文档、Hermes 决策记录 | 冻结 `visual-novel` 只做模板玩法、平台接口、删除回放;标注旧文档冲突口径 | 不再使用“原样迁入外部平台工程”作为实现目标 | 文档和 Hermes 记录明确三条硬边界;`npm run check:encoding` 通过 | -| `VN-01` 契约与领域 | Batch 1 | PRD 第 6 到 8 章契约 | TS contracts、Rust shared-contracts、`module-visual-novel`、领域单测 | 不写 HTTP、DB reducer、LLM、UI;不出现 replay 类型 | TS/Rust 契约一致;草稿校验、step 解析、状态推进、重生成测试通过 | -| `VN-02` SpacetimeDB 与 facade | Batch 1 | `VN-01` 契约草案、SpacetimeDB 约束文档 | 表、reducer/procedure、migration、表目录、bindings、`spacetime-client` facade | 不手改 bindings 绕过 schema;不建 replay 表或私有 save 表 | schema 可生成;表目录写明 event 不是回放;`cargo check -p spacetime-client` 通过 | -| `VN-03` Prompt / LLM 工具 | Batch 1 | `VN-01` draft 与 step 契约 | 创作 prompt、运行 GM prompt、repair prompt、工具参数 | 不照搬外部平台规则、扣费规则、回放规则;不让前端猜业务 step | fixture 输出可解析;坏格式进入 repair 或可重试错误 | -| `VN-04` 前端 UI 骨架 | Batch 1 | PRD 第 12 章 UI 要求 | workspace、result、runtime mock 骨架;移动端优先布局;独立面板模式 | 不接真实 API;不写规则长文;不出现回放入口 | 桌面 / 移动基础布局可检查;长文本不撑破按钮 | -| `VN-05` API Server | Batch 2 | `VN-01`、`VN-02`,真实 LLM 依赖 `VN-03` | creation、works、runtime、history、regenerate 路由;SSE;平台 save archive 接入 | 不把领域规则塞 handler;不新增 replay 路由;不新增私有 save API | `/api/creation/visual-novel/*` 和 `/api/runtime/visual-novel/*` 可 smoke;`cargo check -p api-server` 通过 | -| `VN-06` 前端 service 与入口 | Batch 2 | `VN-01` TS 契约草案;mock 可先行 | service client、selection stage、入口分流、壳层挂载、登录态清理 | 不设计表单细节;不写 runtime UI 细节;不绕过平台入口 | 创作中心可进入 workspace;service 路由与 PRD 一致;`npm run typecheck` 通过 | -| `VN-07` 创作工作台与结果页 | Batch 2 | `VN-04`、`VN-06`,真实生成依赖 `VN-05` | `idea` / `document` / `blank`、Agent 进度、结果页表单、保存草稿、测试 run 入口 | 不做正式玩家 runtime;不新增说明页;复杂内容不在卡片下展开 | 三种创建起点可用;草稿写入 `VisualNovelResultDraft`;发布校验 issue 可见 | -| `VN-08` 前端运行时 | Batch 2 | `VN-04`、`VN-06`,真实运行依赖 `VN-05` | runtime shell、SSE 消费、step 渲染、历史、设置、存档、重生成 | 不做回放 UI;历史不生成分享播放页;`raw_text` 不作为业务真相 | typed step 可渲染;历史与重生成可用;save archive 可继续体验 | -| `VN-09` 作品架 / 广场 / 发布 | Batch 3 | `VN-05` works/gallery API、`VN-06` 壳层 | work summary 聚合、作品架、公开聚合、public work code、详情跳转 | 不新增独立视觉小说市场;不迁入外部平台详情页;不做分享回放 | 发布后作品架可见;公开作品可启动 run;聚合 key 不冲突 | -| `VN-10` 资产与文档输入 | Batch 3 | `VN-05` action/API、平台资产接口 | 文档资产读取、封面/场景/角色/音乐资产引用、可选图片生成 action | 不迁入外部 R2;不存大 Data URL;不绕过资产鉴权 | 文档创建使用资产引用;SpacetimeDB 不保存二进制或大 base64 | -| `VN-11` 负向扫描 | Batch 1 到发布前 | 所有任务增量改动 | 扫描 replay 和外部平台功能;补负向测试或脚本清单 | 不把历史误判为回放;不做大范围重构 | 新工程代码无 replay;外部平台账号/订单/会员/后台等未误入 | -| `VN-12` 全链路验收 | Batch 4 | `VN-05` 到 `VN-10` 主链路完成 | 创作、结果页、测试 run、发布、正式 run、历史、重生成、存档联调 | 不跳过移动端;不只测 mock;不忽略负向验收 | 全链路可跑;前端 typecheck、后端相关 cargo 检查、编码检查通过 | -| `VN-13` 文档与交接 | Batch 4 | 实际工程落地结果 | PRD、技术方案、表目录、Hermes 决策 / 踩坑同步 | 不让旧 TXT 文档重新成为实现口径;不留下过期接口描述 | 新开发者可按最新文档维护;文档与代码一致 | +| 任务 | 可并行窗口 | 输入依赖 | 必须完成 | 禁止事项 | 验收口径 | +| ----------------------------- | ---------------- | --------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `VN-00` 口径冻结 | Batch 0 | 本 PRD、旧 TXT 文档、Hermes 决策记录 | 冻结 `visual-novel` 只做模板玩法、平台接口、删除回放;标注旧文档冲突口径 | 不再使用“原样迁入外部平台工程”作为实现目标 | 文档和 Hermes 记录明确三条硬边界;`npm run check:encoding` 通过 | +| `VN-01` 契约与领域 | Batch 1 | PRD 第 6 到 8 章契约 | TS contracts、Rust shared-contracts、`module-visual-novel`、领域单测 | 不写 HTTP、DB reducer、LLM、UI;不出现 replay 类型 | TS/Rust 契约一致;草稿校验、step 解析、状态推进、重生成测试通过 | +| `VN-02` SpacetimeDB 与 facade | Batch 1 | `VN-01` 契约草案、SpacetimeDB 约束文档 | 表、reducer/procedure、migration、表目录、bindings、`spacetime-client` facade | 不手改 bindings 绕过 schema;不建 replay 表或私有 save 表 | schema 可生成;表目录写明 event 不是回放;`cargo check -p spacetime-client` 通过 | +| `VN-03` Prompt / LLM 工具 | Batch 1 | `VN-01` draft 与 step 契约 | 创作 prompt、运行 GM prompt、repair prompt、工具参数 | 不照搬外部平台规则、扣费规则、回放规则;不让前端猜业务 step | fixture 输出可解析;坏格式进入 repair 或可重试错误 | +| `VN-04` 前端 UI 骨架 | Batch 1 | PRD 第 12 章 UI 要求 | workspace、result、runtime mock 骨架;移动端优先布局;独立面板模式 | 不接真实 API;不写规则长文;不出现回放入口 | 桌面 / 移动基础布局可检查;长文本不撑破按钮 | +| `VN-05` API Server | Batch 2 | `VN-01`、`VN-02`,真实 LLM 依赖 `VN-03` | creation、works、runtime、history、regenerate 路由;SSE;平台 save archive 接入 | 不把领域规则塞 handler;不新增 replay 路由;不新增私有 save API | `/api/creation/visual-novel/*` 和 `/api/runtime/visual-novel/*` 可 smoke;`cargo check -p api-server` 通过 | +| `VN-06` 前端 service 与入口 | Batch 2 | `VN-01` TS 契约草案;mock 可先行 | service client、selection stage、入口分流、壳层挂载、登录态清理 | 不设计表单细节;不写 runtime UI 细节;不绕过平台入口 | 创作中心可进入 workspace;service 路由与 PRD 一致;`npm run typecheck` 通过 | +| `VN-07` 创作工作台与结果页 | Batch 2 | `VN-04`、`VN-06`,真实生成依赖 `VN-05` | `idea` / `document` / `blank`、Agent 进度、结果页表单、保存草稿、测试 run 入口 | 不做正式玩家 runtime;不新增说明页;复杂内容不在卡片下展开 | 三种创建起点可用;草稿写入 `VisualNovelResultDraft`;发布校验 issue 可见 | +| `VN-08` 前端运行时 | Batch 2 | `VN-04`、`VN-06`,真实运行依赖 `VN-05` | runtime shell、SSE 消费、step 渲染、历史、设置、存档、重生成 | 不做回放 UI;历史不生成分享播放页;`raw_text` 不作为业务真相 | typed step 可渲染;历史与重生成可用;save archive 可继续体验 | +| `VN-09` 作品架 / 广场 / 发布 | Batch 3 | `VN-05` works/gallery API、`VN-06` 壳层 | work summary 聚合、作品架、公开聚合、public work code、详情跳转 | 不新增独立视觉小说市场;不迁入外部平台详情页;不做分享回放 | 发布后作品架可见;公开作品可启动 run;聚合 key 不冲突 | +| `VN-10` 资产与文档输入 | Batch 3 | `VN-05` action/API、平台资产接口 | 文档资产读取、封面/场景/角色/音乐资产引用、可选图片生成 action | 不迁入外部 R2;不存大 Data URL;不绕过资产鉴权 | 文档创建使用资产引用;SpacetimeDB 不保存二进制或大 base64 | +| `VN-11` 负向扫描 | Batch 1 到发布前 | 所有任务增量改动 | 扫描 replay 和外部平台功能;补负向测试或脚本清单 | 不把历史误判为回放;不做大范围重构 | 新工程代码无 replay;外部平台账号/订单/会员/后台等未误入 | +| `VN-12` 全链路验收 | Batch 4 | `VN-05` 到 `VN-10` 主链路完成 | 创作、结果页、测试 run、发布、正式 run、历史、重生成、存档联调 | 不跳过移动端;不只测 mock;不忽略负向验收 | 全链路可跑;前端 typecheck、后端相关 cargo 检查、编码检查通过 | +| `VN-13` 文档与交接 | Batch 4 | 实际工程落地结果 | PRD、技术方案、表目录、Hermes 决策 / 踩坑同步 | 不让旧 TXT 文档重新成为实现口径;不留下过期接口描述 | 新开发者可按最新文档维护;文档与代码一致 | 每个任务的交付回复必须包含: @@ -1143,20 +1153,20 @@ cd server-rs cargo check -p spacetime-client ``` - 阻塞关系: - - 1. 依赖 `VN-01` 的 Rust 契约。 - 2. 阻塞 `VN-05` 的真实数据库联调。 +阻塞关系: - VN-02 实施收口记录(2026-05-05): +1. 依赖 `VN-01` 的 Rust 契约。 +2. 阻塞 `VN-05` 的真实数据库联调。 - 1. 已新增 `server-rs/crates/spacetime-module/src/visual_novel.rs`,落地 `visual_novel_agent_session`、`visual_novel_agent_message`、`visual_novel_work_profile`、`visual_novel_runtime_run`、`visual_novel_runtime_history_entry`、`visual_novel_runtime_event` 六张表及对应 procedure。 - 2. 已同步 `server-rs/crates/spacetime-module/src/lib.rs`、`server-rs/crates/spacetime-module/src/migration.rs`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md` 和 Rust bindings。 - 3. 已新增 `server-rs/crates/spacetime-client/src/visual_novel.rs` typed facade,并从 `server-rs/crates/spacetime-client/src/lib.rs` 导出视觉小说 record / input 类型。 - 4. `visual_novel_runtime_event` 只作为 `public event` 审计事件表使用;`visual_novel_runtime_history_entry` 只保存继续体验与历史重生成所需 step 和快照哈希,二者均不是 replay 数据源。 - 5. 本阶段未新增 Axum 路由、LLM prompt、前端 UI、replay 表、replay 路由或私有 save 表;后续 VN-05 只应通过本阶段 facade 接入真实数据库。 - - ### VN-03:创作与运行 Prompt / LLM 工具 +VN-02 实施收口记录(2026-05-05): + +1. 已新增 `server-rs/crates/spacetime-module/src/visual_novel.rs`,落地 `visual_novel_agent_session`、`visual_novel_agent_message`、`visual_novel_work_profile`、`visual_novel_runtime_run`、`visual_novel_runtime_history_entry`、`visual_novel_runtime_event` 六张表及对应 procedure。 +2. 已同步 `server-rs/crates/spacetime-module/src/lib.rs`、`server-rs/crates/spacetime-module/src/migration.rs`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md` 和 Rust bindings。 +3. 已新增 `server-rs/crates/spacetime-client/src/visual_novel.rs` typed facade,并从 `server-rs/crates/spacetime-client/src/lib.rs` 导出视觉小说 record / input 类型。 +4. `visual_novel_runtime_event` 只作为 `public event` 审计事件表使用;`visual_novel_runtime_history_entry` 只保存继续体验与历史重生成所需 step 和快照哈希,二者均不是 replay 数据源。 +5. 本阶段未新增 Axum 路由、LLM prompt、前端 UI、replay 表、replay 路由或私有 save 表;后续 VN-05 只应通过本阶段 facade 接入真实数据库。 + +### VN-03:创作与运行 Prompt / LLM 工具 负责范围: diff --git a/docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md b/docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md index 7f9d4a27..c96168ed 100644 --- a/docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md +++ b/docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md @@ -65,7 +65,7 @@ Admin Web -> spacetime-module creation_entry_type_config 表 ``` -`visible=false` 会让创作中心不展示对应入口;`open=false` 会让前端展示锁定态;api-server 的运行态熔断继续以 `visible && open` 判断路由是否可用。 +`visible=false` 会让创作中心不展示对应入口;`open=false` 会让前端展示锁定态,并让 api-server 熔断对应玩法 API。隐藏入口但仍保留既有作品号、广场详情或试玩链路时,应只关闭 `visible`,不要关闭 `open`。 ## 注意 diff --git a/docs/technical/CREATION_ENTRY_CONFIG_AND_RUNTIME_NOT_FOUND_RECOVERY_2026-05-10.md b/docs/technical/CREATION_ENTRY_CONFIG_AND_RUNTIME_NOT_FOUND_RECOVERY_2026-05-10.md index 4568eb50..6ab599ef 100644 --- a/docs/technical/CREATION_ENTRY_CONFIG_AND_RUNTIME_NOT_FOUND_RECOVERY_2026-05-10.md +++ b/docs/technical/CREATION_ENTRY_CONFIG_AND_RUNTIME_NOT_FOUND_RECOVERY_2026-05-10.md @@ -36,7 +36,7 @@ SpacetimeDB 新增两张表: 其中: - `visible=false`:前端隐藏入口。 -- `open=false`:前端展示为锁定/暂不可创建,api-server 也可据此熔断运行时入口。 +- `open=false`:前端展示为锁定/暂不可创建,api-server 据此熔断对应玩法 API;只隐藏创作页入口但保留既有作品链路时不要关闭 `open`。 - `sort_order`:数据库排序字段,前端只做可见/锁定分组派生。 ## API diff --git a/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md b/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md index 1e894d92..358b0cd8 100644 --- a/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md +++ b/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md @@ -19,7 +19,7 @@ 生成页步骤固定为: ```text -生成游戏名称 -> 生成物品名称 -> 生成素材图 -> 切割独立图片 -> 上传图片资产 -> 写入草稿页 +生成游戏名称 -> 生成物品名称 -> 生成素材图 -> 切割独立图片 -> 上传图片资产 -> 生成3D模型 -> 写入草稿页 ``` 生成页只展示题材和物品数量,不展示玩法规则说明。 @@ -36,13 +36,14 @@ 4. 调用文本模型生成 `3` 个题材下的短物品名称。 5. 调用项目当前图片链路 VectorEngine `gpt-image-2-all` 生成一张 `1:1` 素材图,提示词必须合入入口页选择的 `assetStylePrompt`。历史 `nanobanana2` 图片选项当前按项目统一决策回落到 VectorEngine,不重新接入 APIMart 图片网关。 6. 将素材图按 `n*n` 网格切割成独立图片。当前 `3` 件物品使用 `2*2` 网格,取前 `3` 格。 -7. 将素材图和每张独立图片上传到 OSS,其中独立图片作为草稿页素材预览和后续 Rodin 图生模型参考图。 -8. 调用现有 SpacetimeDB compile procedure 写入草稿,并把本次生成的独立物品图片列表序列化写入 `match3d_work_profile.generated_item_assets_json`。这一步对标拼图的 `save_puzzle_generated_images`:生成资产不能只挂在本次 HTTP response 上,否则退出结果页后从草稿架读取 `getMatch3DWorkDetail` 会丢失素材列表。 -9. 在 HTTP 返回的 draft/profile DTO 中附带本次生成的素材资产预览信息,独立图片状态为 `image_ready`,模型字段保持为空;后续重进草稿页时从 work profile 的持久化 `generatedItemAssets` 恢复同一批素材。 +7. 将素材图和每张独立图片上传到 OSS,其中独立图片作为草稿页素材预览和 Rodin 图生模型参考图。 +8. 使用每张独立图片作为参考图,并行调用 Hyper3D Rodin 图生模型;所有 3D 模型任务必须在同一阶段同时提交、同时轮询状态、同时下载并转存 OSS,禁止逐个物品串行等待模型完成。每个任务按官方 minimal example 轮询状态;只有 `jobs` 全部进入 `Done` 才能视为任务完成,任一 job `Failed` 则失败。完成后选择 `.glb` 下载文件,并把 GLB 转存到 OSS。Rodin 的 `subscriptionKey` 是上游 opaque token,不做 256 字符这类短文本长度限制。Rodin 任务状态进入完成态后,下载列表仍可能延迟发布;后端必须对下载列表继续轮询,并兼容 `url`、`downloadUrl`、`fileUrl`、`signedUrl` 等下载字段别名,只有预览图而没有模型文件时不能伪装成 GLB 成功。 +9. 调用现有 SpacetimeDB compile procedure 写入草稿,并把本次生成的独立物品图片和模型列表序列化写入 `match3d_work_profile.generated_item_assets_json`。这一步对标拼图的 `save_puzzle_generated_images`:生成资产不能只挂在本次 HTTP response 上,否则退出结果页后从草稿架读取 `getMatch3DWorkDetail` 会丢失素材列表。 +10. 在 HTTP 返回的 draft/profile DTO 中附带本次生成的素材资产预览信息,模型生成成功的素材状态为 `model_ready`;后续重进草稿页时从 work profile 的持久化 `generatedItemAssets` 恢复同一批素材。 若文本模型不可用或返回无法解析,后端必须降级为 `{themeText}抓大鹅` 与本地标签兜底,不阻断素材生成;但描述仍保持空字符串。 -草稿生成阶段不调用 Hyper3D Rodin,不等待 `subscriptionKey`,也不下载模型文件;Rodin 生成只在结果页 `3D素材` Tab 由用户手动触发。手动生成得到的上游下载 URL 仍不得直接写入 Match3D profile,后续正式资产绑定以独立技术方案为准。 +草稿生成阶段会调用 Hyper3D Rodin 并等待 GLB 下载完成;前端 `match3d_compile_draft` action 请求超时必须覆盖该长耗时链路,当前 Match3D client 使用 20 分钟超时。Rodin 单模型状态轮询预算为 10 分钟,下载列表发布轮询预算为 5 分钟;由于 3 个模型并行生成,总耗时按最慢模型计算,不能按模型数量线性叠加。结果页 `3D素材` Tab 直接加载已生成模型;用户点击 `重新生成` 时再复用 Rodin 安全代理,首版重新生成只更新当前页面内预览状态,后续正式资产绑定以独立技术方案为准。 ## 4. 图片提示词 @@ -83,12 +84,34 @@ generated-match3d-assets ```text generated-match3d-assets/{sessionId}/{profileId}/material-sheet/{taskId}/sheet.png -generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/image.png +generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/image/image.png +generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/model/{taskUuid}/model.glb ``` `itemSlug` 必须带 `itemId` 前缀,例如 `match3d-item-1-item`。中文物品名清洗后可能都退回 `item`,不能只用物品名做路径,否则多张切割图会写到同一个 object key,导致草稿页预览图全部一致。 -HTTP DTO 同时返回 `imageSrc`、`imageObjectKey`、空的 `modelSrc`、空的 `modelObjectKey` 和 `status = image_ready`。前端预览图片继续走 `ResolvedAssetImage` 换签;后续手动生成的模型文件也必须通过 `useResolvedAssetReadUrl` / `/api/assets/read-url` 换签后打开,不直接请求裸 `/generated-match3d-assets/...` 路径。 +HTTP DTO 同时返回 `imageSrc`、`imageObjectKey`、`modelSrc`、`modelObjectKey`、`modelFileName`、`taskUuid`、`subscriptionKey` 和 `status`。模型生成成功后 `status = model_ready`;若后续允许部分模型失败降级,失败素材必须带 `error`,且不能伪装成可预览模型。前端模型预览必须通过 `/api/assets/read-bytes` 读取私有 GLB 字节并转成 Blob URL 后交给 Three.js,不直接请求裸 `/generated-match3d-assets/...` 路径。 + +## 5.1 运行态模型消费 + +生成模型不仅用于结果页预览,也必须进入游戏运行态。运行态入口的传递链路为: + +```text +Match3DWorkProfile / PlatformMatch3DGalleryCard +-> Match3DRuntimeShell(generatedItemAssets) +-> Match3DPhysicsBoard / Match3DTrayPreviewBoard +``` + +`Match3DPhysicsBoard` 与 `Match3DTrayPreviewBoard` 按运行快照中的 `itemTypeId` 稳定排序后,把生成出的模型顺序映射到对应类型。当前 MVP 固定 `clearCount = 3`,因此 `match3d-type-01/02/03` 分别对应生成列表的第 `1/2/3` 个模型;后续恢复更多物品生成时,后端必须继续保证 `generatedItemAssets` 顺序与类型编号一致。 + +前端加载规则: + +1. 优先读取 `modelSrc`;为空时使用 `modelObjectKey`。 +2. 通过 `readAssetBytes` 调用 `/api/assets/read-bytes`,由同源后端读取 OSS 私有对象字节。 +3. 使用 Three.js `GLTFLoader.parseAsync` 解析 GLB 字节,并按物品类型缓存模板。 +4. 场内每个物品和备选栏预览都从模板 clone 独立对象,点击命中继续写入 `itemInstanceId`。 +5. 物理碰撞和边界仍沿用现有 `visualKey` 的程序化几何,生成 GLB 只替换视觉模型,不承接规则真相。 +6. 模型缺失、读取失败或 WebGL 回退时,继续使用默认积木素材,不能阻断开局、点击、入槽或结算。 ## 6. 自动保存与草稿恢复 @@ -135,4 +158,4 @@ cargo check -p spacetime-client --manifest-path server-rs\Cargo.toml cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml ``` -真实草稿生成需要本地私密环境配置 `VECTOR_ENGINE_API_KEY` 和 OSS 访问变量;`HYPER3D_API_KEY` 只在结果页手动生成 3D 模型时需要。后端改动后使用 `npm run api-server` 启动,并检查 `/healthz`。 +真实草稿生成需要本地私密环境配置 `VECTOR_ENGINE_API_KEY`、`HYPER3D_API_KEY` 和 OSS 访问变量。后端改动后使用 `npm run api-server` 启动,并检查 `/healthz`。 diff --git a/docs/technical/MATCH3D_RODIN_ASSET_TAB_2026-05-10.md b/docs/technical/MATCH3D_RODIN_ASSET_TAB_2026-05-10.md index 810dec1c..f8639640 100644 --- a/docs/technical/MATCH3D_RODIN_ASSET_TAB_2026-05-10.md +++ b/docs/technical/MATCH3D_RODIN_ASSET_TAB_2026-05-10.md @@ -18,13 +18,13 @@ ## 3. Rodin 任务边界 -前端只维护当前页面内的临时重新生成任务状态: +前端只维护当前页面内的临时重新生成任务状态;草稿生成得到的正式模型资产从 `generatedItemAssets.modelSrc` 恢复: 1. 素材槽位名称。 2. 模型预览。草稿生成的 `/generated-match3d-assets/...` GLB 必须通过同源 `/api/assets/read-bytes` 由后端换签并读取字节,前端再转成 Blob URL 后交给 Three.js GLTFLoader,避免浏览器直接 `fetch` OSS 签名 URL 时被 CORS 拦截。 3. 图生模型参考图只作为重新生成的隐藏输入来源,不在详情页展示。上传图片在前端直接读成 Data URL;草稿生成的 `/generated-match3d-assets/...` 图片必须通过 `/api/assets/read-bytes` 转成 Data URL 后提交给 Hyper3D。 -4. Hyper3D `taskUuid` 与 `subscriptionKey`。 -5. 查询到的状态、进度与下载文件列表。 +4. Hyper3D `taskUuid` 与 `subscriptionKey` 仅用于重新生成过程,不在详情页展示。 +5. 查询到的状态、进度与下载文件列表仅作为内部状态,不在详情页展示。 正式资产链后续再接: diff --git a/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md b/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md index b5e0f275..1fff2a68 100644 --- a/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md +++ b/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md @@ -2,18 +2,19 @@ ## 背景 -创作中心的模板 Tab、平台创作类型弹层和旧“新建作品”卡片配置都依赖同一组玩法模板。此前入口开放状态、隐藏状态和中文文案集中写在 `src/components/platform-entry/platformEntryCreationTypes.ts` 与入口组件中,后续切换玩法开放节奏时容易出现多个入口不一致。 +创作中心的模板 Tab、平台创作类型弹层和旧“新建作品”卡片配置都依赖同一组玩法模板。此前入口开放状态、隐藏状态和中文文案集中写在前端配置与入口组件中,后续切换玩法开放节奏时容易出现多个入口不一致。当前入口配置事实源已经迁移到 SpacetimeDB,由 `api-server` 通过 `GET /api/creation-entry/config` 下发。 ## 落地规则 -1. 新建作品入口配置统一放在 `src/config/newWorkEntryConfig.ts`。 +1. 新建作品入口配置统一存放在 SpacetimeDB 的 `creation_entry_config` / `creation_entry_type_config` 表;默认种子位于 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`。 2. `visible` 控制玩法是否展示在创作 Tab 模板入口、新建作品入口和创作类型弹层中。 -3. `open` 控制玩法是否允许点击创建;`open: false` 时入口保持展示但禁用。 +3. `open` 控制玩法是否允许点击创建以及对应 runtime/API 路由是否放行;`open: false` 时入口保持展示但禁用,并由 `api-server` 熔断对应玩法 API。 4. `title`、`subtitle`、`badge` 控制玩法卡片文案。 5. `startCard` 控制旧创作中心顶部新建作品模块的标题、辅助文案和移动端角标文案;当前创作 Tab 首屏标题固定在 `PlatformEntryFlowShellImpl.tsx`,不再由 `startCard` 控制。 6. `typeModal` 控制平台创作类型弹层标题和描述。 7. 入口排序仍遵循“可创建玩法在前,未开放玩法在后”;同组内部沿用配置顺序。 8. `creative-agent` 可以继续保留运行链路,但默认 `visible: false`,不出现在创作 Tab 模板入口。 +9. 前端 `src/components/platform-entry/platformEntryCreationTypes.ts` 只做展示派生,不再承载默认入口配置。 ## 当前状态 @@ -23,17 +24,17 @@ | 大鱼吃小鱼 | 否 | 是 | 功能仍保留,不在新建作品入口展示 | | 拼图 | 是 | 是 | 创作 Tab 默认选中并内嵌展示拼图创作表单,提交后进入拼图草稿生成 | | 抓大鹅 | 是 | 是 | 点击后进入抓大鹅 Match3D Agent 共创工作台 | -| 方洞挑战 | 是 | 是 | 点击后进入方洞挑战 Agent 共创工作台,支持草稿、结果页、发布、试玩、作品架与广场 | +| 方洞挑战 | 否 | 是 | 创作页入口暂时完全隐藏,既有草稿、结果页、发布、试玩、作品架与广场链路保留 | | AIRP | 是 | 否 | 保留入口,显示敬请期待 | | 视觉小说 | 是 | 是 | 点击后进入视觉小说创作工作台 | | 智能创作 | 否 | 是 | 入口隐藏,既有 `creative-agent` 链路保留 | ## 验收 -1. 修改 `src/config/newWorkEntryConfig.ts` 后,创作 Tab 模板入口、创作中心顶部卡带和平台创作类型弹层应同步变化。 +1. 修改 SpacetimeDB 入口配置后,创作 Tab 模板入口、创作中心顶部卡带和平台创作类型弹层应同步变化。 2. 隐藏玩法不触发入口预加载,也不出现在新建作品入口中。 3. 未开放玩法点击态保持禁用,不应进入鉴权或创建会话链路。 4. 已开放玩法点击后必须进入对应创建链路;若用户未登录,先走登录保护。 5. 创作 Tab 首屏应显示“10分钟创作一个精品互动玩法”,并默认展示拼图创作表单。 6. 智能创作入口隐藏后,不应出现“Hi, 朋友”“问一问百梦”或“一句话生成闪应用”等旧首页入口。 -7. 方洞挑战作品发布后应生成 `SH-` 作品号,并能从作品架、广场详情和试玩 runtime 回到同一作品详情。 +7. 方洞挑战入口隐藏后,不应出现在创作 Tab 模板入口、创作中心顶部卡带、平台创作类型弹层和创作页作品架中;既有 `SH-` 作品号、广场详情和试玩 runtime 链路不因此删除。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 21f8a808..552a6789 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -6,7 +6,7 @@ - [CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md](./CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md):冻结儿童动作识别互动玩法 Demo 固定热身关的开发落地规格,覆盖横屏展示、摄像头背景虚化、角色剪影、绿色圆环 2 秒保持、动作教学、当前会话内空间边界记录和后续关卡安全暂停规则。 - [RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md](./RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md):记录运行态输入设备抽象层,明确鼠标、触控、mocap 等设备统一归一为通用拖拽语义,玩法组件只负责解释目标和落点。 -- [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。 +- [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异;同时冻结 `shared-contracts` 不得反向依赖 `platform-*`,避免 SpacetimeDB 模块发布时拉入 `wasm-bindgen`。 - [DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md](./DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md):记录本地完整 Rust 栈启动时 `api-server`、主站 Vite 和后台 Vite 端口占用的误判根因、脚本预检策略和 Windows 排障命令。 - [VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md](./VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md):记录 GPT-image-2 图片生成从 APIMart 迁移到 VectorEngine `gpt-image-2-all` 的接口、环境变量、尺寸映射、错误口径和验收命令。 - [SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md](./SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md):记录本地 `spacetime publish` 被 sccache wrapper 通信异常阻断时的根因、debug 构建参数口径和手动排障命令。 diff --git a/docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md b/docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md index 29fae1be..3b46a842 100644 --- a/docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md +++ b/docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md @@ -16,6 +16,8 @@ 4. 成员 crate 只保留自身需要表达的差异,例如 `features`、`optional = true` 或 target-specific dependency。 5. 需要关闭 default features 的依赖,应优先在 workspace 根依赖中声明;成员 crate 不再重复覆盖同一项。 6. `module-assets` 这类有默认服务端 feature 的领域 crate,在 workspace 根内按 `default-features = false` 维护;需要服务端 OSS/HTTP 能力的 adapter crate 显式启用 `features = ["server-service"]`。 +7. `shared-contracts` 只能承载前后端公开 DTO 和轻量枚举,禁止直接依赖 `platform-*` 服务实现 crate;需要把平台实现响应转换为公开 DTO 时,转换函数放在 `api-server` 等 adapter 层。 +8. `spacetime-module` 的传递依赖不能包含 `reqwest`、`web-sys`、`js-sys`、`wasm-bindgen` 等 Web/HTTP 客户端链路;发布前可用 `cargo tree -i wasm-bindgen --manifest-path server-rs/Cargo.toml -p spacetime-module --target wasm32-unknown-unknown` 排查。 ## 3. 本次收敛范围 @@ -53,3 +55,29 @@ npm.cmd run check:encoding -- docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDA ``` 若仅改 Cargo 依赖配置且未触碰 API smoke 相关代码,不强制启动 `npm run api-server`;若后续改动同时涉及 API 路由、SpacetimeDB facade 或运行时行为,仍按 `AGENTS.md` 和 DDD 文档执行后端 smoke。 + +## 6. SpacetimeDB 模块依赖边界补充 + +2026-05-11 本地重置 SpacetimeDB 并重新发布 `xushi-p4wfr` 时,`spacetime publish` 在 Rust 编译成功后报 `wasm-bindgen detected`。排查命令显示链路为: + +```text +spacetime-module -> module-runtime -> shared-contracts -> platform-oss -> reqwest -> wasm-bindgen +``` + +根因是 `shared-contracts` 为了复用 OSS 直传/读签名返回类型,直接依赖了 `platform-oss`。这违反 DDD 分层边界:契约 crate 不能依赖平台副作用实现,否则所有引用契约的纯领域和 SpacetimeDB 模块都会被迫拉入 HTTP client。 + +修正口径: + +1. `shared-contracts::assets` 定义独立的公开 DTO 和 `DirectUploadObjectAccess` 轻量枚举。 +2. `platform-oss` 保持 OSS 签名、读写请求和错误分类实现,不被契约层引用。 +3. `api-server::assets` 负责把 `platform_oss::OssPostObjectResponse` / `OssSignedGetObjectUrlResponse` 转成 `shared-contracts` DTO。 +4. 后续新增外部平台能力时,重复使用这个边界:平台 crate 不得被 `shared-contracts`、`module-*` 或 `spacetime-module` 反向依赖。 + +最小验证: + +```powershell +cargo tree -i wasm-bindgen --manifest-path server-rs\Cargo.toml -p spacetime-module --target wasm32-unknown-unknown +cargo check -p shared-contracts --manifest-path server-rs\Cargo.toml +cargo check -p api-server --manifest-path server-rs\Cargo.toml +spacetime publish xushi-p4wfr --server local --module-path server-rs\crates\spacetime-module --build-options="--debug" -c=on-conflict --yes +``` diff --git a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md index 38065907..3b33ced9 100644 --- a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md +++ b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md @@ -72,8 +72,8 @@ SELECT * FROM auth_store_snapshot WHERE snapshot_id = 'default'; ### `user_account` - 作用:用户账号主表,保存用户名、公开百梦号、手机号掩码、登录方式、密码登录开关、token 版本和默认不前端展示的运营标签。 -- 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `avatar_url: Option`, `phone_number_masked: Option`, `phone_number_e164: Option`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: String`, `password_login_enabled: bool`, `token_version: u64`, `user_tags: Vec`。 -- 说明:`user_tags` 默认空数组,只允许后端白名单投影到特定业务接口;不得在登录态、个人资料等通用前端响应中直接暴露。 +- 结构:`user_id PK: String`, `public_user_code: String`, `username: String`, `display_name: String`, `avatar_url: Option`, `phone_number_masked: Option`, `phone_number_e164: Option`, `login_method: String`, `binding_status: String`, `wechat_bound: bool`, `password_hash: String`, `password_login_enabled: bool`, `token_version: u64`, `user_tags: Option>`。 +- 说明:`user_tags` 数据库默认 `None`,业务读取时按空数组归一化;只允许后端白名单投影到特定业务接口,不得在登录态、个人资料等通用前端响应中直接暴露。 - 索引:`username`, `public_user_code`。 ```sql @@ -256,11 +256,11 @@ SELECT * FROM profile_redeem_code_usage WHERE user_id = ''; ### `profile_invite_code` -- 作用:用户邀请中心的邀请码主表,也承载后台运营预置邀请码和使用后授予账号的运营标签。 -- 结构:`user_id PK: String`, `invite_code: String`, `metadata_json: String`, `created_at: Timestamp`, `updated_at: Timestamp`, `starts_at: Option`, `expires_at: Option`, `granted_user_tags: Vec`。 +- 作用:用户邀请中心的邀请码主表,也承载后台运营预置邀请码和使用后授予账号的运营标签配置。 +- 结构:`user_id PK: String`, `invite_code: String`, `metadata_json: String`, `created_at: Timestamp`, `updated_at: Timestamp`, `starts_at: Option`, `expires_at: Option`。 - 索引:主键 `user_id`,唯一索引 `invite_code`。 - 后台读取:`GET /admin/api/profile/invite-codes` 只返回 `user_id` 以 `admin:` 开头的后台预置码;普通用户自己的邀请码不得进入后台运营列表。 -- 说明:`granted_user_tags` 默认空数组;用户注册填写该邀请码后合并进 `user_account.user_tags`,不回改历史用户。 +- 说明:使用该邀请码后授予的标签存放在 `metadata_json.userTags`,服务端兼容读取 `metadata_json.user_tags`;用户注册填写该邀请码后合并进 `user_account.user_tags`,不回改历史用户。 ```sql SELECT * FROM profile_invite_code WHERE user_id = ''; @@ -665,7 +665,7 @@ SELECT * FROM match3d_agent_message WHERE session_id = '' ORDER BY c - 作用:抓大鹅 Match3D 作品主表,保存作品基础信息、配置、发布状态、游玩次数和草稿生成出的独立物品素材引用。 - 结构:`profile_id PK: String`, `owner_user_id: String`, `source_session_id: String`, `author_display_name: String`, `game_name: String`, `theme_text: String`, `summary_text: String`, `tags_json: String`, `cover_image_src: String`, `cover_asset_id: String`, `clear_count: u32`, `difficulty: u32`, `config_json: String`, `publication_status: String`, `play_count: u32`, `updated_at: Timestamp`, `published_at: Option`, `generated_item_assets_json: Option`。 -- 说明:`generated_item_assets_json` 保存 `Match3DGeneratedItemAsset` 数组 JSON,用于草稿页退出后从作品架重进时恢复 `3D素材` Tab 中的切割图片预览;基础信息自动保存和发布必须保留该字段。 +- 说明:`generated_item_assets_json` 保存 `Match3DGeneratedItemAsset` 数组 JSON,用于草稿页退出后从作品架重进时恢复 `3D素材` Tab 中的切割图片和 GLB 模型预览;运行态也通过该字段拿到 `modelSrc` / `modelObjectKey` 并优先渲染生成模型。基础信息自动保存和发布必须保留该字段。 - 索引:`owner_user_id`, `publication_status`。 ```sql diff --git a/docs/technical/USER_TAG_INVITE_AND_PUZZLE_LEADERBOARD_2026-05-10.md b/docs/technical/USER_TAG_INVITE_AND_PUZZLE_LEADERBOARD_2026-05-10.md index 6fe00dc3..3dc7d609 100644 --- a/docs/technical/USER_TAG_INVITE_AND_PUZZLE_LEADERBOARD_2026-05-10.md +++ b/docs/technical/USER_TAG_INVITE_AND_PUZZLE_LEADERBOARD_2026-05-10.md @@ -1,13 +1,13 @@ # 用户标签、邀请码授予与拼图榜单展示方案 -更新时间:`2026-05-10` +更新时间:`2026-05-11` ## 1. 目标 本次新增用户标签系统的最小闭环: -1. `user_account` 增加账号标签字段,默认空。 -2. 后台预置邀请码可配置授予标签。 +1. `user_account` 增加账号标签字段,数据库默认空,业务读取时按空数组处理。 +2. 后台预置邀请码可通过原有 `metadata` 字段配置授予标签。 3. 用户填写带标签的邀请码后,把标签合并到自己的账号。 4. 标签默认不在前端资料页、邀请中心或通用接口展示。 5. 拼图排行榜仅对白名单标签做展示,首版只展示 `北科`。 @@ -16,19 +16,21 @@ ### `user_account.user_tags` -- 类型:`Vec`。 -- 默认:空数组。 +- 类型:`Option>`。 +- 默认:`None`,业务层读取时统一按空数组处理。 - 语义:账号级运营标签,属于后台与服务端投影数据,不作为普通前端个人资料字段。 - 写入:首版只由邀请码兑换链路合并写入。 -- 迁移:旧迁移包和旧数据库按空数组兼容。 +- 迁移:旧迁移包和旧数据库按 `null` 兼容,再由业务层归一化为空数组。 -### `profile_invite_code.granted_user_tags` +### `profile_invite_code.metadata_json.userTags` -- 类型:`Vec`。 -- 默认:空数组。 +- 类型:`metadata_json` 对象里的 `userTags: string[]`。 +- 默认:字段缺失或空数组时不授予标签。 - 语义:使用该邀请码后授予被邀请账号的标签列表。 - 范围:后台运营预置码和普通用户个人邀请码都可存字段,但后台表单首版只允许管理员配置预置码。 -- 迁移:旧邀请码按空数组兼容。 +- 存储:不再新增或使用独立的邀请码标签列;后台保存时把用户标签写回 `metadata.userTags`。 +- 解析:服务端优先读取 `metadata_json.userTags`,并兼容解析 `metadata_json.user_tags`。 +- 迁移:旧邀请码缺少 `metadata_json` 时按 `{}` 兼容;旧迁移包里已废弃的独立字段会在导入时丢弃。 ## 3. 标签归一化 @@ -45,35 +47,38 @@ 1. 写入 `profile_referral_relation`。 2. 发放原有双方奖励。 -3. 读取 `profile_invite_code.granted_user_tags`。 +3. 从 `profile_invite_code.metadata_json` 解析 `userTags` / `user_tags`。 4. 将这些标签合并进 `user_account.user_tags`。 -管理员更新邀请码时,`grantedUserTags` 代表覆盖该邀请码之后授予的标签集合;空数组代表不授予标签。更新邀请码不会回溯修改已经使用过该邀请码的账号。 +管理员更新邀请码时,后台表单里的用户标签会覆盖写入 `metadata.userTags`;空数组代表不授予标签。更新邀请码不会回溯修改已经使用过该邀请码的账号。 ## 5. API 契约 -后台邀请码 upsert 请求增加: +后台邀请码 upsert 请求继续只提交 `metadata`,标签写在 `metadata.userTags` 中: ```json { "inviteCode": "BEIKE2026", - "grantedUserTags": ["北科"], - "metadata": {}, + "metadata": { + "userTags": ["北科"] + }, "startsAt": null, "expiresAt": null } ``` -后台邀请码列表和 upsert 返回增加同名字段: +后台邀请码列表和 upsert 返回继续回传 `metadata`: ```json { "inviteCode": "BEIKE2026", - "grantedUserTags": ["北科"] + "metadata": { + "userTags": ["北科"] + } } ``` -用户侧邀请中心、账号资料、登录返回和普通 profile 接口不返回 `userTags`。 +后台表单展示时从 `metadata.userTags` 回显用户标签;用户侧邀请中心、账号资料、登录返回和普通 profile 接口不返回 `userTags`。 ## 6. 拼图排行榜展示 @@ -94,9 +99,10 @@ ## 7. 验收 -1. 新账号 `user_account.user_tags` 默认为空。 -2. 后台创建邀请码时可填写 `北科`,列表和结果面板可回显。 +1. 新账号 `user_account.user_tags` 数据库默认为 `None`,业务读取为空数组。 +2. 后台创建邀请码时可填写 `北科`,请求和返回的 `metadata.userTags` 可回显。 3. 用户填写该邀请码后,账号表 `user_tags` 包含 `北科`。 4. 不带标签的邀请码不改变账号标签。 5. 拼图排行榜中带 `北科` 的用户昵称下方显示 `北科`,其它标签不显示。 -6. 执行 `npm run check:encoding`、`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`,并按影响范围执行后台 typecheck / 拼图前端测试。 +6. 生成 SpacetimeDB bindings 后,不再出现独立的邀请码标签字段。 +7. 执行 `npm run check:encoding`、`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`,并按影响范围执行后台 typecheck / 拼图前端测试。 diff --git a/packages/shared/src/contracts/match3dWorks.ts b/packages/shared/src/contracts/match3dWorks.ts index 358caeb2..83ec0fa5 100644 --- a/packages/shared/src/contracts/match3dWorks.ts +++ b/packages/shared/src/contracts/match3dWorks.ts @@ -37,6 +37,15 @@ export interface PutMatch3DWorkRequest { difficulty: number; } +export interface GenerateMatch3DWorkTagsRequest { + gameName: string; + themeText: string; +} + +export interface GenerateMatch3DWorkTagsResponse { + tags: string[]; +} + export interface Match3DWorkSummary { workId: string; profileId: string; diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index db1e55f3..18bc892c 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -352,7 +352,6 @@ export type AdminDisableProfileRedeemCodeRequest = { export type AdminUpsertProfileInviteCodeRequest = { inviteCode: string; metadata?: Record | null; - grantedUserTags?: string[]; startsAt?: string | null; expiresAt?: string | null; }; @@ -361,7 +360,6 @@ export type ProfileInviteCodeAdminResponse = { userId: string; inviteCode: string; metadata: Record; - grantedUserTags: string[]; startsAt?: string | null; expiresAt?: string | null; status: 'pending' | 'active' | 'expired'; diff --git a/public/visual-novel-style-references/cinematic-anime.png b/public/visual-novel-style-references/cinematic-anime.png new file mode 100644 index 00000000..630763de Binary files /dev/null and b/public/visual-novel-style-references/cinematic-anime.png differ diff --git a/public/visual-novel-style-references/dark-gothic.png b/public/visual-novel-style-references/dark-gothic.png new file mode 100644 index 00000000..74640d90 Binary files /dev/null and b/public/visual-novel-style-references/dark-gothic.png differ diff --git a/public/visual-novel-style-references/ink-fantasy.png b/public/visual-novel-style-references/ink-fantasy.png new file mode 100644 index 00000000..98736cb0 Binary files /dev/null and b/public/visual-novel-style-references/ink-fantasy.png differ diff --git a/public/visual-novel-style-references/pixel-noir.png b/public/visual-novel-style-references/pixel-noir.png new file mode 100644 index 00000000..9461c9fa Binary files /dev/null and b/public/visual-novel-style-references/pixel-noir.png differ diff --git a/public/visual-novel-style-references/soft-pastel.png b/public/visual-novel-style-references/soft-pastel.png new file mode 100644 index 00000000..c9a50572 Binary files /dev/null and b/public/visual-novel-style-references/soft-pastel.png differ diff --git a/public/visual-novel-style-references/watercolor.png b/public/visual-novel-style-references/watercolor.png new file mode 100644 index 00000000..ba54f420 Binary files /dev/null and b/public/visual-novel-style-references/watercolor.png differ diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index c8ad43b0..c8cc837b 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -3009,7 +3009,6 @@ dependencies = [ name = "shared-contracts" version = "0.1.0" dependencies = [ - "platform-oss", "serde", "serde_json", ] diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 29f0ec59..739cdb41 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -19,13 +19,12 @@ use serde::Deserialize; use serde_json::{Map, Value}; use shared_contracts::admin::{ AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload, - AdminUpsertCreationEntryTypeConfigRequest, AdminDatabaseOverviewPayload, AdminDatabaseTableListResponse, AdminDatabaseTableRowPayload, AdminDatabaseTableRowsQuery, AdminDatabaseTableRowsResponse, AdminDatabaseTableStatPayload, AdminDebugHeaderInput, AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest, AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload, AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventListQuery, - AdminTrackingEventListResponse, + AdminTrackingEventListResponse, AdminUpsertCreationEntryTypeConfigRequest, }; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; @@ -196,13 +195,15 @@ pub async fn admin_list_database_table_rows( Ok(json_success_body(Some(&request_context), response)) } - pub async fn admin_get_creation_entry_config( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, ) -> Result, AppError> { - let config = state.get_creation_entry_config().await.map_err(map_admin_spacetime_error)?; + let config = state + .get_creation_entry_config() + .await + .map_err(map_admin_spacetime_error)?; Ok(json_success_body( Some(&request_context), AdminCreationEntryConfigResponse { @@ -1328,8 +1329,10 @@ mod tests { use axum::{http::StatusCode, response::IntoResponse}; use serde_json::json; use shared_contracts::admin::{ - AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload, - AdminUpsertCreationEntryTypeConfigRequest,AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery}; + AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload, + AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery, + AdminUpsertCreationEntryTypeConfigRequest, + }; #[test] fn normalize_debug_path_rejects_absolute_url() { diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 972f8432..51132c88 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -16,8 +16,8 @@ use tracing::{Level, Span, error, info, info_span, warn}; use crate::{ admin::{ admin_debug_http, admin_get_creation_entry_config, admin_list_database_table_rows, - admin_list_database_tables, admin_list_tracking_events, admin_login, admin_me, admin_overview, - admin_upsert_creation_entry_config, require_admin_auth, + admin_list_database_tables, admin_list_tracking_events, admin_login, admin_me, + admin_overview, admin_upsert_creation_entry_config, require_admin_auth, }, ai_tasks::{ append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage, @@ -90,10 +90,10 @@ use crate::{ match3d::{ click_match3d_item, compile_match3d_agent_draft, create_match3d_agent_session, delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up, - get_match3d_agent_session, get_match3d_run, get_match3d_work_detail, get_match3d_works, - list_match3d_gallery, publish_match3d_work, put_match3d_work, restart_match3d_run, - start_match3d_run, stop_match3d_run, stream_match3d_agent_message, - submit_match3d_agent_message, + generate_match3d_work_tags, get_match3d_agent_session, get_match3d_run, + get_match3d_work_detail, get_match3d_works, list_match3d_gallery, publish_match3d_work, + put_match3d_work, restart_match3d_run, start_match3d_run, stop_match3d_run, + stream_match3d_agent_message, submit_match3d_agent_message, }, password_entry::password_entry, password_management::{change_password, reset_password}, @@ -924,6 +924,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/creation/match3d/works/tags", + post(generate_match3d_work_tags).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/creation/match3d/works/{profile_id}", get(get_match3d_work_detail) @@ -1510,7 +1517,7 @@ pub fn build_router(state: AppState) -> Router { )), ) .route("/api/auth/password/reset", post(reset_password)) - // 后端 runtime/API 路由读取入口配置做统一熔断,避免前端隐藏后后端仍可被直接访问。 + // 后端 runtime/API 路由只按 open 做熔断;visible 仅控制创作页入口展示。 .layer(middleware::from_fn_with_state( state.clone(), require_creation_entry_route_enabled, @@ -1993,7 +2000,10 @@ mod tests { assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); let body = read_json_response(response).await; - assert_eq!(body["error"]["details"]["reason"], "creation_entry_disabled"); + assert_eq!( + body["error"]["details"]["reason"], + "creation_entry_disabled" + ); assert_eq!(body["error"]["details"]["creationTypeId"], "puzzle"); } diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index ea7e6c80..41b01cd6 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -19,8 +19,9 @@ use shared_contracts::assets::{ AssetBindingPayload, AssetHistoryEntryPayload, AssetHistoryListResponse, AssetHistoryQuery, AssetObjectPayload, AssetReadUrlPayload, BindAssetObjectRequest, BindAssetObjectResponse, ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest, ConfirmAssetObjectResponse, - CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse, DirectUploadTicketPayload, - GetAssetReadUrlResponse, GetReadUrlQuery, + CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse, DirectUploadObjectAccess, + DirectUploadTicketFormFields, DirectUploadTicketPayload, GetAssetReadUrlResponse, + GetReadUrlQuery, }; use spacetime_client::SpacetimeClientError; @@ -44,7 +45,8 @@ const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 7] = [ "square_hole_shape_image", "square_hole_hole_image", ]; -const ASSET_READ_BYTES_MAX_SIZE_BYTES: u64 = 10 * 1024 * 1024; +// 中文注释:同源字节读取同时服务图片转 Data URL 与 Match3D 私有 GLB 预览,Rodin GLB 可能明显超过图片上限。 +const ASSET_READ_BYTES_MAX_SIZE_BYTES: u64 = 120 * 1024 * 1024; const ASSET_READ_BYTES_DEFAULT_EXPIRE_SECONDS: u64 = 300; pub async fn create_direct_upload_ticket( @@ -73,7 +75,10 @@ pub async fn create_direct_upload_ticket( path_segments: payload.path_segments, file_name: payload.file_name, content_type: payload.content_type, - access: payload.access.unwrap_or(OssObjectAccess::Private), + access: payload + .access + .map(direct_upload_access_to_oss) + .unwrap_or(OssObjectAccess::Private), metadata: payload.metadata, max_size_bytes: payload.max_size_bytes, expire_seconds: payload.expire_seconds, @@ -85,7 +90,7 @@ pub async fn create_direct_upload_ticket( "message": error.to_string(), })) })?; - let upload = DirectUploadTicketPayload::from(signed); + let upload = direct_upload_ticket_payload_from_oss(signed); record_asset_tracking_event( &state, @@ -149,11 +154,76 @@ pub async fn get_asset_read_url( Ok(json_success_body( Some(&request_context), GetAssetReadUrlResponse { - read: AssetReadUrlPayload::from(signed), + read: asset_read_url_payload_from_oss(signed), }, )) } +fn direct_upload_access_to_oss(value: DirectUploadObjectAccess) -> OssObjectAccess { + match value { + DirectUploadObjectAccess::Public => OssObjectAccess::Public, + DirectUploadObjectAccess::Private => OssObjectAccess::Private, + } +} + +fn direct_upload_access_from_oss(value: OssObjectAccess) -> DirectUploadObjectAccess { + match value { + OssObjectAccess::Public => DirectUploadObjectAccess::Public, + OssObjectAccess::Private => DirectUploadObjectAccess::Private, + } +} + +fn direct_upload_ticket_payload_from_oss( + value: platform_oss::OssPostObjectResponse, +) -> DirectUploadTicketPayload { + DirectUploadTicketPayload { + signature_version: value.signature_version.to_string(), + provider: value.provider.to_string(), + bucket: value.bucket, + endpoint: value.endpoint, + host: value.host, + object_key: value.object_key, + legacy_public_path: value.legacy_public_path, + content_type: value.content_type, + access: direct_upload_access_from_oss(value.access), + key_prefix: value.key_prefix, + expires_at: value.expires_at, + max_size_bytes: value.max_size_bytes, + success_action_status: value.success_action_status, + form_fields: direct_upload_ticket_form_fields_from_oss(value.form_fields), + } +} + +fn direct_upload_ticket_form_fields_from_oss( + value: platform_oss::OssPostObjectFormFields, +) -> DirectUploadTicketFormFields { + DirectUploadTicketFormFields { + key: value.key, + policy: value.policy, + signature_version: value.signature_version, + credential: value.credential, + date: value.date, + signature: value.signature, + success_action_status: value.success_action_status, + content_type: value.content_type, + metadata: value.metadata, + } +} + +fn asset_read_url_payload_from_oss( + value: platform_oss::OssSignedGetObjectUrlResponse, +) -> AssetReadUrlPayload { + AssetReadUrlPayload { + provider: value.provider.to_string(), + bucket: value.bucket, + endpoint: value.endpoint, + host: value.host, + object_key: value.object_key, + expires_at: value.expires_at, + signed_url: value.signed_url, + } +} + pub async fn get_asset_read_bytes( State(state): State, Query(query): Query, diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index 11f86f81..380bc62c 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -8,6 +8,9 @@ use axum::{ }; use serde_json::{Value, json}; +#[cfg(test)] +use module_runtime::build_creation_entry_config_response; + use crate::{ api_response::json_success_body, http_error::AppError, request_context::RequestContext, state::AppState, @@ -92,8 +95,8 @@ fn creation_entry_error_response(request_context: &RequestContext, error: AppErr } #[cfg(test)] -pub(crate) fn test_creation_entry_config_response( -) -> shared_contracts::creation_entry_config::CreationEntryConfigResponse { +pub(crate) fn test_creation_entry_config_response() +-> shared_contracts::creation_entry_config::CreationEntryConfigResponse { build_creation_entry_config_response(module_runtime::CreationEntryConfigSnapshot { config_id: module_runtime::CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(), start_card: module_runtime::CreationEntryStartCardSnapshot { @@ -111,7 +114,7 @@ pub(crate) fn test_creation_entry_config_response( test_creation_type("big-fish", false, true, 20), test_creation_type("puzzle", true, true, 30), test_creation_type("match3d", true, true, 40), - test_creation_type("square-hole", true, true, 50), + test_creation_type("square-hole", false, true, 50), test_creation_type("visual-novel", true, true, 60), test_creation_type("airp", true, false, 70), test_creation_type("creative-agent", false, true, 80), diff --git a/server-rs/crates/api-server/src/hyper3d_generation.rs b/server-rs/crates/api-server/src/hyper3d_generation.rs index 4d9329df..c3ca23b4 100644 --- a/server-rs/crates/api-server/src/hyper3d_generation.rs +++ b/server-rs/crates/api-server/src/hyper3d_generation.rs @@ -231,12 +231,7 @@ pub(crate) async fn query_task_status( .await?; let jobs = extract_job_statuses(&response); - let status = normalize_task_status( - find_first_string_by_key(&response, "status") - .or_else(|| jobs.first().map(|job| job.status.clone())) - .as_deref() - .unwrap_or("unknown"), - ); + let status = resolve_hyper3d_overall_status(&response, &jobs); Ok(contract::Hyper3dTaskStatusResponse { ok: true, @@ -539,6 +534,33 @@ fn extract_job_statuses(payload: &Value) -> Vec String { + if !jobs.is_empty() { + if jobs.iter().any(|job| job.status == "failed") { + return "failed".to_string(); + } + if jobs.iter().all(|job| job.status == "done") { + return "done".to_string(); + } + if jobs.iter().any(|job| job.status == "generating") { + return "generating".to_string(); + } + if jobs.iter().any(|job| job.status == "waiting") { + return "waiting".to_string(); + } + return "unknown".to_string(); + } + + normalize_task_status( + find_first_string_by_key(payload, "status") + .as_deref() + .unwrap_or("unknown"), + ) +} + fn extract_job_uuids(payload: &Value) -> Vec { let mut job_uuids = Vec::new(); if let Some(jobs) = find_first_array_by_keys(payload, &["jobs"]) { @@ -580,6 +602,12 @@ fn collect_download_files(value: &Value, output: &mut Vec, } +#[derive(Clone, Debug)] +struct Match3DGeneratedWorkMetadata { + game_name: String, + tags: Vec, +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct Match3DGeneratedItemAssetJson { @@ -146,6 +156,16 @@ struct Match3DAssetUpload { object_key: String, } +#[derive(Clone, Debug)] +struct Match3DGeneratedItemModelSeed { + item_id: String, + item_name: String, + item_slug: String, + image_upload: Match3DAssetUpload, + image_bytes: Vec, + generated_at_micros: i64, +} + struct Match3DRodinModelAsset { task_uuid: String, subscription_key: String, @@ -172,6 +192,19 @@ pub(crate) struct CompileMatch3DDraftRequest { cover_image_src: Option, } +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GenerateMatch3DWorkTagsRequest { + game_name: String, + theme_text: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GenerateMatch3DWorkTagsResponse { + tags: Vec, +} + pub async fn create_match3d_agent_session( State(state): State, Extension(request_context): Extension, @@ -571,6 +604,26 @@ pub async fn put_match3d_work( )) } +pub async fn generate_match3d_work_tags( + State(state): State, + Extension(request_context): Extension, + Extension(_authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; + let tags = generate_match3d_work_tags_for_profile( + &state, + payload.game_name.as_str(), + payload.theme_text.as_str(), + ) + .await; + + Ok(json_success_body( + Some(&request_context), + GenerateMatch3DWorkTagsResponse { tags }, + )) +} + pub async fn publish_match3d_work( State(state): State, Path(profile_id): Path, @@ -994,11 +1047,8 @@ async fn compile_match3d_draft_for_session( )); } - let tags_json = tags - .as_ref() - .map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default()); - let profile_id = build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX); + let generated_work_metadata = generate_match3d_work_metadata(state, &config).await; let generated_item_assets = generate_match3d_item_assets( state, request_context, @@ -1008,6 +1058,13 @@ async fn compile_match3d_draft_for_session( &config, ) .await?; + let resolved_game_name = + normalize_optional_match3d_text(game_name).unwrap_or(generated_work_metadata.game_name); + let resolved_tags = tags + .map(normalize_tags) + .filter(|items| !items.is_empty()) + .unwrap_or(generated_work_metadata.tags); + let tags_json = Some(serde_json::to_string(&resolved_tags).unwrap_or_default()); let session = state .spacetime_client() @@ -1016,8 +1073,8 @@ async fn compile_match3d_draft_for_session( owner_user_id, profile_id, author_display_name: resolve_author_display_name(state, authenticated), - game_name: game_name.or_else(|| Some(format!("{}抓大鹅", config.theme_text))), - summary_text: summary, + game_name: Some(resolved_game_name), + summary_text: normalize_optional_match3d_text(summary).or_else(|| Some(String::new())), tags_json, cover_image_src, cover_asset_id: None, @@ -1535,11 +1592,11 @@ fn first_positive_integer(text: &str) -> Option { } fn normalize_tags(tags: Vec) -> Vec { - let mut result = Vec::new(); + let mut result: Vec = Vec::new(); for tag in tags { - let trimmed = tag.trim(); - if !trimmed.is_empty() && !result.iter().any(|value| value == trimmed) { - result.push(trimmed.to_string()); + let trimmed = normalize_match3d_tag(tag.as_str()); + if !trimmed.is_empty() && !result.iter().any(|value| value == &trimmed) { + result.push(trimmed); } if result.len() >= 6 { break; @@ -1548,6 +1605,138 @@ fn normalize_tags(tags: Vec) -> Vec { result } +fn normalize_optional_match3d_text(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn normalize_match3d_tag(value: &str) -> String { + let trimmed = value.trim(); + let without_number_prefix = trimmed + .char_indices() + .find_map(|(index, ch)| { + if index == 0 || !matches!(ch, '.' | '、' | ')' | ')') { + return None; + } + let prefix = &trimmed[..index]; + if prefix.chars().all(|candidate| candidate.is_ascii_digit()) { + Some(trimmed[index + ch.len_utf8()..].trim_start()) + } else { + None + } + }) + .unwrap_or(trimmed); + + without_number_prefix + .trim_matches(|ch: char| { + ch.is_ascii_punctuation() + || matches!( + ch, + ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' + ) + }) + .trim() + .chars() + .filter(|ch| !matches!(ch, '#' | '"' | '\'' | '`')) + .collect::() + .chars() + .take(6) + .collect::() +} + +fn normalize_match3d_tag_candidates(candidates: impl IntoIterator) -> Vec +where + S: AsRef, +{ + let mut tags = Vec::new(); + for candidate in candidates { + let normalized = normalize_match3d_tag(candidate.as_ref()); + if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) { + continue; + } + tags.push(normalized); + if tags.len() >= 6 { + break; + } + } + for fallback in ["抓大鹅", "经典消除", "3D素材", "轻量休闲", "收集", "挑战"] { + if tags.len() >= 6 { + break; + } + if !tags.iter().any(|tag| tag == fallback) { + tags.push(fallback.to_string()); + } + } + tags +} + +async fn generate_match3d_work_tags_for_profile( + state: &AppState, + game_name: &str, + theme_text: &str, +) -> Vec { + let Some(llm_client) = state + .creative_agent_gpt5_client() + .or_else(|| state.llm_client()) + else { + return fallback_match3d_work_tags(game_name, theme_text); + }; + let user_prompt = format!( + "题材设定:{}\n作品名称:{}\n请生成 3 到 6 个适合抓大鹅作品发布的中文短标签。要求:只返回 JSON 数组,每项 2 到 6 个汉字,不要解释。", + theme_text, game_name + ); + let response = llm_client + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system("你是抓大鹅作品标签编辑,只返回 JSON 字符串数组。"), + LlmMessage::user(user_prompt), + ]) + .with_model(MATCH3D_WORK_METADATA_LLM_MODEL) + .with_responses_api(), + ) + .await; + + match response { + Ok(response) => { + let tags = parse_match3d_tags_from_text(response.content.as_str()); + if tags.len() >= MATCH3D_MIN_GENERATED_TAG_COUNT { + return tags; + } + fallback_match3d_work_tags(game_name, theme_text) + } + Err(error) => { + tracing::warn!( + provider = MATCH3D_WORKS_PROVIDER, + game_name, + error = %error, + "抓大鹅 AI 标签生成失败,降级使用本地标签" + ); + fallback_match3d_work_tags(game_name, theme_text) + } + } +} + +const MATCH3D_MIN_GENERATED_TAG_COUNT: usize = 3; + +fn parse_match3d_tags_from_text(raw: &str) -> Vec { + let raw = raw.trim(); + let json_text = if let Some(start) = raw.find('[') + && let Some(end) = raw.rfind(']') + && end > start + { + &raw[start..=end] + } else { + raw + }; + let parsed = serde_json::from_str::>(json_text).unwrap_or_default(); + normalize_match3d_tag_candidates(parsed) +} + +fn fallback_match3d_work_tags(game_name: &str, theme_text: &str) -> Vec { + normalize_match3d_tag_candidates([theme_text, game_name, "抓大鹅", "经典消除", "3D素材"]) +} + fn serialize_match3d_generated_item_assets(assets: &[Match3DGeneratedItemAsset]) -> Option { if assets.is_empty() { return None; @@ -1634,7 +1823,7 @@ async fn generate_match3d_item_assets( let item_images = slice_match3d_material_sheet(&material_sheet.image, &item_names) .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; - let mut item_assets = Vec::with_capacity(item_images.len()); + let mut model_seeds = Vec::with_capacity(item_images.len()); for (index, item_image) in item_images.into_iter().enumerate() { let item_name = item_names .get(index) @@ -1661,36 +1850,61 @@ async fn generate_match3d_item_assets( ) .await .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; - let model_asset = generate_match3d_rodin_model_asset( - state, - owner_user_id, - session_id, - profile_id, - &item_slug, - &item_name, - config, - image_bytes, - generated_at_micros.saturating_add(100 + index as i64), - ) - .await - .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; - item_assets.push(Match3DGeneratedItemAsset { + model_seeds.push(Match3DGeneratedItemModelSeed { item_id, item_name, - image_src: Some(image_upload.src), - image_object_key: Some(image_upload.object_key), - model_src: Some(model_asset.upload.src), - model_object_key: Some(model_asset.upload.object_key), - model_file_name: Some(model_asset.model_file_name), - task_uuid: Some(model_asset.task_uuid), - subscription_key: Some(model_asset.subscription_key), - status: "model_ready".to_string(), - error: None, + item_slug, + image_upload, + image_bytes, + generated_at_micros: generated_at_micros.saturating_add(100 + index as i64), }); } + // 中文注释:Rodin 单个模型耗时不可控,必须在图片切割和入库后并行提交所有图生模型, + // 避免多个物品的排队和轮询时间串行叠加导致 action 超时。 + let model_results = try_join_all(model_seeds.into_iter().map(|seed| { + async move { + let Match3DGeneratedItemModelSeed { + item_id, + item_name, + item_slug, + image_upload, + image_bytes, + generated_at_micros, + } = seed; + let model_asset = generate_match3d_rodin_model_asset( + state, + owner_user_id, + session_id, + profile_id, + item_slug.as_str(), + item_name.as_str(), + config, + image_bytes, + generated_at_micros, + ) + .await?; + + Ok::<_, AppError>(Match3DGeneratedItemAsset { + item_id, + item_name, + image_src: Some(image_upload.src), + image_object_key: Some(image_upload.object_key), + model_src: Some(model_asset.upload.src), + model_object_key: Some(model_asset.upload.object_key), + model_file_name: Some(model_asset.model_file_name), + task_uuid: Some(model_asset.task_uuid), + subscription_key: Some(model_asset.subscription_key), + status: "model_ready".to_string(), + error: None, + }) + } + })) + .await + .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; + // 中文注释:草稿阶段必须同时产出 GLB 模型,结果页直接加载模型预览。 - Ok(item_assets) + Ok(model_results) } struct Match3DMaterialSheet { @@ -1702,6 +1916,93 @@ struct Match3DSlicedItemImage { bytes: Vec, } +async fn generate_match3d_work_metadata( + state: &AppState, + config: &Match3DConfigJson, +) -> Match3DGeneratedWorkMetadata { + let Some(llm_client) = state + .creative_agent_gpt5_client() + .or_else(|| state.llm_client()) + else { + return fallback_match3d_work_metadata(config.theme_text.as_str()); + }; + let system_prompt = "你是抓大鹅游戏的作品命名编辑,只返回 JSON。"; + let user_prompt = format!( + "题材设定:{}\n请生成抓大鹅游戏作品元信息。要求:只返回 JSON 对象,字段为 gameName、tags。gameName 为 4 到 12 个中文字符,不要包含“作品”“游戏”;tags 为 3 到 6 个中文短标签,每个 2 到 6 个汉字。不要生成描述。", + config.theme_text + ); + let response = llm_client + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(system_prompt), + LlmMessage::user(user_prompt), + ]) + .with_model(MATCH3D_WORK_METADATA_LLM_MODEL) + .with_responses_api(), + ) + .await; + + match response { + Ok(response) => parse_match3d_work_metadata(response.content.as_str()) + .unwrap_or_else(|| fallback_match3d_work_metadata(config.theme_text.as_str())), + Err(error) => { + tracing::warn!( + provider = MATCH3D_AGENT_PROVIDER, + theme_text = config.theme_text.as_str(), + error = %error, + "抓大鹅作品名称生成失败,降级使用本地元信息" + ); + fallback_match3d_work_metadata(config.theme_text.as_str()) + } + } +} + +fn parse_match3d_work_metadata(raw: &str) -> Option { + let raw = raw.trim(); + let json_text = if let Some(start) = raw.find('{') + && let Some(end) = raw.rfind('}') + && end > start + { + &raw[start..=end] + } else { + raw + }; + let value = serde_json::from_str::(json_text).ok()?; + let game_name = normalize_match3d_game_name(value.get("gameName")?.as_str()?); + if game_name.is_empty() { + return None; + } + let tags = value + .get("tags") + .and_then(Value::as_array) + .map(|items| normalize_match3d_tag_candidates(items.iter().filter_map(Value::as_str))) + .unwrap_or_default(); + Some(Match3DGeneratedWorkMetadata { + game_name, + tags: normalize_match3d_tag_candidates(tags), + }) +} + +fn normalize_match3d_game_name(raw: &str) -> String { + raw.trim() + .trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、']) + .chars() + .filter(|character| !character.is_control()) + .take(16) + .collect::() + .trim() + .to_string() +} + +fn fallback_match3d_work_metadata(theme_text: &str) -> Match3DGeneratedWorkMetadata { + let theme = theme_text.trim(); + let normalized_theme = if theme.is_empty() { "主题" } else { theme }; + Match3DGeneratedWorkMetadata { + game_name: format!("{normalized_theme}抓大鹅"), + tags: normalize_match3d_tag_candidates([normalized_theme, "抓大鹅", "经典消除", "3D素材"]), + } +} + async fn generate_match3d_item_names( state: &AppState, config: &Match3DConfigJson, @@ -1844,25 +2145,12 @@ async fn generate_match3d_rodin_model_asset( ) .await?; - wait_for_match3d_rodin_model( - state, - submit_response.subscription_key.as_str(), - item_name, - ) - .await?; - let download_response = query_downloads( - state, - hyper3d_contract::Hyper3dDownloadRequest { - task_uuid: submit_response.task_uuid.clone(), - }, - ) - .await?; - let model_file = select_match3d_glb_download( - &download_response.files, - submit_response.task_uuid.as_str(), - item_name, - )?; - let downloaded_model = download_match3d_rodin_model(model_file).await?; + wait_for_match3d_rodin_model(state, submit_response.subscription_key.as_str(), item_name) + .await?; + let model_file = + wait_for_match3d_rodin_download_file(state, submit_response.task_uuid.as_str(), item_name) + .await?; + let downloaded_model = download_match3d_rodin_model(&model_file).await?; let uploaded_model = persist_match3d_generated_bytes( state, owner_user_id, @@ -1940,10 +2228,7 @@ async fn wait_for_match3d_rodin_model( } if attempt + 1 < MATCH3D_RODIN_STATUS_MAX_ATTEMPTS { - tokio::time::sleep(Duration::from_millis( - MATCH3D_RODIN_STATUS_POLL_INTERVAL_MS, - )) - .await; + tokio::time::sleep(Duration::from_millis(MATCH3D_RODIN_STATUS_POLL_INTERVAL_MS)).await; } } @@ -1952,25 +2237,77 @@ async fn wait_for_match3d_rodin_model( ))) } -fn select_match3d_glb_download<'a>( - files: &'a [hyper3d_contract::Hyper3dDownloadFilePayload], +async fn wait_for_match3d_rodin_download_file( + state: &AppState, task_uuid: &str, item_name: &str, -) -> Result<&'a hyper3d_contract::Hyper3dDownloadFilePayload, AppError> { +) -> Result { + for attempt in 0..MATCH3D_RODIN_DOWNLOAD_MAX_ATTEMPTS { + let download_response = query_downloads( + state, + hyper3d_contract::Hyper3dDownloadRequest { + task_uuid: task_uuid.to_string(), + }, + ) + .await?; + if let Some(model_file) = find_match3d_glb_download(&download_response.files) { + return Ok(model_file.clone()); + } + + // 中文注释:Rodin 状态 Done 后下载列表偶尔会延迟发布,短轮询避免把已完成任务误判失败。 + if attempt + 1 < MATCH3D_RODIN_DOWNLOAD_MAX_ATTEMPTS { + tokio::time::sleep(Duration::from_millis( + MATCH3D_RODIN_DOWNLOAD_POLL_INTERVAL_MS, + )) + .await; + } + } + + Err(match3d_bad_gateway(format!( + "{item_name} 3D 模型已完成但未返回可下载模型文件:{task_uuid}" + ))) +} + +fn find_match3d_glb_download( + files: &[hyper3d_contract::Hyper3dDownloadFilePayload], +) -> Option<&hyper3d_contract::Hyper3dDownloadFilePayload> { files .iter() - .find(|file| { - file.name.to_ascii_lowercase().ends_with(".glb") - || file.url.to_ascii_lowercase().split('?').next().unwrap_or("").ends_with(".glb") - }) - .or_else(|| files.first()) - .ok_or_else(|| { - match3d_bad_gateway(format!( - "{item_name} 3D 模型已完成但未返回可下载模型文件:{task_uuid}" - )) + .find(|file| match3d_download_file_has_extension(file, ".glb")) + .or_else(|| { + files + .iter() + .find(|file| !is_match3d_preview_or_image_download(file)) }) } +fn match3d_download_file_has_extension( + file: &hyper3d_contract::Hyper3dDownloadFilePayload, + extension: &str, +) -> bool { + file.name.to_ascii_lowercase().ends_with(extension) + || file + .url + .to_ascii_lowercase() + .split('?') + .next() + .unwrap_or("") + .ends_with(extension) +} + +fn is_match3d_preview_or_image_download( + file: &hyper3d_contract::Hyper3dDownloadFilePayload, +) -> bool { + let name = file.name.to_ascii_lowercase(); + let url_path = file.url.to_ascii_lowercase(); + let url_path = url_path.split('?').next().unwrap_or(url_path.as_str()); + name.contains("preview") + || url_path.contains("preview") + || [".png", ".jpg", ".jpeg", ".webp", ".gif"] + .iter() + .any(|extension| name.ends_with(extension) || url_path.ends_with(extension)) +} + async fn download_match3d_rodin_model( file: &hyper3d_contract::Hyper3dDownloadFilePayload, ) -> Result { @@ -2010,17 +2347,22 @@ async fn download_match3d_rodin_model( fn normalize_match3d_model_file_name(raw: &str) -> String { let trimmed = raw.trim().rsplit('/').next().unwrap_or(raw).trim(); let without_query = trimmed.split('?').next().unwrap_or(trimmed).trim(); - let sanitized = sanitize_match3d_asset_segment(without_query, "model"); - if sanitized.to_ascii_lowercase().ends_with(".glb") { - sanitized - } else { - "model.glb".to_string() - } + let normalized = without_query.to_ascii_lowercase(); + let stem = without_query + .strip_suffix(".glb") + .or_else(|| { + normalized + .strip_suffix(".glb") + .map(|_| &without_query[..without_query.len().saturating_sub(4)]) + }) + .unwrap_or(without_query); + let sanitized_stem = sanitize_match3d_asset_segment(stem, "model"); + format!("{sanitized_stem}.glb") } fn normalize_match3d_model_content_type(raw: &str) -> String { let normalized = raw.split(';').next().unwrap_or(raw).trim().to_lowercase(); - if normalized == "model/gltf-binary" || normalized == "application/octet-stream" { + if normalized == "model/gltf-binary" { return normalized; } "model/gltf-binary".to_string() @@ -2445,6 +2787,96 @@ mod tests { ); } + #[test] + fn match3d_work_metadata_parses_gpt4o_json() { + let metadata = parse_match3d_work_metadata( + r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅","经典消除","轻量休闲"]}"#, + ) + .expect("metadata should parse"); + + assert_eq!(metadata.game_name, "果园大鹅宴"); + assert_eq!( + metadata.tags, + vec!["水果", "抓大鹅", "经典消除", "轻量休闲", "3D素材", "收集"] + ); + } + + #[test] + fn match3d_work_metadata_fallback_keeps_empty_description_boundary() { + let metadata = fallback_match3d_work_metadata("水果"); + + assert_eq!(metadata.game_name, "水果抓大鹅"); + assert!(metadata.tags.contains(&"水果".to_string())); + assert!(metadata.tags.contains(&"抓大鹅".to_string())); + } + + #[test] + fn match3d_tag_normalization_only_strips_numbered_list_prefix() { + assert_eq!(normalize_match3d_tag("3D素材"), "3D素材"); + assert_eq!(normalize_match3d_tag("1. 3D素材"), "3D素材"); + assert_eq!(normalize_match3d_tag("2、轻量休闲"), "轻量休闲"); + } + + #[test] + fn match3d_model_download_metadata_normalizes_to_glb() { + assert_eq!( + normalize_match3d_model_file_name("https://example.com/Fruit Model.GLB?token=1"), + "fruit-model.glb" + ); + assert_eq!(normalize_match3d_model_file_name("模型文件"), "model.glb"); + assert_eq!( + normalize_match3d_model_content_type("application/octet-stream"), + "model/gltf-binary" + ); + assert_eq!( + normalize_match3d_model_content_type("model/gltf-binary; charset=utf-8"), + "model/gltf-binary" + ); + } + + #[test] + fn match3d_model_download_prefers_glb_file() { + let files = vec![ + hyper3d_contract::Hyper3dDownloadFilePayload { + name: "preview.png".to_string(), + url: "https://cdn.example/preview.png".to_string(), + }, + hyper3d_contract::Hyper3dDownloadFilePayload { + name: "model".to_string(), + url: "https://cdn.example/model.glb?token=1".to_string(), + }, + ]; + + let selected = find_match3d_glb_download(&files).expect("glb download should be selected"); + + assert_eq!(selected.url, "https://cdn.example/model.glb?token=1"); + } + + #[test] + fn match3d_model_download_falls_back_to_first_file() { + let files = vec![hyper3d_contract::Hyper3dDownloadFilePayload { + name: "model".to_string(), + url: "https://cdn.example/download?id=1".to_string(), + }]; + + let selected = + find_match3d_glb_download(&files).expect("opaque download url should be accepted"); + + assert_eq!(selected.url, "https://cdn.example/download?id=1"); + } + + #[test] + fn match3d_model_download_does_not_accept_preview_image_only() { + let files = vec![hyper3d_contract::Hyper3dDownloadFilePayload { + name: "preview.png".to_string(), + url: "https://cdn.example/preview.png".to_string(), + }]; + + let result = find_match3d_glb_download(&files); + + assert!(result.is_none()); + } + #[test] fn match3d_work_summary_maps_persisted_generated_item_assets() { let response = map_match3d_work_summary_response(Match3DWorkProfileRecord { diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index b7442568..8c1434de 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -675,7 +675,6 @@ pub async fn admin_upsert_profile_invite_code( admin.session().username.clone(), payload.invite_code, metadata_json, - payload.granted_user_tags, starts_at_micros, expires_at_micros, updated_at_micros as i64, @@ -1124,7 +1123,6 @@ fn build_profile_invite_code_admin_response( user_id: record.user_id, invite_code: record.invite_code, metadata, - granted_user_tags: record.granted_user_tags, starts_at: record.starts_at, expires_at: record.expires_at, status: record.status.as_str().to_string(), diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index d73d0651..7750a5da 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -11,7 +11,6 @@ use module_auth::{ RefreshSessionService, WechatAuthService, WechatAuthStateService, }; use module_runtime::RuntimeSnapshotRecord; -use shared_contracts::creation_entry_config::CreationEntryConfigResponse; #[cfg(test)] use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros}; use platform_agent::MockLangChainRustAgentExecutor; @@ -23,6 +22,7 @@ use platform_auth::{ use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider}; use platform_oss::{OssClient, OssConfig, OssError}; use serde_json::Value; +use shared_contracts::creation_entry_config::CreationEntryConfigResponse; use shared_contracts::creative_agent::CreativeAgentSessionSnapshot; use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError}; use time::OffsetDateTime; @@ -274,7 +274,7 @@ impl AppState { .creation_types .iter() .find(|item| item.id == creation_type_id) - .map(|item| item.visible && item.open) + .map(|item| item.open) .unwrap_or(true)) } @@ -291,7 +291,6 @@ impl AppState { .iter_mut() .find(|item| item.id == creation_type_id) { - item.visible = enabled; item.open = enabled; } else { config.creation_types.push( diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 14fc798f..f43e2e77 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -751,7 +751,6 @@ pub fn build_runtime_profile_invite_code_record( user_id: snapshot.user_id, invite_code: snapshot.invite_code, metadata_json: snapshot.metadata_json, - granted_user_tags: snapshot.granted_user_tags, starts_at: snapshot.starts_at_micros.map(format_utc_micros), starts_at_micros: snapshot.starts_at_micros, expires_at: snapshot.expires_at_micros.map(format_utc_micros), diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 51cec786..1a2f10c5 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -428,7 +428,6 @@ pub fn build_runtime_profile_invite_code_admin_upsert_input( admin_user_id: String, invite_code: String, metadata_json: String, - granted_user_tags: Vec, starts_at_micros: Option, expires_at_micros: Option, updated_at_micros: i64, @@ -437,7 +436,6 @@ pub fn build_runtime_profile_invite_code_admin_upsert_input( let invite_code = normalize_invite_code(invite_code).ok_or(RuntimeProfileFieldError::MissingInviteCode)?; let metadata_json = normalize_invite_code_metadata_json(metadata_json)?; - let granted_user_tags = normalize_profile_user_tags(granted_user_tags)?; crate::commands::validate_runtime_profile_invite_code_validity_window( starts_at_micros, expires_at_micros, @@ -447,7 +445,6 @@ pub fn build_runtime_profile_invite_code_admin_upsert_input( admin_user_id, invite_code, metadata_json, - granted_user_tags, starts_at_micros, expires_at_micros, updated_at_micros, @@ -767,13 +764,54 @@ pub fn normalize_invite_code_metadata_json( return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata); } - let parsed = serde_json::from_str::(trimmed) + let mut parsed = serde_json::from_str::(trimmed) .map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)?; if !parsed.is_object() { return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata); } - serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata) + normalize_invite_code_metadata_user_tags(&mut parsed)?; + let normalized = serde_json::to_string(&parsed) + .map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)?; + if normalized.len() > PROFILE_INVITE_CODE_METADATA_MAX_BYTES { + return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata); + } + Ok(normalized) +} + +fn normalize_invite_code_metadata_user_tags( + metadata: &mut Value, +) -> Result<(), RuntimeProfileFieldError> { + let Some(object) = metadata.as_object_mut() else { + return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata); + }; + // 中文注释:邀请码授予标签复用 metadata,保存时统一收敛成 camelCase 字段。 + let raw = object + .remove("userTags") + .or_else(|| object.remove("user_tags")); + object.remove("user_tags"); + + let Some(raw) = raw else { + return Ok(()); + }; + let Value::Array(items) = raw else { + return Err(RuntimeProfileFieldError::InvalidUserTag); + }; + let mut raw_tags = Vec::new(); + for item in items { + let Value::String(value) = item else { + return Err(RuntimeProfileFieldError::InvalidUserTag); + }; + raw_tags.push(value); + } + let tags = normalize_profile_user_tags(raw_tags)?; + if !tags.is_empty() { + object.insert( + "userTags".to_string(), + Value::Array(tags.into_iter().map(Value::String).collect()), + ); + } + Ok(()) } pub fn normalize_profile_user_tags( @@ -801,7 +839,7 @@ pub fn validate_runtime_profile_invite_code_validity_window( starts_at_micros: Option, expires_at_micros: Option, ) -> Result<(), RuntimeProfileFieldError> { - if matches!((starts_at_micros, expires_at_micros), (Some(starts_at), Some(expires_at)) if starts_at > expires_at) + if matches!((starts_at_micros, expires_at_micros), (Some(starts_at), Some(expires_at)) if starts_at >= expires_at) { return Err(RuntimeProfileFieldError::InvalidInviteCodeValidityWindow); } diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 9bbde479..67d280c2 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1190,7 +1190,6 @@ pub struct RuntimeProfileInviteCodeAdminUpsertInput { pub admin_user_id: String, pub invite_code: String, pub metadata_json: String, - pub granted_user_tags: Vec, pub starts_at_micros: Option, pub expires_at_micros: Option, pub updated_at_micros: i64, @@ -1208,7 +1207,6 @@ pub struct RuntimeProfileInviteCodeSnapshot { pub user_id: String, pub invite_code: String, pub metadata_json: String, - pub granted_user_tags: Vec, pub starts_at_micros: Option, pub expires_at_micros: Option, pub created_at_micros: i64, @@ -1581,7 +1579,6 @@ pub struct RuntimeProfileInviteCodeRecord { pub user_id: String, pub invite_code: String, pub metadata_json: String, - pub granted_user_tags: Vec, pub starts_at: Option, pub starts_at_micros: Option, pub expires_at: Option, diff --git a/server-rs/crates/module-runtime/tests/invite_code_validity.rs b/server-rs/crates/module-runtime/tests/invite_code_validity.rs index 2997afcc..a3037503 100644 --- a/server-rs/crates/module-runtime/tests/invite_code_validity.rs +++ b/server-rs/crates/module-runtime/tests/invite_code_validity.rs @@ -1,6 +1,7 @@ use module_runtime::{ RuntimeProfileFieldError, RuntimeProfileInviteCodeSnapshot, RuntimeProfileInviteCodeStatus, - build_runtime_profile_invite_code_record, resolve_runtime_profile_invite_code_status, + build_runtime_profile_invite_code_record, normalize_invite_code_metadata_json, + resolve_runtime_profile_invite_code_status, validate_runtime_profile_invite_code_validity_window, }; @@ -15,11 +16,14 @@ fn invite_code_validity_window_rejects_start_after_expire() { } #[test] -fn invite_code_validity_window_allows_open_ended_or_equal_boundary() { +fn invite_code_validity_window_allows_open_ended_and_rejects_equal_boundary() { assert!(validate_runtime_profile_invite_code_validity_window(None, None).is_ok()); assert!(validate_runtime_profile_invite_code_validity_window(Some(10), None).is_ok()); assert!(validate_runtime_profile_invite_code_validity_window(None, Some(10)).is_ok()); - assert!(validate_runtime_profile_invite_code_validity_window(Some(10), Some(10)).is_ok()); + assert_eq!( + validate_runtime_profile_invite_code_validity_window(Some(10), Some(10)), + Err(RuntimeProfileFieldError::InvalidInviteCodeValidityWindow) + ); } #[test] @@ -48,7 +52,6 @@ fn invite_code_record_formats_window_and_status() { user_id: "user-1".to_string(), invite_code: "SY00000001".to_string(), metadata_json: "{}".to_string(), - granted_user_tags: Vec::new(), starts_at_micros: Some(0), expires_at_micros: Some(1_000_000), created_at_micros: 0, @@ -59,3 +62,33 @@ fn invite_code_record_formats_window_and_status() { assert_eq!(record.expires_at.as_deref(), Some("1970-01-01T00:00:01Z")); assert_eq!(record.status, RuntimeProfileInviteCodeStatus::Expired); } + +#[test] +fn invite_code_metadata_normalizes_user_tags() { + let normalized = normalize_invite_code_metadata_json( + r#"{"source":"admin","user_tags":[" 北科 ","北科",""]}"#.to_string(), + ) + .expect("metadata should normalize"); + + assert_eq!(normalized, r#"{"source":"admin","userTags":["北科"]}"#); +} + +#[test] +fn invite_code_metadata_removes_empty_user_tags() { + let normalized = normalize_invite_code_metadata_json(r#"{"userTags":[]}"#.to_string()) + .expect("empty tags should be valid"); + + assert_eq!(normalized, "{}"); +} + +#[test] +fn invite_code_metadata_rejects_invalid_user_tags_shape() { + assert_eq!( + normalize_invite_code_metadata_json(r#"{"userTags":"北科"}"#.to_string()), + Err(RuntimeProfileFieldError::InvalidUserTag) + ); + assert_eq!( + normalize_invite_code_metadata_json(r#"{"userTags":["北科",1]}"#.to_string()), + Err(RuntimeProfileFieldError::InvalidUserTag) + ); +} diff --git a/server-rs/crates/shared-contracts/Cargo.toml b/server-rs/crates/shared-contracts/Cargo.toml index df973184..176e7614 100644 --- a/server-rs/crates/shared-contracts/Cargo.toml +++ b/server-rs/crates/shared-contracts/Cargo.toml @@ -5,6 +5,5 @@ version.workspace = true license.workspace = true [dependencies] -platform-oss = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index b4444b02..8d0cb19e 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -11,7 +11,6 @@ pub struct AdminLoginRequest { // 登录成功后返回管理员访问令牌与基础会话信息。 - /// 后台创作入口开关列表响应。 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index 5a66a1c0..dc92c67b 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -1,8 +1,5 @@ use std::collections::BTreeMap; -use platform_oss::{ - OssObjectAccess, OssPostObjectFormFields, OssPostObjectResponse, OssSignedGetObjectUrlResponse, -}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -16,7 +13,7 @@ pub struct CreateDirectUploadTicketRequest { #[serde(default)] pub content_type: Option, #[serde(default)] - pub access: Option, + pub access: Option, #[serde(default)] pub metadata: BTreeMap, #[serde(default)] @@ -45,6 +42,13 @@ pub enum ConfirmAssetObjectAccessPolicy { PublicRead, } +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DirectUploadObjectAccess { + Public, + Private, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ConfirmAssetObjectRequest { @@ -513,7 +517,7 @@ pub struct DirectUploadTicketPayload { pub legacy_public_path: String, #[serde(default)] pub content_type: Option, - pub access: OssObjectAccess, + pub access: DirectUploadObjectAccess, pub key_prefix: String, pub expires_at: String, pub max_size_bytes: u64, @@ -614,57 +618,6 @@ pub struct AssetBindingPayload { pub updated_at: String, } -impl From for DirectUploadTicketFormFields { - fn from(value: OssPostObjectFormFields) -> Self { - Self { - key: value.key, - policy: value.policy, - signature_version: value.signature_version, - credential: value.credential, - date: value.date, - signature: value.signature, - success_action_status: value.success_action_status, - content_type: value.content_type, - metadata: value.metadata, - } - } -} - -impl From for DirectUploadTicketPayload { - fn from(value: OssPostObjectResponse) -> Self { - Self { - signature_version: value.signature_version.to_string(), - provider: value.provider.to_string(), - bucket: value.bucket, - endpoint: value.endpoint, - host: value.host, - object_key: value.object_key, - legacy_public_path: value.legacy_public_path, - content_type: value.content_type, - access: value.access, - key_prefix: value.key_prefix, - expires_at: value.expires_at, - max_size_bytes: value.max_size_bytes, - success_action_status: value.success_action_status, - form_fields: value.form_fields.into(), - } - } -} - -impl From for AssetReadUrlPayload { - fn from(value: OssSignedGetObjectUrlResponse) -> Self { - Self { - provider: value.provider.to_string(), - bucket: value.bucket, - endpoint: value.endpoint, - host: value.host, - object_key: value.object_key, - expires_at: value.expires_at, - signed_url: value.signed_url, - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -708,8 +661,8 @@ mod tests { #[test] fn direct_upload_ticket_response_keeps_form_fields_shape() { let payload = serde_json::to_value(CreateDirectUploadTicketResponse { - upload: DirectUploadTicketPayload::from(OssPostObjectResponse { - signature_version: "v4", + upload: DirectUploadTicketPayload { + signature_version: "v4".to_string(), provider: "aliyun-oss", bucket: "genarrative-assets".to_string(), endpoint: "oss-cn-shanghai.aliyuncs.com".to_string(), @@ -717,12 +670,12 @@ mod tests { object_key: "generated-characters/hero/master.png".to_string(), legacy_public_path: "/generated-characters/hero/master.png".to_string(), content_type: Some("image/png".to_string()), - access: OssObjectAccess::Private, + access: DirectUploadObjectAccess::Private, key_prefix: "generated-characters/hero".to_string(), expires_at: "2026-04-21T00:00:00Z".to_string(), max_size_bytes: 1024, success_action_status: 200, - form_fields: OssPostObjectFormFields { + form_fields: DirectUploadTicketFormFields { key: "generated-characters/hero/master.png".to_string(), policy: "policy".to_string(), signature_version: "OSS4-HMAC-SHA256".to_string(), @@ -736,7 +689,7 @@ mod tests { "character_visual".to_string(), )]), }, - }), + }, }) .expect("payload should serialize"); diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index 30613c10..802b6a1a 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -6,8 +6,8 @@ pub mod auth; pub mod big_fish; pub mod big_fish_works; pub mod creation_agent_document_input; -pub mod creative_agent; pub mod creation_entry_config; +pub mod creative_agent; pub mod hyper3d; pub mod llm; pub mod match3d_agent; diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index c9a4f46c..d0a56bd6 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -486,8 +486,6 @@ pub struct AdminUpsertProfileInviteCodeRequest { #[serde(default)] pub metadata: Option, #[serde(default)] - pub granted_user_tags: Vec, - #[serde(default)] pub starts_at: Option, #[serde(default)] pub expires_at: Option, @@ -526,7 +524,6 @@ pub struct ProfileInviteCodeAdminResponse { pub user_id: String, pub invite_code: String, pub metadata: serde_json::Value, - pub granted_user_tags: Vec, pub starts_at: Option, pub expires_at: Option, pub status: String, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index ccbb99f9..36bf0780 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -46,9 +46,7 @@ impl From for AssetHistoryListInput { } } -impl From - for CreationEntryTypeAdminUpsertInput -{ +impl From for CreationEntryTypeAdminUpsertInput { fn from(input: module_runtime::CreationEntryTypeAdminUpsertInput) -> Self { Self { id: input.id, @@ -337,7 +335,6 @@ impl From admin_user_id: input.admin_user_id, invite_code: input.invite_code, metadata_json: input.metadata_json, - granted_user_tags: input.granted_user_tags, starts_at_micros: input.starts_at_micros, expires_at_micros: input.expires_at_micros, updated_at_micros: input.updated_at_micros, @@ -723,7 +720,8 @@ pub(crate) fn map_asset_history_list_result( .collect()) } -pub type CreationEntryConfigRecord = shared_contracts::creation_entry_config::CreationEntryConfigResponse; +pub type CreationEntryConfigRecord = + shared_contracts::creation_entry_config::CreationEntryConfigResponse; pub(crate) fn map_creation_entry_config_procedure_result( result: CreationEntryConfigProcedureResult, @@ -2385,7 +2383,6 @@ pub(crate) fn map_runtime_profile_invite_code_snapshot( user_id: snapshot.user_id, invite_code: snapshot.invite_code, metadata_json: snapshot.metadata_json, - granted_user_tags: snapshot.granted_user_tags, starts_at_micros: snapshot.starts_at_micros, expires_at_micros: snapshot.expires_at_micros, created_at_micros: snapshot.created_at_micros, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_snapshot_type.rs index 90e59621..424caf89 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_snapshot_type.rs @@ -5,8 +5,8 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; use super::creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot; -use super::creation_entry_type_config_snapshot_type::CreationEntryTypeSnapshot; use super::creation_entry_type_modal_snapshot_type::CreationEntryTypeModalSnapshot; +use super::creation_entry_type_snapshot_type::CreationEntryTypeSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_table.rs new file mode 100644 index 00000000..99d0794d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_table.rs @@ -0,0 +1,161 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::creation_entry_config_type::CreationEntryConfig; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `creation_entry_config`. +/// +/// Obtain a handle from the [`CreationEntryConfigTableAccess::creation_entry_config`] method on [`super::RemoteTables`], +/// like `ctx.db.creation_entry_config()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.creation_entry_config().on_insert(...)`. +pub struct CreationEntryConfigTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `creation_entry_config`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait CreationEntryConfigTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`CreationEntryConfigTableHandle`], which mediates access to the table `creation_entry_config`. + fn creation_entry_config(&self) -> CreationEntryConfigTableHandle<'_>; +} + +impl CreationEntryConfigTableAccess for super::RemoteTables { + fn creation_entry_config(&self) -> CreationEntryConfigTableHandle<'_> { + CreationEntryConfigTableHandle { + imp: self + .imp + .get_table::("creation_entry_config"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct CreationEntryConfigInsertCallbackId(__sdk::CallbackId); +pub struct CreationEntryConfigDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for CreationEntryConfigTableHandle<'ctx> { + type Row = CreationEntryConfig; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = CreationEntryConfigInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> CreationEntryConfigInsertCallbackId { + CreationEntryConfigInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: CreationEntryConfigInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = CreationEntryConfigDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> CreationEntryConfigDeleteCallbackId { + CreationEntryConfigDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: CreationEntryConfigDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct CreationEntryConfigUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for CreationEntryConfigTableHandle<'ctx> { + type UpdateCallbackId = CreationEntryConfigUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> CreationEntryConfigUpdateCallbackId { + CreationEntryConfigUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: CreationEntryConfigUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `config_id` unique index on the table `creation_entry_config`, +/// which allows point queries on the field of the same name +/// via the [`CreationEntryConfigConfigIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.creation_entry_config().config_id().find(...)`. +pub struct CreationEntryConfigConfigIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> CreationEntryConfigTableHandle<'ctx> { + /// Get a handle on the `config_id` unique index on the table `creation_entry_config`. + pub fn config_id(&self) -> CreationEntryConfigConfigIdUnique<'ctx> { + CreationEntryConfigConfigIdUnique { + imp: self.imp.get_unique_constraint::("config_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> CreationEntryConfigConfigIdUnique<'ctx> { + /// Find the subscribed row whose `config_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("creation_entry_config"); + _table.add_unique_constraint::("config_id", |row| &row.config_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `CreationEntryConfig`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait creation_entry_configQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `CreationEntryConfig`. + fn creation_entry_config(&self) -> __sdk::__query_builder::Table; +} + +impl creation_entry_configQueryTableAccess for __sdk::QueryTableAccessor { + fn creation_entry_config(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("creation_entry_config") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_type.rs new file mode 100644 index 00000000..e06f35f4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_type.rs @@ -0,0 +1,70 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CreationEntryConfig { + pub config_id: String, + pub start_title: String, + pub start_description: String, + pub start_idle_badge: String, + pub start_busy_badge: String, + pub modal_title: String, + pub modal_description: String, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for CreationEntryConfig { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `CreationEntryConfig`. +/// +/// Provides typed access to columns for query building. +pub struct CreationEntryConfigCols { + pub config_id: __sdk::__query_builder::Col, + pub start_title: __sdk::__query_builder::Col, + pub start_description: __sdk::__query_builder::Col, + pub start_idle_badge: __sdk::__query_builder::Col, + pub start_busy_badge: __sdk::__query_builder::Col, + pub modal_title: __sdk::__query_builder::Col, + pub modal_description: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for CreationEntryConfig { + type Cols = CreationEntryConfigCols; + fn cols(table_name: &'static str) -> Self::Cols { + CreationEntryConfigCols { + config_id: __sdk::__query_builder::Col::new(table_name, "config_id"), + start_title: __sdk::__query_builder::Col::new(table_name, "start_title"), + start_description: __sdk::__query_builder::Col::new(table_name, "start_description"), + start_idle_badge: __sdk::__query_builder::Col::new(table_name, "start_idle_badge"), + start_busy_badge: __sdk::__query_builder::Col::new(table_name, "start_busy_badge"), + modal_title: __sdk::__query_builder::Col::new(table_name, "modal_title"), + modal_description: __sdk::__query_builder::Col::new(table_name, "modal_description"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `CreationEntryConfig`. +/// +/// Provides typed access to indexed columns for query building. +pub struct CreationEntryConfigIxCols { + pub config_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for CreationEntryConfig { + type IxCols = CreationEntryConfigIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + CreationEntryConfigIxCols { + config_id: __sdk::__query_builder::IxCol::new(table_name, "config_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for CreationEntryConfig {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_config_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_config_table.rs new file mode 100644 index 00000000..d4a86172 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_config_table.rs @@ -0,0 +1,162 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::creation_entry_type_config_type::CreationEntryTypeConfig; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `creation_entry_type_config`. +/// +/// Obtain a handle from the [`CreationEntryTypeConfigTableAccess::creation_entry_type_config`] method on [`super::RemoteTables`], +/// like `ctx.db.creation_entry_type_config()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.creation_entry_type_config().on_insert(...)`. +pub struct CreationEntryTypeConfigTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `creation_entry_type_config`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait CreationEntryTypeConfigTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`CreationEntryTypeConfigTableHandle`], which mediates access to the table `creation_entry_type_config`. + fn creation_entry_type_config(&self) -> CreationEntryTypeConfigTableHandle<'_>; +} + +impl CreationEntryTypeConfigTableAccess for super::RemoteTables { + fn creation_entry_type_config(&self) -> CreationEntryTypeConfigTableHandle<'_> { + CreationEntryTypeConfigTableHandle { + imp: self + .imp + .get_table::("creation_entry_type_config"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct CreationEntryTypeConfigInsertCallbackId(__sdk::CallbackId); +pub struct CreationEntryTypeConfigDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for CreationEntryTypeConfigTableHandle<'ctx> { + type Row = CreationEntryTypeConfig; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = CreationEntryTypeConfigInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> CreationEntryTypeConfigInsertCallbackId { + CreationEntryTypeConfigInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: CreationEntryTypeConfigInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = CreationEntryTypeConfigDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> CreationEntryTypeConfigDeleteCallbackId { + CreationEntryTypeConfigDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: CreationEntryTypeConfigDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct CreationEntryTypeConfigUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for CreationEntryTypeConfigTableHandle<'ctx> { + type UpdateCallbackId = CreationEntryTypeConfigUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> CreationEntryTypeConfigUpdateCallbackId { + CreationEntryTypeConfigUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: CreationEntryTypeConfigUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `id` unique index on the table `creation_entry_type_config`, +/// which allows point queries on the field of the same name +/// via the [`CreationEntryTypeConfigIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.creation_entry_type_config().id().find(...)`. +pub struct CreationEntryTypeConfigIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> CreationEntryTypeConfigTableHandle<'ctx> { + /// Get a handle on the `id` unique index on the table `creation_entry_type_config`. + pub fn id(&self) -> CreationEntryTypeConfigIdUnique<'ctx> { + CreationEntryTypeConfigIdUnique { + imp: self.imp.get_unique_constraint::("id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> CreationEntryTypeConfigIdUnique<'ctx> { + /// Find the subscribed row whose `id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("creation_entry_type_config"); + _table.add_unique_constraint::("id", |row| &row.id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `CreationEntryTypeConfig`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait creation_entry_type_configQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `CreationEntryTypeConfig`. + fn creation_entry_type_config(&self) -> __sdk::__query_builder::Table; +} + +impl creation_entry_type_configQueryTableAccess for __sdk::QueryTableAccessor { + fn creation_entry_type_config(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("creation_entry_type_config") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_config_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_config_type.rs new file mode 100644 index 00000000..c6290d03 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_config_type.rs @@ -0,0 +1,75 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CreationEntryTypeConfig { + pub id: String, + pub title: String, + pub subtitle: String, + pub badge: String, + pub image_src: String, + pub visible: bool, + pub open: bool, + pub sort_order: i32, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for CreationEntryTypeConfig { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `CreationEntryTypeConfig`. +/// +/// Provides typed access to columns for query building. +pub struct CreationEntryTypeConfigCols { + pub id: __sdk::__query_builder::Col, + pub title: __sdk::__query_builder::Col, + pub subtitle: __sdk::__query_builder::Col, + pub badge: __sdk::__query_builder::Col, + pub image_src: __sdk::__query_builder::Col, + pub visible: __sdk::__query_builder::Col, + pub open: __sdk::__query_builder::Col, + pub sort_order: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for CreationEntryTypeConfig { + type Cols = CreationEntryTypeConfigCols; + fn cols(table_name: &'static str) -> Self::Cols { + CreationEntryTypeConfigCols { + id: __sdk::__query_builder::Col::new(table_name, "id"), + title: __sdk::__query_builder::Col::new(table_name, "title"), + subtitle: __sdk::__query_builder::Col::new(table_name, "subtitle"), + badge: __sdk::__query_builder::Col::new(table_name, "badge"), + image_src: __sdk::__query_builder::Col::new(table_name, "image_src"), + visible: __sdk::__query_builder::Col::new(table_name, "visible"), + open: __sdk::__query_builder::Col::new(table_name, "open"), + sort_order: __sdk::__query_builder::Col::new(table_name, "sort_order"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `CreationEntryTypeConfig`. +/// +/// Provides typed access to indexed columns for query building. +pub struct CreationEntryTypeConfigIxCols { + pub id: __sdk::__query_builder::IxCol, + pub sort_order: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for CreationEntryTypeConfig { + type IxCols = CreationEntryTypeConfigIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + CreationEntryTypeConfigIxCols { + id: __sdk::__query_builder::IxCol::new(table_name, "id"), + sort_order: __sdk::__query_builder::IxCol::new(table_name, "sort_order"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for CreationEntryTypeConfig {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_snapshot_type.rs similarity index 100% rename from server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_config_snapshot_type.rs rename to server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_snapshot_type.rs diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_creation_entry_config_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_creation_entry_config_procedure.rs index ef41e73c..8a3a38dd 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_creation_entry_config_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_creation_entry_config_procedure.rs @@ -25,6 +25,7 @@ pub trait get_creation_entry_config { fn get_creation_entry_config_then( &self, + __callback: impl FnOnce( &super::ProcedureEventContext, Result, @@ -36,6 +37,7 @@ pub trait get_creation_entry_config { impl get_creation_entry_config for super::RemoteProcedures { fn get_creation_entry_config_then( &self, + __callback: impl FnOnce( &super::ProcedureEventContext, Result, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index b74d08ca..5898b71c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -188,6 +188,16 @@ pub mod create_profile_recharge_order_and_return_procedure; pub mod create_puzzle_agent_session_procedure; pub mod create_square_hole_agent_session_procedure; pub mod create_visual_novel_agent_session_procedure; +pub mod creation_entry_config_procedure_result_type; +pub mod creation_entry_config_snapshot_type; +pub mod creation_entry_config_table; +pub mod creation_entry_config_type; +pub mod creation_entry_start_card_snapshot_type; +pub mod creation_entry_type_admin_upsert_input_type; +pub mod creation_entry_type_config_table; +pub mod creation_entry_type_config_type; +pub mod creation_entry_type_modal_snapshot_type; +pub mod creation_entry_type_snapshot_type; pub mod custom_world_agent_action_execute_input_type; pub mod custom_world_agent_action_execute_result_type; pub mod custom_world_agent_card_detail_get_input_type; @@ -249,12 +259,6 @@ pub mod custom_world_theme_mode_type; pub mod custom_world_work_summary_snapshot_type; pub mod custom_world_works_list_input_type; pub mod custom_world_works_list_result_type; -pub mod creation_entry_config_procedure_result_type; -pub mod creation_entry_config_snapshot_type; -pub mod creation_entry_start_card_snapshot_type; -pub mod creation_entry_type_admin_upsert_input_type; -pub mod creation_entry_type_config_snapshot_type; -pub mod creation_entry_type_modal_snapshot_type; pub mod database_migration_authorize_operator_input_type; pub mod database_migration_export_input_type; pub mod database_migration_import_chunk_input_type; @@ -296,11 +300,11 @@ pub mod finish_match_3_d_time_up_procedure; pub mod finish_square_hole_time_up_procedure; pub mod generate_big_fish_asset_procedure; pub mod get_auth_store_snapshot_procedure; -pub mod get_creation_entry_config_procedure; pub mod get_battle_state_procedure; pub mod get_big_fish_run_procedure; pub mod get_big_fish_session_procedure; pub mod get_chapter_progression_procedure; +pub mod get_creation_entry_config_procedure; pub mod get_custom_world_agent_card_detail_procedure; pub mod get_custom_world_agent_operation_procedure; pub mod get_custom_world_agent_session_procedure; @@ -759,8 +763,8 @@ pub mod update_square_hole_work_procedure; pub mod update_visual_novel_work_procedure; pub mod upsert_auth_store_snapshot_procedure; pub mod upsert_chapter_progression_and_return_procedure; -pub mod upsert_creation_entry_type_config_procedure; pub mod upsert_chapter_progression_reducer; +pub mod upsert_creation_entry_type_config_procedure; pub mod upsert_custom_world_agent_operation_progress_procedure; pub mod upsert_custom_world_profile_and_return_procedure; pub mod upsert_custom_world_profile_reducer; @@ -992,6 +996,16 @@ pub use create_profile_recharge_order_and_return_procedure::create_profile_recha pub use create_puzzle_agent_session_procedure::create_puzzle_agent_session; pub use create_square_hole_agent_session_procedure::create_square_hole_agent_session; pub use create_visual_novel_agent_session_procedure::create_visual_novel_agent_session; +pub use creation_entry_config_procedure_result_type::CreationEntryConfigProcedureResult; +pub use creation_entry_config_snapshot_type::CreationEntryConfigSnapshot; +pub use creation_entry_config_table::*; +pub use creation_entry_config_type::CreationEntryConfig; +pub use creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot; +pub use creation_entry_type_admin_upsert_input_type::CreationEntryTypeAdminUpsertInput; +pub use creation_entry_type_config_table::*; +pub use creation_entry_type_config_type::CreationEntryTypeConfig; +pub use creation_entry_type_modal_snapshot_type::CreationEntryTypeModalSnapshot; +pub use creation_entry_type_snapshot_type::CreationEntryTypeSnapshot; pub use custom_world_agent_action_execute_input_type::CustomWorldAgentActionExecuteInput; pub use custom_world_agent_action_execute_result_type::CustomWorldAgentActionExecuteResult; pub use custom_world_agent_card_detail_get_input_type::CustomWorldAgentCardDetailGetInput; @@ -1053,12 +1067,6 @@ pub use custom_world_theme_mode_type::CustomWorldThemeMode; pub use custom_world_work_summary_snapshot_type::CustomWorldWorkSummarySnapshot; pub use custom_world_works_list_input_type::CustomWorldWorksListInput; pub use custom_world_works_list_result_type::CustomWorldWorksListResult; -pub use creation_entry_config_procedure_result_type::CreationEntryConfigProcedureResult; -pub use creation_entry_config_snapshot_type::CreationEntryConfigSnapshot; -pub use creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot; -pub use creation_entry_type_admin_upsert_input_type::CreationEntryTypeAdminUpsertInput; -pub use creation_entry_type_config_snapshot_type::CreationEntryTypeSnapshot; -pub use creation_entry_type_modal_snapshot_type::CreationEntryTypeModalSnapshot; pub use database_migration_authorize_operator_input_type::DatabaseMigrationAuthorizeOperatorInput; pub use database_migration_export_input_type::DatabaseMigrationExportInput; pub use database_migration_import_chunk_input_type::DatabaseMigrationImportChunkInput; @@ -1100,11 +1108,11 @@ pub use finish_match_3_d_time_up_procedure::finish_match_3_d_time_up; pub use finish_square_hole_time_up_procedure::finish_square_hole_time_up; pub use generate_big_fish_asset_procedure::generate_big_fish_asset; pub use get_auth_store_snapshot_procedure::get_auth_store_snapshot; -pub use get_creation_entry_config_procedure::get_creation_entry_config; pub use get_battle_state_procedure::get_battle_state; pub use get_big_fish_run_procedure::get_big_fish_run; pub use get_big_fish_session_procedure::get_big_fish_session; pub use get_chapter_progression_procedure::get_chapter_progression; +pub use get_creation_entry_config_procedure::get_creation_entry_config; pub use get_custom_world_agent_card_detail_procedure::get_custom_world_agent_card_detail; pub use get_custom_world_agent_operation_procedure::get_custom_world_agent_operation; pub use get_custom_world_agent_session_procedure::get_custom_world_agent_session; @@ -1563,8 +1571,8 @@ pub use update_square_hole_work_procedure::update_square_hole_work; pub use update_visual_novel_work_procedure::update_visual_novel_work; pub use upsert_auth_store_snapshot_procedure::upsert_auth_store_snapshot; pub use upsert_chapter_progression_and_return_procedure::upsert_chapter_progression_and_return; -pub use upsert_creation_entry_type_config_procedure::upsert_creation_entry_type_config; pub use upsert_chapter_progression_reducer::upsert_chapter_progression; +pub use upsert_creation_entry_type_config_procedure::upsert_creation_entry_type_config; pub use upsert_custom_world_agent_operation_progress_procedure::upsert_custom_world_agent_operation_progress; pub use upsert_custom_world_profile_and_return_procedure::upsert_custom_world_profile_and_return; pub use upsert_custom_world_profile_reducer::upsert_custom_world_profile; @@ -1904,6 +1912,8 @@ pub struct DbUpdate { big_fish_event: __sdk::TableUpdate, big_fish_runtime_run: __sdk::TableUpdate, chapter_progression: __sdk::TableUpdate, + creation_entry_config: __sdk::TableUpdate, + creation_entry_type_config: __sdk::TableUpdate, custom_world_agent_message: __sdk::TableUpdate, custom_world_agent_operation: __sdk::TableUpdate, custom_world_agent_session: __sdk::TableUpdate, @@ -2026,6 +2036,12 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "chapter_progression" => db_update .chapter_progression .append(chapter_progression_table::parse_table_update(table_update)?), + "creation_entry_config" => db_update.creation_entry_config.append( + creation_entry_config_table::parse_table_update(table_update)?, + ), + "creation_entry_type_config" => db_update.creation_entry_type_config.append( + creation_entry_type_config_table::parse_table_update(table_update)?, + ), "custom_world_agent_message" => db_update.custom_world_agent_message.append( custom_world_agent_message_table::parse_table_update(table_update)?, ), @@ -2311,6 +2327,18 @@ impl __sdk::DbUpdate for DbUpdate { &self.chapter_progression, ) .with_updates_by_pk(|row| &row.chapter_progression_id); + diff.creation_entry_config = cache + .apply_diff_to_table::( + "creation_entry_config", + &self.creation_entry_config, + ) + .with_updates_by_pk(|row| &row.config_id); + diff.creation_entry_type_config = cache + .apply_diff_to_table::( + "creation_entry_type_config", + &self.creation_entry_type_config, + ) + .with_updates_by_pk(|row| &row.id); diff.custom_world_agent_message = cache .apply_diff_to_table::( "custom_world_agent_message", @@ -2683,6 +2711,12 @@ impl __sdk::DbUpdate for DbUpdate { "chapter_progression" => db_update .chapter_progression .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "creation_entry_config" => db_update + .creation_entry_config + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "creation_entry_type_config" => db_update + .creation_entry_type_config + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "custom_world_agent_message" => db_update .custom_world_agent_message .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -2930,6 +2964,12 @@ impl __sdk::DbUpdate for DbUpdate { "chapter_progression" => db_update .chapter_progression .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "creation_entry_config" => db_update + .creation_entry_config + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "creation_entry_type_config" => db_update + .creation_entry_type_config + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "custom_world_agent_message" => db_update .custom_world_agent_message .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -3143,6 +3183,8 @@ pub struct AppliedDiff<'r> { big_fish_event: __sdk::TableAppliedDiff<'r, BigFishEvent>, big_fish_runtime_run: __sdk::TableAppliedDiff<'r, BigFishRuntimeRun>, chapter_progression: __sdk::TableAppliedDiff<'r, ChapterProgression>, + creation_entry_config: __sdk::TableAppliedDiff<'r, CreationEntryConfig>, + creation_entry_type_config: __sdk::TableAppliedDiff<'r, CreationEntryTypeConfig>, custom_world_agent_message: __sdk::TableAppliedDiff<'r, CustomWorldAgentMessage>, custom_world_agent_operation: __sdk::TableAppliedDiff<'r, CustomWorldAgentOperation>, custom_world_agent_session: __sdk::TableAppliedDiff<'r, CustomWorldAgentSession>, @@ -3299,6 +3341,16 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.chapter_progression, event, ); + callbacks.invoke_table_row_callbacks::( + "creation_entry_config", + &self.creation_entry_config, + event, + ); + callbacks.invoke_table_row_callbacks::( + "creation_entry_type_config", + &self.creation_entry_type_config, + event, + ); callbacks.invoke_table_row_callbacks::( "custom_world_agent_message", &self.custom_world_agent_message, @@ -4265,6 +4317,8 @@ impl __sdk::SpacetimeModule for RemoteModule { big_fish_event_table::register_table(client_cache); big_fish_runtime_run_table::register_table(client_cache); chapter_progression_table::register_table(client_cache); + creation_entry_config_table::register_table(client_cache); + creation_entry_type_config_table::register_table(client_cache); custom_world_agent_message_table::register_table(client_cache); custom_world_agent_operation_table::register_table(client_cache); custom_world_agent_session_table::register_table(client_cache); @@ -4345,6 +4399,8 @@ impl __sdk::SpacetimeModule for RemoteModule { "big_fish_event", "big_fish_runtime_run", "chapter_progression", + "creation_entry_config", + "creation_entry_type_config", "custom_world_agent_message", "custom_world_agent_operation", "custom_world_agent_session", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs index 401b6157..1ace70e7 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs @@ -14,7 +14,6 @@ pub struct ProfileInviteCode { pub updated_at: __sdk::Timestamp, pub starts_at: Option<__sdk::Timestamp>, pub expires_at: Option<__sdk::Timestamp>, - pub granted_user_tags: Vec, } impl __sdk::InModule for ProfileInviteCode { @@ -32,7 +31,6 @@ pub struct ProfileInviteCodeCols { pub updated_at: __sdk::__query_builder::Col, pub starts_at: __sdk::__query_builder::Col>, pub expires_at: __sdk::__query_builder::Col>, - pub granted_user_tags: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for ProfileInviteCode { @@ -46,7 +44,6 @@ impl __sdk::__query_builder::HasCols for ProfileInviteCode { updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), starts_at: __sdk::__query_builder::Col::new(table_name, "starts_at"), expires_at: __sdk::__query_builder::Col::new(table_name, "expires_at"), - granted_user_tags: __sdk::__query_builder::Col::new(table_name, "granted_user_tags"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs index 66216638..77c8079a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs @@ -10,7 +10,6 @@ pub struct RuntimeProfileInviteCodeAdminUpsertInput { pub admin_user_id: String, pub invite_code: String, pub metadata_json: String, - pub granted_user_tags: Vec, pub starts_at_micros: Option, pub expires_at_micros: Option, pub updated_at_micros: i64, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs index 51cf26c6..da1ac766 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs @@ -10,7 +10,6 @@ pub struct RuntimeProfileInviteCodeSnapshot { pub user_id: String, pub invite_code: String, pub metadata_json: String, - pub granted_user_tags: Vec, pub starts_at_micros: Option, pub expires_at_micros: Option, pub created_at_micros: i64, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_creation_entry_type_config_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_creation_entry_type_config_procedure.rs index bca10bf8..98f1f7fb 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/upsert_creation_entry_type_config_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/upsert_creation_entry_type_config_procedure.rs @@ -29,6 +29,7 @@ pub trait upsert_creation_entry_type_config { fn upsert_creation_entry_type_config_then( &self, input: CreationEntryTypeAdminUpsertInput, + __callback: impl FnOnce( &super::ProcedureEventContext, Result, @@ -41,6 +42,7 @@ impl upsert_creation_entry_type_config for super::RemoteProcedures { fn upsert_creation_entry_type_config_then( &self, input: CreationEntryTypeAdminUpsertInput, + __callback: impl FnOnce( &super::ProcedureEventContext, Result, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs index 116ead66..fe8fed51 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs @@ -20,7 +20,7 @@ pub struct UserAccount { pub password_hash: String, pub password_login_enabled: bool, pub token_version: u64, - pub user_tags: Vec, + pub user_tags: Option>, } impl __sdk::InModule for UserAccount { @@ -44,7 +44,7 @@ pub struct UserAccountCols { pub password_hash: __sdk::__query_builder::Col, pub password_login_enabled: __sdk::__query_builder::Col, pub token_version: __sdk::__query_builder::Col, - pub user_tags: __sdk::__query_builder::Col>, + pub user_tags: __sdk::__query_builder::Col>>, } impl __sdk::__query_builder::HasCols for UserAccount { diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 870274c6..cdd9ad7a 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -23,15 +23,14 @@ impl SpacetimeClient { ) -> Result { let procedure_input: CreationEntryTypeAdminUpsertInput = input.into(); self.call_after_connect(move |connection, sender| { - connection.procedures().upsert_creation_entry_type_config_then( - procedure_input, - move |_, result| { + connection + .procedures() + .upsert_creation_entry_type_config_then(procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_creation_entry_config_procedure_result); send_once(&sender, mapped); - }, - ); + }); }) .await } @@ -687,7 +686,6 @@ impl SpacetimeClient { admin_user_id: String, invite_code: String, metadata_json: String, - granted_user_tags: Vec, starts_at_micros: Option, expires_at_micros: Option, updated_at_micros: i64, @@ -696,7 +694,6 @@ impl SpacetimeClient { admin_user_id, invite_code, metadata_json, - granted_user_tags, starts_at_micros, expires_at_micros, updated_at_micros, diff --git a/server-rs/crates/spacetime-module/src/auth/procedures.rs b/server-rs/crates/spacetime-module/src/auth/procedures.rs index cd013890..3c9a37e6 100644 --- a/server-rs/crates/spacetime-module/src/auth/procedures.rs +++ b/server-rs/crates/spacetime-module/src/auth/procedures.rs @@ -33,6 +33,12 @@ pub struct AuthStoreSnapshotProcedureResult { pub error_message: Option, } +fn normalize_user_account_tags( + tags: Option>, +) -> Result, module_runtime::RuntimeProfileFieldError> { + module_runtime::normalize_profile_user_tags(tags.unwrap_or_default()) +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct AuthStoreSnapshotImportRecord { pub imported_user_count: u32, @@ -210,8 +216,10 @@ fn import_auth_store_snapshot_tx( password_hash: stored_user.password_hash, password_login_enabled: stored_user.password_login_enabled, token_version: user.token_version, - user_tags: module_runtime::normalize_profile_user_tags(user.user_tags) - .map_err(|error| error.to_string())?, + user_tags: Some( + module_runtime::normalize_profile_user_tags(user.user_tags) + .map_err(|error| error.to_string())?, + ), }); imported_user_count += 1; @@ -341,7 +349,8 @@ fn export_auth_store_snapshot_from_tables_tx( binding_status: user.binding_status, wechat_bound: user.wechat_bound, token_version: user.token_version, - user_tags: user.user_tags, + user_tags: normalize_user_account_tags(user.user_tags) + .map_err(|error| error.to_string())?, }; users_by_username.insert( user.username, diff --git a/server-rs/crates/spacetime-module/src/auth/tables.rs b/server-rs/crates/spacetime-module/src/auth/tables.rs index 50e95572..9f12e29e 100644 --- a/server-rs/crates/spacetime-module/src/auth/tables.rs +++ b/server-rs/crates/spacetime-module/src/auth/tables.rs @@ -28,7 +28,8 @@ pub struct UserAccount { pub(crate) password_hash: String, pub(crate) password_login_enabled: bool, pub(crate) token_version: u64, - pub(crate) user_tags: Vec, + #[default(None::>)] + pub(crate) user_tags: Option>, } #[spacetimedb::table( diff --git a/server-rs/crates/spacetime-module/src/match3d/mod.rs b/server-rs/crates/spacetime-module/src/match3d/mod.rs index c114529c..57d22f81 100644 --- a/server-rs/crates/spacetime-module/src/match3d/mod.rs +++ b/server-rs/crates/spacetime-module/src/match3d/mod.rs @@ -452,8 +452,12 @@ fn compile_match3d_draft_tx( .unwrap_or_else(|| default_tags(&config.theme_text)); let game_name = clean_optional(&input.game_name).unwrap_or_else(|| format!("{}抓大鹅", config.theme_text)); - let summary_text = clean_optional(&input.summary_text) - .unwrap_or_else(|| format!("{}主题的经典消除玩法。", config.theme_text)); + let summary_text = input + .summary_text + .as_deref() + .map(str::trim) + .unwrap_or_default() + .to_string(); let draft = Match3DDraftSnapshot { profile_id: input.profile_id.clone(), game_name: game_name.clone(), diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 897855a6..28b9df3f 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1140,7 +1140,7 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde // 中文注释:账号标签字段晚于认证表加入,旧迁移包默认无标签。 object .entry("user_tags".to_string()) - .or_insert_with(|| serde_json::Value::Array(Vec::new())); + .or_insert(serde_json::Value::Null); } } if table_name == "profile_invite_code" { @@ -1149,10 +1149,6 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde object .entry("metadata_json".to_string()) .or_insert_with(|| serde_json::Value::String("{}".to_string())); - // 中文注释:邀请码授予标签字段晚于邀请表加入,旧迁移包默认不授予标签。 - object - .entry("granted_user_tags".to_string()) - .or_insert_with(|| serde_json::Value::Array(Vec::new())); } } if table_name == "big_fish_creation_session" { diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 1e635a27..2019be9f 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -3226,7 +3226,11 @@ fn list_puzzle_leaderboard_entries( .user_account() .user_id() .find(&row.user_id) - .map(|account| visible_runtime_profile_user_tags(&account.user_tags)) + .map(|account| { + visible_runtime_profile_user_tags( + account.user_tags.as_deref().unwrap_or_default(), + ) + }) .unwrap_or_default(), rank: index as u32 + 1, nickname: row.nickname, diff --git a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs index a5f2abfd..8f2720b0 100644 --- a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs +++ b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs @@ -188,7 +188,7 @@ fn default_creation_entry_type_configs(now: Timestamp) -> Vec, #[default(None::)] pub(crate) expires_at: Option, - pub(crate) granted_user_tags: Vec, } #[spacetimedb::table( @@ -2062,7 +2061,8 @@ fn redeem_profile_referral_invite_code_record( if inviter_code.user_id == invitee_user_id { return Err("不能填写自己的邀请码".to_string()); } - let granted_user_tags = inviter_code.granted_user_tags.clone(); + let invite_metadata_user_tags = + profile_invite_code_metadata_user_tags(&inviter_code.metadata_json)?; let invitee_balance_after = apply_profile_wallet_delta( ctx, @@ -2109,7 +2109,7 @@ fn redeem_profile_referral_invite_code_record( invitee_reward_granted: true, bound_at, }); - merge_user_account_tags(ctx, &invitee_user_id, granted_user_tags)?; + merge_user_account_tags(ctx, &invitee_user_id, invite_metadata_user_tags)?; Ok(RuntimeReferralRedeemSnapshot { center: build_profile_referral_invite_center_snapshot(ctx, &invitee_user_id), @@ -2283,7 +2283,6 @@ fn admin_upsert_profile_invite_code_record( input.admin_user_id, input.invite_code, input.metadata_json, - input.granted_user_tags, input.starts_at_micros, input.expires_at_micros, input.updated_at_micros, @@ -2320,7 +2319,6 @@ fn admin_upsert_profile_invite_code_record( expires_at: validated_input .expires_at_micros .map(Timestamp::from_micros_since_unix_epoch), - granted_user_tags: validated_input.granted_user_tags, }); return Ok(build_profile_invite_code_snapshot_from_row(&inserted)); } @@ -2337,7 +2335,6 @@ fn admin_upsert_profile_invite_code_record( expires_at: validated_input .expires_at_micros .map(Timestamp::from_micros_since_unix_epoch), - granted_user_tags: validated_input.granted_user_tags, }); Ok(build_profile_invite_code_snapshot_from_row(&inserted)) } @@ -2464,7 +2461,6 @@ fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInv updated_at: ctx.timestamp, starts_at: None, expires_at: None, - granted_user_tags: Vec::new(), }) } @@ -2483,14 +2479,33 @@ fn merge_user_account_tags( return Err("用户不存在".to_string()); }; - account.user_tags.extend(granted_tags); + let mut next_tags = account.user_tags.take().unwrap_or_default(); + next_tags.extend(granted_tags); account.user_tags = - normalize_profile_user_tags(account.user_tags).map_err(|error| error.to_string())?; + Some(normalize_profile_user_tags(next_tags).map_err(|error| error.to_string())?); ctx.db.user_account().user_id().delete(&account.user_id); ctx.db.user_account().insert(account); Ok(()) } +fn profile_invite_code_metadata_user_tags(metadata_json: &str) -> Result, String> { + let metadata = serde_json::from_str::(metadata_json) + .map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata.to_string())?; + let tags = metadata + .get("userTags") + .or_else(|| metadata.get("user_tags")) + .and_then(JsonValue::as_array) + .map(|items| { + items + .iter() + .filter_map(JsonValue::as_str) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default(); + normalize_profile_user_tags(tags).map_err(|error| error.to_string()) +} + fn validate_profile_invite_code_redeem_time( invite_code: &ProfileInviteCode, now_micros: i64, @@ -3515,7 +3530,6 @@ fn build_profile_invite_code_snapshot_from_row( user_id: row.user_id.clone(), invite_code: row.invite_code.clone(), metadata_json: row.metadata_json.clone(), - granted_user_tags: row.granted_user_tags.clone(), starts_at_micros: row .starts_at .map(|value| value.to_micros_since_unix_epoch()), diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 3b159df9..2c91ebbd 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'; import { afterEach, expect, test, vi } from 'vitest'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; +import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes'; import type { CreationEntryConfig } from '../../services/creationEntryConfigService'; import { CustomWorldCreationHub } from './CustomWorldCreationHub'; @@ -51,7 +52,7 @@ const testEntryConfig = { subtitle: '形状投放挑战', badge: '可创建', imageSrc: '/creation-type-references/square-hole.webp', - visible: true, + visible: false, open: true, sortOrder: 50, updatedAtMicros: 1, @@ -164,6 +165,30 @@ const baseDraftItem: CustomWorldWorkSummary = { canEnterWorld: false, }; +const hiddenSquareHoleItem: SquareHoleWorkSummary = { + workId: 'square-hole:work-hidden', + profileId: 'square-hole-profile-hidden', + ownerUserId: 'user-1', + gameName: '隐藏方洞挑战', + themeText: '方洞', + twistRule: '隐藏入口', + summary: '入口隐藏后,这条作品不应出现在创作页作品架。', + tags: ['方洞'], + coverImageSrc: null, + backgroundPrompt: '', + backgroundImageSrc: null, + shapeOptions: [], + holeOptions: [], + shapeCount: 0, + difficulty: 1, + publicationStatus: 'draft', + playCount: 0, + updatedAt: new Date('2026-05-10T10:00:00.000Z').toISOString(), + publishedAt: null, + publishReady: false, + sourceSessionId: 'square-hole-session-hidden', +}; + test('creation hub reflects updated draft title summary and counts after rerender', async () => { const user = userEvent.setup(); const onCreateType = vi.fn(); @@ -185,19 +210,20 @@ test('creation hub reflects updated draft title summary and counts after rerende expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy(); expect(screen.queryByText('角色 3')).toBeNull(); expect(screen.queryByText('地点 4')).toBeNull(); - const puzzleButton = screen.getByRole('button', { name: /拼图.*创意礼物/u }); - const match3dButton = screen.getByRole('button', { - name: /抓大鹅.*经典消除玩法/u, + const puzzleButton = screen.getByRole('button', { + name: /拼图.*拼图关卡创作/u, + }); + const match3dButton = screen.getByRole('button', { + name: /抓大鹅.*3D 消除关卡/u, }); - const squareHoleButton = screen.getByRole('button', { name: /方洞挑战/u }); - expect((squareHoleButton as HTMLButtonElement).disabled).toBe(false); expect(puzzleButton).toBeTruthy(); expect(match3dButton).toBeTruthy(); expect((puzzleButton as HTMLButtonElement).disabled).toBe(false); expect((match3dButton as HTMLButtonElement).disabled).toBe(false); - expect(screen.getByText('反直觉形状分拣')).toBeTruthy(); + expect(screen.queryByRole('button', { name: /方洞挑战/u })).toBeNull(); + expect(screen.queryByText('反直觉形状分拣')).toBeNull(); expect(screen.queryByRole('button', { name: /智能创作/u })).toBeNull(); - expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull(); + expect(screen.queryByRole('button', { name: /文字冒险/u })).toBeNull(); expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull(); await user.click(match3dButton); @@ -234,6 +260,29 @@ test('creation hub reflects updated draft title summary and counts after rerende expect(screen.queryByText('地点 6')).toBeNull(); }); +test('creation hub hides square hole works when the creation type is hidden', () => { + const onOpenSquareHoleDetail = vi.fn(); + + render( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + onOpenSquareHoleDetail={onOpenSquareHoleDetail} + />, + ); + + expect(screen.queryByText('隐藏方洞挑战')).toBeNull(); + expect(screen.queryByText('入口隐藏后,这条作品不应出现在创作页作品架。')).toBeNull(); +}); + test('creation hub mixes puzzle works into the same grid and uses puzzle tag to distinguish', () => { render( { expect(html).toContain('玩家是失职返乡的守灯人'); expect(html).toContain('守灯会与沉船商盟争夺航道解释权'); expect(html).toContain('拼图'); - expect(html).toContain('创意礼物,生活分享'); + expect(html).toContain('拼图关卡创作'); expect(html).toContain('抓大鹅'); - expect(html).toContain('经典消除玩法'); - expect(html).not.toContain('角色扮演'); + expect(html).toContain('3D 消除关卡'); + expect(html).not.toContain('文字冒险'); expect(html).not.toContain('大鱼吃小鱼'); }); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 87a338a4..1367df9a 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -9,6 +9,7 @@ import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contrac import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; import type { CustomWorldProfile } from '../../types'; import type { CreationEntryConfig } from '../../services/creationEntryConfigService'; +import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes'; import type { PlatformCreationTypeCard, PlatformCreationTypeId, @@ -169,6 +170,10 @@ export function CustomWorldCreationHub({ }: CustomWorldCreationHubProps) { const [activeFilter, setActiveFilter] = useState('all'); + const isSquareHoleCreationVisible = isPlatformCreationTypeVisible( + creationTypes, + 'square-hole', + ); const shelfItems = useMemo( () => buildCreationWorkShelfItems({ @@ -176,18 +181,20 @@ export function CustomWorldCreationHub({ rpgLibraryEntries, bigFishItems, match3dItems, - squareHoleItems, + squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [], puzzleItems, visualNovelItems, canDeleteRpg: Boolean(onDeletePublished), canDeleteBigFish: Boolean(onDeleteBigFish), canDeleteMatch3D: Boolean(onDeleteMatch3D), - canDeleteSquareHole: Boolean(onDeleteSquareHole), + canDeleteSquareHole: + isSquareHoleCreationVisible && Boolean(onDeleteSquareHole), canDeletePuzzle: Boolean(onDeletePuzzle), canDeleteVisualNovel: Boolean(onDeleteVisualNovel), }), [ bigFishItems, + isSquareHoleCreationVisible, items, match3dItems, onDeleteBigFish, diff --git a/src/components/match3d-result/Match3DModelPreview.tsx b/src/components/match3d-result/Match3DModelPreview.tsx new file mode 100644 index 00000000..82178347 --- /dev/null +++ b/src/components/match3d-result/Match3DModelPreview.tsx @@ -0,0 +1,315 @@ +import { Box, Loader2 } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +import { readAssetBytes } from '../../services/assetReadUrlService'; + +type ThreeModule = typeof import('three'); +type GltfPayload = import('three/examples/jsm/loaders/GLTFLoader.js').GLTF; + +type PreviewStatus = 'empty' | 'loading' | 'ready' | 'fallback'; + +type Match3DModelPreviewProps = { + modelSrc?: string | null; + className?: string; +}; + +function hasWebGLSupport() { + try { + const canvas = document.createElement('canvas'); + return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl')); + } catch { + return false; + } +} + +function disposeThreeObject(object: import('three').Object3D) { + object.traverse((child) => { + const mesh = child as import('three').Mesh; + mesh.geometry?.dispose(); + const material = mesh.material; + if (Array.isArray(material)) { + material.forEach((item) => item.dispose()); + } else { + material?.dispose(); + } + }); +} + +function applyCanvasLayout(canvas: HTMLCanvasElement) { + canvas.style.display = 'block'; + canvas.style.height = '100%'; + canvas.style.inset = '0'; + canvas.style.position = 'absolute'; + canvas.style.width = '100%'; +} + +function centerAndScaleModel(three: ThreeModule, model: import('three').Object3D) { + const bounds = new three.Box3().setFromObject(model); + const size = bounds.getSize(new three.Vector3()); + const maxDimension = Math.max(size.x, size.y, size.z, 0.001); + const scale = 1.45 / maxDimension; + model.scale.setScalar(scale); + + const centeredBounds = new three.Box3().setFromObject(model); + const center = centeredBounds.getCenter(new three.Vector3()); + model.position.sub(center); +} + +export function Match3DModelPreview({ + modelSrc, + className = '', +}: Match3DModelPreviewProps) { + const containerRef = useRef(null); + const canvasHostRef = useRef(null); + const runtimeRef = useRef<{ + animationId: number | null; + cleanup: (() => void) | null; + renderer: import('three').WebGLRenderer; + } | null>(null); + const [status, setStatus] = useState( + modelSrc ? 'loading' : 'empty', + ); + + useEffect(() => { + const container = containerRef.current; + const canvasHost = canvasHostRef.current; + if (!container || !canvasHost) { + return undefined; + } + + const source = modelSrc?.trim() ?? ''; + if (!source) { + setStatus('empty'); + runtimeRef.current?.cleanup?.(); + runtimeRef.current = null; + canvasHost.replaceChildren(); + return undefined; + } + + let cancelled = false; + let objectUrl: string | null = null; + + const teardown = () => { + const runtime = runtimeRef.current; + if (runtime?.animationId != null) { + window.cancelAnimationFrame(runtime.animationId); + } + runtime?.cleanup?.(); + runtime?.renderer.dispose(); + runtime?.renderer.domElement.remove(); + runtimeRef.current = null; + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + objectUrl = null; + } + canvasHost.replaceChildren(); + }; + + const setup = async () => { + if (!hasWebGLSupport()) { + setStatus('fallback'); + return; + } + + setStatus('loading'); + try { + const [three, loaderModule, response] = await Promise.all([ + import('three'), + import('three/examples/jsm/loaders/GLTFLoader.js'), + readAssetBytes(source, { expireSeconds: 600 }), + ]); + if (cancelled || !containerRef.current) { + return; + } + + const bytes = await response.arrayBuffer(); + if (bytes.byteLength === 0) { + throw new Error('empty model'); + } + + const blob = new Blob([bytes], { + type: 'model/gltf-binary', + }); + objectUrl = URL.createObjectURL(blob); + + const renderer = new three.WebGLRenderer({ + alpha: true, + antialias: true, + }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8)); + renderer.outputColorSpace = three.SRGBColorSpace; + applyCanvasLayout(renderer.domElement); + canvasHost.appendChild(renderer.domElement); + + const scene = new three.Scene(); + scene.background = null; + + const camera = new three.PerspectiveCamera(35, 1, 0.1, 100); + camera.position.set(0.06, 0.92, 2.8); + camera.lookAt(0, 0.1, 0); + + scene.add(new three.AmbientLight(0xffffff, 1.55)); + + const keyLight = new three.DirectionalLight(0xffffff, 2.8); + keyLight.position.set(-3.6, 4.8, 3.5); + scene.add(keyLight); + + const fillLight = new three.DirectionalLight(0xfef3c7, 0.62); + fillLight.position.set(2.5, 1.8, -3.2); + scene.add(fillLight); + + const rimLight = new three.DirectionalLight(0xffffff, 0.8); + rimLight.position.set(1.8, 3.6, -4.4); + scene.add(rimLight); + + const modelRoot = new three.Group(); + modelRoot.rotation.set(0.2, 0.45, 0.02); + scene.add(modelRoot); + + const loader = new loaderModule.GLTFLoader(); + const gltf = await new Promise( + (resolve, reject) => { + loader.load( + objectUrl as string, + (loaded: GltfPayload) => resolve(loaded), + undefined, + (error) => reject(error), + ); + }, + ); + if (cancelled) { + const cancelledModel = gltf.scene ?? gltf.scenes[0]; + if (cancelledModel) { + disposeThreeObject(cancelledModel); + } + return; + } + + const model = gltf.scene ?? gltf.scenes[0]; + if (!model) { + throw new Error('missing model scene'); + } + + modelRoot.add(model); + centerAndScaleModel(three, model); + + const resize = () => { + const rect = container.getBoundingClientRect(); + const width = Math.max(1, rect.width); + const height = Math.max(1, rect.height); + renderer.setSize(width, height, false); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + renderer.render(scene, camera); + }; + + const resizeObserver = window.ResizeObserver + ? new window.ResizeObserver(resize) + : null; + resizeObserver?.observe(container); + resize(); + + const pointerState = { + dragging: false, + lastX: 0, + lastY: 0, + }; + + const handlePointerDown = (event: PointerEvent) => { + if (event.button !== 0) { + return; + } + pointerState.dragging = true; + pointerState.lastX = event.clientX; + pointerState.lastY = event.clientY; + if (typeof container.setPointerCapture === 'function') { + container.setPointerCapture(event.pointerId); + } + }; + const handlePointerMove = (event: PointerEvent) => { + if (!pointerState.dragging) { + return; + } + const deltaX = event.clientX - pointerState.lastX; + const deltaY = event.clientY - pointerState.lastY; + pointerState.lastX = event.clientX; + pointerState.lastY = event.clientY; + modelRoot.rotation.y += deltaX * 0.01; + modelRoot.rotation.x = Math.max( + -1.15, + Math.min(1.15, modelRoot.rotation.x + deltaY * 0.01), + ); + renderer.render(scene, camera); + }; + const handlePointerEnd = (event: PointerEvent) => { + pointerState.dragging = false; + if ( + typeof container.hasPointerCapture === 'function' && + container.hasPointerCapture(event.pointerId) + ) { + container.releasePointerCapture(event.pointerId); + } + }; + + container.addEventListener('pointerdown', handlePointerDown); + container.addEventListener('pointermove', handlePointerMove); + container.addEventListener('pointerup', handlePointerEnd); + container.addEventListener('pointercancel', handlePointerEnd); + + const animate = () => { + renderer.render(scene, camera); + if (runtimeRef.current) { + runtimeRef.current.animationId = window.requestAnimationFrame(animate); + } + }; + + runtimeRef.current = { + animationId: window.requestAnimationFrame(animate), + cleanup: () => { + resizeObserver?.disconnect(); + container.removeEventListener('pointerdown', handlePointerDown); + container.removeEventListener('pointermove', handlePointerMove); + container.removeEventListener('pointerup', handlePointerEnd); + container.removeEventListener('pointercancel', handlePointerEnd); + disposeThreeObject(modelRoot); + }, + renderer, + }; + + setStatus('ready'); + } catch { + if (!cancelled) { + setStatus('fallback'); + } + } + }; + + void setup(); + + return () => { + cancelled = true; + teardown(); + }; + }, [modelSrc]); + + return ( +
+