diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index d6e0a5a1..924b7785 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -113,6 +113,30 @@ - 验证方式:新增玩法 PRD 必须显式声明单图资产槽位和系列素材槽位;新增工作台测试确认没有默认聊天式 Agent 输入;skill 通过 `quick_validate.py`。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`.codex/skills/genarrative-play-type-integration/SKILL.md`、`.hermes/skills/genarrative-play-type-integration/SKILL.md`。 +## 2026-05-20 敲木鱼玩法按完整平台纵切接入 + +- 背景:敲木鱼玩法需要对齐拼图 / 跳一跳的创作闭环,不能做成孤立 demo 或前端本地计数工具。 +- 决策:新增 `wooden-fish` 玩法,采用表单 / 图片输入工作台、单图敲击物资产槽位、敲击音效资产槽位和最多 8 条飘字配置;公开作品号前缀为 `WF-*`;运行态只在单次 run 内累计总敲击次数和词条计数。后端新增独立 `module-wooden-fish`、shared contracts、SpacetimeDB `wooden_fish_*` 表 / public views、`spacetime-client` facade 和 `/api/creation/wooden-fish/*`、`/api/runtime/wooden-fish/*` 路由,前端接入平台入口、生成页、结果页、运行态、公开详情和推荐试玩。 +- 影响范围:`CONTEXT.md`、`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`packages/shared/src/contracts/woodenFish.ts`、`server-rs/crates/shared-contracts/src/wooden_fish.rs`、`server-rs/crates/module-wooden-fish/`、`server-rs/crates/spacetime-module/src/wooden_fish*`、`server-rs/crates/spacetime-client/src/wooden_fish.rs`、`src/components/wooden-fish-*`。 +- 验证方式:执行敲木鱼契约 / module / facade / runtime model / platform entry 定向测试、`npm run typecheck`、`npm run check:encoding`、`npm run check:spacetime-schema`、`cargo check -p api-server --manifest-path server-rs\Cargo.toml`,本地 smoke 使用 mock 短信配置后检查 `/healthz`。 +- 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 2026-05-21 敲木鱼敲击音效复用通用 Vidu 音效链路 + +- 背景:敲木鱼创作需要通过“敲击音效”描述生成真实短音频,不能继续由 `spacetime-client` 合成 `/generated-wooden-fish-assets/...` 假路径;同时拼图和抓大鹅音频生成入口仍需保持关闭。 +- 决策:通用 `/api/creation/audio/sound-effect` 提交 Vidu 音效任务;`/api/creation/audio/sound-effect/{task_id}/asset` 只对木鱼 `hit_sound` 目标开放,完成查询、下载、OSS 私有对象、`asset_object` 和 entity binding 写入。木鱼 `compile-draft` / `generate-hit-sound` 在 `api-server` 内复用同一 helper 生成并注入 `hitSoundAsset`,`spacetime-client` 缺少真实 `hitSoundAsset` 时拒绝编译。拼图和抓大鹅相关目标继续返回 `410 Gone`。 +- 影响范围:`server-rs/crates/api-server/src/vector_engine_audio_generation.rs`、`server-rs/crates/api-server/src/wooden_fish.rs`、`server-rs/crates/spacetime-client/src/wooden_fish.rs`、`shared-contracts` / `packages/shared` 的 `creationAudio` 契约、敲木鱼 PRD 与平台链路文档。 +- 验证方式:执行 `cargo test -p shared-contracts creation_audio --manifest-path server-rs\Cargo.toml`、`cargo test -p spacetime-client wooden_fish --manifest-path server-rs\Cargo.toml`、`cargo test -p api-server wooden_fish --manifest-path server-rs\Cargo.toml`、`cargo test -p api-server disabled_creation_audio_targets_return_gone_except_wooden_fish_sound_effects --manifest-path server-rs\Cargo.toml`、`npm run typecheck`、`npm run check:encoding`,本地 smoke 检查 `/healthz`;真实生成需同时配置 VectorEngine 与 OSS AccessKey。 +- 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 2026-05-21 敲木鱼默认敲击物使用内置透明 PNG + +- 背景:默认敲木鱼图案若继续用“木鱼”关键词临时生成,image2 容易语义化重画并改变用户认可的原始造型。 +- 决策:默认模板使用内置资源 `/wooden-fish/default-hit-object.png` 写回 `bundled-default` 敲击物资产;仅当用户输入自定义关键词、上传参考图或主动重生成敲击物时,才走 image2 -> OSS -> asset object -> entity binding 链路。创作入口卡片、结果页、运行态和公开列表兜底统一使用该 PNG。 +- 影响范围:敲木鱼工作台默认提示词、api-server 木鱼默认资产编排、创作入口种子与迁移、平台公开卡片兜底、PRD 与平台链路文档。 +- 验证方式:默认 `compile-draft` 返回的 `hitObjectAsset.generationProvider` 应为 `bundled-default` 且 `imageSrc=/wooden-fish/default-hit-object.png`;自定义关键词或参考图仍走 image2;前端静态资源可通过 Vite 直接访问。 +- 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-05-19 系列素材 n*n 图集抽为 api-server 通用模块 - 背景:抓大鹅物品 sheet 已包含 prompt 组装、固定网格切图、绿幕 / 近白底透明化、切片 PNG 持久化和 prompt 追踪;继续留在 Match3D 私有模块会让跳一跳、后续地块 / 道具类玩法重复复制同一套算法和 OSS 元数据口径。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 01c35907..163fa7cf 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -179,6 +179,14 @@ - 验证:执行 `node scripts/generate-edutainment-tv-map-concepts.mjs --dry-run`,输出应只显示 `imageReferenceCount: 1`,不出现完整 base64。 - 关联:`scripts/generate-edutainment-tv-map-concepts.mjs`、`docs/design/【前端体验】寓教于乐电视端乐园地图入口概念图-2026-05-18.md`。 +## 生成图资产不能只拼 generated legacy path + +- 现象:结果页或运行态拿到 `/generated-*-assets/.../image.png` 后图片不显示;前端 `ResolvedAssetImage` 会先调用 `/api/assets/read-url?legacyPublicPath=...`,但换签后的 OSS URL 仍指向不存在对象。 +- 原因:后端只写了看起来像生成图的 legacy path,没有真正调用 image2、上传 OSS、登记 `asset_object` 并绑定实体。`/api/assets/read-url` 只负责签名读取,不会凭空生成或补写对象。 +- 处理:玩法生成链路必须在 `api-server` 完成外部副作用:调用 VectorEngine `gpt-image-2-all`,用 `GeneratedImageAssetAdapter` 准备 `PutObject`,上传 OSS 私有对象,调用 `confirm_asset_object` 和 `bind_asset_object_to_entity`,再把返回的 `legacyPublicPath` 写入玩法 profile。 +- 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;契约测试应断言前端 JSON 自带的 `hitObjectAsset` 会被忽略,spacetime-client 定向测试应断言缺少服务端注入的真实 `hitObjectAsset` 时不能编译;浏览器 Network 中 generated 图片应先换签,签名 URL 指向已存在对象。 +- 关联:`server-rs/crates/api-server/src/wooden_fish.rs`、`server-rs/crates/spacetime-client/src/wooden_fish.rs`、`src/components/ResolvedAssetImage.tsx`、`src/services/assetReadUrlService.ts`。 + ## 忘记密码后仍提示手机号或密码错误先查认证快照同步 - 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。 @@ -558,7 +566,7 @@ - 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。 - 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;真实阿里云链路已经改为普通短信 `SendSms`,验证码由当前 `api-server` 进程本地生成、哈希存储和校验,旧 `SendSmsVerifyCode` / `CheckSmsVerifyCode` 托管验证码参数不再参与真实校验。若接口直接返回“手机号登录暂未启用”,说明当前运行中的 `api-server` 进程内 `sms_auth_enabled=false`:常见原因是修改 `.env.local` 后没有重启后端,或外层 shell 已经设置了非空 `SMS_AUTH_ENABLED` 导致 dotenv 不覆盖。历史上 cmd 里 `set SMS_AUTH_ENABLED="true"` 会把引号也传进进程,Rust bool 解析失败后保持默认 false。 -- 处理:真实短信联调时把 `.env.local` 的 `SMS_AUTH_ENABLED=true`、`SMS_AUTH_PROVIDER=aliyun` 显式打开,并确认 `ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com`、`ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技`、`ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486`、`ALIYUN_SMS_TEMPLATE_PARAM_KEY=code` 后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE`。Shell 临时覆盖时 PowerShell 用 `$env:SMS_AUTH_ENABLED="true"`,cmd 用 `set SMS_AUTH_ENABLED=true`,不要把引号作为值的一部分。`api-server` 重启会清掉未校验的本地验证码。 +- 处理:真实短信联调时把 `.env.local` 的 `SMS_AUTH_ENABLED=true`、`SMS_AUTH_PROVIDER=aliyun` 显式打开,并确认 `ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com`、`ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技`、`ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486`、`ALIYUN_SMS_TEMPLATE_PARAM_KEY=code` 后重启 `api-server`;如果只想验证 UI、账号链路或 `/healthz`,则保留 / 切换为 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE`。注意 `npm run dev:api-server` 会先合并 `.env.local`,且 `SMS_AUTH_ENABLED`、`SMS_AUTH_PROVIDER`、`SMS_AUTH_MOCK_VERIFY_CODE` 属于允许本地文件覆盖的键;如果 `.env.local` 写着 `SMS_AUTH_PROVIDER=aliyun` 但没有 `ALIYUN_SMS_ACCESS_KEY_*`,单纯在 PowerShell 里临时设置 mock 仍可能被覆盖,health smoke 会在启动阶段报“阿里云短信 AccessKey 未配置”。不改 `.env.local` 的临时 smoke 可以直接运行 `cargo run -p api-server --manifest-path server-rs\Cargo.toml` 并在同一 shell 中显式设置 `SMS_AUTH_PROVIDER=mock`、`GENARRATIVE_API_HOST`、`GENARRATIVE_API_PORT`、`GENARRATIVE_SPACETIME_SERVER_URL`、`GENARRATIVE_SPACETIME_DATABASE`。Shell 临时覆盖时 PowerShell 用 `$env:SMS_AUTH_ENABLED="true"`,cmd 用 `set SMS_AUTH_ENABLED=true`,不要把引号作为值的一部分。`api-server` 重启会清掉未校验的本地验证码。 - 验证:分别请求浏览器域名和 Rust API 直连的 `/api/auth/login-options`,都应返回 `["phone","password"]`;`api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效。需要直接确认平台层真实调用阿里云时,配置 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET` 和 `ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 后手动执行 `cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms_real_provider_sends_verify_code -- --ignored --nocapture`。 - 关联:`server-rs/crates/api-server/src/config.rs`、`scripts/dev-utils.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`、`docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md`。 diff --git a/.hermes/shared-memory/project-overview.md b/.hermes/shared-memory/project-overview.md index 040d5771..45ffc1e4 100644 --- a/.hermes/shared-memory/project-overview.md +++ b/.hermes/shared-memory/project-overview.md @@ -10,6 +10,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台,把 A - RPG / 自定义世界创作与运行时。 - 拼图玩法创作、草稿、发布、运行态和排行榜。 +- 敲木鱼玩法创作、草稿、发布、运行态、公开详情和分享码。 - 抓大鹅 Match3D 创作、2D 多视角素材生成、发布和运行态。 - 大鱼吃小鱼、方洞挑战、视觉小说、汪汪声浪和儿童向寓教于乐玩法。 - 账号、短信 / 密码 / 微信登录、个人资料、任务、钱包、邀请码、充值、反馈、法律信息和后台管理。 diff --git a/CONTEXT.md b/CONTEXT.md index 2937f24b..c431cb69 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -18,6 +18,28 @@ _Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成 ## Language +### Wooden Fish + +**敲木鱼**: +轻量点击型互动玩法,玩家在单次运行中点击非功能区敲击中央物品,触发敲击音效、敲击动画、随机飘字和本次运行内的词条计数。 +_Avoid_: 长期功德账本、排行榜玩法、全局账户累计 + +**敲击物图案**: +敲木鱼作品中被玩家点击敲击的单张物品图案;默认模板使用内置透明 PNG `/wooden-fish/default-hit-object.png`,用户自定义关键词或上传图时再使用 image2 生成最终资产,上传图只作为 image2 参考。 +_Avoid_: 直接把上传图作为运行态素材、系列素材图集 + +**敲击音效**: +敲木鱼作品中每次有效敲击播放的短音频资产,可由描述生成、文件上传或麦克风录制产生,最终统一写回作品的敲击音效资产槽位。 +_Avoid_: 背景音乐、长音频轨道、运行态实时录音 + +**飘字**: +每次有效敲击后从作品配置中等概率抽取词条,并在敲击物上方以“词条+1”短暂漂浮显示的文本;配置里只保存幸运、健康、财富、姻缘、幸福、事业、成功、功德等词条名本身。 +_Avoid_: 带权重奖励、账户属性、可结算货币 + +**单次 run 计数**: +敲木鱼运行态只在当前 run 内累计总敲击次数和已出现飘字词条计数,run 结束后作为摘要保存,不形成账号级长期账本。 +_Avoid_: 用户永久功德值、跨作品累计值、排行榜积分 + ### Bark Battle **汪汪声浪大作战**: diff --git a/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md b/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md new file mode 100644 index 00000000..fa7c4535 --- /dev/null +++ b/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md @@ -0,0 +1,348 @@ +# 敲木鱼玩法模板 PRD 2026-05-20 + +## 1. 目标 + +新增一个可创作、可试玩、可发布的轻量休闲玩法模板: + +```text +敲木鱼 +``` + +模板按平台新增玩法 SOP 接入完整闭环: + +```text +创作入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态 -> 公开详情/分享 +``` + +首版默认屏幕中央展示内置卡通透明敲击物图案 `/wooden-fish/default-hit-object.png`。玩家点击运行态非功能区时触发一次敲击:播放敲击音效、敲击物图案执行被敲击动画,并在敲击物上方随机飘出一条祝福词。顶部计数器只在某条祝福词首次出现时创建,之后该词每次出现都累加。计数仅属于当前单次 run,不进入账号长期账本。 + +## 2. 模板定位 + +模板 ID: + +```text +wooden-fish +``` + +用户展示名: + +```text +敲木鱼 +``` + +公开作品号前缀: + +```text +WF-* +``` + +体验关键词: + +1. 单屏点击; +2. 轻量解压; +3. 飘字反馈; +4. 单局累计; +5. 可自定义敲击物、敲击音效和祝福词。 + +## 3. 与拼图创作流程的复用边界 + +可以复用: + +1. 创作入口配置、入口开关和作品架; +2. 表单/图片输入工作台; +3. 生成过程页和生成中恢复; +4. 结果页的返回编辑、局部重生成、试玩、发布; +5. 公开列表、公开详情、分享码和推荐流分发; +6. 平台资产对象、OSS 私有读取换签和音频资产持久化能力。 + +不复用: + +1. 拼图关卡、棋盘、拼块、排行榜和关卡推进语义; +2. 跳一跳地块图集和蓄力判定语义; +3. 抓大鹅物品消除、五视角图集和容器语义; +4. 任何长期功德账本、账号维度排行榜或全局累计。 + +## 4. 创作工具平台接入声明 + +- 工作台模式:表单/图片输入创作工作台 +- 创作链路:入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态 +- 单图资产槽位: + - `slotId=hit-object` + - `slotType=hit-object-image` + - `slotName=敲击物图案` + - 提示词来源:`hitObjectPrompt` 与可选 `hitObjectReferenceImageSrc` + - 写回字段:`hitObjectAsset` + - 是否允许历史图:允许 + - 是否允许 AI 重绘:允许;上传图只作为 image2 参考,最终运行态只消费 image2 生成图 +- 系列素材槽位:无;首版只有单图敲击物,不生成图集 +- 音频资产槽位: + - `slotId=hit-sound` + - `slotType=hit-sound-audio` + - `slotName=敲击音效` + - 来源:`hitSoundPrompt` 生成,或上传/麦克风录制音频 + - 写回字段:`hitSoundAsset` + - 描述生成能力:复用通用创作音频接口 `/api/creation/audio/sound-effect` 的 VectorEngine Vidu 音效生成与 OSS 持久化链路 +- API 命名空间: + - `/api/creation/wooden-fish/...` + - `/api/runtime/wooden-fish/...` +- 业务真相: + - 后端裁决并持久化 session、work profile、发布状态、run 摘要和公开投影; + - 前端只负责点击低延迟表现、音频播放、动画、飘字渲染和定期 checkpoint。 +- 创作工具模式例外:无 +- 验证命令: + - `npm run check:encoding` + - `npm run typecheck` + - `cargo test -p shared-contracts wooden_fish --manifest-path server-rs/Cargo.toml` + - `cargo test -p module-wooden-fish --manifest-path server-rs/Cargo.toml` + - `cargo check -p api-server --manifest-path server-rs/Cargo.toml` + - `npm run spacetime:generate` + - `npm run check:spacetime-schema` + - `npm run dev:api-server` 后检查 `/healthz` + +## 5. 创作输入 + +工作台提交结构化 payload,不提交聊天消息。 + +必填字段: + +1. `templateId = "wooden-fish"`; +2. `workTitle`:作品标题; +3. `hitObjectPrompt`:用户想敲的对象关键词或描述,默认“默认敲击物图案,圆润木质质感,透明背景”; +4. `floatingWords[]`:祝福词,最多 8 条,不填或清空时使用默认祝福词。 + +可选字段: + +1. `workDescription`:作品简介; +2. `themeTags[]`:最多 6 个标签; +3. `hitObjectReferenceImageSrc`:上传或历史图引用,只能作为 image2 参考,不可直接进入运行态; +4. `hitSoundPrompt`:生成音效描述; +5. `hitSoundAsset`:用户上传或录音产生的音频资产。 + +默认祝福词: + +```text +幸运 +健康 +财富 +姻缘 +幸福 +事业 +成功 +功德 +``` + +`floatingWords[]` 保存词条名本身,不保存 `+1` 后缀;运行态每次敲击时再把飘字展示为“词条+1”。 + +## 6. 生成规则 + +### 6.1 敲击物图案 + +默认模板在用户未自定义关键词且未上传参考图时,`compile-draft` 使用内置透明 PNG `/wooden-fish/default-hit-object.png` 写回 `hitObjectAsset`,`generationProvider="bundled-default"`。这张图来自 image2 对原始参考图的卡通风格化重绘,固定为模板默认资源,避免默认关键词在每次生成时改变造型。 + +用户输入自定义关键词、上传参考图,或在结果页主动重生成敲击物时,`compile-draft` 与 `regenerate-hit-object` 必须为敲击物图案生成 image2 单图资产,并由 `api-server` 注入写回 `hitObjectAsset`。前端 action 请求不得自带 `hitObjectAsset` 短路生成。如果用户上传参考图,后端只能把该图作为 image2 参考图或编辑输入;运行态不得直接使用上传图。 + +落库链路固定为:`api-server` 调用 VectorEngine `gpt-image-2-all` -> 服务端上传 OSS 私有对象 -> `confirm_asset_object` 登记资产对象 -> `bind_asset_object_to_entity` 绑定到 `entityKind='wooden_fish_work'`、`slot='hit_object'`、`assetKind='wooden_fish_hit_object'` -> 把 `legacyPublicPath` 写入 `hitObjectAsset.imageSrc`。不得只拼 `/generated-wooden-fish-assets/...` 占位路径;前端会对 generated legacy path 走 `/api/assets/read-url` 换签,OSS 中没有真实对象时图片无法显示。 + +默认图案要求: + +1. 中央主体使用 `/wooden-fish/default-hit-object.png`; +2. 透明背景; +3. 适合移动端居中展示; +4. 不包含 UI、按钮、说明文字、水印或品牌标识; +5. 图片主体需留出敲击动画缩放空间。 + +### 6.2 敲击音效 + +音效统一写回 `hitSoundAsset`。 + +生成或写回规则: + +1. 若 payload 已包含上传/录音音频资产,`compile-draft` 跳过音效生成,直接持久化该资产; +2. 若 payload 只包含 `hitSoundPrompt`,`api-server` 复用通用创作音频能力提交 VectorEngine Vidu `sound_effect` 任务,轮询生成结果,下载音频,写入 OSS 私有对象,登记 `asset_object`,并绑定到 `entityKind='wooden_fish_work'`、`slot='hit_sound'`、`assetKind='wooden_fish_hit_sound'`,最后注入 `hitSoundAsset`; +3. 若两者都没有,后端生成默认“清脆短促木鱼敲击声”; +4. 音效资产必须包含可播放地址、对象键、asset object id、来源和可选时长; +5. 通用创作音频接口对 `wooden_fish` 的 `hit_sound` 目标不得返回 `410 Gone`,对应 `storagePrefix='wooden_fish_assets'`; +6. `spacetime-client` 不得自行合成 `/generated-wooden-fish-assets/...` 音效占位路径,缺少真实 `hitSoundAsset` 时必须拒绝编译。 + +### 6.3 封面 + +首版封面使用 `hitObjectAsset.imageSrc` 作为 `coverImageSrc`。不单独新增第三次图片生成。 + +## 7. 契约草案 + +`WoodenFishDraft` 至少包含: + +1. `templateId = "wooden-fish"`; +2. `templateName = "敲木鱼"`; +3. `profileId`; +4. `workTitle`; +5. `workDescription`; +6. `themeTags[]`; +7. `hitObjectPrompt`; +8. `hitObjectReferenceImageSrc`; +9. `hitSoundPrompt`; +10. `floatingWords[]`; +11. `hitObjectAsset`; +12. `hitSoundAsset`; +13. `coverImageSrc`; +14. `generationStatus`。 + +`WoodenFishImageAsset` 至少包含: + +1. `assetId`; +2. `imageSrc`; +3. `imageObjectKey`; +4. `assetObjectId`; +5. `generationProvider`; +6. `prompt`; +7. `width`; +8. `height`。 + +`WoodenFishAudioAsset` 至少包含: + +1. `assetId`; +2. `audioSrc`; +3. `audioObjectKey`; +4. `assetObjectId`; +5. `source = generated | uploaded | recorded | placeholder`; +6. `prompt`; +7. `durationMs`。 + +`WoodenFishRunSnapshot` 至少包含: + +1. `runId`; +2. `profileId`; +3. `ownerUserId`; +4. `status = playing | finished`; +5. `totalTapCount`; +6. `wordCounters[]`; +7. `startedAtMs`; +8. `updatedAtMs`; +9. `finishedAtMs`。 + +## 8. API 草案 + +HTTP 路由: + +```text +POST /api/creation/wooden-fish/sessions +GET /api/creation/wooden-fish/sessions/{sessionId} +POST /api/creation/wooden-fish/sessions/{sessionId}/actions +GET /api/creation/wooden-fish/works/{profileId} +POST /api/creation/wooden-fish/works/{profileId}/publish +GET /api/runtime/wooden-fish/works/{profileId} +POST /api/runtime/wooden-fish/runs +POST /api/runtime/wooden-fish/runs/{runId}/checkpoint +POST /api/runtime/wooden-fish/runs/{runId}/finish +GET /api/runtime/wooden-fish/gallery +GET /api/runtime/wooden-fish/gallery/{publicWorkCode} +``` + +动作类型: + +```text +compile-draft +regenerate-hit-object +generate-hit-sound +replace-hit-sound +update-work-meta +update-floating-words +publish +start-run +checkpoint +finish +``` + +`compile-draft` 是长耗时动作。前端进入生成页后应展示可恢复进度;如果请求失败,标记失败前必须复读 session,确认后端是否已经生成并写回草稿。 + +## 9. SpacetimeDB 表和 view + +新增表: + +1. `wooden_fish_agent_session`; +2. `wooden_fish_work_profile`; +3. `wooden_fish_runtime_run`; +4. `wooden_fish_event`。 + +新增 view: + +1. `wooden_fish_gallery_card_view`:公开列表卡片投影,只暴露已发布作品; +2. `wooden_fish_gallery_view`:公开详情兼容投影,包含图案、音效和祝福词配置。 + +新增或调整表、procedure、view 后必须同步 `migration.rs`、后端表目录、生成 bindings,并执行 `npm run check:spacetime-schema`。 + +## 10. 结果页能力 + +结果页必须展示: + +1. 作品标题和简介; +2. 敲击物图案; +3. 敲击音效试听; +4. 祝福词配置; +5. 标签; +6. 试玩; +7. 发布; +8. 返回编辑。 + +结果页必须支持: + +1. 重生成敲击物图案; +2. 生成、上传或替换敲击音效; +3. 修改标题、简介和标签; +4. 修改祝福词,最多 8 条。 + +图案重生成和音效生成是独立局部生成态,不得把已有可查看结果重新变成不可打开的全局生成中。 + +## 11. 运行态规则 + +运行态采用全屏单击模型。 + +功能区: + +1. 顶部计数器; +2. 设置、暂停、返回、发布分享等按钮; +3. 结果弹层和音频授权提示。 + +点击规则: + +1. 点击非功能区才算一次敲击; +2. 每次敲击立即本地累加 `totalTapCount`; +3. 随机等概率从 `floatingWords[]` 中取一个词条; +4. 若词条首次出现,顶部创建对应计数器; +5. 后续同词条出现时对应计数器 +1; +6. 播放敲击音效; +7. 敲击物图案执行压缩、回弹或轻微震动动画; +8. 木鱼上方飘出“词条+1”并淡出。 + +音频播放: + +1. 前端使用小复音池; +2. 设置最小播放间隔,避免极端连点导致浏览器抖动; +3. 点击计数不能因为音频节流而丢失; +4. 签名 URL 未就绪时先静音表现,不请求裸 generated 私有路径。 + +后端只保存 run 摘要,不保存每次点击的完整明细;`checkpoint` 和 `finish` 都写入总敲击次数与词条计数快照。 + +## 12. 公开链路 + +平台首页推荐、发现、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='wooden-fish'` 与 `WF-*` 公开作品号识别敲木鱼作品。 + +公开列表优先消费 `wooden_fish_gallery_card_view` 订阅缓存。公开详情如果卡片摘要不足以进入运行态,必须补读完整 work profile。 + +## 13. 验收 + +1. 创作入口能看到 `敲木鱼` 模板; +2. 工作台可以填写敲击物描述、上传参考图、配置音效和祝福词; +3. 提交后生成 image2 敲击物图案; +4. 上传图不会直接进入运行态; +5. 用户上传或录制音效时跳过音效生成并持久化该资产; +6. 结果页能看到图案、试听音效、编辑祝福词并试玩; +7. 运行态功能区点击不触发敲击; +8. 非功能区点击会计数、播放音效、播放敲击动画并飘字; +9. 顶部计数器只在词条首次出现时创建; +10. 连点不丢计数; +11. `checkpoint` 和 `finish` 只保存单次 run 摘要; +12. 作品可以发布、进入公开列表和公开详情; +13. `WF-*` 公开作品号能进入分享和运行态; +14. `npm run check:encoding` 通过; +15. schema 变更后 `npm run check:spacetime-schema` 通过。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 5aace093..af9d5cb2 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -44,7 +44,7 @@ npm run check:server-rs-ddd `server-rs/crates/spacetime-client/src/mapper.rs` 只作为聚合入口,负责声明 `src/mapper/` 下的领域子模块并 re-export 原有 record / mapper 能力;不要在该文件继续堆叠大段映射实现。 -当前子模块按调用领域拆分:`assets.rs`、`auth.rs`、`runtime.rs`、`runtime_profile.rs`、`custom_world.rs`、`puzzle.rs`、`match3d.rs`、`square_hole.rs`、`visual_novel.rs`、`big_fish.rs`、`story.rs`、`ai.rs`、`bark_battle.rs`、`combat.rs`、`inventory.rs`、`npc.rs`,跨领域轻量 helper 和共享 record 统一放在 `common.rs`。该拆分只改变 `spacetime-client` 文件组织,不改变 SpacetimeDB schema、生成绑定、procedure result 契约或外部 DTO;后续新增 mapper 时优先落到对应领域子模块,不得重新引入跨层 JSON 字符串兼容结构。 +当前子模块按调用领域拆分:`assets.rs`、`auth.rs`、`runtime.rs`、`runtime_profile.rs`、`custom_world.rs`、`puzzle.rs`、`match3d.rs`、`jump_hop.rs`、`wooden_fish.rs`、`square_hole.rs`、`visual_novel.rs`、`big_fish.rs`、`story.rs`、`ai.rs`、`bark_battle.rs`、`combat.rs`、`inventory.rs`、`npc.rs`,跨领域轻量 helper 和共享 record 统一放在 `common.rs`。该拆分只改变 `spacetime-client` 文件组织,不改变 SpacetimeDB schema、生成绑定、procedure result 契约或外部 DTO;后续新增 mapper 时优先落到对应领域子模块,不得重新引入跨层 JSON 字符串兼容结构。 ## API 路由分组 @@ -60,6 +60,7 @@ npm run check:server-rs-ddd - 自定义世界 / RPG:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。 - 拼图:`/api/runtime/puzzle/*`。 - 抓大鹅 Match3D:`/api/creation/match3d/*`、`/api/runtime/match3d/*`。 +- 敲木鱼:`/api/creation/wooden-fish/*`、`/api/runtime/wooden-fish/*`。 - 方洞挑战:`/api/creation/square-hole/*`、`/api/runtime/square-hole/*`。 - 视觉小说:`/api/creation/visual-novel/*`、`/api/runtime/visual-novel/*`。 - 大鱼吃小鱼:`/api/runtime/big-fish/*`。 @@ -156,7 +157,7 @@ npm run check:server-rs-ddd - Match3D 封面和 9:16 纯背景:VectorEngine `/v1/images/generations`。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。 - Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;新 Match3D 草稿和批量新增不再生成 GLB。 -- 音频:视觉小说专用音频路由保留;拼图和抓大鹅生成入口暂时关闭,通用 `/api/creation/audio/*` 对相关目标返回 `410 Gone`。 +- 音频:视觉小说专用音频路由保留;拼图和抓大鹅生成入口暂时关闭,通用 `/api/creation/audio/*` 对相关目标返回 `410 Gone`;敲木鱼 `hit_sound` 目标例外开放,复用 VectorEngine Vidu 音效生成、OSS 私有对象、`asset_object` 和 entity binding 链路,目标字段固定为 `entityKind='wooden_fish_work'`、`slot='hit_sound'`、`assetKind='wooden_fish_hit_sound'`、`storagePrefix='wooden_fish_assets'`。 - OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。 ## SpacetimeDB 表目录 @@ -396,6 +397,40 @@ npm run check:server-rs-ddd - 源码:`server-rs/crates/spacetime-module/src/jump_hop.rs` - 说明:跳一跳公开详情兼容投影,包含作品、路径和素材字段;公开列表主路径优先使用 `jump_hop_gallery_card_view`。 +### `wooden_fish_agent_session` + +- Rust 结构体:`WoodenFishAgentSessionRow` +- 源码:`server-rs/crates/spacetime-module/src/wooden_fish/tables.rs` + +### `wooden_fish_event` + +- Rust 结构体:`WoodenFishEventRow` +- 源码:`server-rs/crates/spacetime-module/src/wooden_fish/tables.rs` + +### `wooden_fish_runtime_run` + +- Rust 结构体:`WoodenFishRuntimeRunRow` +- 源码:`server-rs/crates/spacetime-module/src/wooden_fish/tables.rs` + +### `wooden_fish_work_profile` + +- Rust 结构体:`WoodenFishWorkProfileRow` +- 源码:`server-rs/crates/spacetime-module/src/wooden_fish/tables.rs` + +### SpacetimeDB view:`wooden_fish_gallery_card_view` + +- Rust view:`wooden_fish_gallery_card_view` +- 返回类型:`Vec` +- 源码:`server-rs/crates/spacetime-module/src/wooden_fish.rs` +- 说明:敲木鱼公开广场列表卡片投影,只暴露 `publication_status = published` 的作品卡片字段;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM wooden_fish_gallery_card_view` 后,从本地 cache 构造敲木鱼公开列表响应。个人作品列表、详情、发布和运行态仍按 procedure 路径处理。 + +### SpacetimeDB view:`wooden_fish_gallery_view` + +- Rust view:`wooden_fish_gallery_view` +- 返回类型:`Vec` +- 源码:`server-rs/crates/spacetime-module/src/wooden_fish.rs` +- 说明:敲木鱼公开详情兼容投影,包含敲击物图案、敲击音效和飘字配置;公开列表主路径优先使用 `wooden_fish_gallery_card_view`。 + ### `match3d_agent_message` - Rust 结构体:`Match3DAgentMessageRow` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 06f781e9..6d35324e 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -87,6 +87,26 @@ 平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带角色图、地块图集和路径配置,必须先补读完整 work profile 再传入运行态。 +## 敲木鱼 + +对外名称:`敲木鱼`。工程域:`wooden-fish`。PRD 见 `docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`。 + +首版定位为单屏点击解压模板,链路对齐拼图的创作闭环: + +```text +创作入口 -> 工作台 -> 生成过程页 -> 结果页 -> 试玩 -> 发布 -> 运行态 +``` + +创作输入固定为: + +1. `敲什么`:单图资产槽位。默认模板使用内置透明 PNG `/wooden-fish/default-hit-object.png` 作为 `bundled-default` 敲击物资产,避免默认关键词被重新语义化改形;用户输入自定义关键词或上传参考图时,后端必须使用 image2 生成最终敲击物图案,上传图只作为参考,不直接进入运行态。自定义 `compile-draft` / `regenerate-hit-object` 必须完成 image2 -> OSS 私有对象 -> asset object 登记和绑定后,再由 `api-server` 注入真实 `hitObjectAsset.imageSrc`,不能只写 `/generated-wooden-fish-assets/...` 占位路径,也不能接受前端请求自带的 `hitObjectAsset` 短路生成。 +2. `敲击音效`:音频资产槽位,支持描述生成、上传和麦克风录制,统一写回 `hitSoundAsset`。描述生成复用通用 `/api/creation/audio/sound-effect` 的 VectorEngine Vidu 音效生成、下载、OSS 私有对象、asset object 登记和 entity binding 链路;木鱼目标固定为 `entityKind='wooden_fish_work'`、`slot='hit_sound'`、`assetKind='wooden_fish_hit_sound'`、`storagePrefix='wooden_fish_assets'`,不得再返回 `410 Gone`,也不得由 `spacetime-client` 合成假音频路径。 +3. `功德有什么`:最多 8 条飘字,默认 `幸运、健康、财富、姻缘、幸福、事业、成功、功德`;创作态只保存词条名,运行态飘字展示时再追加 `+1`。 + +运行态规则真相以后端 run 摘要为准,前端只做点击低延迟表现、敲击动画、音频播放和飘字渲染。每次非功能区点击在当前 run 内累计 `totalTapCount` 和 `wordCounters`;计数不进入账号长期账本,不做排行榜。顶部计数器仅在词条首次出现时创建,后续同词条继续累加。 + +平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='wooden-fish'` 与 `WF-*` 公开作品号识别敲木鱼作品;公开列表应走 `wooden_fish_gallery_card_view` 订阅缓存,公开详情或运行态启动时卡片摘要不足则补读完整 work profile。 + ## 抓大鹅 Match3D 对外名称:`抓大鹅`。工程域:`match3d`。 diff --git a/packages/shared/src/contracts/creationAudio.ts b/packages/shared/src/contracts/creationAudio.ts index e1ad6cc3..5b820f56 100644 --- a/packages/shared/src/contracts/creationAudio.ts +++ b/packages/shared/src/contracts/creationAudio.ts @@ -39,7 +39,12 @@ export interface PublishGeneratedAudioAssetRequest { slot: string; assetKind: string; profileId?: string | null; - storagePrefix?: 'puzzle_assets' | 'match3d_assets' | 'custom_world_scenes' | null; + storagePrefix?: + | 'puzzle_assets' + | 'match3d_assets' + | 'wooden_fish_assets' + | 'custom_world_scenes' + | null; } export interface GeneratedAudioAssetResponse { diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index 8f8b20e8..49f37d51 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -5,3 +5,4 @@ export type * from './jumpHop'; export type * from './puzzleCreativeTemplate'; export type * from './visualNovel'; export type * from './barkBattle'; +export type * from './woodenFish'; diff --git a/packages/shared/src/contracts/woodenFish.ts b/packages/shared/src/contracts/woodenFish.ts new file mode 100644 index 00000000..e1026579 --- /dev/null +++ b/packages/shared/src/contracts/woodenFish.ts @@ -0,0 +1,195 @@ +export type WoodenFishGenerationStatus = + | 'draft' + | 'generating' + | 'ready' + | 'failed'; + +export type WoodenFishActionType = + | 'compile-draft' + | 'regenerate-hit-object' + | 'generate-hit-sound' + | 'replace-hit-sound' + | 'update-work-meta' + | 'update-floating-words'; + +export type WoodenFishRunStatus = 'playing' | 'finished'; + +export interface WoodenFishImageAsset { + assetId: string; + imageSrc: string; + imageObjectKey: string; + assetObjectId: string; + generationProvider: string; + prompt: string; + width: number; + height: number; +} + +export interface WoodenFishAudioAsset { + assetId: string; + audioSrc: string; + audioObjectKey: string; + assetObjectId: string; + source: string; + prompt?: string | null; + durationMs?: number | null; +} + +export interface WoodenFishWorkspaceCreateRequest { + templateId: string; + workTitle: string; + workDescription: string; + themeTags: string[]; + hitObjectPrompt: string; + hitObjectReferenceImageSrc?: string | null; + hitSoundPrompt?: string | null; + hitSoundAsset?: WoodenFishAudioAsset | null; + floatingWords: string[]; +} + +export interface WoodenFishActionRequest { + actionType: WoodenFishActionType; + profileId?: string | null; + workTitle?: string | null; + workDescription?: string | null; + themeTags?: string[] | null; + hitObjectPrompt?: string | null; + hitObjectReferenceImageSrc?: string | null; + hitSoundPrompt?: string | null; + hitSoundAsset?: WoodenFishAudioAsset | null; + floatingWords?: string[] | null; +} + +export interface WoodenFishWordCounter { + text: string; + count: number; +} + +export interface WoodenFishDraftResponse { + templateId: string; + templateName: string; + profileId: string | null; + workTitle: string; + workDescription: string; + themeTags: string[]; + hitObjectPrompt: string; + hitObjectReferenceImageSrc: string | null; + hitSoundPrompt: string | null; + floatingWords: string[]; + hitObjectAsset: WoodenFishImageAsset | null; + hitSoundAsset: WoodenFishAudioAsset | null; + coverImageSrc: string | null; + generationStatus: WoodenFishGenerationStatus; +} + +export interface WoodenFishSessionSnapshotResponse { + sessionId: string; + ownerUserId: string; + status: WoodenFishGenerationStatus; + draft: WoodenFishDraftResponse | null; + createdAt: string; + updatedAt: string; +} + +export interface WoodenFishSessionResponse { + session: WoodenFishSessionSnapshotResponse; +} + +export interface WoodenFishActionResponse { + actionType: WoodenFishActionType; + session: WoodenFishSessionSnapshotResponse; + work: WoodenFishWorkProfileResponse | null; +} + +export interface WoodenFishWorkSummaryResponse { + runtimeKind: 'wooden-fish'; + workId: string; + profileId: string; + ownerUserId: string; + sourceSessionId: string | null; + workTitle: string; + workDescription: string; + themeTags: string[]; + coverImageSrc: string | null; + publicationStatus: string; + playCount: number; + updatedAt: string; + publishedAt: string | null; + publishReady: boolean; + generationStatus: WoodenFishGenerationStatus; +} + +export interface WoodenFishWorkProfileResponse { + summary: WoodenFishWorkSummaryResponse; + draft: WoodenFishDraftResponse; + hitObjectAsset: WoodenFishImageAsset; + hitSoundAsset: WoodenFishAudioAsset; + floatingWords: string[]; +} + +export interface WoodenFishWorkDetailResponse { + item: WoodenFishWorkProfileResponse; +} + +export interface WoodenFishWorkMutationResponse { + item: WoodenFishWorkProfileResponse; +} + +export interface WoodenFishGalleryCardResponse { + publicWorkCode: string; + workId: string; + profileId: string; + ownerUserId: string; + authorDisplayName: string; + workTitle: string; + workDescription: string; + coverImageSrc: string | null; + themeTags: string[]; + publicationStatus: string; + playCount: number; + updatedAt: string; + publishedAt: string | null; + generationStatus: WoodenFishGenerationStatus; +} + +export interface WoodenFishGalleryResponse { + items: WoodenFishGalleryCardResponse[]; + hasMore: boolean; + nextCursor: string | null; +} + +export interface WoodenFishGalleryDetailResponse { + item: WoodenFishWorkProfileResponse; +} + +export interface WoodenFishRuntimeRunSnapshotResponse { + runId: string; + profileId: string; + ownerUserId: string; + status: WoodenFishRunStatus; + totalTapCount: number; + wordCounters: WoodenFishWordCounter[]; + startedAtMs: number; + updatedAtMs: number; + finishedAtMs: number | null; +} + +export interface WoodenFishRunResponse { + run: WoodenFishRuntimeRunSnapshotResponse; +} + +export interface WoodenFishStartRunRequest { + profileId: string; +} + +export interface WoodenFishCheckpointRunRequest { + totalTapCount: number; + wordCounters: WoodenFishWordCounter[]; + clientEventId: string; +} + +export interface WoodenFishFinishRunRequest { + totalTapCount: number; + wordCounters: WoodenFishWordCounter[]; + clientEventId: string; +} diff --git a/public/wooden-fish/default-hit-object.png b/public/wooden-fish/default-hit-object.png new file mode 100644 index 00000000..44a6002c Binary files /dev/null and b/public/wooden-fish/default-hit-object.png differ diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 6cb28534..ff327c57 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -1947,6 +1947,14 @@ dependencies = [ "spacetimedb", ] +[[package]] +name = "module-wooden-fish" +version = "0.1.0" +dependencies = [ + "serde", + "spacetimedb", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -3278,6 +3286,7 @@ dependencies = [ "module-square-hole", "module-story", "module-visual-novel", + "module-wooden-fish", "opentelemetry", "serde", "serde_json", @@ -3311,6 +3320,7 @@ dependencies = [ "module-runtime-item", "module-square-hole", "module-story", + "module-wooden-fish", "serde", "serde_json", "sha2", diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 1531dfc3..577c61bd 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -18,6 +18,7 @@ members = [ "crates/module-inventory", "crates/module-custom-world", "crates/module-jump-hop", + "crates/module-wooden-fish", "crates/module-match3d", "crates/module-npc", "crates/module-puzzle", @@ -59,6 +60,7 @@ module-creative-agent = { path = "crates/module-creative-agent", default-feature module-custom-world = { path = "crates/module-custom-world", default-features = false } module-inventory = { path = "crates/module-inventory", default-features = false } module-jump-hop = { path = "crates/module-jump-hop", default-features = false } +module-wooden-fish = { path = "crates/module-wooden-fish", default-features = false } module-match3d = { path = "crates/module-match3d", default-features = false } module-npc = { path = "crates/module-npc", default-features = false } module-progression = { path = "crates/module-progression", default-features = false } diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index d540f418..2b3458e1 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -60,6 +60,7 @@ pub fn build_router(state: AppState) -> Router { .merge(modules::match3d::router(state.clone())) .merge(modules::square_hole::router(state.clone())) .merge(modules::jump_hop::router(state.clone())) + .merge(modules::wooden_fish::router(state.clone())) .merge(modules::puzzle::router(state.clone())) .merge(visual_novel_router(state.clone())) .route( diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 674e51c0..61bfce45 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -89,6 +89,7 @@ mod volcengine_speech; mod wechat_auth; mod wechat_pay; mod wechat_provider; +mod wooden_fish; mod work_author; mod work_play_tracking; diff --git a/server-rs/crates/api-server/src/match3d/item_assets.rs b/server-rs/crates/api-server/src/match3d/item_assets.rs index 87f86542..51c270ed 100644 --- a/server-rs/crates/api-server/src/match3d/item_assets.rs +++ b/server-rs/crates/api-server/src/match3d/item_assets.rs @@ -1,12 +1,12 @@ use super::*; +#[cfg(test)] +use crate::generated_asset_sheets::crop_generated_asset_sheet_view_edge_matte; use crate::generated_asset_sheets::{ GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage, build_generated_asset_sheet_prompt, persist_generated_asset_sheet_bytes, slice_generated_asset_sheet, }; -#[cfg(test)] -use crate::generated_asset_sheets::crop_generated_asset_sheet_view_edge_matte; pub(super) async fn generate_match3d_item_assets( state: &AppState, diff --git a/server-rs/crates/api-server/src/modules.rs b/server-rs/crates/api-server/src/modules.rs index a86abae9..d890a36f 100644 --- a/server-rs/crates/api-server/src/modules.rs +++ b/server-rs/crates/api-server/src/modules.rs @@ -14,3 +14,4 @@ pub mod profile; pub mod puzzle; pub mod square_hole; pub mod story; +pub mod wooden_fish; diff --git a/server-rs/crates/api-server/src/modules/wooden_fish.rs b/server-rs/crates/api-server/src/modules/wooden_fish.rs new file mode 100644 index 00000000..f9ad51a3 --- /dev/null +++ b/server-rs/crates/api-server/src/modules/wooden_fish.rs @@ -0,0 +1,80 @@ +use axum::{ + Router, middleware, + routing::{get, post}, +}; + +use crate::{ + auth::require_bearer_auth, + state::AppState, + wooden_fish::{ + checkpoint_wooden_fish_run, create_wooden_fish_session, execute_wooden_fish_action, + finish_wooden_fish_run, get_wooden_fish_gallery_detail, get_wooden_fish_runtime_work, + get_wooden_fish_session, list_wooden_fish_gallery, publish_wooden_fish_work, + start_wooden_fish_run, + }, +}; + +pub fn router(state: AppState) -> Router { + Router::new() + .route( + "/api/creation/wooden-fish/sessions", + post(create_wooden_fish_session).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/wooden-fish/sessions/{session_id}", + get(get_wooden_fish_session).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/wooden-fish/sessions/{session_id}/actions", + post(execute_wooden_fish_action).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/wooden-fish/works/{profile_id}/publish", + post(publish_wooden_fish_work).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/wooden-fish/works/{profile_id}", + get(get_wooden_fish_runtime_work), + ) + .route( + "/api/runtime/wooden-fish/runs", + post(start_wooden_fish_run).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/wooden-fish/runs/{run_id}/checkpoint", + post(checkpoint_wooden_fish_run).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/wooden-fish/runs/{run_id}/finish", + post(finish_wooden_fish_run).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/wooden-fish/gallery", + get(list_wooden_fish_gallery), + ) + .route( + "/api/runtime/wooden-fish/gallery/{public_work_code}", + get(get_wooden_fish_gallery_detail), + ) +} diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 02f8dcd0..a86348b6 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -1258,9 +1258,7 @@ fn build_admin_runtime( #[cfg(debug_assertions)] fn is_missing_creation_entry_config_procedure(error: &SpacetimeClientError) -> bool { match error { - SpacetimeClientError::Procedure(message) => { - message.contains("No such procedure") - } + SpacetimeClientError::Procedure(message) => message.contains("No such procedure"), _ => false, } } diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation.rs index d5708bf3..04e51f6e 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation.rs @@ -233,13 +233,15 @@ pub async fn create_visual_novel_sound_effect_task( } pub async fn create_sound_effect_task( - State(_state): State, + State(state): State, axum::extract::Extension(request_context): axum::extract::Extension, payload: Result, JsonRejection>, ) -> Result, Response> { - let _ = parse_json_payload(&request_context, payload)?; - Err(creation_audio_generation_disabled_error() - .into_response_with_context(Some(&request_context))) + let Json(payload) = parse_json_payload(&request_context, payload)?; + create_sound_effect_task_response(&state, payload.prompt, payload.duration, payload.seed) + .await + .map(|task| json_success_body(Some(&request_context), task)) + .map_err(|error| error.into_response_with_context(Some(&request_context))) } pub(crate) async fn generate_sound_effect_asset_for_creation( @@ -518,15 +520,25 @@ pub async fn publish_background_music_asset( } pub async fn publish_sound_effect_asset( - State(_state): State, - Path(_task_id): Path, + State(state): State, + Path(task_id): Path, axum::extract::Extension(request_context): axum::extract::Extension, - axum::extract::Extension(_authenticated): axum::extract::Extension, + axum::extract::Extension(authenticated): axum::extract::Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let payload = parse_json_payload(&request_context, payload)?.0; - Err(creation_audio_generation_disabled_error_for_target(payload) - .into_response_with_context(Some(&request_context))) + let target = build_creation_audio_target(payload, AudioAssetSlot::SoundEffect) + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + publish_generated_audio_asset( + &state, + authenticated.claims().user_id(), + task_id, + AudioAssetSlot::SoundEffect, + target, + ) + .await + .map(|payload| json_success_body(Some(&request_context), payload)) + .map_err(|error| error.into_response_with_context(Some(&request_context))) } async fn publish_generated_audio_asset( @@ -860,10 +872,36 @@ fn build_visual_novel_audio_target( }) } +fn build_creation_audio_target( + payload: creation_audio::PublishGeneratedAudioAssetRequest, + slot: AudioAssetSlot, +) -> Result { + if matches!(slot, AudioAssetSlot::SoundEffect) + && payload.entity_kind.trim() == "wooden_fish_work" + && payload.slot.trim() == "hit_sound" + && payload.asset_kind.trim() == "wooden_fish_hit_sound" + && payload.storage_prefix + == Some(creation_audio::CreationAudioStoragePrefix::WoodenFishAssets) + { + let entity_id = normalize_limited_text(&payload.entity_id, "entityId", 160)?; + return Ok(AudioAssetBindingTarget { + storage_scope: payload.entity_kind.trim().to_string(), + entity_kind: payload.entity_kind.trim().to_string(), + entity_id, + slot: payload.slot.trim().to_string(), + asset_kind: payload.asset_kind.trim().to_string(), + profile_id: normalize_optional_text(payload.profile_id.as_deref()), + storage_prefix: LegacyAssetPrefix::WoodenFishAssets, + }); + } + + Err(creation_audio_generation_disabled_error_for_target(payload)) +} + fn creation_audio_generation_disabled_error() -> AppError { AppError::from_status(StatusCode::GONE).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, - "message": "拼图与抓大鹅音频生成入口已临时关闭", + "message": "当前创作音频目标未开放", })) } @@ -872,8 +910,9 @@ fn creation_audio_generation_disabled_error_for_target( ) -> AppError { creation_audio_generation_disabled_error().with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, - "message": "拼图与抓大鹅音频生成入口已临时关闭", + "message": "当前创作音频目标未开放", "entityKind": payload.entity_kind.trim(), + "slot": payload.slot.trim(), })) } @@ -1434,7 +1473,7 @@ mod tests { } #[test] - fn disabled_creation_audio_targets_return_gone() { + fn disabled_creation_audio_targets_return_gone_except_wooden_fish_sound_effects() { let payload = creation_audio::PublishGeneratedAudioAssetRequest { entity_kind: "puzzle_work".to_string(), entity_id: "puzzle-profile-1".to_string(), @@ -1467,6 +1506,22 @@ mod tests { }; let error = creation_audio_generation_disabled_error_for_target(payload); assert_eq!(error.status_code(), StatusCode::GONE); + + let payload = creation_audio::PublishGeneratedAudioAssetRequest { + entity_kind: "wooden_fish_work".to_string(), + entity_id: "wooden-fish-profile-1".to_string(), + slot: "hit_sound".to_string(), + asset_kind: "wooden_fish_hit_sound".to_string(), + profile_id: Some("wooden-fish-profile-1".to_string()), + storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::WoodenFishAssets), + }; + let target = build_creation_audio_target(payload, AudioAssetSlot::SoundEffect) + .expect("wooden fish hit sound target should be enabled"); + + assert_eq!(target.entity_kind, "wooden_fish_work"); + assert_eq!(target.slot, "hit_sound"); + assert_eq!(target.storage_prefix, LegacyAssetPrefix::WoodenFishAssets); + assert_eq!(target.storage_scope, "wooden_fish_work"); } #[test] diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs new file mode 100644 index 00000000..b0072a5b --- /dev/null +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -0,0 +1,1107 @@ +use std::{ + collections::BTreeMap, + time::{SystemTime, UNIX_EPOCH}, +}; + +use axum::{ + Json, + extract::{Extension, Path, State, rejection::JsonRejection}, + http::{HeaderName, StatusCode, header}, + response::Response, +}; +use module_assets::{ + AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, + build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, +}; +use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; +use serde_json::{Value, json}; +use shared_contracts::wooden_fish::{ + WoodenFishActionRequest, WoodenFishAudioAsset, WoodenFishCheckpointRunRequest, + WoodenFishDraftResponse, WoodenFishFinishRunRequest, WoodenFishGalleryDetailResponse, + WoodenFishGenerationStatus, WoodenFishImageAsset, WoodenFishRunResponse, + WoodenFishSessionResponse, WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest, + WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkspaceCreateRequest, +}; +use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; +use spacetime_client::SpacetimeClientError; + +use crate::generated_image_assets::{ + GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, + adapter::GeneratedImageAssetAdapterMetadata, adapter::GeneratedImageAssetPersistInput, + normalize_generated_image_asset_mime, +}; +use crate::{ + api_response::json_success_body, + auth::AuthenticatedAccessToken, + http_error::AppError, + openai_image_generation::{ + DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation, + require_openai_image_settings, + }, + platform_errors::map_oss_error, + request_context::RequestContext, + state::AppState, + vector_engine_audio_generation::{ + GeneratedCreationAudioTarget, generate_sound_effect_asset_for_creation, + }, +}; + +const WOODEN_FISH_PROVIDER: &str = "wooden-fish"; +const WOODEN_FISH_CREATION_PROVIDER: &str = "wooden-fish-creation"; +const WOODEN_FISH_RUNTIME_PROVIDER: &str = "wooden-fish-runtime"; +const WOODEN_FISH_TEMPLATE_ID: &str = "wooden-fish"; +const WOODEN_FISH_TEMPLATE_NAME: &str = "敲木鱼"; +const DEFAULT_HIT_OBJECT_PROMPT: &str = "默认敲击物图案,圆润木质质感,透明背景"; +const DEFAULT_HIT_SOUND_PROMPT: &str = "清脆短促的木鱼敲击声"; +const DEFAULT_HIT_OBJECT_ASSET_ID: &str = "wooden-fish-default-hit-object"; +const DEFAULT_HIT_OBJECT_IMAGE_SRC: &str = "/wooden-fish/default-hit-object.png"; +const WOODEN_FISH_ENTITY_KIND: &str = "wooden_fish_work"; +const WOODEN_FISH_HIT_OBJECT_SLOT: &str = "hit_object"; +const WOODEN_FISH_HIT_OBJECT_ASSET_KIND: &str = "wooden_fish_hit_object"; +const WOODEN_FISH_HIT_SOUND_SLOT: &str = "hit_sound"; +const WOODEN_FISH_HIT_SOUND_ASSET_KIND: &str = "wooden_fish_hit_sound"; +const WOODEN_FISH_HIT_SOUND_DURATION_SECONDS: u8 = 3; + +pub async fn create_wooden_fish_session( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_CREATION_PROVIDER)?; + validate_workspace_request(&request_context, &payload)?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let session_id = build_prefixed_uuid_id("wooden-fish-session-"); + let now = current_utc_micros(); + let draft = build_wooden_fish_draft(&payload); + let session = WoodenFishSessionSnapshotResponse { + session_id, + owner_user_id, + status: WoodenFishGenerationStatus::Draft, + draft: Some(draft), + created_at: format_timestamp_micros(now), + updated_at: format_timestamp_micros(now), + }; + + Ok(json_success_body( + Some(&request_context), + WoodenFishSessionResponse { + session: state + .spacetime_client() + .create_wooden_fish_session(session) + .await + .map_err(|error| { + wooden_fish_error_response( + &request_context, + WOODEN_FISH_CREATION_PROVIDER, + map_wooden_fish_client_error(error), + ) + })?, + }, + )) +} + +pub async fn get_wooden_fish_session( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &session_id, "sessionId")?; + let owner_user_id = authenticated.claims().user_id().to_string(); + let session = state + .spacetime_client() + .get_wooden_fish_session(session_id, owner_user_id) + .await + .map_err(|error| { + wooden_fish_error_response( + &request_context, + WOODEN_FISH_CREATION_PROVIDER, + map_wooden_fish_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + WoodenFishSessionResponse { session }, + )) +} + +pub async fn execute_wooden_fish_action( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + ensure_non_empty(&request_context, &session_id, "sessionId")?; + let Json(mut payload) = + wooden_fish_json(payload, &request_context, WOODEN_FISH_CREATION_PROVIDER)?; + let owner_user_id = authenticated.claims().user_id().to_string(); + maybe_generate_hit_object_asset( + &state, + &request_context, + &session_id, + owner_user_id.as_str(), + &mut payload, + ) + .await?; + maybe_generate_hit_sound_asset( + &state, + &request_context, + &session_id, + owner_user_id.as_str(), + &mut payload, + ) + .await?; + let response = state + .spacetime_client() + .execute_wooden_fish_action(session_id, owner_user_id, payload) + .await + .map_err(|error| { + wooden_fish_error_response( + &request_context, + WOODEN_FISH_CREATION_PROVIDER, + map_wooden_fish_client_error(error), + ) + })?; + + Ok(json_success_body(Some(&request_context), response)) +} + +pub async fn publish_wooden_fish_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &profile_id, "profileId")?; + let work = state + .spacetime_client() + .publish_wooden_fish_work(profile_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + wooden_fish_error_response( + &request_context, + WOODEN_FISH_CREATION_PROVIDER, + map_wooden_fish_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + WoodenFishWorkMutationResponse { item: work }, + )) +} + +pub async fn get_wooden_fish_runtime_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &profile_id, "profileId")?; + let work = state + .spacetime_client() + .get_wooden_fish_runtime_work(profile_id) + .await + .map_err(|error| { + wooden_fish_error_response( + &request_context, + WOODEN_FISH_RUNTIME_PROVIDER, + map_wooden_fish_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + WoodenFishWorkDetailResponse { item: work }, + )) +} + +pub async fn start_wooden_fish_run( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_RUNTIME_PROVIDER)?; + ensure_non_empty(&request_context, &payload.profile_id, "profileId")?; + let run = state + .spacetime_client() + .start_wooden_fish_run(payload, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + wooden_fish_error_response( + &request_context, + WOODEN_FISH_RUNTIME_PROVIDER, + map_wooden_fish_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + WoodenFishRunResponse { run }, + )) +} + +pub async fn checkpoint_wooden_fish_run( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + ensure_non_empty(&request_context, &run_id, "runId")?; + let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_RUNTIME_PROVIDER)?; + let run = state + .spacetime_client() + .checkpoint_wooden_fish_run( + run_id, + authenticated.claims().user_id().to_string(), + payload, + ) + .await + .map_err(|error| { + wooden_fish_error_response( + &request_context, + WOODEN_FISH_RUNTIME_PROVIDER, + map_wooden_fish_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + WoodenFishRunResponse { run }, + )) +} + +pub async fn finish_wooden_fish_run( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + ensure_non_empty(&request_context, &run_id, "runId")?; + let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_RUNTIME_PROVIDER)?; + let run = state + .spacetime_client() + .finish_wooden_fish_run( + run_id, + authenticated.claims().user_id().to_string(), + payload, + ) + .await + .map_err(|error| { + wooden_fish_error_response( + &request_context, + WOODEN_FISH_RUNTIME_PROVIDER, + map_wooden_fish_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + WoodenFishRunResponse { run }, + )) +} + +pub async fn list_wooden_fish_gallery( + State(state): State, + Extension(request_context): Extension, +) -> Result, Response> { + let gallery = state + .spacetime_client() + .list_wooden_fish_gallery() + .await + .map_err(|error| { + wooden_fish_error_response( + &request_context, + WOODEN_FISH_RUNTIME_PROVIDER, + map_wooden_fish_client_error(error), + ) + })?; + + Ok(json_success_body(Some(&request_context), gallery)) +} + +pub async fn get_wooden_fish_gallery_detail( + State(state): State, + Path(public_work_code): Path, + Extension(request_context): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &public_work_code, "publicWorkCode")?; + let work = state + .spacetime_client() + .get_wooden_fish_gallery_detail(public_work_code) + .await + .map_err(|error| { + wooden_fish_error_response( + &request_context, + WOODEN_FISH_RUNTIME_PROVIDER, + map_wooden_fish_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + WoodenFishGalleryDetailResponse { item: work }, + )) +} + +fn build_wooden_fish_draft(payload: &WoodenFishWorkspaceCreateRequest) -> WoodenFishDraftResponse { + WoodenFishDraftResponse { + template_id: WOODEN_FISH_TEMPLATE_ID.to_string(), + template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(), + profile_id: None, + work_title: payload.work_title.trim().to_string(), + work_description: payload.work_description.trim().to_string(), + theme_tags: normalize_tags(payload.theme_tags.clone()), + hit_object_prompt: clean_string(&payload.hit_object_prompt, DEFAULT_HIT_OBJECT_PROMPT), + hit_object_reference_image_src: payload + .hit_object_reference_image_src + .as_ref() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + hit_sound_prompt: payload + .hit_sound_prompt + .as_ref() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .or_else(|| Some(DEFAULT_HIT_SOUND_PROMPT.to_string())), + floating_words: normalize_floating_words(payload.floating_words.clone()), + hit_object_asset: None, + hit_sound_asset: payload.hit_sound_asset.clone(), + cover_image_src: None, + generation_status: WoodenFishGenerationStatus::Draft, + } +} + +fn validate_workspace_request( + request_context: &RequestContext, + payload: &WoodenFishWorkspaceCreateRequest, +) -> Result<(), Response> { + ensure_non_empty(request_context, &payload.work_title, "workTitle")?; + if payload.template_id.trim() != WOODEN_FISH_TEMPLATE_ID { + return Err(wooden_fish_error_response( + request_context, + WOODEN_FISH_CREATION_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": WOODEN_FISH_PROVIDER, + "message": "templateId 必须为 wooden-fish", + })), + )); + } + Ok(()) +} + +async fn maybe_generate_hit_object_asset( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + payload: &mut WoodenFishActionRequest, +) -> Result<(), Response> { + if !matches!( + payload.action_type, + shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft + | shared_contracts::wooden_fish::WoodenFishActionType::RegenerateHitObject + ) { + return Ok(()); + } + if payload.hit_object_asset.is_some() { + return Ok(()); + } + + let profile_id = + resolve_hit_object_profile_id(state, request_context, session_id, owner_user_id, payload) + .await?; + payload.profile_id = Some(profile_id.clone()); + let prompt = payload + .hit_object_prompt + .as_deref() + .map(|value| clean_string(value, DEFAULT_HIT_OBJECT_PROMPT)) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_HIT_OBJECT_PROMPT.to_string()); + let reference_images = payload + .hit_object_reference_image_src + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| vec![value.to_string()]) + .unwrap_or_default(); + + if reference_images.is_empty() && is_default_hit_object_prompt(prompt.as_str()) { + payload.hit_object_asset = Some(default_wooden_fish_hit_object_asset()); + return Ok(()); + } + + let asset = generate_wooden_fish_hit_object_asset( + state, + owner_user_id, + session_id, + profile_id.as_str(), + prompt.as_str(), + reference_images.as_slice(), + ) + .await + .map_err(|error| { + wooden_fish_error_response(request_context, WOODEN_FISH_CREATION_PROVIDER, error) + })?; + payload.hit_object_asset = Some(asset); + Ok(()) +} + +fn default_wooden_fish_hit_object_asset() -> WoodenFishImageAsset { + WoodenFishImageAsset { + asset_id: DEFAULT_HIT_OBJECT_ASSET_ID.to_string(), + image_src: DEFAULT_HIT_OBJECT_IMAGE_SRC.to_string(), + image_object_key: "public/wooden-fish/default-hit-object.png".to_string(), + asset_object_id: DEFAULT_HIT_OBJECT_ASSET_ID.to_string(), + generation_provider: "bundled-default".to_string(), + prompt: DEFAULT_HIT_OBJECT_PROMPT.to_string(), + width: 1024, + height: 1024, + } +} + +fn is_default_hit_object_prompt(prompt: &str) -> bool { + let normalized = normalize_hit_object_prompt_for_default_match(prompt); + normalized.is_empty() + || normalized + == normalize_hit_object_prompt_for_default_match(DEFAULT_HIT_OBJECT_PROMPT) + || normalized + == normalize_hit_object_prompt_for_default_match("卡通木鱼,圆润可爱,透明背景") + || normalized + == normalize_hit_object_prompt_for_default_match("卡通木鱼,透明背景,居中,圆润可爱") + || normalized == normalize_hit_object_prompt_for_default_match("卡通木鱼") +} + +fn normalize_hit_object_prompt_for_default_match(prompt: &str) -> String { + prompt + .chars() + .filter(|ch| !ch.is_whitespace() && !matches!(ch, ',' | ',' | '。' | '.')) + .collect::() +} + +async fn resolve_hit_object_profile_id( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + payload: &WoodenFishActionRequest, +) -> Result { + if let Some(profile_id) = payload + .profile_id + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + return Ok(profile_id.to_string()); + } + if matches!( + payload.action_type, + shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft + ) { + return Ok(build_prefixed_uuid_id("wooden-fish-profile-")); + } + + let session = state + .spacetime_client() + .get_wooden_fish_session(session_id.to_string(), owner_user_id.to_string()) + .await + .map_err(|error| { + wooden_fish_error_response( + request_context, + WOODEN_FISH_CREATION_PROVIDER, + map_wooden_fish_client_error(error), + ) + })?; + session + .draft + .and_then(|draft| draft.profile_id) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + wooden_fish_error_response( + request_context, + WOODEN_FISH_CREATION_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": WOODEN_FISH_PROVIDER, + "message": "wooden-fish action 需要先完成 compile-draft", + })), + ) + }) +} + +async fn maybe_generate_hit_sound_asset( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + payload: &mut WoodenFishActionRequest, +) -> Result<(), Response> { + if !matches!( + payload.action_type, + shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft + | shared_contracts::wooden_fish::WoodenFishActionType::GenerateHitSound + ) { + return Ok(()); + } + if matches!( + payload.action_type, + shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft + ) && payload.hit_sound_asset.is_some() + { + return Ok(()); + } + + let profile_id = + resolve_hit_object_profile_id(state, request_context, session_id, owner_user_id, payload) + .await?; + payload.profile_id = Some(profile_id.clone()); + let prompt = payload + .hit_sound_prompt + .as_deref() + .map(|value| clean_string(value, DEFAULT_HIT_SOUND_PROMPT)) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_HIT_SOUND_PROMPT.to_string()); + + let asset = generate_wooden_fish_hit_sound_asset( + state, + owner_user_id, + profile_id.as_str(), + prompt.as_str(), + ) + .await + .map_err(|error| { + wooden_fish_error_response(request_context, WOODEN_FISH_CREATION_PROVIDER, error) + })?; + payload.hit_sound_asset = Some(asset); + Ok(()) +} + +async fn generate_wooden_fish_hit_sound_asset( + state: &AppState, + owner_user_id: &str, + profile_id: &str, + prompt: &str, +) -> Result { + let final_prompt = build_wooden_fish_hit_sound_prompt(prompt); + let generated = generate_sound_effect_asset_for_creation( + state, + owner_user_id, + final_prompt.clone(), + Some(WOODEN_FISH_HIT_SOUND_DURATION_SECONDS), + None, + GeneratedCreationAudioTarget { + entity_kind: WOODEN_FISH_ENTITY_KIND.to_string(), + entity_id: profile_id.to_string(), + slot: WOODEN_FISH_HIT_SOUND_SLOT.to_string(), + asset_kind: WOODEN_FISH_HIT_SOUND_ASSET_KIND.to_string(), + profile_id: Some(profile_id.to_string()), + storage_prefix: LegacyAssetPrefix::WoodenFishAssets, + }, + ) + .await?; + map_generated_creation_audio_to_wooden_fish_asset( + profile_id, + final_prompt.as_str(), + generated, + WOODEN_FISH_HIT_SOUND_DURATION_SECONDS, + ) +} + +fn build_wooden_fish_hit_sound_prompt(prompt: &str) -> String { + format!( + "为敲木鱼玩法生成一次点击触发的短促敲击音效:{}。要求:干净、清脆、无旋律、无环境噪声、无语音、无文字提示音,适合高频点击时叠加播放。", + clean_string(prompt, DEFAULT_HIT_SOUND_PROMPT) + ) +} + +fn map_generated_creation_audio_to_wooden_fish_asset( + profile_id: &str, + prompt: &str, + asset: shared_contracts::creation_audio::CreationAudioAsset, + duration_seconds: u8, +) -> Result { + let asset_object_id = asset + .asset_object_id + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "敲木鱼音效生成完成但缺少资产对象 ID", + })) + })?; + let audio_object_key = asset.audio_src.trim().trim_start_matches('/').to_string(); + if audio_object_key.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "敲木鱼音效生成完成但缺少音频地址", + })), + ); + } + + Ok(WoodenFishAudioAsset { + asset_id: format!("{profile_id}-hit-sound-{}", asset.task_id), + audio_src: asset.audio_src, + audio_object_key, + asset_object_id, + source: "generated".to_string(), + prompt: asset.prompt.or_else(|| Some(prompt.to_string())), + duration_ms: Some(u32::from(duration_seconds) * 1_000), + }) +} + +async fn generate_wooden_fish_hit_object_asset( + state: &AppState, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + prompt: &str, + reference_images: &[String], +) -> Result { + let settings = require_openai_image_settings(state)?; + let http_client = build_openai_image_http_client(&settings)?; + let final_prompt = build_wooden_fish_hit_object_prompt(prompt); + let generated = create_openai_image_generation( + &http_client, + &settings, + final_prompt.as_str(), + Some(build_wooden_fish_hit_object_negative_prompt().as_str()), + "1024x1024", + 1, + reference_images, + "生成敲木鱼敲击物图案失败", + ) + .await?; + let task_id = generated.task_id.clone(); + let image = generated.images.into_iter().next().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "生成敲木鱼敲击物图案失败:上游未返回图片", + })) + })?; + let generated_at_micros = current_utc_micros(); + let persisted = persist_wooden_fish_hit_object_asset( + state, + owner_user_id, + session_id, + profile_id, + task_id.as_str(), + &final_prompt, + image, + generated_at_micros, + ) + .await?; + + Ok(persisted) +} + +fn build_wooden_fish_hit_object_prompt(prompt: &str) -> String { + format!( + "请使用 gpt-image-2 生成一个适合点击敲击玩法的单个物品图案:{}。画面要求:单个主体,卡通插画风格,透明或纯净浅色背景,居中构图,圆润可爱,边缘清晰,适合移动端屏幕中央展示和点击动画缩放。不要包含文字、按钮、UI、边框、水印、品牌标识或人物手部。", + clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT) + ) +} + +fn build_wooden_fish_hit_object_negative_prompt() -> String { + "不要生成文字、Logo、水印、按钮、界面截图、复杂背景、多个主体、真实摄影质感、恐怖或血腥元素。" + .to_string() +} + +async fn persist_wooden_fish_hit_object_asset( + state: &AppState, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + task_id: &str, + prompt: &str, + image: DownloadedOpenAiImage, + generated_at_micros: i64, +) -> Result { + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + let http_client = reqwest::Client::new(); + let prepared = + GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput { + prefix: LegacyAssetPrefix::WoodenFishAssets, + path_segments: vec![ + sanitize_wooden_fish_asset_segment(session_id, "session"), + sanitize_wooden_fish_asset_segment(profile_id, "profile"), + WOODEN_FISH_HIT_OBJECT_SLOT.to_string(), + format!("asset-{generated_at_micros}"), + ], + file_stem: "image".to_string(), + image: GeneratedImageAssetDataUrl { + format: normalize_generated_image_asset_mime(image.mime_type.as_str()), + bytes: image.bytes, + }, + access: OssObjectAccess::Private, + metadata: GeneratedImageAssetAdapterMetadata { + asset_kind: Some(WOODEN_FISH_HIT_OBJECT_ASSET_KIND.to_string()), + owner_user_id: Some(owner_user_id.to_string()), + entity_kind: Some(WOODEN_FISH_ENTITY_KIND.to_string()), + entity_id: Some(profile_id.to_string()), + slot: Some(WOODEN_FISH_HIT_OBJECT_SLOT.to_string()), + provider: Some("image2".to_string()), + task_id: Some(task_id.to_string()), + }, + extra_metadata: BTreeMap::from([ + ("profile_id".to_string(), profile_id.to_string()), + ("session_id".to_string(), session_id.to_string()), + ]), + }) + .map_err(map_wooden_fish_generated_image_asset_error)?; + let persisted_mime_type = prepared.format.mime_type.clone(); + let put_result = oss_client + .put_object(&http_client, prepared.request) + .await + .map_err(map_wooden_fish_asset_oss_error)?; + let head = oss_client + .head_object( + &http_client, + OssHeadObjectRequest { + object_key: put_result.object_key.clone(), + }, + ) + .await + .map_err(map_wooden_fish_asset_oss_error)?; + let asset_object = state + .spacetime_client() + .confirm_asset_object( + build_asset_object_upsert_input( + generate_asset_object_id(generated_at_micros), + head.bucket, + head.object_key.clone(), + AssetObjectAccessPolicy::Private, + head.content_type.or(Some(persisted_mime_type)), + head.content_length, + head.etag, + WOODEN_FISH_HIT_OBJECT_ASSET_KIND.to_string(), + Some(task_id.to_string()), + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + Some(profile_id.to_string()), + generated_at_micros, + ) + .map_err(map_wooden_fish_asset_field_error)?, + ) + .await + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) + })?; + if let Err(error) = state + .spacetime_client() + .bind_asset_object_to_entity( + build_asset_entity_binding_input( + generate_asset_binding_id(generated_at_micros), + asset_object.asset_object_id.clone(), + WOODEN_FISH_ENTITY_KIND.to_string(), + profile_id.to_string(), + WOODEN_FISH_HIT_OBJECT_SLOT.to_string(), + WOODEN_FISH_HIT_OBJECT_ASSET_KIND.to_string(), + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + generated_at_micros, + ) + .map_err(map_wooden_fish_asset_field_error)?, + ) + .await + { + tracing::warn!( + provider = "spacetimedb", + owner_user_id, + session_id, + profile_id, + error = %error, + "敲木鱼图片资产绑定失败,历史素材索引可能缺少绑定记录" + ); + } + + Ok(WoodenFishImageAsset { + asset_id: format!("{profile_id}-hit-object-{generated_at_micros}"), + image_src: put_result.legacy_public_path, + image_object_key: head.object_key, + asset_object_id: asset_object.asset_object_id, + generation_provider: "image2".to_string(), + prompt: prompt.to_string(), + width: 1024, + height: 1024, + }) +} + +fn map_wooden_fish_generated_image_asset_error( + error: crate::generated_image_assets::helpers::GeneratedImageAssetHelperError, +) -> AppError { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "generated-image-assets", + "message": format!("准备敲木鱼图片资产上传请求失败:{error:?}"), + })) +} + +fn map_wooden_fish_asset_oss_error(error: platform_oss::OssError) -> AppError { + map_oss_error(error, "aliyun-oss") +} + +fn map_wooden_fish_asset_field_error(error: AssetObjectFieldError) -> AppError { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "wooden-fish-assets", + "message": error.to_string(), + })) +} + +fn sanitize_wooden_fish_asset_segment(value: &str, fallback: &str) -> String { + let sanitized = value + .trim() + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) { + ch + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_string(); + if sanitized.is_empty() { + fallback.to_string() + } else { + sanitized + } +} + +fn ensure_non_empty( + request_context: &RequestContext, + value: &str, + field: &str, +) -> Result<(), Response> { + if value.trim().is_empty() { + return Err(wooden_fish_error_response( + request_context, + WOODEN_FISH_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": WOODEN_FISH_PROVIDER, + "field": field, + "message": format!("{field} 不能为空"), + })), + )); + } + Ok(()) +} + +fn clean_string(value: &str, fallback: &str) -> String { + let value = value.trim(); + if value.is_empty() { + fallback.to_string() + } else { + value.to_string() + } +} + +fn normalize_tags(tags: Vec) -> Vec { + let mut normalized = Vec::new(); + for tag in tags { + let tag = tag.trim(); + if tag.is_empty() || normalized.iter().any(|item| item == tag) { + continue; + } + normalized.push(tag.to_string()); + if normalized.len() >= 6 { + break; + } + } + normalized +} + +fn normalize_floating_words(words: Vec) -> Vec { + let mut normalized = Vec::new(); + for word in words { + let word = normalize_floating_word(&word); + if word.is_empty() || normalized.iter().any(|item| item == &word) { + continue; + } + normalized.push(word); + if normalized.len() >= 8 { + break; + } + } + if normalized.is_empty() { + vec![ + "幸运".to_string(), + "健康".to_string(), + "财富".to_string(), + "姻缘".to_string(), + "幸福".to_string(), + "事业".to_string(), + "成功".to_string(), + "功德".to_string(), + ] + } else { + normalized + } +} + +fn normalize_floating_word(word: &str) -> String { + word.trim() + .trim_end_matches(|ch: char| ch == '1' || ch.is_whitespace()) + .trim_end_matches(['+', '+']) + .trim() + .to_string() +} + +fn wooden_fish_json( + payload: Result, JsonRejection>, + request_context: &RequestContext, + provider: &str, +) -> Result, Response> { + payload.map_err(|error| { + wooden_fish_error_response( + request_context, + provider, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": provider, + "message": error.to_string(), + })), + ) + }) +} + +fn map_wooden_fish_client_error(error: SpacetimeClientError) -> AppError { + let status = match &error { + SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, + SpacetimeClientError::Procedure(message) + if message.contains("不存在") + || message.contains("not found") + || message.contains("does not exist") => + { + StatusCode::NOT_FOUND + } + SpacetimeClientError::Procedure(message) + if message.contains("发布需要") + || message.contains("不能为空") + || message.contains("必须") => + { + StatusCode::BAD_REQUEST + } + _ => StatusCode::BAD_GATEWAY, + }; + + AppError::from_status(status).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) +} + +fn wooden_fish_error_response( + request_context: &RequestContext, + provider: &str, + error: AppError, +) -> Response { + let mut response = error.into_response_with_context(Some(request_context)); + response.headers_mut().insert( + HeaderName::from_static("x-genarrative-provider"), + header::HeaderValue::from_str(provider) + .unwrap_or_else(|_| header::HeaderValue::from_static("wooden-fish")), + ); + response +} + +fn current_utc_micros() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_micros().min(i64::MAX as u128) as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wooden_fish_hit_object_prompt_keeps_user_object_and_image2_constraints() { + let prompt = build_wooden_fish_hit_object_prompt("赛博莲花木鱼"); + + assert!(prompt.contains("赛博莲花木鱼")); + assert!(prompt.contains("gpt-image-2")); + assert!(prompt.contains("单个主体")); + assert!(prompt.contains("不要包含文字")); + } + + #[test] + fn wooden_fish_default_hit_object_uses_bundled_asset() { + let asset = default_wooden_fish_hit_object_asset(); + + assert_eq!(asset.asset_id, DEFAULT_HIT_OBJECT_ASSET_ID); + assert_eq!(asset.image_src, DEFAULT_HIT_OBJECT_IMAGE_SRC); + assert_eq!(asset.generation_provider, "bundled-default"); + assert_eq!(asset.width, 1024); + assert_eq!(asset.height, 1024); + } + + #[test] + fn wooden_fish_default_prompt_matches_legacy_defaults() { + assert!(is_default_hit_object_prompt(DEFAULT_HIT_OBJECT_PROMPT)); + assert!(is_default_hit_object_prompt("卡通木鱼,圆润可爱,透明背景")); + assert!(is_default_hit_object_prompt("卡通木鱼,透明背景,居中,圆润可爱")); + assert!(is_default_hit_object_prompt("卡通木鱼")); + assert!(!is_default_hit_object_prompt("赛博莲花木鱼")); + } + + #[test] + fn wooden_fish_asset_segment_sanitizes_for_oss_object_key() { + assert_eq!( + sanitize_wooden_fish_asset_segment("wooden-fish/profile:1", "fallback"), + "wooden-fish-profile-1" + ); + assert_eq!( + sanitize_wooden_fish_asset_segment(" ", "fallback"), + "fallback" + ); + } + + #[test] + fn wooden_fish_audio_asset_maps_from_generated_sound_effect() { + let asset = shared_contracts::creation_audio::CreationAudioAsset { + task_id: "task-hit-sound-1".to_string(), + provider: "vector-engine-vidu".to_string(), + asset_object_id: Some("assetobj-hit-sound-1".to_string()), + asset_kind: Some(WOODEN_FISH_HIT_SOUND_ASSET_KIND.to_string()), + audio_src: "/generated-wooden-fish-assets/wooden-fish-profile-1/hit-sound.mp3" + .to_string(), + prompt: Some("清脆木鱼声".to_string()), + title: None, + updated_at: None, + }; + + let mapped = map_generated_creation_audio_to_wooden_fish_asset( + "wooden-fish-profile-1", + "清脆木鱼声", + asset, + WOODEN_FISH_HIT_SOUND_DURATION_SECONDS, + ) + .expect("generated sound effect should map to wooden fish audio asset"); + + assert_eq!( + mapped.asset_id, + "wooden-fish-profile-1-hit-sound-task-hit-sound-1" + ); + assert_eq!( + mapped.audio_object_key, + "generated-wooden-fish-assets/wooden-fish-profile-1/hit-sound.mp3" + ); + assert_eq!(mapped.asset_object_id, "assetobj-hit-sound-1"); + assert_eq!(mapped.source, "generated"); + assert_eq!(mapped.duration_ms, Some(3_000)); + } +} diff --git a/server-rs/crates/module-jump-hop/src/application.rs b/server-rs/crates/module-jump-hop/src/application.rs index 3521c13a..e7f835bf 100644 --- a/server-rs/crates/module-jump-hop/src/application.rs +++ b/server-rs/crates/module-jump-hop/src/application.rs @@ -348,15 +348,15 @@ mod tests { assert_eq!(perfect.status, JumpHopRunStatus::Playing); assert_eq!(perfect.current_platform_index, 1); - let hit = apply_jump(&run, perfect_charge.saturating_add(80), 200) - .expect("jump should resolve"); + let hit = + apply_jump(&run, perfect_charge.saturating_add(80), 200).expect("jump should resolve"); assert_eq!( hit.last_jump.as_ref().unwrap().result, JumpHopJumpResultKind::Hit ); - let miss = apply_jump(&run, perfect_charge.saturating_add(900), 200) - .expect("jump should resolve"); + let miss = + apply_jump(&run, perfect_charge.saturating_add(900), 200).expect("jump should resolve"); assert_eq!(miss.status, JumpHopRunStatus::Failed); assert_eq!( miss.last_jump.as_ref().unwrap().result, diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 7cf2a293..5d6a6475 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -105,6 +105,17 @@ pub fn default_creation_entry_type_snapshots( 45, updated_at_micros, ), + build_default_creation_entry_type_snapshot( + "wooden-fish", + "敲木鱼", + "点击祈福轻玩法", + "可创建", + "/wooden-fish/default-hit-object.png", + true, + true, + 47, + updated_at_micros, + ), build_default_creation_entry_type_snapshot( "square-hole", "方洞", diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index caad0895..892c9ade 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -251,6 +251,25 @@ mod tests { assert_eq!(bark_battle.sort_order, 85); } + #[test] + fn default_creation_entry_types_include_wooden_fish() { + let configs = default_creation_entry_type_snapshots(1); + let wooden_fish = configs + .iter() + .find(|item| item.id == "wooden-fish") + .expect("wooden-fish creation entry should be seeded"); + + assert_eq!(wooden_fish.title, "敲木鱼"); + assert!(wooden_fish.visible); + assert!(wooden_fish.open); + assert_eq!(wooden_fish.badge, "可创建"); + assert_eq!(wooden_fish.sort_order, 47); + assert_eq!( + wooden_fish.image_src, + "/wooden-fish/default-hit-object.png" + ); + } + #[test] fn normalized_clamps_music_volume_into_valid_range() { let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light); diff --git a/server-rs/crates/module-wooden-fish/Cargo.toml b/server-rs/crates/module-wooden-fish/Cargo.toml new file mode 100644 index 00000000..30e0fd30 --- /dev/null +++ b/server-rs/crates/module-wooden-fish/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "module-wooden-fish" +edition.workspace = true +version.workspace = true +license.workspace = true + +[features] +default = [] +spacetime-types = ["dep:spacetimedb"] + +[dependencies] +serde = { workspace = true } +spacetimedb = { workspace = true, optional = true } diff --git a/server-rs/crates/module-wooden-fish/src/application.rs b/server-rs/crates/module-wooden-fish/src/application.rs new file mode 100644 index 00000000..419f58b5 --- /dev/null +++ b/server-rs/crates/module-wooden-fish/src/application.rs @@ -0,0 +1,4 @@ +pub use crate::domain::{ + apply_run_checkpoint, default_floating_words, finish_run, normalize_floating_words, + normalize_word_counters, +}; diff --git a/server-rs/crates/module-wooden-fish/src/commands.rs b/server-rs/crates/module-wooden-fish/src/commands.rs new file mode 100644 index 00000000..f361ad29 --- /dev/null +++ b/server-rs/crates/module-wooden-fish/src/commands.rs @@ -0,0 +1,8 @@ +pub fn normalize_wooden_fish_prompt(value: &str, fallback: &str) -> String { + let value = value.trim(); + if value.is_empty() { + fallback.to_string() + } else { + value.to_string() + } +} diff --git a/server-rs/crates/module-wooden-fish/src/domain.rs b/server-rs/crates/module-wooden-fish/src/domain.rs new file mode 100644 index 00000000..1dc4e5fe --- /dev/null +++ b/server-rs/crates/module-wooden-fish/src/domain.rs @@ -0,0 +1,281 @@ +use serde::{Deserialize, Serialize}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +pub const WOODEN_FISH_SESSION_ID_PREFIX: &str = "wooden-fish-session-"; +pub const WOODEN_FISH_PROFILE_ID_PREFIX: &str = "wooden-fish-profile-"; +pub const WOODEN_FISH_WORK_ID_PREFIX: &str = "wooden-fish-work-"; +pub const WOODEN_FISH_RUN_ID_PREFIX: &str = "wooden-fish-run-"; + +pub const DEFAULT_FLOATING_WORDS: [&str; 8] = [ + "幸运", "健康", "财富", "姻缘", "幸福", "事业", "成功", "功德", +]; + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum WoodenFishRunStatus { + Playing, + Finished, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WoodenFishWordCounter { + pub text: String, + pub count: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WoodenFishRunSnapshot { + pub run_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub status: WoodenFishRunStatus, + pub total_tap_count: u32, + pub word_counters: Vec, + pub started_at_ms: u64, + pub updated_at_ms: u64, + pub finished_at_ms: Option, +} + +pub fn default_floating_words() -> Vec { + DEFAULT_FLOATING_WORDS + .iter() + .map(|value| (*value).to_string()) + .collect() +} + +pub fn normalize_floating_words(words: &[String]) -> Vec { + let mut normalized: Vec = Vec::new(); + for word in words { + let value = normalize_floating_word(word); + if !value.is_empty() && !normalized.iter().any(|item| item == &value) { + normalized.push(value); + } + if normalized.len() == 8 { + break; + } + } + if normalized.is_empty() { + default_floating_words() + } else { + normalized + } +} + +fn normalize_floating_word(word: &str) -> String { + word.trim() + .trim_end_matches(|ch: char| ch == '1' || ch.is_whitespace()) + .trim_end_matches(['+', '+']) + .trim() + .to_string() +} + +pub fn apply_run_checkpoint( + current: &WoodenFishRunSnapshot, + total_tap_count: u32, + word_counters: Vec, + updated_at_ms: u64, +) -> WoodenFishRunSnapshot { + WoodenFishRunSnapshot { + total_tap_count, + word_counters: normalize_word_counters(word_counters), + updated_at_ms, + ..current.clone() + } +} + +pub fn finish_run( + current: &WoodenFishRunSnapshot, + total_tap_count: u32, + word_counters: Vec, + finished_at_ms: u64, +) -> WoodenFishRunSnapshot { + WoodenFishRunSnapshot { + status: WoodenFishRunStatus::Finished, + total_tap_count, + word_counters: normalize_word_counters(word_counters), + updated_at_ms: finished_at_ms, + finished_at_ms: Some(finished_at_ms), + ..current.clone() + } +} + +pub fn normalize_word_counters(counters: Vec) -> Vec { + let mut normalized: Vec = Vec::new(); + for counter in counters { + let text = normalize_floating_word(&counter.text); + if text.is_empty() || counter.count == 0 { + continue; + } + if let Some(existing) = normalized.iter_mut().find(|item| item.text == text) { + existing.count = existing.count.saturating_add(counter.count); + } else { + normalized.push(WoodenFishWordCounter { + text, + count: counter.count, + }); + } + if normalized.len() == 8 { + break; + } + } + normalized +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn floating_words_default_to_eight_blessing_entries() { + assert_eq!( + default_floating_words(), + vec![ + "幸运".to_string(), + "健康".to_string(), + "财富".to_string(), + "姻缘".to_string(), + "幸福".to_string(), + "事业".to_string(), + "成功".to_string(), + "功德".to_string(), + ] + ); + } + + #[test] + fn floating_words_are_trimmed_deduped_and_capped_at_eight() { + let words = vec![ + " 幸运+1 ".to_string(), + "幸运".to_string(), + "健康".to_string(), + "财富".to_string(), + "姻缘".to_string(), + "幸福".to_string(), + "事业".to_string(), + "成功".to_string(), + "功德".to_string(), + "快乐".to_string(), + ]; + + assert_eq!(normalize_floating_words(&words).len(), 8); + assert_eq!(normalize_floating_words(&words)[0], "幸运"); + assert_eq!(normalize_floating_words(&words)[7], "功德"); + } + + #[test] + fn checkpoint_replaces_single_run_counter_snapshot() { + let current = WoodenFishRunSnapshot { + run_id: "wooden-fish-run-1".to_string(), + profile_id: "wooden-fish-profile-1".to_string(), + owner_user_id: "user-1".to_string(), + status: WoodenFishRunStatus::Playing, + total_tap_count: 0, + word_counters: Vec::new(), + started_at_ms: 100, + updated_at_ms: 100, + finished_at_ms: None, + }; + + let next = apply_run_checkpoint( + ¤t, + 2, + vec![WoodenFishWordCounter { + text: "功德".to_string(), + count: 2, + }], + 160, + ); + + assert_eq!(next.total_tap_count, 2); + assert_eq!(next.word_counters[0].text, "功德"); + assert_eq!(next.updated_at_ms, 160); + assert_eq!(next.started_at_ms, 100); + } + + #[test] + fn word_counters_are_trimmed_deduped_and_capped_at_eight() { + let counters = vec![ + WoodenFishWordCounter { + text: " 幸运+1 ".to_string(), + count: 1, + }, + WoodenFishWordCounter { + text: "幸运+1".to_string(), + count: 2, + }, + WoodenFishWordCounter { + text: "健康+1".to_string(), + count: 1, + }, + WoodenFishWordCounter { + text: "财富+1".to_string(), + count: 1, + }, + WoodenFishWordCounter { + text: "姻缘+1".to_string(), + count: 1, + }, + WoodenFishWordCounter { + text: "幸福+1".to_string(), + count: 1, + }, + WoodenFishWordCounter { + text: "事业+1".to_string(), + count: 1, + }, + WoodenFishWordCounter { + text: "成功+1".to_string(), + count: 1, + }, + WoodenFishWordCounter { + text: "功德+1".to_string(), + count: 1, + }, + WoodenFishWordCounter { + text: "快乐+1".to_string(), + count: 1, + }, + ]; + + let normalized = normalize_word_counters(counters); + + assert_eq!(normalized.len(), 8); + assert_eq!(normalized[0].text, "幸运"); + assert_eq!(normalized[0].count, 3); + assert_eq!(normalized[7].text, "功德"); + } + + #[test] + fn finish_run_sets_status_and_finished_timestamp() { + let current = WoodenFishRunSnapshot { + run_id: "wooden-fish-run-1".to_string(), + profile_id: "wooden-fish-profile-1".to_string(), + owner_user_id: "user-1".to_string(), + status: WoodenFishRunStatus::Playing, + total_tap_count: 3, + word_counters: Vec::new(), + started_at_ms: 100, + updated_at_ms: 130, + finished_at_ms: None, + }; + + let next = finish_run( + ¤t, + 4, + vec![WoodenFishWordCounter { + text: "健康".to_string(), + count: 4, + }], + 220, + ); + + assert_eq!(next.status, WoodenFishRunStatus::Finished); + assert_eq!(next.total_tap_count, 4); + assert_eq!(next.updated_at_ms, 220); + assert_eq!(next.finished_at_ms, Some(220)); + assert_eq!(next.word_counters[0].text, "健康"); + } +} diff --git a/server-rs/crates/module-wooden-fish/src/errors.rs b/server-rs/crates/module-wooden-fish/src/errors.rs new file mode 100644 index 00000000..e7d4ff72 --- /dev/null +++ b/server-rs/crates/module-wooden-fish/src/errors.rs @@ -0,0 +1,23 @@ +use std::fmt::{self, Display}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WoodenFishError { + MissingRunId, + MissingProfileId, + MissingOwnerUserId, + RunNotPlaying, +} + +impl Display for WoodenFishError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::MissingRunId => "缺少 runId", + Self::MissingProfileId => "缺少 profileId", + Self::MissingOwnerUserId => "owner_user_id 缺失", + Self::RunNotPlaying => "当前运行态不是 playing", + }; + write!(f, "{message}") + } +} + +impl std::error::Error for WoodenFishError {} diff --git a/server-rs/crates/module-wooden-fish/src/events.rs b/server-rs/crates/module-wooden-fish/src/events.rs new file mode 100644 index 00000000..ae4f1a49 --- /dev/null +++ b/server-rs/crates/module-wooden-fish/src/events.rs @@ -0,0 +1,25 @@ +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum WoodenFishDomainEvent { + DraftCompiled { + profile_id: String, + owner_user_id: String, + occurred_at_micros: i64, + }, + WorkPublished { + profile_id: String, + owner_user_id: String, + occurred_at_micros: i64, + }, + RunCheckpointed { + run_id: String, + owner_user_id: String, + total_tap_count: u32, + occurred_at_micros: i64, + }, + RunFinished { + run_id: String, + owner_user_id: String, + total_tap_count: u32, + occurred_at_micros: i64, + }, +} diff --git a/server-rs/crates/module-wooden-fish/src/lib.rs b/server-rs/crates/module-wooden-fish/src/lib.rs new file mode 100644 index 00000000..6acdc7c7 --- /dev/null +++ b/server-rs/crates/module-wooden-fish/src/lib.rs @@ -0,0 +1,11 @@ +mod application; +mod commands; +mod domain; +mod errors; +mod events; + +pub use application::*; +pub use commands::*; +pub use domain::*; +pub use errors::*; +pub use events::*; diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 61a4f301..8a00d7a6 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -20,12 +20,13 @@ const OSS_V4_REQUEST: &str = "aliyun_v4_request"; const OSS_V4_SERVICE: &str = "oss"; const OSS_UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD"; -pub const LEGACY_PUBLIC_PREFIXES: [&str; 10] = [ +pub const LEGACY_PUBLIC_PREFIXES: [&str; 11] = [ "generated-character-drafts", "generated-characters", "generated-animations", "generated-big-fish-assets", "generated-square-hole-assets", + "generated-wooden-fish-assets", "generated-match3d-assets", "generated-puzzle-assets", "generated-custom-world-scenes", @@ -47,6 +48,7 @@ pub enum LegacyAssetPrefix { Animations, BigFishAssets, SquareHoleAssets, + WoodenFishAssets, Match3DAssets, PuzzleAssets, CustomWorldScenes, @@ -234,6 +236,7 @@ impl LegacyAssetPrefix { "generated-animations" => Some(Self::Animations), "generated-big-fish-assets" => Some(Self::BigFishAssets), "generated-square-hole-assets" => Some(Self::SquareHoleAssets), + "generated-wooden-fish-assets" => Some(Self::WoodenFishAssets), "generated-match3d-assets" => Some(Self::Match3DAssets), "generated-puzzle-assets" => Some(Self::PuzzleAssets), "generated-custom-world-scenes" => Some(Self::CustomWorldScenes), @@ -250,6 +253,7 @@ impl LegacyAssetPrefix { Self::Animations => "generated-animations", Self::BigFishAssets => "generated-big-fish-assets", Self::SquareHoleAssets => "generated-square-hole-assets", + Self::WoodenFishAssets => "generated-wooden-fish-assets", Self::Match3DAssets => "generated-match3d-assets", Self::PuzzleAssets => "generated-puzzle-assets", Self::CustomWorldScenes => "generated-custom-world-scenes", @@ -1313,8 +1317,13 @@ mod tests { LegacyAssetPrefix::parse("/generated-match3d-assets/*"), Some(LegacyAssetPrefix::Match3DAssets) ); + assert_eq!( + LegacyAssetPrefix::parse("/generated-wooden-fish-assets/*"), + Some(LegacyAssetPrefix::WoodenFishAssets) + ); assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-puzzle-assets")); assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-match3d-assets")); + assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-wooden-fish-assets")); assert_eq!(LegacyAssetPrefix::parse("unknown"), None); } @@ -1554,6 +1563,12 @@ mod tests { ), Some(LegacyAssetPrefix::CustomWorldScenes) ); + assert_eq!( + LegacyAssetPrefix::from_object_key( + "generated-wooden-fish-assets/session/profile/hit_object/asset/image.png" + ), + Some(LegacyAssetPrefix::WoodenFishAssets) + ); assert_eq!( LegacyAssetPrefix::from_object_key("workflow-cache/demo.json"), None diff --git a/server-rs/crates/shared-contracts/src/creation_audio.rs b/server-rs/crates/shared-contracts/src/creation_audio.rs index 21d37478..f6a3ca41 100644 --- a/server-rs/crates/shared-contracts/src/creation_audio.rs +++ b/server-rs/crates/shared-contracts/src/creation_audio.rs @@ -61,6 +61,7 @@ pub enum CreationAudioStoragePrefix { PuzzleAssets, #[serde(rename = "match3d_assets")] Match3DAssets, + WoodenFishAssets, CustomWorldScenes, } @@ -125,4 +126,20 @@ mod tests { assert_eq!(payload["taskId"], json!("task-1")); assert_eq!(payload["audioSrc"], json!("/generated-puzzle-assets/a.mp3")); } + + #[test] + fn creation_audio_contracts_support_wooden_fish_storage_prefix() { + let request = PublishGeneratedAudioAssetRequest { + entity_kind: "wooden_fish_work".to_string(), + entity_id: "wooden-fish-profile-1".to_string(), + slot: "hit_sound".to_string(), + asset_kind: "wooden_fish_hit_sound".to_string(), + profile_id: Some("wooden-fish-profile-1".to_string()), + storage_prefix: Some(CreationAudioStoragePrefix::WoodenFishAssets), + }; + + let payload = serde_json::to_value(request).expect("request should serialize"); + + assert_eq!(payload["storagePrefix"], json!("wooden_fish_assets")); + } } diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index 5324480d..ea60bfe9 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -29,3 +29,4 @@ pub mod square_hole_runtime; pub mod square_hole_works; pub mod story; pub mod visual_novel; +pub mod wooden_fish; diff --git a/server-rs/crates/shared-contracts/src/wooden_fish.rs b/server-rs/crates/shared-contracts/src/wooden_fish.rs new file mode 100644 index 00000000..105d11b1 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/wooden_fish.rs @@ -0,0 +1,485 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum WoodenFishGenerationStatus { + Draft, + Generating, + Ready, + Failed, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum WoodenFishActionType { + CompileDraft, + RegenerateHitObject, + GenerateHitSound, + ReplaceHitSound, + UpdateWorkMeta, + UpdateFloatingWords, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum WoodenFishRunStatus { + Playing, + Finished, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishImageAsset { + pub asset_id: String, + pub image_src: String, + pub image_object_key: String, + pub asset_object_id: String, + pub generation_provider: String, + pub prompt: String, + pub width: u32, + pub height: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishAudioAsset { + pub asset_id: String, + pub audio_src: String, + pub audio_object_key: String, + pub asset_object_id: String, + pub source: String, + #[serde(default)] + pub prompt: Option, + #[serde(default)] + pub duration_ms: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishWorkspaceCreateRequest { + pub template_id: String, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub hit_object_prompt: String, + #[serde(default)] + pub hit_object_reference_image_src: Option, + #[serde(default)] + pub hit_sound_prompt: Option, + #[serde(default)] + pub hit_sound_asset: Option, + pub floating_words: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishActionRequest { + pub action_type: WoodenFishActionType, + #[serde(default, skip_deserializing)] + pub profile_id: Option, + #[serde(default)] + pub work_title: Option, + #[serde(default)] + pub work_description: Option, + #[serde(default)] + pub theme_tags: Option>, + #[serde(default)] + pub hit_object_prompt: Option, + #[serde(default)] + pub hit_object_reference_image_src: Option, + #[serde(default, skip_deserializing)] + pub hit_object_asset: Option, + #[serde(default)] + pub hit_sound_prompt: Option, + #[serde(default)] + pub hit_sound_asset: Option, + #[serde(default)] + pub floating_words: Option>, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishWordCounter { + pub text: String, + pub count: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishDraftResponse { + pub template_id: String, + pub template_name: String, + #[serde(default)] + pub profile_id: Option, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub hit_object_prompt: String, + #[serde(default)] + pub hit_object_reference_image_src: Option, + #[serde(default)] + pub hit_sound_prompt: Option, + pub floating_words: Vec, + #[serde(default)] + pub hit_object_asset: Option, + #[serde(default)] + pub hit_sound_asset: Option, + #[serde(default)] + pub cover_image_src: Option, + pub generation_status: WoodenFishGenerationStatus, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishSessionSnapshotResponse { + pub session_id: String, + pub owner_user_id: String, + pub status: WoodenFishGenerationStatus, + #[serde(default)] + pub draft: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishSessionResponse { + pub session: WoodenFishSessionSnapshotResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishActionResponse { + pub action_type: WoodenFishActionType, + pub session: WoodenFishSessionSnapshotResponse, + #[serde(default)] + pub work: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishWorkSummaryResponse { + pub runtime_kind: String, + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + #[serde(default)] + pub source_session_id: Option, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + #[serde(default)] + pub cover_image_src: Option, + pub publication_status: String, + pub play_count: u32, + pub updated_at: String, + #[serde(default)] + pub published_at: Option, + pub publish_ready: bool, + pub generation_status: WoodenFishGenerationStatus, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishWorkProfileResponse { + pub summary: WoodenFishWorkSummaryResponse, + pub draft: WoodenFishDraftResponse, + pub hit_object_asset: WoodenFishImageAsset, + pub hit_sound_asset: WoodenFishAudioAsset, + pub floating_words: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishWorkDetailResponse { + pub item: WoodenFishWorkProfileResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishWorkMutationResponse { + pub item: WoodenFishWorkProfileResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishGalleryCardResponse { + pub public_work_code: String, + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + #[serde(default)] + pub cover_image_src: Option, + pub theme_tags: Vec, + pub publication_status: String, + pub play_count: u32, + pub updated_at: String, + #[serde(default)] + pub published_at: Option, + pub generation_status: WoodenFishGenerationStatus, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishGalleryResponse { + pub items: Vec, + pub has_more: bool, + #[serde(default)] + pub next_cursor: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishGalleryDetailResponse { + pub item: WoodenFishWorkProfileResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishRuntimeRunSnapshotResponse { + pub run_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub status: WoodenFishRunStatus, + pub total_tap_count: u32, + pub word_counters: Vec, + pub started_at_ms: u64, + pub updated_at_ms: u64, + #[serde(default)] + pub finished_at_ms: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishRunResponse { + pub run: WoodenFishRuntimeRunSnapshotResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishStartRunRequest { + pub profile_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishCheckpointRunRequest { + pub total_tap_count: u32, + pub word_counters: Vec, + pub client_event_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishFinishRunRequest { + pub total_tap_count: u32, + pub word_counters: Vec, + pub client_event_id: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn wooden_fish_workspace_request_uses_camel_case_and_default_words() { + let payload = serde_json::to_value(WoodenFishWorkspaceCreateRequest { + template_id: "wooden-fish".to_string(), + work_title: "今日敲木鱼".to_string(), + work_description: "轻松敲击".to_string(), + theme_tags: vec!["休闲".to_string()], + hit_object_prompt: "卡通木鱼".to_string(), + hit_object_reference_image_src: None, + hit_sound_prompt: Some("清脆木鱼声".to_string()), + hit_sound_asset: None, + floating_words: vec![ + "幸运".to_string(), + "健康".to_string(), + "财富".to_string(), + "姻缘".to_string(), + "幸福".to_string(), + "事业".to_string(), + "成功".to_string(), + "功德".to_string(), + ], + }) + .expect("payload should serialize"); + + assert_eq!(payload["templateId"], json!("wooden-fish")); + assert_eq!(payload["hitObjectPrompt"], json!("卡通木鱼")); + assert_eq!(payload["hitSoundPrompt"], json!("清脆木鱼声")); + assert_eq!(payload["floatingWords"][7], json!("功德")); + } + + #[test] + fn wooden_fish_runtime_snapshot_counts_words_inside_one_run() { + let payload = serde_json::to_value(WoodenFishRuntimeRunSnapshotResponse { + run_id: "wooden-fish-run-1".to_string(), + profile_id: "wooden-fish-profile-1".to_string(), + owner_user_id: "user-1".to_string(), + status: WoodenFishRunStatus::Playing, + total_tap_count: 3, + word_counters: vec![ + WoodenFishWordCounter { + text: "幸运".to_string(), + count: 2, + }, + WoodenFishWordCounter { + text: "功德".to_string(), + count: 1, + }, + ], + started_at_ms: 100, + updated_at_ms: 130, + finished_at_ms: None, + }) + .expect("payload should serialize"); + + assert_eq!(payload["status"], json!("playing")); + assert_eq!(payload["totalTapCount"], json!(3)); + assert_eq!(payload["wordCounters"][0]["text"], json!("幸运")); + } + + #[test] + fn wooden_fish_action_request_serializes_audio_and_image_fields() { + let payload = serde_json::to_value(WoodenFishActionRequest { + action_type: WoodenFishActionType::ReplaceHitSound, + profile_id: Some("wooden-fish-profile-1".to_string()), + work_title: None, + work_description: None, + theme_tags: None, + hit_object_prompt: Some("卡通铜钹".to_string()), + hit_object_reference_image_src: Some("/uploads/reference.png".to_string()), + hit_object_asset: Some(WoodenFishImageAsset { + asset_id: "image-1".to_string(), + image_src: "/generated-wooden-fish-assets/profile/hit-object/image.png".to_string(), + image_object_key: "generated-wooden-fish-assets/profile/hit-object/image.png" + .to_string(), + asset_object_id: "image-object-1".to_string(), + generation_provider: "image2".to_string(), + prompt: "卡通铜钹".to_string(), + width: 1024, + height: 1024, + }), + hit_sound_prompt: Some("短促木鱼声".to_string()), + hit_sound_asset: Some(WoodenFishAudioAsset { + asset_id: "sound-1".to_string(), + audio_src: "/generated/wooden-fish.mp3".to_string(), + audio_object_key: "generated/wooden-fish.mp3".to_string(), + asset_object_id: "asset-object-1".to_string(), + source: "upload".to_string(), + prompt: None, + duration_ms: Some(800), + }), + floating_words: Some(vec!["功德".to_string()]), + }) + .expect("payload should serialize"); + + assert_eq!(payload["actionType"], json!("replace-hit-sound")); + assert_eq!(payload["profileId"], json!("wooden-fish-profile-1")); + assert_eq!(payload["hitObjectPrompt"], json!("卡通铜钹")); + assert_eq!( + payload["hitObjectAsset"]["imageObjectKey"], + json!("generated-wooden-fish-assets/profile/hit-object/image.png") + ); + assert_eq!(payload["hitSoundAsset"]["source"], json!("upload")); + assert_eq!(payload["hitSoundAsset"]["durationMs"], json!(800)); + } + + #[test] + fn wooden_fish_action_request_ignores_client_hit_object_asset() { + let payload = serde_json::from_value::(json!({ + "actionType": "compile-draft", + "hitObjectPrompt": "卡通铜钹", + "hitObjectAsset": { + "assetId": "client-image", + "imageSrc": "/generated-wooden-fish-assets/client/image.png", + "imageObjectKey": "generated-wooden-fish-assets/client/image.png", + "assetObjectId": "client-asset-object", + "generationProvider": "client", + "prompt": "跳过生成", + "width": 1024, + "height": 1024 + } + })) + .expect("payload should deserialize"); + + assert_eq!(payload.action_type, WoodenFishActionType::CompileDraft); + assert_eq!(payload.hit_object_prompt.as_deref(), Some("卡通铜钹")); + assert_eq!(payload.hit_object_asset, None); + } + + #[test] + fn wooden_fish_work_profile_keeps_summary_and_runtime_assets() { + let image = WoodenFishImageAsset { + asset_id: "image-1".to_string(), + image_src: "/generated/wooden-fish.png".to_string(), + image_object_key: "generated/wooden-fish.png".to_string(), + asset_object_id: "image-object-1".to_string(), + generation_provider: "image2".to_string(), + prompt: "卡通木鱼".to_string(), + width: 1024, + height: 1024, + }; + let audio = WoodenFishAudioAsset { + asset_id: "sound-1".to_string(), + audio_src: "/generated/wooden-fish.mp3".to_string(), + audio_object_key: "generated/wooden-fish.mp3".to_string(), + asset_object_id: "sound-object-1".to_string(), + source: "generated".to_string(), + prompt: Some("清脆木鱼".to_string()), + duration_ms: Some(600), + }; + let profile = WoodenFishWorkProfileResponse { + summary: WoodenFishWorkSummaryResponse { + runtime_kind: "wooden-fish".to_string(), + work_id: "wooden-fish-profile-1".to_string(), + profile_id: "wooden-fish-profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_session_id: Some("wooden-fish-session-1".to_string()), + work_title: "敲木鱼".to_string(), + work_description: String::new(), + theme_tags: vec!["休闲".to_string()], + cover_image_src: Some(image.image_src.clone()), + publication_status: "draft".to_string(), + play_count: 0, + updated_at: "2026-05-20T00:00:00Z".to_string(), + published_at: None, + publish_ready: true, + generation_status: WoodenFishGenerationStatus::Ready, + }, + draft: WoodenFishDraftResponse { + template_id: "wooden-fish".to_string(), + template_name: "敲木鱼".to_string(), + profile_id: Some("wooden-fish-profile-1".to_string()), + work_title: "敲木鱼".to_string(), + work_description: String::new(), + theme_tags: vec!["休闲".to_string()], + hit_object_prompt: "卡通木鱼".to_string(), + hit_object_reference_image_src: None, + hit_sound_prompt: Some("清脆木鱼".to_string()), + floating_words: vec!["功德".to_string()], + hit_object_asset: Some(image.clone()), + hit_sound_asset: Some(audio.clone()), + cover_image_src: Some(image.image_src.clone()), + generation_status: WoodenFishGenerationStatus::Ready, + }, + hit_object_asset: image, + hit_sound_asset: audio, + floating_words: vec!["功德".to_string()], + }; + + let payload = serde_json::to_value(profile).expect("profile should serialize"); + + assert_eq!(payload["summary"]["runtimeKind"], json!("wooden-fish")); + assert_eq!( + payload["hitObjectAsset"]["generationProvider"], + json!("image2") + ); + assert_eq!(payload["hitSoundAsset"]["source"], json!("generated")); + } +} diff --git a/server-rs/crates/shared-logging/src/lib.rs b/server-rs/crates/shared-logging/src/lib.rs index ad77a6fb..6ab9470d 100644 --- a/server-rs/crates/shared-logging/src/lib.rs +++ b/server-rs/crates/shared-logging/src/lib.rs @@ -4,10 +4,7 @@ use opentelemetry::{KeyValue, global, trace::TracerProvider}; use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; use opentelemetry_otlp::WithExportConfig; use opentelemetry_sdk::{ - Resource, - logs::SdkLoggerProvider, - metrics::SdkMeterProvider, - trace::SdkTracerProvider, + Resource, logs::SdkLoggerProvider, metrics::SdkMeterProvider, trace::SdkTracerProvider, }; use tracing::warn; use tracing_subscriber::{ @@ -54,9 +51,7 @@ pub fn init_tracing(default_filter: &str, otel_config: OtelConfig) -> Result<(), tracing_opentelemetry::layer() .with_tracer(otel.tracer_provider.tracer("genarrative-api")), ) - .with( - OpenTelemetryTracingBridge::new(&otel.logger_provider).with_filter(LevelFilter::INFO), - ) + .with(OpenTelemetryTracingBridge::new(&otel.logger_provider).with_filter(LevelFilter::INFO)) .try_init() .map_err(|error| io::Error::other(format!("初始化 tracing subscriber 失败:{error}"))) } @@ -127,10 +122,12 @@ fn build_otel_pipeline() -> Option { .with_periodic_exporter(metric_exporter) .build(); let logger_provider = SdkLoggerProvider::builder() - .with_resource(Resource::builder() - .with_service_name(read_env_or_default("OTEL_SERVICE_NAME", "genarrative-api")) - .with_attribute(KeyValue::new("service.namespace", "genarrative")) - .build()) + .with_resource( + Resource::builder() + .with_service_name(read_env_or_default("OTEL_SERVICE_NAME", "genarrative-api")) + .with_attribute(KeyValue::new("service.namespace", "genarrative")) + .build(), + ) .with_batch_exporter(log_exporter) .build(); diff --git a/server-rs/crates/spacetime-client/Cargo.toml b/server-rs/crates/spacetime-client/Cargo.toml index 95c172ef..fcb0c9ce 100644 --- a/server-rs/crates/spacetime-client/Cargo.toml +++ b/server-rs/crates/spacetime-client/Cargo.toml @@ -12,6 +12,7 @@ module-combat = { workspace = true } module-custom-world = { workspace = true } module-inventory = { workspace = true } module-jump-hop = { workspace = true } +module-wooden-fish = { workspace = true } module-match3d = { workspace = true } module-npc = { workspace = true } module-puzzle = { workspace = true } diff --git a/server-rs/crates/spacetime-client/src/jump_hop.rs b/server-rs/crates/spacetime-client/src/jump_hop.rs index 1b22eddf..7d798b88 100644 --- a/server-rs/crates/spacetime-client/src/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/jump_hop.rs @@ -5,11 +5,11 @@ use crate::mapper::{ map_jump_hop_works_procedure_result, }; use shared_contracts::jump_hop::{ - JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, - JumpHopDraftResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, JumpHopJumpRequest, - JumpHopRestartRunRequest, JumpHopRuntimeRunSnapshotResponse, JumpHopSessionSnapshotResponse, - JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, JumpHopTileType, - JumpHopWorkProfileResponse, + JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, + JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, + JumpHopJumpRequest, JumpHopRestartRunRequest, JumpHopRuntimeRunSnapshotResponse, + JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, + JumpHopTileType, JumpHopWorkProfileResponse, }; use shared_kernel::build_prefixed_uuid_id; @@ -21,10 +21,9 @@ impl SpacetimeClient { &self, session: JumpHopSessionSnapshotResponse, ) -> Result { - let draft = session - .draft - .clone() - .ok_or_else(|| SpacetimeClientError::validation_failed("jump-hop session 缺少 draft"))?; + let draft = session.draft.clone().ok_or_else(|| { + SpacetimeClientError::validation_failed("jump-hop session 缺少 draft") + })?; let theme_tags_json = Some(json_string(&draft.theme_tags)?); let config_json = Some(build_config_json(&draft)?); let work_title = draft.work_title.clone(); @@ -164,15 +163,14 @@ impl SpacetimeClient { procedure_input: JumpHopWorkUpdateInput, ) -> Result { self.call_after_connect("update_jump_hop_work", move |connection, sender| { - connection.procedures().update_jump_hop_work_then( - procedure_input, - move |_, result| { + connection + .procedures() + .update_jump_hop_work_then(procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_jump_hop_work_procedure_result); send_once(&sender, mapped); - }, - ); + }); }) .await } @@ -212,15 +210,14 @@ impl SpacetimeClient { }; self.call_after_connect("list_jump_hop_works", move |connection, sender| { - connection.procedures().list_jump_hop_works_then( - procedure_input, - move |_, result| { + connection + .procedures() + .list_jump_hop_works_then(procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_jump_hop_works_procedure_result); send_once(&sender, mapped); - }, - ); + }); }) .await } @@ -229,7 +226,8 @@ impl SpacetimeClient { &self, profile_id: String, ) -> Result { - self.get_jump_hop_work_profile(profile_id, String::new()).await + self.get_jump_hop_work_profile(profile_id, String::new()) + .await } pub async fn start_jump_hop_run( @@ -253,15 +251,14 @@ impl SpacetimeClient { procedure_input: JumpHopRunStartInput, ) -> Result { self.call_after_connect("start_jump_hop_run", move |connection, sender| { - connection.procedures().start_jump_hop_run_then( - procedure_input, - move |_, result| { + connection + .procedures() + .start_jump_hop_run_then(procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_jump_hop_run_procedure_result); send_once(&sender, mapped); - }, - ); + }); }) .await } @@ -277,15 +274,14 @@ impl SpacetimeClient { }; self.call_after_connect("get_jump_hop_run", move |connection, sender| { - connection.procedures().get_jump_hop_run_then( - procedure_input, - move |_, result| { + connection + .procedures() + .get_jump_hop_run_then(procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_jump_hop_run_procedure_result); send_once(&sender, mapped); - }, - ); + }); }) .await } @@ -305,15 +301,14 @@ impl SpacetimeClient { }; self.call_after_connect("jump_hop_jump", move |connection, sender| { - connection.procedures().jump_hop_jump_then( - procedure_input, - move |_, result| { + connection + .procedures() + .jump_hop_jump_then(procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_jump_hop_run_procedure_result); send_once(&sender, mapped); - }, - ); + }); }) .await } @@ -333,15 +328,14 @@ impl SpacetimeClient { }; self.call_after_connect("restart_jump_hop_run", move |connection, sender| { - connection.procedures().restart_jump_hop_run_then( - procedure_input, - move |_, result| { + connection + .procedures() + .restart_jump_hop_run_then(procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_jump_hop_run_procedure_result); send_once(&sender, mapped); - }, - ); + }); }) .await } @@ -430,16 +424,16 @@ fn build_jump_hop_action_plan( JumpHopAssetRefresh::Preserve, now_micros, )?), - JumpHopActionType::RegenerateCharacter => JumpHopActionProcedure::Compile( - build_compile_input( + JumpHopActionType::RegenerateCharacter => { + JumpHopActionProcedure::Compile(build_compile_input( current, owner_user_id, &profile_id, &mut draft, JumpHopAssetRefresh::Character, now_micros, - )?, - ), + )?) + } JumpHopActionType::RegenerateTiles => JumpHopActionProcedure::Compile(build_compile_input( current, owner_user_id, @@ -472,7 +466,11 @@ fn merge_action_into_draft( scope, JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::UpdateWorkMeta ) { - if let Some(value) = payload.work_title.as_ref().filter(|value| !value.trim().is_empty()) { + if let Some(value) = payload + .work_title + .as_ref() + .filter(|value| !value.trim().is_empty()) + { draft.work_title = value.trim().to_string(); } if let Some(value) = payload.work_description.as_ref() { @@ -523,7 +521,9 @@ fn merge_action_into_draft( draft.tile_prompt = value.trim().to_string(); } if draft.work_title.trim().is_empty() { - return Err(SpacetimeClientError::validation_failed("jump-hop work_title 不能为空")); + return Err(SpacetimeClientError::validation_failed( + "jump-hop work_title 不能为空", + )); } Ok(draft) } @@ -762,7 +762,9 @@ fn ensure_tile_assets( .map(|(index, tile_type)| JumpHopTileAsset { tile_type, image_src: format!("/generated-jump-hop-assets/{profile_id}/tiles/{index}{suffix}.png"), - image_object_key: format!("generated-jump-hop-assets/{profile_id}/tiles/{index}{suffix}.png"), + image_object_key: format!( + "generated-jump-hop-assets/{profile_id}/tiles/{index}{suffix}.png" + ), asset_object_id: format!("{profile_id}-tile-{index}{suffix}-object"), source_atlas_cell: format!("cell-{index}{suffix}"), visual_width: 256, @@ -788,7 +790,9 @@ fn resolve_cover_composite( { return Some(value.to_string()); } - let suffix = asset_revision_suffix((!matches!(refresh, JumpHopAssetRefresh::Preserve)).then_some(now_micros)); + let suffix = asset_revision_suffix( + (!matches!(refresh, JumpHopAssetRefresh::Preserve)).then_some(now_micros), + ); Some(format!( "/generated-jump-hop-assets/{profile_id}/cover-composite{suffix}.png" )) @@ -850,9 +854,27 @@ mod tests { assert_eq!(input.session_id, SESSION_ID); assert_eq!(input.owner_user_id, OWNER_USER_ID); assert_eq!(input.generation_status.as_deref(), Some("ready")); - assert!(input.character_asset_json.as_deref().unwrap_or("").contains("-character")); - assert!(input.tile_atlas_asset_json.as_deref().unwrap_or("").contains("-tile-atlas")); - assert!(input.tile_assets_json.as_deref().unwrap_or("").contains("tile-0-object")); + assert!( + input + .character_asset_json + .as_deref() + .unwrap_or("") + .contains("-character") + ); + assert!( + input + .tile_atlas_asset_json + .as_deref() + .unwrap_or("") + .contains("-tile-atlas") + ); + assert!( + input + .tile_assets_json + .as_deref() + .unwrap_or("") + .contains("tile-0-object") + ); assert_eq!(draft.generation_status, JumpHopGenerationStatus::Ready); } @@ -869,10 +891,34 @@ mod tests { let JumpHopActionProcedure::Compile(input) = plan else { panic!("regenerate-character should call compile_jump_hop_draft"); }; - assert!(!input.character_asset_json.as_deref().unwrap_or("").contains("old-character")); - assert!(input.character_asset_json.as_deref().unwrap_or("").contains(&NOW_MICROS.to_string())); - assert!(input.tile_atlas_asset_json.as_deref().unwrap_or("").contains("old-tile-atlas")); - assert!(input.tile_assets_json.as_deref().unwrap_or("").contains("old-normal-tile")); + assert!( + !input + .character_asset_json + .as_deref() + .unwrap_or("") + .contains("old-character") + ); + assert!( + input + .character_asset_json + .as_deref() + .unwrap_or("") + .contains(&NOW_MICROS.to_string()) + ); + assert!( + input + .tile_atlas_asset_json + .as_deref() + .unwrap_or("") + .contains("old-tile-atlas") + ); + assert!( + input + .tile_assets_json + .as_deref() + .unwrap_or("") + .contains("old-normal-tile") + ); } #[test] @@ -888,11 +934,41 @@ mod tests { let JumpHopActionProcedure::Compile(input) = plan else { panic!("regenerate-tiles should call compile_jump_hop_draft"); }; - assert!(input.character_asset_json.as_deref().unwrap_or("").contains("old-character")); - assert!(!input.tile_atlas_asset_json.as_deref().unwrap_or("").contains("old-tile-atlas")); - assert!(!input.tile_assets_json.as_deref().unwrap_or("").contains("old-normal-tile")); - assert!(input.tile_atlas_asset_json.as_deref().unwrap_or("").contains(&NOW_MICROS.to_string())); - assert!(input.tile_assets_json.as_deref().unwrap_or("").contains(&NOW_MICROS.to_string())); + assert!( + input + .character_asset_json + .as_deref() + .unwrap_or("") + .contains("old-character") + ); + assert!( + !input + .tile_atlas_asset_json + .as_deref() + .unwrap_or("") + .contains("old-tile-atlas") + ); + assert!( + !input + .tile_assets_json + .as_deref() + .unwrap_or("") + .contains("old-normal-tile") + ); + assert!( + input + .tile_atlas_asset_json + .as_deref() + .unwrap_or("") + .contains(&NOW_MICROS.to_string()) + ); + assert!( + input + .tile_assets_json + .as_deref() + .unwrap_or("") + .contains(&NOW_MICROS.to_string()) + ); } #[test] @@ -934,8 +1010,20 @@ mod tests { }; assert_eq!(input.difficulty.as_deref(), Some("challenge")); assert!(input.style_preset.is_none()); - assert_eq!(draft.character_asset.as_ref().map(|asset| asset.asset_id.as_str()), Some("old-character")); - assert_eq!(draft.tile_assets.first().map(|asset| asset.asset_object_id.as_str()), Some("old-normal-tile-object")); + assert_eq!( + draft + .character_asset + .as_ref() + .map(|asset| asset.asset_id.as_str()), + Some("old-character") + ); + assert_eq!( + draft + .tile_assets + .first() + .map(|asset| asset.asset_object_id.as_str()), + Some("old-normal-tile-object") + ); } fn action(action_type: JumpHopActionType) -> JumpHopActionRequest { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 26ff1ad9..87faa017 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -30,16 +30,8 @@ pub use mapper::{ CustomWorldPublishGateRecord, CustomWorldPublishWorldRecord, CustomWorldPublishWorldRecordInput, CustomWorldPublishedProfileCompileRecord, CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, - CustomWorldWorkSummaryRecord, Match3DAgentMessageFinalizeRecordInput, - Match3DAgentMessageRecord, Match3DAgentMessageSubmitRecordInput, - Match3DAgentSessionCreateRecordInput, Match3DAgentSessionRecord, Match3DAnchorItemRecord, - Match3DAnchorPackRecord, Match3DClickConfirmationRecord, Match3DCompileDraftRecordInput, - Match3DCreatorConfigRecord, Match3DItemSnapshotRecord, Match3DResultDraftRecord, - Match3DRunClickRecordInput, Match3DRunRecord, Match3DRunRestartRecordInput, - Match3DRunStartRecordInput, Match3DRunStopRecordInput, Match3DRunTimeUpRecordInput, - Match3DTraySlotRecord, Match3DWorkProfileRecord, Match3DWorkUpdateRecordInput, - JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, - JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, + CustomWorldWorkSummaryRecord, JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, + JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath, JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus, @@ -47,7 +39,14 @@ pub use mapper::{ JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse, - JumpHopWorkspaceCreateRequest, + JumpHopWorkspaceCreateRequest, Match3DAgentMessageFinalizeRecordInput, + Match3DAgentMessageRecord, Match3DAgentMessageSubmitRecordInput, + Match3DAgentSessionCreateRecordInput, Match3DAgentSessionRecord, Match3DAnchorItemRecord, + Match3DAnchorPackRecord, Match3DClickConfirmationRecord, Match3DCompileDraftRecordInput, + Match3DCreatorConfigRecord, Match3DItemSnapshotRecord, Match3DResultDraftRecord, + Match3DRunClickRecordInput, Match3DRunRecord, Match3DRunRestartRecordInput, + Match3DRunStartRecordInput, Match3DRunStopRecordInput, Match3DRunTimeUpRecordInput, + Match3DTraySlotRecord, Match3DWorkProfileRecord, Match3DWorkUpdateRecordInput, NpcBattleInteractionRecord, NpcInteractionRecord, NpcStateRecord, PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, @@ -80,7 +79,15 @@ pub use mapper::{ VisualNovelHistoryEntryRecordInput, VisualNovelRunRecord, VisualNovelRunSnapshotRecordInput, VisualNovelRunStartRecordInput, VisualNovelRuntimeEventRecord, VisualNovelRuntimeEventRecordInput, VisualNovelWorkCompileRecordInput, - VisualNovelWorkProfileRecord, VisualNovelWorkUpdateRecordInput, + VisualNovelWorkProfileRecord, VisualNovelWorkUpdateRecordInput, WoodenFishActionRequest, + WoodenFishActionResponse, WoodenFishActionType, WoodenFishAudioAsset, + WoodenFishCheckpointRunRequest, WoodenFishDraftResponse, WoodenFishFinishRunRequest, + WoodenFishGalleryCardResponse, WoodenFishGalleryDetailResponse, WoodenFishGalleryResponse, + WoodenFishGenerationStatus, WoodenFishImageAsset, WoodenFishRunResponse, WoodenFishRunStatus, + WoodenFishRuntimeRunSnapshotResponse, WoodenFishSessionResponse, + WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest, WoodenFishWordCounter, + WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkProfileResponse, + WoodenFishWorkSummaryResponse, WoodenFishWorkspaceCreateRequest, }; pub mod ai; @@ -105,6 +112,7 @@ pub mod square_hole; pub mod story; pub mod story_runtime; pub mod visual_novel; +pub mod wooden_fish; use std::{ collections::HashMap, @@ -563,6 +571,7 @@ impl SpacetimeClient { for query in [ "SELECT * FROM puzzle_gallery_card_view", "SELECT * FROM jump_hop_gallery_card_view", + "SELECT * FROM wooden_fish_gallery_card_view", "SELECT * FROM custom_world_gallery_entry", "SELECT * FROM match_3_d_gallery_view", "SELECT * FROM square_hole_gallery_view", @@ -578,6 +587,7 @@ impl SpacetimeClient { for query in [ "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'", "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'jump-hop'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'wooden-fish'", "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'", "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'match3d'", "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'", diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 23ce69a8..34d50960 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -18,6 +18,7 @@ mod runtime_profile; mod square_hole; mod story; mod visual_novel; +mod wooden_fish; pub use self::ai::{ AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, AiTaskStageRecord, @@ -36,18 +37,6 @@ pub use self::combat::{ BarkBattleDraftConfigRecord, BarkBattleRunRecord, BarkBattleRuntimeConfigRecord, ResolveCombatActionRecord, }; -pub use self::jump_hop::{ - JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, - JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, - JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, - JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath, - JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus, - JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse, - JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, - JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, - JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse, - JumpHopWorkspaceCreateRequest, -}; pub use self::common::{ BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord, BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, @@ -77,6 +66,18 @@ pub use self::common::{ VisualNovelRunSnapshotRecordInput, VisualNovelRunStartRecordInput, VisualNovelWorkCompileRecordInput, }; +pub use self::jump_hop::{ + JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, + JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, + JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, + JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath, + JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus, + JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse, + JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, + JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, + JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse, + JumpHopWorkspaceCreateRequest, +}; pub use self::match3d::{ Match3DAgentMessageFinalizeRecordInput, Match3DAgentMessageRecord, Match3DAgentMessageSubmitRecordInput, Match3DAgentSessionCreateRecordInput, @@ -118,6 +119,16 @@ pub use self::runtime_profile::{ SquareHoleDropConfirmationRecord, SquareHoleDropFeedbackRecord, SquareHoleRunRecord, }; pub use self::story::{VisualNovelRuntimeEventRecord, VisualNovelRuntimeEventRecordInput}; +pub use self::wooden_fish::{ + WoodenFishActionRequest, WoodenFishActionResponse, WoodenFishActionType, WoodenFishAudioAsset, + WoodenFishCheckpointRunRequest, WoodenFishDraftResponse, WoodenFishFinishRunRequest, + WoodenFishGalleryCardResponse, WoodenFishGalleryDetailResponse, WoodenFishGalleryResponse, + WoodenFishGenerationStatus, WoodenFishImageAsset, WoodenFishRunResponse, WoodenFishRunStatus, + WoodenFishRuntimeRunSnapshotResponse, WoodenFishSessionResponse, + WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest, WoodenFishWordCounter, + WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkProfileResponse, + WoodenFishWorkSummaryResponse, WoodenFishWorkspaceCreateRequest, +}; pub(crate) use self::ai::map_ai_task_procedure_result; pub(crate) use self::assets::{map_entity_binding_procedure_result, map_procedure_result}; @@ -220,3 +231,8 @@ pub(crate) use self::visual_novel::{ map_visual_novel_runtime_event_procedure_result, map_visual_novel_work_procedure_result, map_visual_novel_works_procedure_result, }; +pub(crate) use self::wooden_fish::{ + map_wooden_fish_agent_session_procedure_result, map_wooden_fish_gallery_card_view_row, + map_wooden_fish_run_procedure_result, map_wooden_fish_work_procedure_result, + map_wooden_fish_works_procedure_result, +}; diff --git a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs index e5716a14..a2384840 100644 --- a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs @@ -161,7 +161,11 @@ fn map_jump_hop_work_snapshot( path: map_jump_hop_path(snapshot.path), character_asset, tile_atlas_asset, - tile_assets: snapshot.tile_assets.into_iter().map(map_tile_asset).collect(), + tile_assets: snapshot + .tile_assets + .into_iter() + .map(map_tile_asset) + .collect(), }) } @@ -180,7 +184,11 @@ fn map_jump_hop_draft_snapshot(snapshot: JumpHopDraftSnapshot) -> JumpHopDraftRe end_mood_prompt: snapshot.end_mood_prompt, character_asset: snapshot.character_asset.map(map_character_asset), tile_atlas_asset: snapshot.tile_atlas_asset.map(map_character_asset), - tile_assets: snapshot.tile_assets.into_iter().map(map_tile_asset).collect(), + tile_assets: snapshot + .tile_assets + .into_iter() + .map(map_tile_asset) + .collect(), path: snapshot.path.map(map_jump_hop_path), cover_composite: snapshot.cover_composite, generation_status: parse_generation_status(&snapshot.generation_status), @@ -268,7 +276,9 @@ fn map_jump_hop_run_snapshot(snapshot: JumpHopRunSnapshot) -> JumpHopRuntimeRunS crate::module_bindings::JumpHopJumpResultKind::Miss => JumpHopJumpResult::Miss, crate::module_bindings::JumpHopJumpResultKind::Hit => JumpHopJumpResult::Hit, crate::module_bindings::JumpHopJumpResultKind::Finish => JumpHopJumpResult::Finish, - crate::module_bindings::JumpHopJumpResultKind::Perfect => JumpHopJumpResult::Perfect, + crate::module_bindings::JumpHopJumpResultKind::Perfect => { + JumpHopJumpResult::Perfect + } }, }), started_at_ms: snapshot.started_at_ms, diff --git a/server-rs/crates/spacetime-client/src/mapper/wooden_fish.rs b/server-rs/crates/spacetime-client/src/mapper/wooden_fish.rs new file mode 100644 index 00000000..80beca4c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/wooden_fish.rs @@ -0,0 +1,237 @@ +use super::*; +pub use shared_contracts::wooden_fish::{ + WoodenFishActionRequest, WoodenFishActionResponse, WoodenFishActionType, WoodenFishAudioAsset, + WoodenFishCheckpointRunRequest, WoodenFishDraftResponse, WoodenFishFinishRunRequest, + WoodenFishGalleryCardResponse, WoodenFishGalleryDetailResponse, WoodenFishGalleryResponse, + WoodenFishGenerationStatus, WoodenFishImageAsset, WoodenFishRunResponse, WoodenFishRunStatus, + WoodenFishRuntimeRunSnapshotResponse, WoodenFishSessionResponse, + WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest, WoodenFishWordCounter, + WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkProfileResponse, + WoodenFishWorkSummaryResponse, WoodenFishWorkspaceCreateRequest, +}; + +pub(crate) fn map_wooden_fish_agent_session_procedure_result( + result: WoodenFishAgentSessionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + let session = result + .session + .ok_or_else(|| SpacetimeClientError::missing_snapshot("wooden fish agent session 快照"))?; + Ok(map_wooden_fish_session_snapshot(session)) +} + +pub(crate) fn map_wooden_fish_work_procedure_result( + result: WoodenFishWorkProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + let work = result + .work + .ok_or_else(|| SpacetimeClientError::missing_snapshot("wooden fish work 快照"))?; + map_wooden_fish_work_snapshot(work) +} + +pub(crate) fn map_wooden_fish_works_procedure_result( + result: WoodenFishWorksProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + result + .items + .into_iter() + .map(map_wooden_fish_work_snapshot) + .collect() +} + +pub(crate) fn map_wooden_fish_run_procedure_result( + result: WoodenFishRunProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + let run = result + .run + .ok_or_else(|| SpacetimeClientError::missing_snapshot("wooden fish run 快照"))?; + Ok(map_wooden_fish_run_snapshot(run)) +} + +pub(crate) fn map_wooden_fish_gallery_card_view_row( + row: WoodenFishGalleryCardViewRow, +) -> WoodenFishGalleryCardResponse { + WoodenFishGalleryCardResponse { + public_work_code: row.public_work_code, + work_id: row.work_id, + profile_id: row.profile_id, + owner_user_id: row.owner_user_id, + author_display_name: row.author_display_name, + work_title: row.work_title, + work_description: row.work_description, + cover_image_src: empty_string_to_none(row.cover_image_src), + theme_tags: row.theme_tags, + publication_status: normalize_publication_status(&row.publication_status).to_string(), + play_count: row.play_count, + updated_at: format_timestamp_micros(row.updated_at_micros), + published_at: row.published_at_micros.map(format_timestamp_micros), + generation_status: parse_generation_status(&row.generation_status), + } +} + +fn map_wooden_fish_session_snapshot( + snapshot: WoodenFishAgentSessionSnapshot, +) -> WoodenFishSessionSnapshotResponse { + WoodenFishSessionSnapshotResponse { + session_id: snapshot.session_id, + owner_user_id: snapshot.owner_user_id, + status: snapshot + .draft + .as_ref() + .map(|draft| parse_generation_status(&draft.generation_status)) + .unwrap_or(WoodenFishGenerationStatus::Draft), + draft: snapshot.draft.map(map_wooden_fish_draft_snapshot), + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +fn map_wooden_fish_work_snapshot( + snapshot: WoodenFishWorkSnapshot, +) -> Result { + let draft = WoodenFishDraftResponse { + template_id: "wooden-fish".to_string(), + template_name: "敲木鱼".to_string(), + profile_id: Some(snapshot.profile_id.clone()), + work_title: snapshot.work_title.clone(), + work_description: snapshot.work_description.clone(), + theme_tags: snapshot.theme_tags.clone(), + hit_object_prompt: snapshot.hit_object_prompt.clone(), + hit_object_reference_image_src: snapshot.hit_object_reference_image_src.clone(), + hit_sound_prompt: snapshot.hit_sound_prompt.clone(), + floating_words: snapshot.floating_words.clone(), + hit_object_asset: snapshot.hit_object_asset.clone().map(map_image_asset), + hit_sound_asset: snapshot.hit_sound_asset.clone().map(map_audio_asset), + cover_image_src: empty_string_to_none(snapshot.cover_image_src.clone()), + generation_status: parse_generation_status(&snapshot.generation_status), + }; + let hit_object_asset = draft + .hit_object_asset + .clone() + .ok_or_else(|| SpacetimeClientError::missing_snapshot("wooden fish hit object asset"))?; + let hit_sound_asset = draft + .hit_sound_asset + .clone() + .ok_or_else(|| SpacetimeClientError::missing_snapshot("wooden fish hit sound asset"))?; + Ok(WoodenFishWorkProfileResponse { + summary: WoodenFishWorkSummaryResponse { + runtime_kind: "wooden-fish".to_string(), + work_id: snapshot.work_id, + profile_id: snapshot.profile_id, + owner_user_id: snapshot.owner_user_id, + source_session_id: empty_string_to_none(snapshot.source_session_id), + work_title: snapshot.work_title, + work_description: snapshot.work_description, + theme_tags: snapshot.theme_tags, + cover_image_src: empty_string_to_none(snapshot.cover_image_src), + publication_status: normalize_publication_status(&snapshot.publication_status) + .to_string(), + play_count: snapshot.play_count, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + published_at: snapshot.published_at_micros.map(format_timestamp_micros), + publish_ready: snapshot.publish_ready, + generation_status: parse_generation_status(&snapshot.generation_status), + }, + draft, + hit_object_asset, + hit_sound_asset, + floating_words: snapshot.floating_words, + }) +} + +fn map_wooden_fish_draft_snapshot(snapshot: WoodenFishDraftSnapshot) -> WoodenFishDraftResponse { + WoodenFishDraftResponse { + template_id: snapshot.template_id, + template_name: snapshot.template_name, + profile_id: snapshot.profile_id, + work_title: snapshot.work_title, + work_description: snapshot.work_description, + theme_tags: snapshot.theme_tags, + hit_object_prompt: snapshot.hit_object_prompt, + hit_object_reference_image_src: snapshot.hit_object_reference_image_src, + hit_sound_prompt: snapshot.hit_sound_prompt, + floating_words: snapshot.floating_words, + hit_object_asset: snapshot.hit_object_asset.map(map_image_asset), + hit_sound_asset: snapshot.hit_sound_asset.map(map_audio_asset), + cover_image_src: snapshot.cover_image_src, + generation_status: parse_generation_status(&snapshot.generation_status), + } +} + +fn map_image_asset(snapshot: WoodenFishImageAssetSnapshot) -> WoodenFishImageAsset { + WoodenFishImageAsset { + asset_id: snapshot.asset_id, + image_src: snapshot.image_src, + image_object_key: snapshot.image_object_key, + asset_object_id: snapshot.asset_object_id, + generation_provider: snapshot.generation_provider, + prompt: snapshot.prompt, + width: snapshot.width, + height: snapshot.height, + } +} + +fn map_audio_asset(snapshot: WoodenFishAudioAssetSnapshot) -> WoodenFishAudioAsset { + WoodenFishAudioAsset { + asset_id: snapshot.asset_id, + audio_src: snapshot.audio_src, + audio_object_key: snapshot.audio_object_key, + asset_object_id: snapshot.asset_object_id, + source: snapshot.source, + prompt: snapshot.prompt, + duration_ms: snapshot.duration_ms, + } +} + +fn map_wooden_fish_run_snapshot( + snapshot: crate::module_bindings::WoodenFishRunSnapshot, +) -> WoodenFishRuntimeRunSnapshotResponse { + WoodenFishRuntimeRunSnapshotResponse { + run_id: snapshot.run_id, + profile_id: snapshot.profile_id, + owner_user_id: snapshot.owner_user_id, + status: match snapshot.status { + crate::module_bindings::WoodenFishRunStatus::Playing => WoodenFishRunStatus::Playing, + crate::module_bindings::WoodenFishRunStatus::Finished => WoodenFishRunStatus::Finished, + }, + total_tap_count: snapshot.total_tap_count, + word_counters: snapshot + .word_counters + .into_iter() + .map(|counter| WoodenFishWordCounter { + text: counter.text, + count: counter.count, + }) + .collect(), + started_at_ms: snapshot.started_at_ms, + updated_at_ms: snapshot.updated_at_ms, + finished_at_ms: snapshot.finished_at_ms, + } +} + +fn parse_generation_status(value: &str) -> WoodenFishGenerationStatus { + match value { + "generating" => WoodenFishGenerationStatus::Generating, + "ready" => WoodenFishGenerationStatus::Ready, + "failed" => WoodenFishGenerationStatus::Failed, + _ => WoodenFishGenerationStatus::Draft, + } +} + +fn normalize_publication_status(value: &str) -> &str { + match value { + "Published" | "published" => "published", + _ => "draft", + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 5b6a593d..b5adca82 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -191,6 +191,7 @@ pub mod chapter_progression_procedure_result_type; pub mod chapter_progression_snapshot_type; pub mod chapter_progression_table; pub mod chapter_progression_type; +pub mod checkpoint_wooden_fish_run_procedure; pub mod claim_profile_task_reward_and_return_procedure; pub mod claim_puzzle_work_point_incentive_procedure; pub mod clear_database_migration_import_chunks_procedure; @@ -204,6 +205,7 @@ pub mod compile_match_3_d_draft_procedure; pub mod compile_puzzle_agent_draft_procedure; pub mod compile_square_hole_draft_procedure; pub mod compile_visual_novel_work_profile_procedure; +pub mod compile_wooden_fish_draft_procedure; pub mod complete_ai_stage_and_return_procedure; pub mod complete_ai_task_and_return_procedure; pub mod confirm_asset_object_and_return_procedure; @@ -225,6 +227,7 @@ 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 create_wooden_fish_agent_session_procedure; pub mod creation_entry_config_procedure_result_type; pub mod creation_entry_config_snapshot_type; pub mod creation_entry_config_table; @@ -336,6 +339,7 @@ pub mod finalize_visual_novel_agent_message_turn_procedure; pub mod finish_bark_battle_run_procedure; pub mod finish_match_3_d_time_up_procedure; pub mod finish_square_hole_time_up_procedure; +pub mod finish_wooden_fish_run_procedure; pub mod generate_big_fish_asset_procedure; pub mod get_auth_store_snapshot_procedure; pub mod get_bark_battle_run_procedure; @@ -378,6 +382,9 @@ pub mod get_story_session_state_procedure; pub mod get_visual_novel_agent_session_procedure; pub mod get_visual_novel_run_procedure; pub mod get_visual_novel_work_detail_procedure; +pub mod get_wooden_fish_agent_session_procedure; +pub mod get_wooden_fish_run_procedure; +pub mod get_wooden_fish_work_profile_procedure; pub mod grant_inventory_item_input_type; pub mod grant_new_user_registration_wallet_reward_procedure; pub mod grant_player_progression_experience_and_return_procedure; @@ -456,6 +463,7 @@ pub mod list_puzzle_works_procedure; pub mod list_square_hole_works_procedure; pub mod list_visual_novel_runtime_history_procedure; pub mod list_visual_novel_works_procedure; +pub mod list_wooden_fish_works_procedure; pub mod mark_profile_recharge_order_paid_and_return_procedure; pub mod match_3_d_agent_message_finalize_input_type; pub mod match_3_d_agent_message_row_type; @@ -562,6 +570,7 @@ pub mod publish_match_3_d_work_procedure; pub mod publish_puzzle_work_procedure; pub mod publish_square_hole_work_procedure; pub mod publish_visual_novel_work_procedure; +pub mod publish_wooden_fish_work_procedure; pub mod put_database_migration_import_chunk_procedure; pub mod puzzle_agent_message_finalize_input_type; pub mod puzzle_agent_message_kind_type; @@ -876,6 +885,7 @@ pub mod start_match_3_d_run_procedure; pub mod start_puzzle_run_procedure; pub mod start_square_hole_run_procedure; pub mod start_visual_novel_run_procedure; +pub mod start_wooden_fish_run_procedure; pub mod stop_match_3_d_run_procedure; pub mod stop_square_hole_run_procedure; pub mod story_continue_input_type; @@ -922,6 +932,7 @@ pub mod update_puzzle_run_pause_procedure; pub mod update_puzzle_work_procedure; pub mod update_square_hole_work_procedure; pub mod update_visual_novel_work_procedure; +pub mod update_wooden_fish_work_procedure; pub mod upsert_auth_store_snapshot_procedure; pub mod upsert_chapter_progression_and_return_procedure; pub mod upsert_chapter_progression_reducer; @@ -984,6 +995,42 @@ pub mod visual_novel_work_snapshot_type; pub mod visual_novel_work_update_input_type; pub mod visual_novel_works_list_input_type; pub mod visual_novel_works_procedure_result_type; +pub mod wooden_fish_agent_session_create_input_type; +pub mod wooden_fish_agent_session_get_input_type; +pub mod wooden_fish_agent_session_procedure_result_type; +pub mod wooden_fish_agent_session_row_type; +pub mod wooden_fish_agent_session_snapshot_type; +pub mod wooden_fish_agent_session_table; +pub mod wooden_fish_audio_asset_snapshot_type; +pub mod wooden_fish_creator_config_snapshot_type; +pub mod wooden_fish_draft_compile_input_type; +pub mod wooden_fish_draft_snapshot_type; +pub mod wooden_fish_event_row_type; +pub mod wooden_fish_event_table; +pub mod wooden_fish_gallery_card_view_row_type; +pub mod wooden_fish_gallery_card_view_table; +pub mod wooden_fish_gallery_view_row_type; +pub mod wooden_fish_gallery_view_table; +pub mod wooden_fish_image_asset_snapshot_type; +pub mod wooden_fish_run_checkpoint_input_type; +pub mod wooden_fish_run_finish_input_type; +pub mod wooden_fish_run_get_input_type; +pub mod wooden_fish_run_procedure_result_type; +pub mod wooden_fish_run_snapshot_type; +pub mod wooden_fish_run_start_input_type; +pub mod wooden_fish_run_status_type; +pub mod wooden_fish_runtime_run_row_type; +pub mod wooden_fish_runtime_run_table; +pub mod wooden_fish_word_counter_type; +pub mod wooden_fish_work_get_input_type; +pub mod wooden_fish_work_procedure_result_type; +pub mod wooden_fish_work_profile_row_type; +pub mod wooden_fish_work_profile_table; +pub mod wooden_fish_work_publish_input_type; +pub mod wooden_fish_work_snapshot_type; +pub mod wooden_fish_work_update_input_type; +pub mod wooden_fish_works_list_input_type; +pub mod wooden_fish_works_procedure_result_type; pub use accept_quest_reducer::accept_quest; pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion; @@ -1170,6 +1217,7 @@ pub use chapter_progression_procedure_result_type::ChapterProgressionProcedureRe pub use chapter_progression_snapshot_type::ChapterProgressionSnapshot; pub use chapter_progression_table::*; pub use chapter_progression_type::ChapterProgression; +pub use checkpoint_wooden_fish_run_procedure::checkpoint_wooden_fish_run; pub use claim_profile_task_reward_and_return_procedure::claim_profile_task_reward_and_return; pub use claim_puzzle_work_point_incentive_procedure::claim_puzzle_work_point_incentive; pub use clear_database_migration_import_chunks_procedure::clear_database_migration_import_chunks; @@ -1183,6 +1231,7 @@ pub use compile_match_3_d_draft_procedure::compile_match_3_d_draft; pub use compile_puzzle_agent_draft_procedure::compile_puzzle_agent_draft; pub use compile_square_hole_draft_procedure::compile_square_hole_draft; pub use compile_visual_novel_work_profile_procedure::compile_visual_novel_work_profile; +pub use compile_wooden_fish_draft_procedure::compile_wooden_fish_draft; pub use complete_ai_stage_and_return_procedure::complete_ai_stage_and_return; pub use complete_ai_task_and_return_procedure::complete_ai_task_and_return; pub use confirm_asset_object_and_return_procedure::confirm_asset_object_and_return; @@ -1204,6 +1253,7 @@ 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 create_wooden_fish_agent_session_procedure::create_wooden_fish_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::*; @@ -1315,6 +1365,7 @@ pub use finalize_visual_novel_agent_message_turn_procedure::finalize_visual_nove pub use finish_bark_battle_run_procedure::finish_bark_battle_run; 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 finish_wooden_fish_run_procedure::finish_wooden_fish_run; 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_bark_battle_run_procedure::get_bark_battle_run; @@ -1357,6 +1408,9 @@ pub use get_story_session_state_procedure::get_story_session_state; pub use get_visual_novel_agent_session_procedure::get_visual_novel_agent_session; pub use get_visual_novel_run_procedure::get_visual_novel_run; pub use get_visual_novel_work_detail_procedure::get_visual_novel_work_detail; +pub use get_wooden_fish_agent_session_procedure::get_wooden_fish_agent_session; +pub use get_wooden_fish_run_procedure::get_wooden_fish_run; +pub use get_wooden_fish_work_profile_procedure::get_wooden_fish_work_profile; pub use grant_inventory_item_input_type::GrantInventoryItemInput; pub use grant_new_user_registration_wallet_reward_procedure::grant_new_user_registration_wallet_reward; pub use grant_player_progression_experience_and_return_procedure::grant_player_progression_experience_and_return; @@ -1435,6 +1489,7 @@ pub use list_puzzle_works_procedure::list_puzzle_works; pub use list_square_hole_works_procedure::list_square_hole_works; pub use list_visual_novel_runtime_history_procedure::list_visual_novel_runtime_history; pub use list_visual_novel_works_procedure::list_visual_novel_works; +pub use list_wooden_fish_works_procedure::list_wooden_fish_works; pub use mark_profile_recharge_order_paid_and_return_procedure::mark_profile_recharge_order_paid_and_return; pub use match_3_d_agent_message_finalize_input_type::Match3DAgentMessageFinalizeInput; pub use match_3_d_agent_message_row_type::Match3DAgentMessageRow; @@ -1541,6 +1596,7 @@ pub use publish_match_3_d_work_procedure::publish_match_3_d_work; pub use publish_puzzle_work_procedure::publish_puzzle_work; pub use publish_square_hole_work_procedure::publish_square_hole_work; pub use publish_visual_novel_work_procedure::publish_visual_novel_work; +pub use publish_wooden_fish_work_procedure::publish_wooden_fish_work; pub use put_database_migration_import_chunk_procedure::put_database_migration_import_chunk; pub use puzzle_agent_message_finalize_input_type::PuzzleAgentMessageFinalizeInput; pub use puzzle_agent_message_kind_type::PuzzleAgentMessageKind; @@ -1855,6 +1911,7 @@ pub use start_match_3_d_run_procedure::start_match_3_d_run; pub use start_puzzle_run_procedure::start_puzzle_run; pub use start_square_hole_run_procedure::start_square_hole_run; pub use start_visual_novel_run_procedure::start_visual_novel_run; +pub use start_wooden_fish_run_procedure::start_wooden_fish_run; pub use stop_match_3_d_run_procedure::stop_match_3_d_run; pub use stop_square_hole_run_procedure::stop_square_hole_run; pub use story_continue_input_type::StoryContinueInput; @@ -1901,6 +1958,7 @@ pub use update_puzzle_run_pause_procedure::update_puzzle_run_pause; pub use update_puzzle_work_procedure::update_puzzle_work; pub use update_square_hole_work_procedure::update_square_hole_work; pub use update_visual_novel_work_procedure::update_visual_novel_work; +pub use update_wooden_fish_work_procedure::update_wooden_fish_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_chapter_progression_reducer::upsert_chapter_progression; @@ -1963,6 +2021,42 @@ pub use visual_novel_work_snapshot_type::VisualNovelWorkSnapshot; pub use visual_novel_work_update_input_type::VisualNovelWorkUpdateInput; pub use visual_novel_works_list_input_type::VisualNovelWorksListInput; pub use visual_novel_works_procedure_result_type::VisualNovelWorksProcedureResult; +pub use wooden_fish_agent_session_create_input_type::WoodenFishAgentSessionCreateInput; +pub use wooden_fish_agent_session_get_input_type::WoodenFishAgentSessionGetInput; +pub use wooden_fish_agent_session_procedure_result_type::WoodenFishAgentSessionProcedureResult; +pub use wooden_fish_agent_session_row_type::WoodenFishAgentSessionRow; +pub use wooden_fish_agent_session_snapshot_type::WoodenFishAgentSessionSnapshot; +pub use wooden_fish_agent_session_table::*; +pub use wooden_fish_audio_asset_snapshot_type::WoodenFishAudioAssetSnapshot; +pub use wooden_fish_creator_config_snapshot_type::WoodenFishCreatorConfigSnapshot; +pub use wooden_fish_draft_compile_input_type::WoodenFishDraftCompileInput; +pub use wooden_fish_draft_snapshot_type::WoodenFishDraftSnapshot; +pub use wooden_fish_event_row_type::WoodenFishEventRow; +pub use wooden_fish_event_table::*; +pub use wooden_fish_gallery_card_view_row_type::WoodenFishGalleryCardViewRow; +pub use wooden_fish_gallery_card_view_table::*; +pub use wooden_fish_gallery_view_row_type::WoodenFishGalleryViewRow; +pub use wooden_fish_gallery_view_table::*; +pub use wooden_fish_image_asset_snapshot_type::WoodenFishImageAssetSnapshot; +pub use wooden_fish_run_checkpoint_input_type::WoodenFishRunCheckpointInput; +pub use wooden_fish_run_finish_input_type::WoodenFishRunFinishInput; +pub use wooden_fish_run_get_input_type::WoodenFishRunGetInput; +pub use wooden_fish_run_procedure_result_type::WoodenFishRunProcedureResult; +pub use wooden_fish_run_snapshot_type::WoodenFishRunSnapshot; +pub use wooden_fish_run_start_input_type::WoodenFishRunStartInput; +pub use wooden_fish_run_status_type::WoodenFishRunStatus; +pub use wooden_fish_runtime_run_row_type::WoodenFishRuntimeRunRow; +pub use wooden_fish_runtime_run_table::*; +pub use wooden_fish_word_counter_type::WoodenFishWordCounter; +pub use wooden_fish_work_get_input_type::WoodenFishWorkGetInput; +pub use wooden_fish_work_procedure_result_type::WoodenFishWorkProcedureResult; +pub use wooden_fish_work_profile_row_type::WoodenFishWorkProfileRow; +pub use wooden_fish_work_profile_table::*; +pub use wooden_fish_work_publish_input_type::WoodenFishWorkPublishInput; +pub use wooden_fish_work_snapshot_type::WoodenFishWorkSnapshot; +pub use wooden_fish_work_update_input_type::WoodenFishWorkUpdateInput; +pub use wooden_fish_works_list_input_type::WoodenFishWorksListInput; +pub use wooden_fish_works_procedure_result_type::WoodenFishWorksProcedureResult; #[derive(Clone, PartialEq, Debug)] @@ -2337,6 +2431,12 @@ pub struct DbUpdate { visual_novel_runtime_history_entry: __sdk::TableUpdate, visual_novel_runtime_run: __sdk::TableUpdate, visual_novel_work_profile: __sdk::TableUpdate, + wooden_fish_agent_session: __sdk::TableUpdate, + wooden_fish_event: __sdk::TableUpdate, + wooden_fish_gallery_card_view: __sdk::TableUpdate, + wooden_fish_gallery_view: __sdk::TableUpdate, + wooden_fish_runtime_run: __sdk::TableUpdate, + wooden_fish_work_profile: __sdk::TableUpdate, } impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { @@ -2660,6 +2760,24 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "visual_novel_work_profile" => db_update.visual_novel_work_profile.append( visual_novel_work_profile_table::parse_table_update(table_update)?, ), + "wooden_fish_agent_session" => db_update.wooden_fish_agent_session.append( + wooden_fish_agent_session_table::parse_table_update(table_update)?, + ), + "wooden_fish_event" => db_update + .wooden_fish_event + .append(wooden_fish_event_table::parse_table_update(table_update)?), + "wooden_fish_gallery_card_view" => db_update.wooden_fish_gallery_card_view.append( + wooden_fish_gallery_card_view_table::parse_table_update(table_update)?, + ), + "wooden_fish_gallery_view" => db_update.wooden_fish_gallery_view.append( + wooden_fish_gallery_view_table::parse_table_update(table_update)?, + ), + "wooden_fish_runtime_run" => db_update.wooden_fish_runtime_run.append( + wooden_fish_runtime_run_table::parse_table_update(table_update)?, + ), + "wooden_fish_work_profile" => db_update.wooden_fish_work_profile.append( + wooden_fish_work_profile_table::parse_table_update(table_update)?, + ), unknown => { return Err(__sdk::InternalError::unknown_name( @@ -3159,6 +3277,27 @@ impl __sdk::DbUpdate for DbUpdate { &self.visual_novel_work_profile, ) .with_updates_by_pk(|row| &row.profile_id); + diff.wooden_fish_agent_session = cache + .apply_diff_to_table::( + "wooden_fish_agent_session", + &self.wooden_fish_agent_session, + ) + .with_updates_by_pk(|row| &row.session_id); + diff.wooden_fish_event = cache + .apply_diff_to_table::("wooden_fish_event", &self.wooden_fish_event) + .with_updates_by_pk(|row| &row.event_id); + diff.wooden_fish_runtime_run = cache + .apply_diff_to_table::( + "wooden_fish_runtime_run", + &self.wooden_fish_runtime_run, + ) + .with_updates_by_pk(|row| &row.run_id); + diff.wooden_fish_work_profile = cache + .apply_diff_to_table::( + "wooden_fish_work_profile", + &self.wooden_fish_work_profile, + ) + .with_updates_by_pk(|row| &row.profile_id); diff.big_fish_gallery_view = cache.apply_diff_to_table::( "big_fish_gallery_view", &self.big_fish_gallery_view, @@ -3191,6 +3330,15 @@ impl __sdk::DbUpdate for DbUpdate { "visual_novel_gallery_view", &self.visual_novel_gallery_view, ); + diff.wooden_fish_gallery_card_view = cache + .apply_diff_to_table::( + "wooden_fish_gallery_card_view", + &self.wooden_fish_gallery_card_view, + ); + diff.wooden_fish_gallery_view = cache.apply_diff_to_table::( + "wooden_fish_gallery_view", + &self.wooden_fish_gallery_view, + ); diff } @@ -3501,6 +3649,24 @@ impl __sdk::DbUpdate for DbUpdate { "visual_novel_work_profile" => db_update .visual_novel_work_profile .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "wooden_fish_agent_session" => db_update + .wooden_fish_agent_session + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "wooden_fish_event" => db_update + .wooden_fish_event + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "wooden_fish_gallery_card_view" => db_update + .wooden_fish_gallery_card_view + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "wooden_fish_gallery_view" => db_update + .wooden_fish_gallery_view + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "wooden_fish_runtime_run" => db_update + .wooden_fish_runtime_run + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "wooden_fish_work_profile" => db_update + .wooden_fish_work_profile + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), unknown => { return Err( __sdk::InternalError::unknown_name("table", unknown, "QueryRows").into(), @@ -3817,6 +3983,24 @@ impl __sdk::DbUpdate for DbUpdate { "visual_novel_work_profile" => db_update .visual_novel_work_profile .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "wooden_fish_agent_session" => db_update + .wooden_fish_agent_session + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "wooden_fish_event" => db_update + .wooden_fish_event + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "wooden_fish_gallery_card_view" => db_update + .wooden_fish_gallery_card_view + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "wooden_fish_gallery_view" => db_update + .wooden_fish_gallery_view + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "wooden_fish_runtime_run" => db_update + .wooden_fish_runtime_run + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "wooden_fish_work_profile" => db_update + .wooden_fish_work_profile + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), unknown => { return Err( __sdk::InternalError::unknown_name("table", unknown, "QueryRows").into(), @@ -3936,6 +4120,12 @@ pub struct AppliedDiff<'r> { __sdk::TableAppliedDiff<'r, VisualNovelRuntimeHistoryEntryRow>, visual_novel_runtime_run: __sdk::TableAppliedDiff<'r, VisualNovelRuntimeRunRow>, visual_novel_work_profile: __sdk::TableAppliedDiff<'r, VisualNovelWorkProfileRow>, + wooden_fish_agent_session: __sdk::TableAppliedDiff<'r, WoodenFishAgentSessionRow>, + wooden_fish_event: __sdk::TableAppliedDiff<'r, WoodenFishEventRow>, + wooden_fish_gallery_card_view: __sdk::TableAppliedDiff<'r, WoodenFishGalleryCardViewRow>, + wooden_fish_gallery_view: __sdk::TableAppliedDiff<'r, WoodenFishGalleryViewRow>, + wooden_fish_runtime_run: __sdk::TableAppliedDiff<'r, WoodenFishRuntimeRunRow>, + wooden_fish_work_profile: __sdk::TableAppliedDiff<'r, WoodenFishWorkProfileRow>, __unused: std::marker::PhantomData<&'r ()>, } @@ -4434,6 +4624,36 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.visual_novel_work_profile, event, ); + callbacks.invoke_table_row_callbacks::( + "wooden_fish_agent_session", + &self.wooden_fish_agent_session, + event, + ); + callbacks.invoke_table_row_callbacks::( + "wooden_fish_event", + &self.wooden_fish_event, + event, + ); + callbacks.invoke_table_row_callbacks::( + "wooden_fish_gallery_card_view", + &self.wooden_fish_gallery_card_view, + event, + ); + callbacks.invoke_table_row_callbacks::( + "wooden_fish_gallery_view", + &self.wooden_fish_gallery_view, + event, + ); + callbacks.invoke_table_row_callbacks::( + "wooden_fish_runtime_run", + &self.wooden_fish_runtime_run, + event, + ); + callbacks.invoke_table_row_callbacks::( + "wooden_fish_work_profile", + &self.wooden_fish_work_profile, + event, + ); } } @@ -5195,6 +5415,12 @@ impl __sdk::SpacetimeModule for RemoteModule { visual_novel_runtime_history_entry_table::register_table(client_cache); visual_novel_runtime_run_table::register_table(client_cache); visual_novel_work_profile_table::register_table(client_cache); + wooden_fish_agent_session_table::register_table(client_cache); + wooden_fish_event_table::register_table(client_cache); + wooden_fish_gallery_card_view_table::register_table(client_cache); + wooden_fish_gallery_view_table::register_table(client_cache); + wooden_fish_runtime_run_table::register_table(client_cache); + wooden_fish_work_profile_table::register_table(client_cache); } const ALL_TABLE_NAMES: &'static [&'static str] = &[ "ai_result_reference", @@ -5298,5 +5524,11 @@ impl __sdk::SpacetimeModule for RemoteModule { "visual_novel_runtime_history_entry", "visual_novel_runtime_run", "visual_novel_work_profile", + "wooden_fish_agent_session", + "wooden_fish_event", + "wooden_fish_gallery_card_view", + "wooden_fish_gallery_view", + "wooden_fish_runtime_run", + "wooden_fish_work_profile", ]; } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/checkpoint_wooden_fish_run_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/checkpoint_wooden_fish_run_procedure.rs new file mode 100644 index 00000000..5bd5a45c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/checkpoint_wooden_fish_run_procedure.rs @@ -0,0 +1,59 @@ +// 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}; + +use super::wooden_fish_run_checkpoint_input_type::WoodenFishRunCheckpointInput; +use super::wooden_fish_run_procedure_result_type::WoodenFishRunProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CheckpointWoodenFishRunArgs { + pub input: WoodenFishRunCheckpointInput, +} + +impl __sdk::InModule for CheckpointWoodenFishRunArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `checkpoint_wooden_fish_run`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait checkpoint_wooden_fish_run { + fn checkpoint_wooden_fish_run(&self, input: WoodenFishRunCheckpointInput) { + self.checkpoint_wooden_fish_run_then(input, |_, _| {}); + } + + fn checkpoint_wooden_fish_run_then( + &self, + input: WoodenFishRunCheckpointInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl checkpoint_wooden_fish_run for super::RemoteProcedures { + fn checkpoint_wooden_fish_run_then( + &self, + input: WoodenFishRunCheckpointInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WoodenFishRunProcedureResult>( + "checkpoint_wooden_fish_run", + CheckpointWoodenFishRunArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/compile_wooden_fish_draft_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/compile_wooden_fish_draft_procedure.rs new file mode 100644 index 00000000..61c7b013 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/compile_wooden_fish_draft_procedure.rs @@ -0,0 +1,59 @@ +// 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}; + +use super::wooden_fish_agent_session_procedure_result_type::WoodenFishAgentSessionProcedureResult; +use super::wooden_fish_draft_compile_input_type::WoodenFishDraftCompileInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CompileWoodenFishDraftArgs { + pub input: WoodenFishDraftCompileInput, +} + +impl __sdk::InModule for CompileWoodenFishDraftArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `compile_wooden_fish_draft`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait compile_wooden_fish_draft { + fn compile_wooden_fish_draft(&self, input: WoodenFishDraftCompileInput) { + self.compile_wooden_fish_draft_then(input, |_, _| {}); + } + + fn compile_wooden_fish_draft_then( + &self, + input: WoodenFishDraftCompileInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl compile_wooden_fish_draft for super::RemoteProcedures { + fn compile_wooden_fish_draft_then( + &self, + input: WoodenFishDraftCompileInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WoodenFishAgentSessionProcedureResult>( + "compile_wooden_fish_draft", + CompileWoodenFishDraftArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_wooden_fish_agent_session_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_wooden_fish_agent_session_procedure.rs new file mode 100644 index 00000000..ec6bec0d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_wooden_fish_agent_session_procedure.rs @@ -0,0 +1,59 @@ +// 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}; + +use super::wooden_fish_agent_session_create_input_type::WoodenFishAgentSessionCreateInput; +use super::wooden_fish_agent_session_procedure_result_type::WoodenFishAgentSessionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CreateWoodenFishAgentSessionArgs { + pub input: WoodenFishAgentSessionCreateInput, +} + +impl __sdk::InModule for CreateWoodenFishAgentSessionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `create_wooden_fish_agent_session`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait create_wooden_fish_agent_session { + fn create_wooden_fish_agent_session(&self, input: WoodenFishAgentSessionCreateInput) { + self.create_wooden_fish_agent_session_then(input, |_, _| {}); + } + + fn create_wooden_fish_agent_session_then( + &self, + input: WoodenFishAgentSessionCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl create_wooden_fish_agent_session for super::RemoteProcedures { + fn create_wooden_fish_agent_session_then( + &self, + input: WoodenFishAgentSessionCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WoodenFishAgentSessionProcedureResult>( + "create_wooden_fish_agent_session", + CreateWoodenFishAgentSessionArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/finish_wooden_fish_run_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/finish_wooden_fish_run_procedure.rs new file mode 100644 index 00000000..30e452e1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/finish_wooden_fish_run_procedure.rs @@ -0,0 +1,59 @@ +// 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}; + +use super::wooden_fish_run_finish_input_type::WoodenFishRunFinishInput; +use super::wooden_fish_run_procedure_result_type::WoodenFishRunProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct FinishWoodenFishRunArgs { + pub input: WoodenFishRunFinishInput, +} + +impl __sdk::InModule for FinishWoodenFishRunArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `finish_wooden_fish_run`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait finish_wooden_fish_run { + fn finish_wooden_fish_run(&self, input: WoodenFishRunFinishInput) { + self.finish_wooden_fish_run_then(input, |_, _| {}); + } + + fn finish_wooden_fish_run_then( + &self, + input: WoodenFishRunFinishInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl finish_wooden_fish_run for super::RemoteProcedures { + fn finish_wooden_fish_run_then( + &self, + input: WoodenFishRunFinishInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WoodenFishRunProcedureResult>( + "finish_wooden_fish_run", + FinishWoodenFishRunArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs index b4d81a52..cce1dbb5 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs @@ -1,4 +1,3 @@ - // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_wooden_fish_agent_session_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_wooden_fish_agent_session_procedure.rs new file mode 100644 index 00000000..e0b80d0e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_wooden_fish_agent_session_procedure.rs @@ -0,0 +1,59 @@ +// 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}; + +use super::wooden_fish_agent_session_get_input_type::WoodenFishAgentSessionGetInput; +use super::wooden_fish_agent_session_procedure_result_type::WoodenFishAgentSessionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetWoodenFishAgentSessionArgs { + pub input: WoodenFishAgentSessionGetInput, +} + +impl __sdk::InModule for GetWoodenFishAgentSessionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_wooden_fish_agent_session`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_wooden_fish_agent_session { + fn get_wooden_fish_agent_session(&self, input: WoodenFishAgentSessionGetInput) { + self.get_wooden_fish_agent_session_then(input, |_, _| {}); + } + + fn get_wooden_fish_agent_session_then( + &self, + input: WoodenFishAgentSessionGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_wooden_fish_agent_session for super::RemoteProcedures { + fn get_wooden_fish_agent_session_then( + &self, + input: WoodenFishAgentSessionGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WoodenFishAgentSessionProcedureResult>( + "get_wooden_fish_agent_session", + GetWoodenFishAgentSessionArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_wooden_fish_run_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_wooden_fish_run_procedure.rs new file mode 100644 index 00000000..8a0e145a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_wooden_fish_run_procedure.rs @@ -0,0 +1,59 @@ +// 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}; + +use super::wooden_fish_run_get_input_type::WoodenFishRunGetInput; +use super::wooden_fish_run_procedure_result_type::WoodenFishRunProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetWoodenFishRunArgs { + pub input: WoodenFishRunGetInput, +} + +impl __sdk::InModule for GetWoodenFishRunArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_wooden_fish_run`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_wooden_fish_run { + fn get_wooden_fish_run(&self, input: WoodenFishRunGetInput) { + self.get_wooden_fish_run_then(input, |_, _| {}); + } + + fn get_wooden_fish_run_then( + &self, + input: WoodenFishRunGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_wooden_fish_run for super::RemoteProcedures { + fn get_wooden_fish_run_then( + &self, + input: WoodenFishRunGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WoodenFishRunProcedureResult>( + "get_wooden_fish_run", + GetWoodenFishRunArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_wooden_fish_work_profile_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_wooden_fish_work_profile_procedure.rs new file mode 100644 index 00000000..44f67362 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_wooden_fish_work_profile_procedure.rs @@ -0,0 +1,59 @@ +// 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}; + +use super::wooden_fish_work_get_input_type::WoodenFishWorkGetInput; +use super::wooden_fish_work_procedure_result_type::WoodenFishWorkProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetWoodenFishWorkProfileArgs { + pub input: WoodenFishWorkGetInput, +} + +impl __sdk::InModule for GetWoodenFishWorkProfileArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_wooden_fish_work_profile`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_wooden_fish_work_profile { + fn get_wooden_fish_work_profile(&self, input: WoodenFishWorkGetInput) { + self.get_wooden_fish_work_profile_then(input, |_, _| {}); + } + + fn get_wooden_fish_work_profile_then( + &self, + input: WoodenFishWorkGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_wooden_fish_work_profile for super::RemoteProcedures { + fn get_wooden_fish_work_profile_then( + &self, + input: WoodenFishWorkGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WoodenFishWorkProcedureResult>( + "get_wooden_fish_work_profile", + GetWoodenFishWorkProfileArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_wooden_fish_works_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_wooden_fish_works_procedure.rs new file mode 100644 index 00000000..d449ff80 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_wooden_fish_works_procedure.rs @@ -0,0 +1,59 @@ +// 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}; + +use super::wooden_fish_works_list_input_type::WoodenFishWorksListInput; +use super::wooden_fish_works_procedure_result_type::WoodenFishWorksProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListWoodenFishWorksArgs { + pub input: WoodenFishWorksListInput, +} + +impl __sdk::InModule for ListWoodenFishWorksArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_wooden_fish_works`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_wooden_fish_works { + fn list_wooden_fish_works(&self, input: WoodenFishWorksListInput) { + self.list_wooden_fish_works_then(input, |_, _| {}); + } + + fn list_wooden_fish_works_then( + &self, + input: WoodenFishWorksListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_wooden_fish_works for super::RemoteProcedures { + fn list_wooden_fish_works_then( + &self, + input: WoodenFishWorksListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WoodenFishWorksProcedureResult>( + "list_wooden_fish_works", + ListWoodenFishWorksArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/publish_wooden_fish_work_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/publish_wooden_fish_work_procedure.rs new file mode 100644 index 00000000..f370ace6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/publish_wooden_fish_work_procedure.rs @@ -0,0 +1,59 @@ +// 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}; + +use super::wooden_fish_work_procedure_result_type::WoodenFishWorkProcedureResult; +use super::wooden_fish_work_publish_input_type::WoodenFishWorkPublishInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct PublishWoodenFishWorkArgs { + pub input: WoodenFishWorkPublishInput, +} + +impl __sdk::InModule for PublishWoodenFishWorkArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `publish_wooden_fish_work`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait publish_wooden_fish_work { + fn publish_wooden_fish_work(&self, input: WoodenFishWorkPublishInput) { + self.publish_wooden_fish_work_then(input, |_, _| {}); + } + + fn publish_wooden_fish_work_then( + &self, + input: WoodenFishWorkPublishInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl publish_wooden_fish_work for super::RemoteProcedures { + fn publish_wooden_fish_work_then( + &self, + input: WoodenFishWorkPublishInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WoodenFishWorkProcedureResult>( + "publish_wooden_fish_work", + PublishWoodenFishWorkArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/start_wooden_fish_run_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/start_wooden_fish_run_procedure.rs new file mode 100644 index 00000000..daf6358e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/start_wooden_fish_run_procedure.rs @@ -0,0 +1,59 @@ +// 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}; + +use super::wooden_fish_run_procedure_result_type::WoodenFishRunProcedureResult; +use super::wooden_fish_run_start_input_type::WoodenFishRunStartInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct StartWoodenFishRunArgs { + pub input: WoodenFishRunStartInput, +} + +impl __sdk::InModule for StartWoodenFishRunArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `start_wooden_fish_run`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait start_wooden_fish_run { + fn start_wooden_fish_run(&self, input: WoodenFishRunStartInput) { + self.start_wooden_fish_run_then(input, |_, _| {}); + } + + fn start_wooden_fish_run_then( + &self, + input: WoodenFishRunStartInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl start_wooden_fish_run for super::RemoteProcedures { + fn start_wooden_fish_run_then( + &self, + input: WoodenFishRunStartInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WoodenFishRunProcedureResult>( + "start_wooden_fish_run", + StartWoodenFishRunArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_wooden_fish_work_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_wooden_fish_work_procedure.rs new file mode 100644 index 00000000..31785275 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_wooden_fish_work_procedure.rs @@ -0,0 +1,59 @@ +// 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}; + +use super::wooden_fish_work_procedure_result_type::WoodenFishWorkProcedureResult; +use super::wooden_fish_work_update_input_type::WoodenFishWorkUpdateInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpdateWoodenFishWorkArgs { + pub input: WoodenFishWorkUpdateInput, +} + +impl __sdk::InModule for UpdateWoodenFishWorkArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `update_wooden_fish_work`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait update_wooden_fish_work { + fn update_wooden_fish_work(&self, input: WoodenFishWorkUpdateInput) { + self.update_wooden_fish_work_then(input, |_, _| {}); + } + + fn update_wooden_fish_work_then( + &self, + input: WoodenFishWorkUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl update_wooden_fish_work for super::RemoteProcedures { + fn update_wooden_fish_work_then( + &self, + input: WoodenFishWorkUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, WoodenFishWorkProcedureResult>( + "update_wooden_fish_work", + UpdateWoodenFishWorkArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_create_input_type.rs new file mode 100644 index 00000000..1ad698a2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_create_input_type.rs @@ -0,0 +1,22 @@ +// 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 WoodenFishAgentSessionCreateInput { + pub session_id: String, + pub owner_user_id: String, + pub work_title: String, + pub work_description: String, + pub theme_tags_json: Option, + pub config_json: Option, + pub draft_json: Option, + pub created_at_micros: i64, +} + +impl __sdk::InModule for WoodenFishAgentSessionCreateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_get_input_type.rs new file mode 100644 index 00000000..62f4469b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_get_input_type.rs @@ -0,0 +1,16 @@ +// 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 WoodenFishAgentSessionGetInput { + pub session_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for WoodenFishAgentSessionGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_procedure_result_type.rs new file mode 100644 index 00000000..e89fc3a6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_procedure_result_type.rs @@ -0,0 +1,19 @@ +// 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}; + +use super::wooden_fish_agent_session_snapshot_type::WoodenFishAgentSessionSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WoodenFishAgentSessionProcedureResult { + pub ok: bool, + pub session: Option, + pub error_message: Option, +} + +impl __sdk::InModule for WoodenFishAgentSessionProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_row_type.rs new file mode 100644 index 00000000..8f918cdf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_row_type.rs @@ -0,0 +1,81 @@ +// 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 WoodenFishAgentSessionRow { + pub session_id: String, + pub owner_user_id: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: String, + pub config_json: String, + pub draft_json: String, + pub published_profile_id: String, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for WoodenFishAgentSessionRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `WoodenFishAgentSessionRow`. +/// +/// Provides typed access to columns for query building. +pub struct WoodenFishAgentSessionRowCols { + pub session_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub current_turn: __sdk::__query_builder::Col, + pub progress_percent: __sdk::__query_builder::Col, + pub stage: __sdk::__query_builder::Col, + pub config_json: __sdk::__query_builder::Col, + pub draft_json: __sdk::__query_builder::Col, + pub published_profile_id: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for WoodenFishAgentSessionRow { + type Cols = WoodenFishAgentSessionRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + WoodenFishAgentSessionRowCols { + session_id: __sdk::__query_builder::Col::new(table_name, "session_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + current_turn: __sdk::__query_builder::Col::new(table_name, "current_turn"), + progress_percent: __sdk::__query_builder::Col::new(table_name, "progress_percent"), + stage: __sdk::__query_builder::Col::new(table_name, "stage"), + config_json: __sdk::__query_builder::Col::new(table_name, "config_json"), + draft_json: __sdk::__query_builder::Col::new(table_name, "draft_json"), + published_profile_id: __sdk::__query_builder::Col::new( + table_name, + "published_profile_id", + ), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `WoodenFishAgentSessionRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct WoodenFishAgentSessionRowIxCols { + pub owner_user_id: __sdk::__query_builder::IxCol, + pub session_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for WoodenFishAgentSessionRow { + type IxCols = WoodenFishAgentSessionRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + WoodenFishAgentSessionRowIxCols { + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + session_id: __sdk::__query_builder::IxCol::new(table_name, "session_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for WoodenFishAgentSessionRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_snapshot_type.rs new file mode 100644 index 00000000..f4526483 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_snapshot_type.rs @@ -0,0 +1,27 @@ +// 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}; + +use super::wooden_fish_creator_config_snapshot_type::WoodenFishCreatorConfigSnapshot; +use super::wooden_fish_draft_snapshot_type::WoodenFishDraftSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WoodenFishAgentSessionSnapshot { + pub session_id: String, + pub owner_user_id: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: String, + pub config: WoodenFishCreatorConfigSnapshot, + pub draft: Option, + pub published_profile_id: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for WoodenFishAgentSessionSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_table.rs new file mode 100644 index 00000000..68f2e484 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_agent_session_table.rs @@ -0,0 +1,165 @@ +// 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::wooden_fish_agent_session_row_type::WoodenFishAgentSessionRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `wooden_fish_agent_session`. +/// +/// Obtain a handle from the [`WoodenFishAgentSessionTableAccess::wooden_fish_agent_session`] method on [`super::RemoteTables`], +/// like `ctx.db.wooden_fish_agent_session()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.wooden_fish_agent_session().on_insert(...)`. +pub struct WoodenFishAgentSessionTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `wooden_fish_agent_session`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait WoodenFishAgentSessionTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`WoodenFishAgentSessionTableHandle`], which mediates access to the table `wooden_fish_agent_session`. + fn wooden_fish_agent_session(&self) -> WoodenFishAgentSessionTableHandle<'_>; +} + +impl WoodenFishAgentSessionTableAccess for super::RemoteTables { + fn wooden_fish_agent_session(&self) -> WoodenFishAgentSessionTableHandle<'_> { + WoodenFishAgentSessionTableHandle { + imp: self + .imp + .get_table::("wooden_fish_agent_session"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct WoodenFishAgentSessionInsertCallbackId(__sdk::CallbackId); +pub struct WoodenFishAgentSessionDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for WoodenFishAgentSessionTableHandle<'ctx> { + type Row = WoodenFishAgentSessionRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = WoodenFishAgentSessionInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> WoodenFishAgentSessionInsertCallbackId { + WoodenFishAgentSessionInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: WoodenFishAgentSessionInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = WoodenFishAgentSessionDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> WoodenFishAgentSessionDeleteCallbackId { + WoodenFishAgentSessionDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: WoodenFishAgentSessionDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct WoodenFishAgentSessionUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for WoodenFishAgentSessionTableHandle<'ctx> { + type UpdateCallbackId = WoodenFishAgentSessionUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> WoodenFishAgentSessionUpdateCallbackId { + WoodenFishAgentSessionUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: WoodenFishAgentSessionUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `session_id` unique index on the table `wooden_fish_agent_session`, +/// which allows point queries on the field of the same name +/// via the [`WoodenFishAgentSessionSessionIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.wooden_fish_agent_session().session_id().find(...)`. +pub struct WoodenFishAgentSessionSessionIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> WoodenFishAgentSessionTableHandle<'ctx> { + /// Get a handle on the `session_id` unique index on the table `wooden_fish_agent_session`. + pub fn session_id(&self) -> WoodenFishAgentSessionSessionIdUnique<'ctx> { + WoodenFishAgentSessionSessionIdUnique { + imp: self.imp.get_unique_constraint::("session_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> WoodenFishAgentSessionSessionIdUnique<'ctx> { + /// Find the subscribed row whose `session_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::("wooden_fish_agent_session"); + _table.add_unique_constraint::("session_id", |row| &row.session_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 `WoodenFishAgentSessionRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait wooden_fish_agent_sessionQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `WoodenFishAgentSessionRow`. + fn wooden_fish_agent_session(&self) + -> __sdk::__query_builder::Table; +} + +impl wooden_fish_agent_sessionQueryTableAccess for __sdk::QueryTableAccessor { + fn wooden_fish_agent_session( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("wooden_fish_agent_session") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_audio_asset_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_audio_asset_snapshot_type.rs new file mode 100644 index 00000000..9fb39b2d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_audio_asset_snapshot_type.rs @@ -0,0 +1,21 @@ +// 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 WoodenFishAudioAssetSnapshot { + pub asset_id: String, + pub audio_src: String, + pub audio_object_key: String, + pub asset_object_id: String, + pub source: String, + pub prompt: Option, + pub duration_ms: Option, +} + +impl __sdk::InModule for WoodenFishAudioAssetSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_creator_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_creator_config_snapshot_type.rs new file mode 100644 index 00000000..e54b4b3a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_creator_config_snapshot_type.rs @@ -0,0 +1,21 @@ +// 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 WoodenFishCreatorConfigSnapshot { + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub hit_object_prompt: String, + pub hit_object_reference_image_src: Option, + pub hit_sound_prompt: Option, + pub floating_words: Vec, +} + +impl __sdk::InModule for WoodenFishCreatorConfigSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_draft_compile_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_draft_compile_input_type.rs new file mode 100644 index 00000000..d88914d6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_draft_compile_input_type.rs @@ -0,0 +1,30 @@ +// 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 WoodenFishDraftCompileInput { + pub session_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub theme_tags_json: Option, + pub hit_object_prompt: String, + pub hit_object_reference_image_src: Option, + pub hit_sound_prompt: Option, + pub hit_object_asset_json: Option, + pub hit_sound_asset_json: Option, + pub floating_words_json: Option, + pub cover_image_src: Option, + pub generation_status: Option, + pub compiled_at_micros: i64, +} + +impl __sdk::InModule for WoodenFishDraftCompileInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_draft_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_draft_snapshot_type.rs new file mode 100644 index 00000000..f5f93b09 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_draft_snapshot_type.rs @@ -0,0 +1,31 @@ +// 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}; + +use super::wooden_fish_audio_asset_snapshot_type::WoodenFishAudioAssetSnapshot; +use super::wooden_fish_image_asset_snapshot_type::WoodenFishImageAssetSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WoodenFishDraftSnapshot { + pub template_id: String, + pub template_name: String, + pub profile_id: Option, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub hit_object_prompt: String, + pub hit_object_reference_image_src: Option, + pub hit_sound_prompt: Option, + pub floating_words: Vec, + pub hit_object_asset: Option, + pub hit_sound_asset: Option, + pub cover_image_src: Option, + pub generation_status: String, +} + +impl __sdk::InModule for WoodenFishDraftSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_event_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_event_row_type.rs new file mode 100644 index 00000000..9b000003 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_event_row_type.rs @@ -0,0 +1,71 @@ +// 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 WoodenFishEventRow { + pub event_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub run_id: String, + pub event_type: String, + pub result: String, + pub occurred_at: __sdk::Timestamp, +} + +impl __sdk::InModule for WoodenFishEventRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `WoodenFishEventRow`. +/// +/// Provides typed access to columns for query building. +pub struct WoodenFishEventRowCols { + pub event_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub run_id: __sdk::__query_builder::Col, + pub event_type: __sdk::__query_builder::Col, + pub result: __sdk::__query_builder::Col, + pub occurred_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for WoodenFishEventRow { + type Cols = WoodenFishEventRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + WoodenFishEventRowCols { + event_id: __sdk::__query_builder::Col::new(table_name, "event_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + run_id: __sdk::__query_builder::Col::new(table_name, "run_id"), + event_type: __sdk::__query_builder::Col::new(table_name, "event_type"), + result: __sdk::__query_builder::Col::new(table_name, "result"), + occurred_at: __sdk::__query_builder::Col::new(table_name, "occurred_at"), + } + } +} + +/// Indexed column accessor struct for the table `WoodenFishEventRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct WoodenFishEventRowIxCols { + pub event_id: __sdk::__query_builder::IxCol, + pub profile_id: __sdk::__query_builder::IxCol, + pub run_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for WoodenFishEventRow { + type IxCols = WoodenFishEventRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + WoodenFishEventRowIxCols { + event_id: __sdk::__query_builder::IxCol::new(table_name, "event_id"), + profile_id: __sdk::__query_builder::IxCol::new(table_name, "profile_id"), + run_id: __sdk::__query_builder::IxCol::new(table_name, "run_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for WoodenFishEventRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_event_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_event_table.rs new file mode 100644 index 00000000..ba0e979d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_event_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::wooden_fish_event_row_type::WoodenFishEventRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `wooden_fish_event`. +/// +/// Obtain a handle from the [`WoodenFishEventTableAccess::wooden_fish_event`] method on [`super::RemoteTables`], +/// like `ctx.db.wooden_fish_event()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.wooden_fish_event().on_insert(...)`. +pub struct WoodenFishEventTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `wooden_fish_event`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait WoodenFishEventTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`WoodenFishEventTableHandle`], which mediates access to the table `wooden_fish_event`. + fn wooden_fish_event(&self) -> WoodenFishEventTableHandle<'_>; +} + +impl WoodenFishEventTableAccess for super::RemoteTables { + fn wooden_fish_event(&self) -> WoodenFishEventTableHandle<'_> { + WoodenFishEventTableHandle { + imp: self + .imp + .get_table::("wooden_fish_event"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct WoodenFishEventInsertCallbackId(__sdk::CallbackId); +pub struct WoodenFishEventDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for WoodenFishEventTableHandle<'ctx> { + type Row = WoodenFishEventRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = WoodenFishEventInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> WoodenFishEventInsertCallbackId { + WoodenFishEventInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: WoodenFishEventInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = WoodenFishEventDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> WoodenFishEventDeleteCallbackId { + WoodenFishEventDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: WoodenFishEventDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct WoodenFishEventUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for WoodenFishEventTableHandle<'ctx> { + type UpdateCallbackId = WoodenFishEventUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> WoodenFishEventUpdateCallbackId { + WoodenFishEventUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: WoodenFishEventUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `event_id` unique index on the table `wooden_fish_event`, +/// which allows point queries on the field of the same name +/// via the [`WoodenFishEventEventIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.wooden_fish_event().event_id().find(...)`. +pub struct WoodenFishEventEventIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> WoodenFishEventTableHandle<'ctx> { + /// Get a handle on the `event_id` unique index on the table `wooden_fish_event`. + pub fn event_id(&self) -> WoodenFishEventEventIdUnique<'ctx> { + WoodenFishEventEventIdUnique { + imp: self.imp.get_unique_constraint::("event_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> WoodenFishEventEventIdUnique<'ctx> { + /// Find the subscribed row whose `event_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::("wooden_fish_event"); + _table.add_unique_constraint::("event_id", |row| &row.event_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 `WoodenFishEventRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait wooden_fish_eventQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `WoodenFishEventRow`. + fn wooden_fish_event(&self) -> __sdk::__query_builder::Table; +} + +impl wooden_fish_eventQueryTableAccess for __sdk::QueryTableAccessor { + fn wooden_fish_event(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("wooden_fish_event") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_gallery_card_view_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_gallery_card_view_row_type.rs new file mode 100644 index 00000000..a11abdbb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_gallery_card_view_row_type.rs @@ -0,0 +1,76 @@ +// 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 WoodenFishGalleryCardViewRow { + pub public_work_code: String, + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub cover_image_src: String, + pub theme_tags: Vec, + pub publication_status: String, + pub play_count: u32, + pub updated_at_micros: i64, + pub published_at_micros: Option, + pub generation_status: String, +} + +impl __sdk::InModule for WoodenFishGalleryCardViewRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `WoodenFishGalleryCardViewRow`. +/// +/// Provides typed access to columns for query building. +pub struct WoodenFishGalleryCardViewRowCols { + pub public_work_code: __sdk::__query_builder::Col, + pub work_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub author_display_name: __sdk::__query_builder::Col, + pub work_title: __sdk::__query_builder::Col, + pub work_description: __sdk::__query_builder::Col, + pub cover_image_src: __sdk::__query_builder::Col, + pub theme_tags: __sdk::__query_builder::Col>, + pub publication_status: __sdk::__query_builder::Col, + pub play_count: __sdk::__query_builder::Col, + pub updated_at_micros: __sdk::__query_builder::Col, + pub published_at_micros: __sdk::__query_builder::Col>, + pub generation_status: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for WoodenFishGalleryCardViewRow { + type Cols = WoodenFishGalleryCardViewRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + WoodenFishGalleryCardViewRowCols { + public_work_code: __sdk::__query_builder::Col::new(table_name, "public_work_code"), + work_id: __sdk::__query_builder::Col::new(table_name, "work_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + author_display_name: __sdk::__query_builder::Col::new( + table_name, + "author_display_name", + ), + work_title: __sdk::__query_builder::Col::new(table_name, "work_title"), + work_description: __sdk::__query_builder::Col::new(table_name, "work_description"), + cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), + theme_tags: __sdk::__query_builder::Col::new(table_name, "theme_tags"), + publication_status: __sdk::__query_builder::Col::new(table_name, "publication_status"), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + updated_at_micros: __sdk::__query_builder::Col::new(table_name, "updated_at_micros"), + published_at_micros: __sdk::__query_builder::Col::new( + table_name, + "published_at_micros", + ), + generation_status: __sdk::__query_builder::Col::new(table_name, "generation_status"), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_gallery_card_view_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_gallery_card_view_table.rs new file mode 100644 index 00000000..d64eab80 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_gallery_card_view_table.rs @@ -0,0 +1,121 @@ +// 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::wooden_fish_gallery_card_view_row_type::WoodenFishGalleryCardViewRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `wooden_fish_gallery_card_view`. +/// +/// Obtain a handle from the [`WoodenFishGalleryCardViewTableAccess::wooden_fish_gallery_card_view`] method on [`super::RemoteTables`], +/// like `ctx.db.wooden_fish_gallery_card_view()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.wooden_fish_gallery_card_view().on_insert(...)`. +pub struct WoodenFishGalleryCardViewTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `wooden_fish_gallery_card_view`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait WoodenFishGalleryCardViewTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`WoodenFishGalleryCardViewTableHandle`], which mediates access to the table `wooden_fish_gallery_card_view`. + fn wooden_fish_gallery_card_view(&self) -> WoodenFishGalleryCardViewTableHandle<'_>; +} + +impl WoodenFishGalleryCardViewTableAccess for super::RemoteTables { + fn wooden_fish_gallery_card_view(&self) -> WoodenFishGalleryCardViewTableHandle<'_> { + WoodenFishGalleryCardViewTableHandle { + imp: self + .imp + .get_table::("wooden_fish_gallery_card_view"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct WoodenFishGalleryCardViewInsertCallbackId(__sdk::CallbackId); +pub struct WoodenFishGalleryCardViewDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for WoodenFishGalleryCardViewTableHandle<'ctx> { + type Row = WoodenFishGalleryCardViewRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = WoodenFishGalleryCardViewInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> WoodenFishGalleryCardViewInsertCallbackId { + WoodenFishGalleryCardViewInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: WoodenFishGalleryCardViewInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = WoodenFishGalleryCardViewDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> WoodenFishGalleryCardViewDeleteCallbackId { + WoodenFishGalleryCardViewDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: WoodenFishGalleryCardViewDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache + .get_or_make_table::("wooden_fish_gallery_card_view"); +} + +#[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 `WoodenFishGalleryCardViewRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait wooden_fish_gallery_card_viewQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `WoodenFishGalleryCardViewRow`. + fn wooden_fish_gallery_card_view( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl wooden_fish_gallery_card_viewQueryTableAccess for __sdk::QueryTableAccessor { + fn wooden_fish_gallery_card_view( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("wooden_fish_gallery_card_view") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_gallery_view_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_gallery_view_row_type.rs new file mode 100644 index 00000000..d3d7b30f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_gallery_view_row_type.rs @@ -0,0 +1,109 @@ +// 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}; + +use super::wooden_fish_audio_asset_snapshot_type::WoodenFishAudioAssetSnapshot; +use super::wooden_fish_image_asset_snapshot_type::WoodenFishImageAssetSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WoodenFishGalleryViewRow { + pub public_work_code: String, + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub hit_object_prompt: String, + pub hit_object_reference_image_src: Option, + pub hit_sound_prompt: Option, + pub hit_object_asset: Option, + pub hit_sound_asset: Option, + pub floating_words: Vec, + pub cover_image_src: String, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub generation_status: String, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} + +impl __sdk::InModule for WoodenFishGalleryViewRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `WoodenFishGalleryViewRow`. +/// +/// Provides typed access to columns for query building. +pub struct WoodenFishGalleryViewRowCols { + pub public_work_code: __sdk::__query_builder::Col, + pub work_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub source_session_id: __sdk::__query_builder::Col, + pub author_display_name: __sdk::__query_builder::Col, + pub work_title: __sdk::__query_builder::Col, + pub work_description: __sdk::__query_builder::Col, + pub theme_tags: __sdk::__query_builder::Col>, + pub hit_object_prompt: __sdk::__query_builder::Col, + pub hit_object_reference_image_src: + __sdk::__query_builder::Col>, + pub hit_sound_prompt: __sdk::__query_builder::Col>, + pub hit_object_asset: + __sdk::__query_builder::Col>, + pub hit_sound_asset: + __sdk::__query_builder::Col>, + pub floating_words: __sdk::__query_builder::Col>, + pub cover_image_src: __sdk::__query_builder::Col, + pub publication_status: __sdk::__query_builder::Col, + pub publish_ready: __sdk::__query_builder::Col, + pub play_count: __sdk::__query_builder::Col, + pub generation_status: __sdk::__query_builder::Col, + pub updated_at_micros: __sdk::__query_builder::Col, + pub published_at_micros: __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for WoodenFishGalleryViewRow { + type Cols = WoodenFishGalleryViewRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + WoodenFishGalleryViewRowCols { + public_work_code: __sdk::__query_builder::Col::new(table_name, "public_work_code"), + work_id: __sdk::__query_builder::Col::new(table_name, "work_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + source_session_id: __sdk::__query_builder::Col::new(table_name, "source_session_id"), + author_display_name: __sdk::__query_builder::Col::new( + table_name, + "author_display_name", + ), + work_title: __sdk::__query_builder::Col::new(table_name, "work_title"), + work_description: __sdk::__query_builder::Col::new(table_name, "work_description"), + theme_tags: __sdk::__query_builder::Col::new(table_name, "theme_tags"), + hit_object_prompt: __sdk::__query_builder::Col::new(table_name, "hit_object_prompt"), + hit_object_reference_image_src: __sdk::__query_builder::Col::new( + table_name, + "hit_object_reference_image_src", + ), + hit_sound_prompt: __sdk::__query_builder::Col::new(table_name, "hit_sound_prompt"), + hit_object_asset: __sdk::__query_builder::Col::new(table_name, "hit_object_asset"), + hit_sound_asset: __sdk::__query_builder::Col::new(table_name, "hit_sound_asset"), + floating_words: __sdk::__query_builder::Col::new(table_name, "floating_words"), + cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), + publication_status: __sdk::__query_builder::Col::new(table_name, "publication_status"), + publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + generation_status: __sdk::__query_builder::Col::new(table_name, "generation_status"), + updated_at_micros: __sdk::__query_builder::Col::new(table_name, "updated_at_micros"), + published_at_micros: __sdk::__query_builder::Col::new( + table_name, + "published_at_micros", + ), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_gallery_view_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_gallery_view_table.rs new file mode 100644 index 00000000..e4a91629 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_gallery_view_table.rs @@ -0,0 +1,116 @@ +// 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::wooden_fish_audio_asset_snapshot_type::WoodenFishAudioAssetSnapshot; +use super::wooden_fish_gallery_view_row_type::WoodenFishGalleryViewRow; +use super::wooden_fish_image_asset_snapshot_type::WoodenFishImageAssetSnapshot; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `wooden_fish_gallery_view`. +/// +/// Obtain a handle from the [`WoodenFishGalleryViewTableAccess::wooden_fish_gallery_view`] method on [`super::RemoteTables`], +/// like `ctx.db.wooden_fish_gallery_view()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.wooden_fish_gallery_view().on_insert(...)`. +pub struct WoodenFishGalleryViewTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `wooden_fish_gallery_view`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait WoodenFishGalleryViewTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`WoodenFishGalleryViewTableHandle`], which mediates access to the table `wooden_fish_gallery_view`. + fn wooden_fish_gallery_view(&self) -> WoodenFishGalleryViewTableHandle<'_>; +} + +impl WoodenFishGalleryViewTableAccess for super::RemoteTables { + fn wooden_fish_gallery_view(&self) -> WoodenFishGalleryViewTableHandle<'_> { + WoodenFishGalleryViewTableHandle { + imp: self + .imp + .get_table::("wooden_fish_gallery_view"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct WoodenFishGalleryViewInsertCallbackId(__sdk::CallbackId); +pub struct WoodenFishGalleryViewDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for WoodenFishGalleryViewTableHandle<'ctx> { + type Row = WoodenFishGalleryViewRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = WoodenFishGalleryViewInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> WoodenFishGalleryViewInsertCallbackId { + WoodenFishGalleryViewInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: WoodenFishGalleryViewInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = WoodenFishGalleryViewDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> WoodenFishGalleryViewDeleteCallbackId { + WoodenFishGalleryViewDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: WoodenFishGalleryViewDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("wooden_fish_gallery_view"); +} + +#[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 `WoodenFishGalleryViewRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait wooden_fish_gallery_viewQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `WoodenFishGalleryViewRow`. + fn wooden_fish_gallery_view(&self) -> __sdk::__query_builder::Table; +} + +impl wooden_fish_gallery_viewQueryTableAccess for __sdk::QueryTableAccessor { + fn wooden_fish_gallery_view(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("wooden_fish_gallery_view") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_image_asset_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_image_asset_snapshot_type.rs new file mode 100644 index 00000000..52f28559 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_image_asset_snapshot_type.rs @@ -0,0 +1,22 @@ +// 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 WoodenFishImageAssetSnapshot { + pub asset_id: String, + pub image_src: String, + pub image_object_key: String, + pub asset_object_id: String, + pub generation_provider: String, + pub prompt: String, + pub width: u32, + pub height: u32, +} + +impl __sdk::InModule for WoodenFishImageAssetSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_checkpoint_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_checkpoint_input_type.rs new file mode 100644 index 00000000..0a3f0a16 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_checkpoint_input_type.rs @@ -0,0 +1,20 @@ +// 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 WoodenFishRunCheckpointInput { + pub run_id: String, + pub owner_user_id: String, + pub total_tap_count: u32, + pub word_counters_json: String, + pub client_event_id: String, + pub checkpoint_at_ms: i64, +} + +impl __sdk::InModule for WoodenFishRunCheckpointInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_finish_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_finish_input_type.rs new file mode 100644 index 00000000..a968b5ce --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_finish_input_type.rs @@ -0,0 +1,20 @@ +// 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 WoodenFishRunFinishInput { + pub run_id: String, + pub owner_user_id: String, + pub total_tap_count: u32, + pub word_counters_json: String, + pub client_event_id: String, + pub finished_at_ms: i64, +} + +impl __sdk::InModule for WoodenFishRunFinishInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_get_input_type.rs new file mode 100644 index 00000000..0b20556e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_get_input_type.rs @@ -0,0 +1,16 @@ +// 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 WoodenFishRunGetInput { + pub run_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for WoodenFishRunGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_procedure_result_type.rs new file mode 100644 index 00000000..21212b17 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_procedure_result_type.rs @@ -0,0 +1,19 @@ +// 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}; + +use super::wooden_fish_run_snapshot_type::WoodenFishRunSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WoodenFishRunProcedureResult { + pub ok: bool, + pub run: Option, + pub error_message: Option, +} + +impl __sdk::InModule for WoodenFishRunProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_snapshot_type.rs new file mode 100644 index 00000000..dfd085ac --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_snapshot_type.rs @@ -0,0 +1,26 @@ +// 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}; + +use super::wooden_fish_run_status_type::WoodenFishRunStatus; +use super::wooden_fish_word_counter_type::WoodenFishWordCounter; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WoodenFishRunSnapshot { + pub run_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub status: WoodenFishRunStatus, + pub total_tap_count: u32, + pub word_counters: Vec, + pub started_at_ms: u64, + pub updated_at_ms: u64, + pub finished_at_ms: Option, +} + +impl __sdk::InModule for WoodenFishRunSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_start_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_start_input_type.rs new file mode 100644 index 00000000..c9f55321 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_start_input_type.rs @@ -0,0 +1,19 @@ +// 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 WoodenFishRunStartInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub client_event_id: String, + pub started_at_ms: i64, +} + +impl __sdk::InModule for WoodenFishRunStartInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_status_type.rs new file mode 100644 index 00000000..1b48ba39 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_run_status_type.rs @@ -0,0 +1,18 @@ +// 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)] +#[derive(Copy, Eq, Hash)] +pub enum WoodenFishRunStatus { + Playing, + + Finished, +} + +impl __sdk::InModule for WoodenFishRunStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_runtime_run_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_runtime_run_row_type.rs new file mode 100644 index 00000000..8c12c1e5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_runtime_run_row_type.rs @@ -0,0 +1,86 @@ +// 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 WoodenFishRuntimeRunRow { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub status: String, + pub total_tap_count: u32, + pub word_counters_json: String, + pub started_at_ms: i64, + pub updated_at_ms: i64, + pub finished_at_ms: i64, + pub snapshot_json: String, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for WoodenFishRuntimeRunRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `WoodenFishRuntimeRunRow`. +/// +/// Provides typed access to columns for query building. +pub struct WoodenFishRuntimeRunRowCols { + pub run_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub total_tap_count: __sdk::__query_builder::Col, + pub word_counters_json: __sdk::__query_builder::Col, + pub started_at_ms: __sdk::__query_builder::Col, + pub updated_at_ms: __sdk::__query_builder::Col, + pub finished_at_ms: __sdk::__query_builder::Col, + pub snapshot_json: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for WoodenFishRuntimeRunRow { + type Cols = WoodenFishRuntimeRunRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + WoodenFishRuntimeRunRowCols { + run_id: __sdk::__query_builder::Col::new(table_name, "run_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + total_tap_count: __sdk::__query_builder::Col::new(table_name, "total_tap_count"), + word_counters_json: __sdk::__query_builder::Col::new(table_name, "word_counters_json"), + started_at_ms: __sdk::__query_builder::Col::new(table_name, "started_at_ms"), + updated_at_ms: __sdk::__query_builder::Col::new(table_name, "updated_at_ms"), + finished_at_ms: __sdk::__query_builder::Col::new(table_name, "finished_at_ms"), + snapshot_json: __sdk::__query_builder::Col::new(table_name, "snapshot_json"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `WoodenFishRuntimeRunRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct WoodenFishRuntimeRunRowIxCols { + pub owner_user_id: __sdk::__query_builder::IxCol, + pub profile_id: __sdk::__query_builder::IxCol, + pub run_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for WoodenFishRuntimeRunRow { + type IxCols = WoodenFishRuntimeRunRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + WoodenFishRuntimeRunRowIxCols { + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::IxCol::new(table_name, "profile_id"), + run_id: __sdk::__query_builder::IxCol::new(table_name, "run_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for WoodenFishRuntimeRunRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_runtime_run_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_runtime_run_table.rs new file mode 100644 index 00000000..f09d3b6e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_runtime_run_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::wooden_fish_runtime_run_row_type::WoodenFishRuntimeRunRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `wooden_fish_runtime_run`. +/// +/// Obtain a handle from the [`WoodenFishRuntimeRunTableAccess::wooden_fish_runtime_run`] method on [`super::RemoteTables`], +/// like `ctx.db.wooden_fish_runtime_run()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.wooden_fish_runtime_run().on_insert(...)`. +pub struct WoodenFishRuntimeRunTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `wooden_fish_runtime_run`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait WoodenFishRuntimeRunTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`WoodenFishRuntimeRunTableHandle`], which mediates access to the table `wooden_fish_runtime_run`. + fn wooden_fish_runtime_run(&self) -> WoodenFishRuntimeRunTableHandle<'_>; +} + +impl WoodenFishRuntimeRunTableAccess for super::RemoteTables { + fn wooden_fish_runtime_run(&self) -> WoodenFishRuntimeRunTableHandle<'_> { + WoodenFishRuntimeRunTableHandle { + imp: self + .imp + .get_table::("wooden_fish_runtime_run"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct WoodenFishRuntimeRunInsertCallbackId(__sdk::CallbackId); +pub struct WoodenFishRuntimeRunDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for WoodenFishRuntimeRunTableHandle<'ctx> { + type Row = WoodenFishRuntimeRunRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = WoodenFishRuntimeRunInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> WoodenFishRuntimeRunInsertCallbackId { + WoodenFishRuntimeRunInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: WoodenFishRuntimeRunInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = WoodenFishRuntimeRunDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> WoodenFishRuntimeRunDeleteCallbackId { + WoodenFishRuntimeRunDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: WoodenFishRuntimeRunDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct WoodenFishRuntimeRunUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for WoodenFishRuntimeRunTableHandle<'ctx> { + type UpdateCallbackId = WoodenFishRuntimeRunUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> WoodenFishRuntimeRunUpdateCallbackId { + WoodenFishRuntimeRunUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: WoodenFishRuntimeRunUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `run_id` unique index on the table `wooden_fish_runtime_run`, +/// which allows point queries on the field of the same name +/// via the [`WoodenFishRuntimeRunRunIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.wooden_fish_runtime_run().run_id().find(...)`. +pub struct WoodenFishRuntimeRunRunIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> WoodenFishRuntimeRunTableHandle<'ctx> { + /// Get a handle on the `run_id` unique index on the table `wooden_fish_runtime_run`. + pub fn run_id(&self) -> WoodenFishRuntimeRunRunIdUnique<'ctx> { + WoodenFishRuntimeRunRunIdUnique { + imp: self.imp.get_unique_constraint::("run_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> WoodenFishRuntimeRunRunIdUnique<'ctx> { + /// Find the subscribed row whose `run_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::("wooden_fish_runtime_run"); + _table.add_unique_constraint::("run_id", |row| &row.run_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 `WoodenFishRuntimeRunRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait wooden_fish_runtime_runQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `WoodenFishRuntimeRunRow`. + fn wooden_fish_runtime_run(&self) -> __sdk::__query_builder::Table; +} + +impl wooden_fish_runtime_runQueryTableAccess for __sdk::QueryTableAccessor { + fn wooden_fish_runtime_run(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("wooden_fish_runtime_run") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_word_counter_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_word_counter_type.rs new file mode 100644 index 00000000..217899f1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_word_counter_type.rs @@ -0,0 +1,16 @@ +// 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 WoodenFishWordCounter { + pub text: String, + pub count: u32, +} + +impl __sdk::InModule for WoodenFishWordCounter { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_get_input_type.rs new file mode 100644 index 00000000..9867cb79 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_get_input_type.rs @@ -0,0 +1,16 @@ +// 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 WoodenFishWorkGetInput { + pub profile_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for WoodenFishWorkGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_procedure_result_type.rs new file mode 100644 index 00000000..bebf287c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_procedure_result_type.rs @@ -0,0 +1,19 @@ +// 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}; + +use super::wooden_fish_work_snapshot_type::WoodenFishWorkSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WoodenFishWorkProcedureResult { + pub ok: bool, + pub work: Option, + pub error_message: Option, +} + +impl __sdk::InModule for WoodenFishWorkProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_profile_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_profile_row_type.rs new file mode 100644 index 00000000..5e692843 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_profile_row_type.rs @@ -0,0 +1,130 @@ +// 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 WoodenFishWorkProfileRow { + pub profile_id: String, + pub work_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub theme_tags_json: String, + pub hit_object_prompt: String, + pub hit_object_reference_image_src: String, + pub hit_sound_prompt: String, + pub hit_object_asset_json: String, + pub hit_sound_asset_json: String, + pub floating_words_json: String, + pub cover_image_src: String, + pub generation_status: String, + pub publication_status: String, + pub play_count: u32, + pub updated_at: __sdk::Timestamp, + pub published_at: Option<__sdk::Timestamp>, +} + +impl __sdk::InModule for WoodenFishWorkProfileRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `WoodenFishWorkProfileRow`. +/// +/// Provides typed access to columns for query building. +pub struct WoodenFishWorkProfileRowCols { + pub profile_id: __sdk::__query_builder::Col, + pub work_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub source_session_id: __sdk::__query_builder::Col, + pub author_display_name: __sdk::__query_builder::Col, + pub work_title: __sdk::__query_builder::Col, + pub work_description: __sdk::__query_builder::Col, + pub theme_tags_json: __sdk::__query_builder::Col, + pub hit_object_prompt: __sdk::__query_builder::Col, + pub hit_object_reference_image_src: + __sdk::__query_builder::Col, + pub hit_sound_prompt: __sdk::__query_builder::Col, + pub hit_object_asset_json: __sdk::__query_builder::Col, + pub hit_sound_asset_json: __sdk::__query_builder::Col, + pub floating_words_json: __sdk::__query_builder::Col, + pub cover_image_src: __sdk::__query_builder::Col, + pub generation_status: __sdk::__query_builder::Col, + pub publication_status: __sdk::__query_builder::Col, + pub play_count: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, + pub published_at: + __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for WoodenFishWorkProfileRow { + type Cols = WoodenFishWorkProfileRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + WoodenFishWorkProfileRowCols { + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + work_id: __sdk::__query_builder::Col::new(table_name, "work_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + source_session_id: __sdk::__query_builder::Col::new(table_name, "source_session_id"), + author_display_name: __sdk::__query_builder::Col::new( + table_name, + "author_display_name", + ), + work_title: __sdk::__query_builder::Col::new(table_name, "work_title"), + work_description: __sdk::__query_builder::Col::new(table_name, "work_description"), + theme_tags_json: __sdk::__query_builder::Col::new(table_name, "theme_tags_json"), + hit_object_prompt: __sdk::__query_builder::Col::new(table_name, "hit_object_prompt"), + hit_object_reference_image_src: __sdk::__query_builder::Col::new( + table_name, + "hit_object_reference_image_src", + ), + hit_sound_prompt: __sdk::__query_builder::Col::new(table_name, "hit_sound_prompt"), + hit_object_asset_json: __sdk::__query_builder::Col::new( + table_name, + "hit_object_asset_json", + ), + hit_sound_asset_json: __sdk::__query_builder::Col::new( + table_name, + "hit_sound_asset_json", + ), + floating_words_json: __sdk::__query_builder::Col::new( + table_name, + "floating_words_json", + ), + cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), + generation_status: __sdk::__query_builder::Col::new(table_name, "generation_status"), + publication_status: __sdk::__query_builder::Col::new(table_name, "publication_status"), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), + } + } +} + +/// Indexed column accessor struct for the table `WoodenFishWorkProfileRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct WoodenFishWorkProfileRowIxCols { + pub owner_user_id: __sdk::__query_builder::IxCol, + pub profile_id: __sdk::__query_builder::IxCol, + pub publication_status: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for WoodenFishWorkProfileRow { + type IxCols = WoodenFishWorkProfileRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + WoodenFishWorkProfileRowIxCols { + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::IxCol::new(table_name, "profile_id"), + publication_status: __sdk::__query_builder::IxCol::new( + table_name, + "publication_status", + ), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for WoodenFishWorkProfileRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_profile_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_profile_table.rs new file mode 100644 index 00000000..6815f169 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_profile_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::wooden_fish_work_profile_row_type::WoodenFishWorkProfileRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `wooden_fish_work_profile`. +/// +/// Obtain a handle from the [`WoodenFishWorkProfileTableAccess::wooden_fish_work_profile`] method on [`super::RemoteTables`], +/// like `ctx.db.wooden_fish_work_profile()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.wooden_fish_work_profile().on_insert(...)`. +pub struct WoodenFishWorkProfileTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `wooden_fish_work_profile`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait WoodenFishWorkProfileTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`WoodenFishWorkProfileTableHandle`], which mediates access to the table `wooden_fish_work_profile`. + fn wooden_fish_work_profile(&self) -> WoodenFishWorkProfileTableHandle<'_>; +} + +impl WoodenFishWorkProfileTableAccess for super::RemoteTables { + fn wooden_fish_work_profile(&self) -> WoodenFishWorkProfileTableHandle<'_> { + WoodenFishWorkProfileTableHandle { + imp: self + .imp + .get_table::("wooden_fish_work_profile"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct WoodenFishWorkProfileInsertCallbackId(__sdk::CallbackId); +pub struct WoodenFishWorkProfileDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for WoodenFishWorkProfileTableHandle<'ctx> { + type Row = WoodenFishWorkProfileRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = WoodenFishWorkProfileInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> WoodenFishWorkProfileInsertCallbackId { + WoodenFishWorkProfileInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: WoodenFishWorkProfileInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = WoodenFishWorkProfileDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> WoodenFishWorkProfileDeleteCallbackId { + WoodenFishWorkProfileDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: WoodenFishWorkProfileDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct WoodenFishWorkProfileUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for WoodenFishWorkProfileTableHandle<'ctx> { + type UpdateCallbackId = WoodenFishWorkProfileUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> WoodenFishWorkProfileUpdateCallbackId { + WoodenFishWorkProfileUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: WoodenFishWorkProfileUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `profile_id` unique index on the table `wooden_fish_work_profile`, +/// which allows point queries on the field of the same name +/// via the [`WoodenFishWorkProfileProfileIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.wooden_fish_work_profile().profile_id().find(...)`. +pub struct WoodenFishWorkProfileProfileIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> WoodenFishWorkProfileTableHandle<'ctx> { + /// Get a handle on the `profile_id` unique index on the table `wooden_fish_work_profile`. + pub fn profile_id(&self) -> WoodenFishWorkProfileProfileIdUnique<'ctx> { + WoodenFishWorkProfileProfileIdUnique { + imp: self.imp.get_unique_constraint::("profile_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> WoodenFishWorkProfileProfileIdUnique<'ctx> { + /// Find the subscribed row whose `profile_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::("wooden_fish_work_profile"); + _table.add_unique_constraint::("profile_id", |row| &row.profile_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 `WoodenFishWorkProfileRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait wooden_fish_work_profileQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `WoodenFishWorkProfileRow`. + fn wooden_fish_work_profile(&self) -> __sdk::__query_builder::Table; +} + +impl wooden_fish_work_profileQueryTableAccess for __sdk::QueryTableAccessor { + fn wooden_fish_work_profile(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("wooden_fish_work_profile") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_publish_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_publish_input_type.rs new file mode 100644 index 00000000..f9920f38 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_publish_input_type.rs @@ -0,0 +1,17 @@ +// 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 WoodenFishWorkPublishInput { + pub profile_id: String, + pub owner_user_id: String, + pub published_at_micros: i64, +} + +impl __sdk::InModule for WoodenFishWorkPublishInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_snapshot_type.rs new file mode 100644 index 00000000..7c173133 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_snapshot_type.rs @@ -0,0 +1,38 @@ +// 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}; + +use super::wooden_fish_audio_asset_snapshot_type::WoodenFishAudioAssetSnapshot; +use super::wooden_fish_image_asset_snapshot_type::WoodenFishImageAssetSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WoodenFishWorkSnapshot { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub hit_object_prompt: String, + pub hit_object_reference_image_src: Option, + pub hit_sound_prompt: Option, + pub hit_object_asset: Option, + pub hit_sound_asset: Option, + pub floating_words: Vec, + pub cover_image_src: String, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub generation_status: String, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} + +impl __sdk::InModule for WoodenFishWorkSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_update_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_update_input_type.rs new file mode 100644 index 00000000..2981f8e3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_update_input_type.rs @@ -0,0 +1,28 @@ +// 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 WoodenFishWorkUpdateInput { + pub profile_id: String, + pub owner_user_id: String, + pub work_title: String, + pub work_description: String, + pub theme_tags_json: String, + pub hit_object_prompt: Option, + pub hit_object_reference_image_src: Option, + pub hit_sound_prompt: Option, + pub hit_object_asset_json: Option, + pub hit_sound_asset_json: Option, + pub floating_words_json: Option, + pub cover_image_src: Option, + pub generation_status: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for WoodenFishWorkUpdateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_works_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_works_list_input_type.rs new file mode 100644 index 00000000..0571854e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_works_list_input_type.rs @@ -0,0 +1,16 @@ +// 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 WoodenFishWorksListInput { + pub owner_user_id: String, + pub published_only: bool, +} + +impl __sdk::InModule for WoodenFishWorksListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_works_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_works_procedure_result_type.rs new file mode 100644 index 00000000..bb2a8498 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_works_procedure_result_type.rs @@ -0,0 +1,19 @@ +// 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}; + +use super::wooden_fish_work_snapshot_type::WoodenFishWorkSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct WoodenFishWorksProcedureResult { + pub ok: bool, + pub items: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for WoodenFishWorksProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/telemetry.rs b/server-rs/crates/spacetime-client/src/telemetry.rs index c89e0f19..d75fc4a1 100644 --- a/server-rs/crates/spacetime-client/src/telemetry.rs +++ b/server-rs/crates/spacetime-client/src/telemetry.rs @@ -81,7 +81,9 @@ fn spacetime_metrics() -> &'static SpacetimeMetrics { read_duration_ms: meter .f64_histogram("genarrative.spacetime.read.duration_ms") .with_unit("ms") - .with_description("SpacetimeDB local subscription cache read duration in milliseconds") + .with_description( + "SpacetimeDB local subscription cache read duration in milliseconds", + ) .build(), } }) diff --git a/server-rs/crates/spacetime-client/src/wooden_fish.rs b/server-rs/crates/spacetime-client/src/wooden_fish.rs new file mode 100644 index 00000000..9297a163 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/wooden_fish.rs @@ -0,0 +1,1031 @@ +use super::*; +use crate::mapper::{ + map_wooden_fish_agent_session_procedure_result, map_wooden_fish_gallery_card_view_row, + map_wooden_fish_run_procedure_result, map_wooden_fish_work_procedure_result, + map_wooden_fish_works_procedure_result, +}; +use shared_contracts::wooden_fish::{ + WoodenFishActionRequest, WoodenFishActionResponse, WoodenFishActionType, + WoodenFishCheckpointRunRequest, WoodenFishDraftResponse, WoodenFishFinishRunRequest, + WoodenFishGalleryResponse, WoodenFishGenerationStatus, WoodenFishRuntimeRunSnapshotResponse, + WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest, WoodenFishWorkProfileResponse, +}; +use shared_kernel::build_prefixed_uuid_id; + +const WOODEN_FISH_TEMPLATE_ID: &str = "wooden-fish"; +const WOODEN_FISH_TEMPLATE_NAME: &str = "敲木鱼"; +const DEFAULT_HIT_OBJECT_PROMPT: &str = "默认敲击物图案,圆润木质质感,透明背景"; +const DEFAULT_HIT_SOUND_PROMPT: &str = "清脆短促的木鱼敲击声"; + +impl SpacetimeClient { + pub async fn create_wooden_fish_session( + &self, + session: WoodenFishSessionSnapshotResponse, + ) -> Result { + let draft = session.draft.clone().ok_or_else(|| { + SpacetimeClientError::validation_failed("wooden-fish session 缺少 draft") + })?; + let theme_tags_json = Some(json_string(&draft.theme_tags)?); + let config_json = Some(build_config_json(&draft)?); + let draft_json = Some(json_string(&draft)?); + let procedure_input = WoodenFishAgentSessionCreateInput { + session_id: session.session_id, + owner_user_id: session.owner_user_id, + work_title: draft.work_title, + work_description: draft.work_description, + theme_tags_json, + config_json, + draft_json, + created_at_micros: current_unix_micros(), + }; + + self.call_after_connect( + "create_wooden_fish_agent_session", + move |connection, sender| { + connection + .procedures() + .create_wooden_fish_agent_session_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_wooden_fish_agent_session_procedure_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + + pub async fn get_wooden_fish_session( + &self, + session_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = WoodenFishAgentSessionGetInput { + session_id, + owner_user_id, + }; + + self.call_after_connect( + "get_wooden_fish_agent_session", + move |connection, sender| { + connection.procedures().get_wooden_fish_agent_session_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_wooden_fish_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn execute_wooden_fish_action( + &self, + session_id: String, + owner_user_id: String, + payload: WoodenFishActionRequest, + ) -> Result { + let current = self + .get_wooden_fish_session(session_id.clone(), owner_user_id.clone()) + .await?; + let (procedure, _) = build_wooden_fish_action_plan( + ¤t, + &owner_user_id, + &payload, + current_unix_micros(), + )?; + let (session, work) = match procedure { + WoodenFishActionProcedure::Compile(input) => { + let profile_id = input.profile_id.clone(); + let session = self.compile_wooden_fish_draft(input).await?; + let work = self + .get_wooden_fish_work_profile(profile_id, owner_user_id) + .await + .ok(); + (session, work) + } + WoodenFishActionProcedure::Update(input) => { + let work = self.update_wooden_fish_work(input).await?; + let session = apply_wooden_fish_work_to_session(current, &work); + (session, Some(work)) + } + }; + + Ok(WoodenFishActionResponse { + action_type: payload.action_type, + session, + work, + }) + } + + pub async fn compile_wooden_fish_draft( + &self, + procedure_input: WoodenFishDraftCompileInput, + ) -> Result { + self.call_after_connect("compile_wooden_fish_draft", move |connection, sender| { + connection.procedures().compile_wooden_fish_draft_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_wooden_fish_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn get_wooden_fish_work_profile( + &self, + profile_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = WoodenFishWorkGetInput { + profile_id, + owner_user_id, + }; + + self.call_after_connect("get_wooden_fish_work_profile", move |connection, sender| { + connection.procedures().get_wooden_fish_work_profile_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_wooden_fish_work_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn update_wooden_fish_work( + &self, + procedure_input: WoodenFishWorkUpdateInput, + ) -> Result { + self.call_after_connect("update_wooden_fish_work", move |connection, sender| { + connection.procedures().update_wooden_fish_work_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_wooden_fish_work_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn publish_wooden_fish_work( + &self, + profile_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = WoodenFishWorkPublishInput { + profile_id, + owner_user_id, + published_at_micros: current_unix_micros(), + }; + + self.call_after_connect("publish_wooden_fish_work", move |connection, sender| { + connection.procedures().publish_wooden_fish_work_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_wooden_fish_work_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn list_wooden_fish_works( + &self, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = WoodenFishWorksListInput { + owner_user_id, + published_only: false, + }; + + self.call_after_connect("list_wooden_fish_works", move |connection, sender| { + connection.procedures().list_wooden_fish_works_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_wooden_fish_works_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn get_wooden_fish_runtime_work( + &self, + profile_id: String, + ) -> Result { + self.get_wooden_fish_work_profile(profile_id, String::new()) + .await + } + + pub async fn start_wooden_fish_run( + &self, + payload: WoodenFishStartRunRequest, + owner_user_id: String, + ) -> Result { + let run_id = build_prefixed_uuid_id("wooden-fish-run-"); + let procedure_input = WoodenFishRunStartInput { + client_event_id: format!("{run_id}:start"), + run_id, + owner_user_id, + profile_id: payload.profile_id, + started_at_ms: current_unix_micros().div_euclid(1000), + }; + self.start_wooden_fish_run_with_input(procedure_input).await + } + + pub async fn start_wooden_fish_run_with_input( + &self, + procedure_input: WoodenFishRunStartInput, + ) -> Result { + self.call_after_connect("start_wooden_fish_run", move |connection, sender| { + connection.procedures().start_wooden_fish_run_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_wooden_fish_run_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn checkpoint_wooden_fish_run( + &self, + run_id: String, + owner_user_id: String, + payload: WoodenFishCheckpointRunRequest, + ) -> Result { + let procedure_input = WoodenFishRunCheckpointInput { + run_id, + owner_user_id, + total_tap_count: payload.total_tap_count, + word_counters_json: json_string(&payload.word_counters)?, + client_event_id: payload.client_event_id, + checkpoint_at_ms: current_unix_micros().div_euclid(1000), + }; + + self.call_after_connect("checkpoint_wooden_fish_run", move |connection, sender| { + connection.procedures().checkpoint_wooden_fish_run_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_wooden_fish_run_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn finish_wooden_fish_run( + &self, + run_id: String, + owner_user_id: String, + payload: WoodenFishFinishRunRequest, + ) -> Result { + let procedure_input = WoodenFishRunFinishInput { + run_id, + owner_user_id, + total_tap_count: payload.total_tap_count, + word_counters_json: json_string(&payload.word_counters)?, + client_event_id: payload.client_event_id, + finished_at_ms: current_unix_micros().div_euclid(1000), + }; + + self.call_after_connect("finish_wooden_fish_run", move |connection, sender| { + connection.procedures().finish_wooden_fish_run_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_wooden_fish_run_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn list_wooden_fish_gallery( + &self, + ) -> Result { + self.read_after_connect("list_wooden_fish_gallery", move |connection| { + let mut items = connection + .db() + .wooden_fish_gallery_card_view() + .iter() + .collect::>(); + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.profile_id.cmp(&right.profile_id)) + }); + + Ok(WoodenFishGalleryResponse { + items: items + .into_iter() + .map(map_wooden_fish_gallery_card_view_row) + .collect(), + has_more: false, + next_cursor: None, + }) + }) + .await + } + + pub async fn get_wooden_fish_gallery_detail( + &self, + public_work_code: String, + ) -> Result { + let normalized_code = public_work_code.trim().to_string(); + if normalized_code.is_empty() { + return Err(SpacetimeClientError::validation_failed( + "wooden-fish public_work_code 不能为空", + )); + } + + let profile_id = self + .read_after_connect("resolve_wooden_fish_gallery_detail", move |connection| { + connection + .db() + .wooden_fish_gallery_card_view() + .iter() + .find(|row| { + row.public_work_code.eq_ignore_ascii_case(&normalized_code) + || row.profile_id == normalized_code + }) + .map(|row| row.profile_id) + .ok_or_else(|| { + SpacetimeClientError::procedure_failed(Some( + "敲木鱼公开作品不存在".to_string(), + )) + }) + }) + .await?; + + self.get_wooden_fish_work_profile(profile_id, String::new()) + .await + } +} + +enum WoodenFishActionProcedure { + Compile(WoodenFishDraftCompileInput), + Update(WoodenFishWorkUpdateInput), +} + +#[derive(Clone, Copy)] +enum WoodenFishDraftMergeScope { + CompileDraft, + RegenerateHitObject, + GenerateHitSound, + ReplaceHitSound, + UpdateWorkMeta, + UpdateFloatingWords, +} + +#[derive(Clone, Copy)] +enum WoodenFishAssetRefresh { + Preserve, + HitObject, + HitSound, +} + +fn build_wooden_fish_action_plan( + current: &WoodenFishSessionSnapshotResponse, + owner_user_id: &str, + payload: &WoodenFishActionRequest, + now_micros: i64, +) -> Result<(WoodenFishActionProcedure, WoodenFishDraftResponse), SpacetimeClientError> { + let scope = match payload.action_type { + WoodenFishActionType::CompileDraft => WoodenFishDraftMergeScope::CompileDraft, + WoodenFishActionType::RegenerateHitObject => WoodenFishDraftMergeScope::RegenerateHitObject, + WoodenFishActionType::GenerateHitSound => WoodenFishDraftMergeScope::GenerateHitSound, + WoodenFishActionType::ReplaceHitSound => WoodenFishDraftMergeScope::ReplaceHitSound, + WoodenFishActionType::UpdateWorkMeta => WoodenFishDraftMergeScope::UpdateWorkMeta, + WoodenFishActionType::UpdateFloatingWords => WoodenFishDraftMergeScope::UpdateFloatingWords, + }; + let mut draft = merge_action_into_draft(current.draft.clone(), payload, scope)?; + let profile_id = resolve_wooden_fish_profile_id( + &draft, + &payload.action_type, + payload.profile_id.as_deref(), + )?; + draft.profile_id = Some(profile_id.clone()); + + let procedure = match payload.action_type { + WoodenFishActionType::CompileDraft => { + WoodenFishActionProcedure::Compile(build_compile_input( + current, + owner_user_id, + &profile_id, + &mut draft, + WoodenFishAssetRefresh::Preserve, + now_micros, + )?) + } + WoodenFishActionType::RegenerateHitObject => { + WoodenFishActionProcedure::Compile(build_compile_input( + current, + owner_user_id, + &profile_id, + &mut draft, + WoodenFishAssetRefresh::HitObject, + now_micros, + )?) + } + WoodenFishActionType::GenerateHitSound => { + WoodenFishActionProcedure::Compile(build_compile_input( + current, + owner_user_id, + &profile_id, + &mut draft, + WoodenFishAssetRefresh::HitSound, + now_micros, + )?) + } + WoodenFishActionType::ReplaceHitSound => WoodenFishActionProcedure::Update( + build_update_input(owner_user_id, &profile_id, &draft, true, now_micros)?, + ), + WoodenFishActionType::UpdateWorkMeta | WoodenFishActionType::UpdateFloatingWords => { + WoodenFishActionProcedure::Update(build_update_input( + owner_user_id, + &profile_id, + &draft, + false, + now_micros, + )?) + } + }; + + Ok((procedure, draft)) +} + +fn merge_action_into_draft( + draft: Option, + payload: &WoodenFishActionRequest, + scope: WoodenFishDraftMergeScope, +) -> Result { + let mut draft = draft.unwrap_or_else(default_draft); + if matches!( + scope, + WoodenFishDraftMergeScope::CompileDraft | WoodenFishDraftMergeScope::UpdateWorkMeta + ) { + if let Some(value) = payload + .work_title + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + draft.work_title = value.trim().to_string(); + } + if let Some(value) = payload.work_description.as_ref() { + draft.work_description = value.trim().to_string(); + } + if let Some(value) = payload.theme_tags.clone() { + draft.theme_tags = normalize_tags(value); + } + } + if matches!( + scope, + WoodenFishDraftMergeScope::CompileDraft | WoodenFishDraftMergeScope::RegenerateHitObject + ) { + if let Some(value) = payload + .hit_object_prompt + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + draft.hit_object_prompt = value.trim().to_string(); + } + if payload.hit_object_reference_image_src.is_some() { + draft.hit_object_reference_image_src = payload + .hit_object_reference_image_src + .as_ref() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + } + if let Some(asset) = payload.hit_object_asset.clone() { + draft.hit_object_asset = Some(asset); + } + } + if matches!( + scope, + WoodenFishDraftMergeScope::CompileDraft + | WoodenFishDraftMergeScope::GenerateHitSound + | WoodenFishDraftMergeScope::ReplaceHitSound + ) { + if let Some(value) = payload + .hit_sound_prompt + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + draft.hit_sound_prompt = Some(value.trim().to_string()); + } + } + if matches!(scope, WoodenFishDraftMergeScope::GenerateHitSound) { + draft.hit_sound_asset = payload.hit_sound_asset.clone(); + } else if matches!( + scope, + WoodenFishDraftMergeScope::CompileDraft | WoodenFishDraftMergeScope::ReplaceHitSound + ) && let Some(asset) = payload.hit_sound_asset.clone() + { + draft.hit_sound_asset = Some(asset); + } + if matches!( + scope, + WoodenFishDraftMergeScope::CompileDraft | WoodenFishDraftMergeScope::UpdateFloatingWords + ) && let Some(words) = payload.floating_words.clone() + { + draft.floating_words = normalize_floating_words(words); + } + if draft.work_title.trim().is_empty() { + return Err(SpacetimeClientError::validation_failed( + "wooden-fish work_title 不能为空", + )); + } + if draft.hit_object_prompt.trim().is_empty() { + draft.hit_object_prompt = DEFAULT_HIT_OBJECT_PROMPT.to_string(); + } + if draft.hit_object_asset.is_some() + && matches!(scope, WoodenFishDraftMergeScope::RegenerateHitObject) + && payload.hit_object_asset.is_none() + { + draft.hit_object_asset = None; + } + if draft.floating_words.is_empty() { + draft.floating_words = default_floating_words(); + } + Ok(draft) +} + +fn build_compile_input( + current: &WoodenFishSessionSnapshotResponse, + owner_user_id: &str, + profile_id: &str, + draft: &mut WoodenFishDraftResponse, + refresh: WoodenFishAssetRefresh, + now_micros: i64, +) -> Result { + let _refresh = refresh; + let hit_object_asset = draft.hit_object_asset.clone().ok_or_else(|| { + SpacetimeClientError::validation_failed("wooden fish hit object asset 缺少真实生成资产") + })?; + draft.hit_object_asset = Some(hit_object_asset); + draft.cover_image_src = draft + .hit_object_asset + .as_ref() + .map(|asset| asset.image_src.clone()); + draft.generation_status = WoodenFishGenerationStatus::Ready; + + let hit_object_asset = draft + .hit_object_asset + .clone() + .ok_or_else(|| SpacetimeClientError::missing_snapshot("wooden fish hit object asset"))?; + let hit_sound_asset = draft.hit_sound_asset.clone().ok_or_else(|| { + SpacetimeClientError::validation_failed("wooden fish hit sound asset 缺少真实生成资产") + })?; + + Ok(WoodenFishDraftCompileInput { + session_id: current.session_id.clone(), + owner_user_id: owner_user_id.to_string(), + profile_id: profile_id.to_string(), + author_display_name: "敲木鱼玩家".to_string(), + work_title: draft.work_title.clone(), + work_description: draft.work_description.clone(), + theme_tags_json: Some(json_string(&draft.theme_tags)?), + hit_object_prompt: draft.hit_object_prompt.clone(), + hit_object_reference_image_src: draft.hit_object_reference_image_src.clone(), + hit_sound_prompt: draft.hit_sound_prompt.clone(), + hit_object_asset_json: Some(json_string(&hit_object_asset)?), + hit_sound_asset_json: Some(json_string(&hit_sound_asset)?), + floating_words_json: Some(json_string(&draft.floating_words)?), + cover_image_src: draft.cover_image_src.clone(), + generation_status: Some("ready".to_string()), + compiled_at_micros: now_micros, + }) +} + +fn build_update_input( + owner_user_id: &str, + profile_id: &str, + draft: &WoodenFishDraftResponse, + include_hit_sound_asset: bool, + now_micros: i64, +) -> Result { + Ok(WoodenFishWorkUpdateInput { + profile_id: profile_id.to_string(), + owner_user_id: owner_user_id.to_string(), + work_title: draft.work_title.clone(), + work_description: draft.work_description.clone(), + theme_tags_json: json_string(&draft.theme_tags)?, + hit_object_prompt: Some(draft.hit_object_prompt.clone()), + hit_object_reference_image_src: draft.hit_object_reference_image_src.clone(), + hit_sound_prompt: draft.hit_sound_prompt.clone(), + hit_object_asset_json: None, + hit_sound_asset_json: if include_hit_sound_asset { + draft + .hit_sound_asset + .as_ref() + .map(json_string) + .transpose()? + } else { + None + }, + floating_words_json: Some(json_string(&draft.floating_words)?), + cover_image_src: draft.cover_image_src.clone(), + generation_status: None, + updated_at_micros: now_micros, + }) +} + +fn resolve_wooden_fish_profile_id( + draft: &WoodenFishDraftResponse, + action_type: &WoodenFishActionType, + requested_profile_id: Option<&str>, +) -> Result { + if let Some(profile_id) = requested_profile_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(profile_id.to_string()); + } + if let Some(profile_id) = draft + .profile_id + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + return Ok(profile_id.to_string()); + } + if matches!(action_type, WoodenFishActionType::CompileDraft) { + return Ok(build_prefixed_uuid_id("wooden-fish-profile-")); + } + Err(SpacetimeClientError::validation_failed( + "wooden-fish action 需要先完成 compile-draft", + )) +} + +fn apply_wooden_fish_work_to_session( + mut session: WoodenFishSessionSnapshotResponse, + work: &WoodenFishWorkProfileResponse, +) -> WoodenFishSessionSnapshotResponse { + session.status = work.draft.generation_status.clone(); + session.draft = Some(work.draft.clone()); + session.updated_at = work.summary.updated_at.clone(); + session +} + +fn default_draft() -> WoodenFishDraftResponse { + WoodenFishDraftResponse { + template_id: WOODEN_FISH_TEMPLATE_ID.to_string(), + template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(), + profile_id: None, + work_title: WOODEN_FISH_TEMPLATE_NAME.to_string(), + work_description: String::new(), + theme_tags: vec!["休闲".to_string()], + hit_object_prompt: DEFAULT_HIT_OBJECT_PROMPT.to_string(), + hit_object_reference_image_src: None, + hit_sound_prompt: Some(DEFAULT_HIT_SOUND_PROMPT.to_string()), + floating_words: default_floating_words(), + hit_object_asset: None, + hit_sound_asset: None, + cover_image_src: None, + generation_status: WoodenFishGenerationStatus::Draft, + } +} + +fn build_config_json(draft: &WoodenFishDraftResponse) -> Result { + serde_json::to_string(&serde_json::json!({ + "workTitle": draft.work_title, + "workDescription": draft.work_description, + "themeTags": draft.theme_tags, + "hitObjectPrompt": draft.hit_object_prompt, + "hitObjectReferenceImageSrc": draft.hit_object_reference_image_src, + "hitSoundPrompt": draft.hit_sound_prompt, + "floatingWords": draft.floating_words, + })) + .map_err(SpacetimeClientError::validation_failed) +} + +fn normalize_tags(tags: Vec) -> Vec { + tags.into_iter() + .map(|tag| tag.trim().to_string()) + .filter(|tag| !tag.is_empty()) + .take(8) + .collect() +} + +fn default_floating_words() -> Vec { + vec![ + "幸运".to_string(), + "健康".to_string(), + "财富".to_string(), + "姻缘".to_string(), + "幸福".to_string(), + "事业".to_string(), + "成功".to_string(), + "功德".to_string(), + ] +} + +fn normalize_floating_words(words: Vec) -> Vec { + let mut normalized = Vec::new(); + for word in words { + let word = normalize_floating_word(&word); + if word.is_empty() || normalized.iter().any(|item| item == &word) { + continue; + } + normalized.push(word); + if normalized.len() >= 8 { + break; + } + } + if normalized.is_empty() { + default_floating_words() + } else { + normalized + } +} + +fn normalize_floating_word(word: &str) -> String { + word.trim() + .trim_end_matches(|ch: char| ch == '1' || ch.is_whitespace()) + .trim_end_matches(['+', '+']) + .trim() + .to_string() +} + +fn json_string(value: &T) -> Result { + serde_json::to_string(value).map_err(SpacetimeClientError::validation_failed) +} + +#[cfg(test)] +mod tests { + use super::*; + use shared_contracts::wooden_fish::WoodenFishAudioAsset; + + const SESSION_ID: &str = "wooden-fish-session-test"; + const OWNER_USER_ID: &str = "user-test"; + const PROFILE_ID: &str = "wooden-fish-profile-test"; + const NOW_MICROS: i64 = 1_763_456_789_000_000; + + #[test] + fn wooden_fish_action_compile_draft_builds_compile_input_with_assets() { + let session = session_with_draft(draft_without_assets()); + let mut payload = action(WoodenFishActionType::CompileDraft); + payload.hit_object_asset = Some(generated_hit_object_asset("generated-compile-object")); + payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound")); + + let (plan, draft) = + build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) + .expect("compile-draft should build plan"); + + let WoodenFishActionProcedure::Compile(input) = plan else { + panic!("compile-draft should call compile_wooden_fish_draft"); + }; + assert_eq!(input.session_id, SESSION_ID); + assert_eq!(input.owner_user_id, OWNER_USER_ID); + assert_eq!(input.generation_status.as_deref(), Some("ready")); + assert!( + input + .hit_object_asset_json + .as_deref() + .unwrap_or("") + .contains("generated-compile-object") + ); + assert!( + input + .hit_sound_asset_json + .as_deref() + .unwrap_or("") + .contains("generated-compile-sound") + ); + assert_eq!(draft.generation_status, WoodenFishGenerationStatus::Ready); + } + + #[test] + fn wooden_fish_compile_requires_real_hit_sound_asset_from_api_server() { + let session = session_with_draft(draft_without_assets()); + let mut payload = action(WoodenFishActionType::CompileDraft); + payload.hit_object_asset = Some(generated_hit_object_asset("generated-compile-object")); + + let error = + match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) { + Ok(_) => panic!("compile-draft should not synthesize fake hit sound assets"), + Err(error) => error, + }; + + assert!( + error + .to_string() + .contains("hit sound asset 缺少真实生成资产") + ); + } + + #[test] + fn wooden_fish_action_regenerate_hit_object_replaces_only_object_asset() { + let session = session_with_draft(draft_with_assets()); + let mut payload = action(WoodenFishActionType::RegenerateHitObject); + payload.hit_object_prompt = Some("新的敲击物".to_string()); + payload.hit_object_asset = Some(generated_hit_object_asset("generated-object")); + + let (plan, _draft) = + build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) + .expect("regenerate-hit-object should build plan"); + + let WoodenFishActionProcedure::Compile(input) = plan else { + panic!("regenerate-hit-object should call compile_wooden_fish_draft"); + }; + assert!( + !input + .hit_object_asset_json + .as_deref() + .unwrap_or("") + .contains("old-object") + ); + assert!( + input + .hit_object_asset_json + .as_deref() + .unwrap_or("") + .contains("real-profile") + ); + assert!( + !input + .hit_object_asset_json + .as_deref() + .unwrap_or("") + .contains(&NOW_MICROS.to_string()) + ); + assert!( + input + .hit_sound_asset_json + .as_deref() + .unwrap_or("") + .contains("old-sound") + ); + } + + #[test] + fn wooden_fish_action_update_floating_words_builds_update_input() { + let session = session_with_draft(draft_with_assets()); + let mut payload = action(WoodenFishActionType::UpdateFloatingWords); + payload.floating_words = Some(vec![ + " 功德+1 ".to_string(), + "功德+1".to_string(), + "健康+1".to_string(), + ]); + + let (plan, draft) = + build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) + .expect("update-floating-words should build plan"); + + let WoodenFishActionProcedure::Update(input) = plan else { + panic!("update-floating-words should call update_wooden_fish_work"); + }; + assert_eq!(input.profile_id, PROFILE_ID); + assert!(input.hit_sound_asset_json.is_none()); + assert_eq!( + draft.floating_words, + vec!["功德".to_string(), "健康".to_string()] + ); + assert!( + input + .floating_words_json + .as_deref() + .unwrap_or("") + .contains("健康") + ); + } + + fn action(action_type: WoodenFishActionType) -> WoodenFishActionRequest { + WoodenFishActionRequest { + action_type, + profile_id: None, + work_title: None, + work_description: None, + theme_tags: None, + hit_object_prompt: None, + hit_object_reference_image_src: None, + hit_object_asset: None, + hit_sound_prompt: None, + hit_sound_asset: None, + floating_words: None, + } + } + + fn session_with_draft(draft: WoodenFishDraftResponse) -> WoodenFishSessionSnapshotResponse { + WoodenFishSessionSnapshotResponse { + session_id: SESSION_ID.to_string(), + owner_user_id: OWNER_USER_ID.to_string(), + status: draft.generation_status.clone(), + draft: Some(draft), + created_at: "2026-05-20T00:00:00Z".to_string(), + updated_at: "2026-05-20T00:00:00Z".to_string(), + } + } + + fn draft_without_assets() -> WoodenFishDraftResponse { + WoodenFishDraftResponse { + profile_id: None, + ..base_draft() + } + } + + fn generated_hit_object_asset(asset_id: &str) -> WoodenFishImageAsset { + WoodenFishImageAsset { + asset_id: asset_id.to_string(), + image_src: "/generated-wooden-fish-assets/real-profile/hit-object/image.png" + .to_string(), + image_object_key: "generated-wooden-fish-assets/real-profile/hit-object/image.png" + .to_string(), + asset_object_id: format!("{asset_id}-asset"), + generation_provider: "image2".to_string(), + prompt: "新的敲击物".to_string(), + width: 1024, + height: 1024, + } + } + + fn generated_hit_sound_asset(asset_id: &str) -> WoodenFishAudioAsset { + WoodenFishAudioAsset { + asset_id: asset_id.to_string(), + audio_src: "/generated-wooden-fish-assets/real-profile/hit-sound/sound.mp3".to_string(), + audio_object_key: "generated-wooden-fish-assets/real-profile/hit-sound/sound.mp3" + .to_string(), + asset_object_id: format!("{asset_id}-asset"), + source: "generated".to_string(), + prompt: Some("新的木鱼音效".to_string()), + duration_ms: Some(3000), + } + } + + fn draft_with_assets() -> WoodenFishDraftResponse { + WoodenFishDraftResponse { + profile_id: Some(PROFILE_ID.to_string()), + hit_object_asset: Some(WoodenFishImageAsset { + asset_id: "old-object".to_string(), + image_src: "/generated-wooden-fish-assets/old-object.png".to_string(), + image_object_key: "generated-wooden-fish-assets/old-object.png".to_string(), + asset_object_id: "old-object-asset".to_string(), + generation_provider: "image2".to_string(), + prompt: "旧敲击物".to_string(), + width: 1024, + height: 1024, + }), + hit_sound_asset: Some(WoodenFishAudioAsset { + asset_id: "old-sound".to_string(), + audio_src: "/generated-wooden-fish-assets/old-sound.mp3".to_string(), + audio_object_key: "generated-wooden-fish-assets/old-sound.mp3".to_string(), + asset_object_id: "old-sound-asset".to_string(), + source: "generated".to_string(), + prompt: Some("旧音效".to_string()), + duration_ms: Some(700), + }), + cover_image_src: Some("/generated-wooden-fish-assets/old-object.png".to_string()), + generation_status: WoodenFishGenerationStatus::Ready, + ..base_draft() + } + } + + fn base_draft() -> WoodenFishDraftResponse { + WoodenFishDraftResponse { + template_id: WOODEN_FISH_TEMPLATE_ID.to_string(), + template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(), + profile_id: None, + work_title: "旧标题".to_string(), + work_description: "旧描述".to_string(), + theme_tags: vec!["旧标签".to_string()], + hit_object_prompt: "旧敲击物".to_string(), + hit_object_reference_image_src: None, + hit_sound_prompt: Some("旧音效".to_string()), + floating_words: default_floating_words(), + hit_object_asset: None, + hit_sound_asset: None, + cover_image_src: None, + generation_status: WoodenFishGenerationStatus::Draft, + } + } +} diff --git a/server-rs/crates/spacetime-module/Cargo.toml b/server-rs/crates/spacetime-module/Cargo.toml index 16ff92e2..a418c2ad 100644 --- a/server-rs/crates/spacetime-module/Cargo.toml +++ b/server-rs/crates/spacetime-module/Cargo.toml @@ -19,6 +19,7 @@ module-combat = { workspace = true, features = ["spacetime-types"] } module-inventory = { workspace = true, features = ["spacetime-types"] } module-custom-world = { workspace = true, features = ["spacetime-types"] } module-jump-hop = { workspace = true, features = ["spacetime-types"] } +module-wooden-fish = { workspace = true, features = ["spacetime-types"] } module-match3d = { workspace = true } module-npc = { workspace = true, features = ["spacetime-types"] } module-puzzle = { workspace = true, features = ["spacetime-types"] } diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index b89390a4..adaeb114 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -1,4 +1,4 @@ -// 中文注释:SpacetimeDB 绑定生成依赖根模块继续公开 re-export 各领域类型; +// 中文注释:SpacetimeDB 绑定生成依赖根模块继续公开 re-export 各领域类型; // 少数领域 helper 同名只影响 value namespace 导出,不影响 table / reducer 类型。 #![allow(ambiguous_glob_reexports)] @@ -15,6 +15,7 @@ pub use module_quest::*; pub use module_runtime::*; pub use module_runtime_item::*; pub use module_story::*; +pub use module_wooden_fish::*; pub(crate) use serde_json::{Map as JsonMap, Value as JsonValue, json}; pub(crate) use shared_kernel::format_timestamp_micros; @@ -37,6 +38,7 @@ mod puzzle; mod runtime; mod square_hole; mod visual_novel; +mod wooden_fish; pub use ai::*; pub use asset_metadata::*; @@ -53,3 +55,4 @@ pub use migration::*; pub use runtime::*; pub use square_hole::*; pub use visual_novel::*; +pub use wooden_fish::*; diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index d12566c6..4b630149 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -26,6 +26,9 @@ use crate::square_hole::tables::{ square_hole_agent_message, square_hole_agent_session, square_hole_runtime_run, square_hole_work_profile, }; +use crate::wooden_fish::tables::{ + wooden_fish_agent_session, wooden_fish_event, wooden_fish_runtime_run, wooden_fish_work_profile, +}; use crate::{ visual_novel_agent_message, visual_novel_agent_session, visual_novel_runtime_event, visual_novel_runtime_history_entry, visual_novel_runtime_run, visual_novel_work_profile, @@ -241,6 +244,10 @@ macro_rules! migration_tables { jump_hop_work_profile, jump_hop_runtime_run, jump_hop_event, + wooden_fish_agent_session, + wooden_fish_work_profile, + wooden_fish_runtime_run, + wooden_fish_event, square_hole_agent_session, square_hole_agent_message, square_hole_work_profile, 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 77ed87e7..d754501d 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 @@ -192,6 +192,7 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) { }, ); migrate_baby_object_match_entry_from_old_coming_soon_default(ctx, now); + migrate_wooden_fish_entry_from_old_puzzle_image_default(ctx, now); } fn migrate_visual_novel_entry_from_old_visible_default(ctx: &ReducerContext, now: Timestamp) { @@ -296,6 +297,34 @@ fn migrate_baby_object_match_entry_from_old_coming_soon_default( }); } +fn migrate_wooden_fish_entry_from_old_puzzle_image_default(ctx: &ReducerContext, now: Timestamp) { + let id = "wooden-fish".to_string(); + let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else { + return; + }; + + // 中文注释:只替换敲木鱼旧默认入口图,不覆盖后台手动设置过的其它展示信息。 + let still_old_puzzle_image_default = row.title == "敲木鱼" + && row.subtitle == "点击祈福轻玩法" + && row.badge == "可创建" + && row.image_src == "/creation-type-references/puzzle.webp" + && row.visible + && row.open + && row.sort_order == 47; + if !still_old_puzzle_image_default { + return; + } + + ctx.db + .creation_entry_type_config() + .id() + .update(CreationEntryTypeConfig { + image_src: "/wooden-fish/default-hit-object.png".to_string(), + updated_at: now, + ..row + }); +} + fn default_creation_entry_type_configs(now: Timestamp) -> Vec { module_runtime::default_creation_entry_type_snapshots(now.to_micros_since_unix_epoch()) .into_iter() diff --git a/server-rs/crates/spacetime-module/src/wooden_fish.rs b/server-rs/crates/spacetime-module/src/wooden_fish.rs new file mode 100644 index 00000000..7808ce6a --- /dev/null +++ b/server-rs/crates/spacetime-module/src/wooden_fish.rs @@ -0,0 +1,1228 @@ +pub(crate) mod tables; +mod types; + +pub use tables::*; +pub use types::*; + +use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp}; +use module_wooden_fish::{ + WoodenFishRunSnapshot, WoodenFishRunStatus, WoodenFishWordCounter, apply_run_checkpoint, + default_floating_words, finish_run, normalize_floating_words, normalize_word_counters, +}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use spacetimedb::AnonymousViewContext; + +#[spacetimedb::view(accessor = wooden_fish_gallery_view, public)] +pub fn wooden_fish_gallery_view(ctx: &AnonymousViewContext) -> Vec { + let mut items = ctx + .db + .wooden_fish_work_profile() + .by_wooden_fish_work_publication_status() + .filter(WOODEN_FISH_PUBLICATION_PUBLISHED) + .filter_map(|row| match build_gallery_view_row(&row) { + Ok(item) => Some(item), + Err(error) => { + log::warn!( + "敲木鱼公开广场 view 跳过损坏的作品投影 profile_id={}: {}", + row.profile_id, + error + ); + None + } + }) + .collect::>(); + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.profile_id.cmp(&right.profile_id)) + }); + items +} + +#[spacetimedb::view(accessor = wooden_fish_gallery_card_view, public)] +pub fn wooden_fish_gallery_card_view( + ctx: &AnonymousViewContext, +) -> Vec { + wooden_fish_gallery_view(ctx) + .into_iter() + .map(|row| WoodenFishGalleryCardViewRow { + public_work_code: row.public_work_code, + work_id: row.work_id, + profile_id: row.profile_id, + owner_user_id: row.owner_user_id, + author_display_name: row.author_display_name, + work_title: row.work_title, + work_description: row.work_description, + cover_image_src: row.cover_image_src, + theme_tags: row.theme_tags, + publication_status: row.publication_status, + play_count: row.play_count, + updated_at_micros: row.updated_at_micros, + published_at_micros: row.published_at_micros, + generation_status: row.generation_status, + }) + .collect() +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WoodenFishGalleryViewRow { + pub public_work_code: String, + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub hit_object_prompt: String, + pub hit_object_reference_image_src: Option, + pub hit_sound_prompt: Option, + pub hit_object_asset: Option, + pub hit_sound_asset: Option, + pub floating_words: Vec, + pub cover_image_src: String, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub generation_status: String, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WoodenFishGalleryCardViewRow { + pub public_work_code: String, + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub cover_image_src: String, + pub theme_tags: Vec, + pub publication_status: String, + pub play_count: u32, + pub updated_at_micros: i64, + pub published_at_micros: Option, + pub generation_status: String, +} + +#[spacetimedb::procedure] +pub fn create_wooden_fish_agent_session( + ctx: &mut ProcedureContext, + input: WoodenFishAgentSessionCreateInput, +) -> WoodenFishAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| create_wooden_fish_agent_session_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn get_wooden_fish_agent_session( + ctx: &mut ProcedureContext, + input: WoodenFishAgentSessionGetInput, +) -> WoodenFishAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| get_wooden_fish_agent_session_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn compile_wooden_fish_draft( + ctx: &mut ProcedureContext, + input: WoodenFishDraftCompileInput, +) -> WoodenFishAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| compile_wooden_fish_draft_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn get_wooden_fish_work_profile( + ctx: &mut ProcedureContext, + input: WoodenFishWorkGetInput, +) -> WoodenFishWorkProcedureResult { + match ctx.try_with_tx(|tx| get_wooden_fish_work_profile_tx(tx, input.clone())) { + Ok(work) => work_result(work), + Err(message) => work_error(message), + } +} + +#[spacetimedb::procedure] +pub fn update_wooden_fish_work( + ctx: &mut ProcedureContext, + input: WoodenFishWorkUpdateInput, +) -> WoodenFishWorkProcedureResult { + match ctx.try_with_tx(|tx| update_wooden_fish_work_tx(tx, input.clone())) { + Ok(work) => work_result(work), + Err(message) => work_error(message), + } +} + +#[spacetimedb::procedure] +pub fn publish_wooden_fish_work( + ctx: &mut ProcedureContext, + input: WoodenFishWorkPublishInput, +) -> WoodenFishWorkProcedureResult { + match ctx.try_with_tx(|tx| publish_wooden_fish_work_tx(tx, input.clone())) { + Ok(work) => work_result(work), + Err(message) => work_error(message), + } +} + +#[spacetimedb::procedure] +pub fn list_wooden_fish_works( + ctx: &mut ProcedureContext, + input: WoodenFishWorksListInput, +) -> WoodenFishWorksProcedureResult { + match ctx.try_with_tx(|tx| list_wooden_fish_works_tx(tx, input.clone())) { + Ok(items) => WoodenFishWorksProcedureResult { + ok: true, + items, + error_message: None, + }, + Err(message) => WoodenFishWorksProcedureResult { + ok: false, + items: Vec::new(), + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn start_wooden_fish_run( + ctx: &mut ProcedureContext, + input: WoodenFishRunStartInput, +) -> WoodenFishRunProcedureResult { + match ctx.try_with_tx(|tx| start_wooden_fish_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn get_wooden_fish_run( + ctx: &mut ProcedureContext, + input: WoodenFishRunGetInput, +) -> WoodenFishRunProcedureResult { + match ctx.try_with_tx(|tx| get_wooden_fish_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn checkpoint_wooden_fish_run( + ctx: &mut ProcedureContext, + input: WoodenFishRunCheckpointInput, +) -> WoodenFishRunProcedureResult { + match ctx.try_with_tx(|tx| checkpoint_wooden_fish_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn finish_wooden_fish_run( + ctx: &mut ProcedureContext, + input: WoodenFishRunFinishInput, +) -> WoodenFishRunProcedureResult { + match ctx.try_with_tx(|tx| finish_wooden_fish_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +fn create_wooden_fish_agent_session_tx( + ctx: &ReducerContext, + input: WoodenFishAgentSessionCreateInput, +) -> Result { + require_non_empty(&input.session_id, "wooden_fish session_id")?; + require_non_empty(&input.owner_user_id, "wooden_fish owner_user_id")?; + if ctx + .db + .wooden_fish_agent_session() + .session_id() + .find(&input.session_id) + .is_some() + { + return Err("wooden_fish_agent_session.session_id 已存在".to_string()); + } + + let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); + let config = input + .config_json + .as_deref() + .map(parse_config) + .transpose()? + .unwrap_or_else(|| default_config_from_input(&input)); + let draft = input + .draft_json + .as_deref() + .map(parse_json) + .transpose()? + .unwrap_or_else(|| draft_from_config(&config, None, WOODEN_FISH_GENERATION_DRAFT)); + + ctx.db + .wooden_fish_agent_session() + .insert(WoodenFishAgentSessionRow { + session_id: input.session_id.clone(), + owner_user_id: input.owner_user_id.clone(), + current_turn: 0, + progress_percent: 0, + stage: WOODEN_FISH_STAGE_COLLECTING.to_string(), + config_json: to_json_string(&config), + draft_json: to_json_string(&draft), + published_profile_id: String::new(), + created_at, + updated_at: created_at, + }); + + get_wooden_fish_agent_session_tx( + ctx, + WoodenFishAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn get_wooden_fish_agent_session_tx( + ctx: &ReducerContext, + input: WoodenFishAgentSessionGetInput, +) -> Result { + let row = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + build_session_snapshot(&row) +} + +fn compile_wooden_fish_draft_tx( + ctx: &ReducerContext, + input: WoodenFishDraftCompileInput, +) -> Result { + require_non_empty(&input.profile_id, "wooden_fish profile_id")?; + let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + let tags = parse_tags(input.theme_tags_json.as_deref().unwrap_or("[]"))?; + let floating_words = match input + .floating_words_json + .as_deref() + .map(parse_json::>) + .transpose()? + { + Some(words) => normalize_floating_words(&words), + None => default_floating_words(), + }; + let hit_object_asset = input + .hit_object_asset_json + .as_deref() + .map(parse_json) + .transpose()?; + let hit_sound_asset = input + .hit_sound_asset_json + .as_deref() + .map(parse_json) + .transpose()?; + let cover_image_src = input + .cover_image_src + .as_deref() + .and_then(clean_optional) + .or_else(|| { + hit_object_asset + .as_ref() + .map(|asset: &WoodenFishImageAssetSnapshot| asset.image_src.clone()) + }); + let draft = WoodenFishDraftSnapshot { + template_id: WOODEN_FISH_TEMPLATE_ID.to_string(), + template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(), + profile_id: Some(input.profile_id.clone()), + work_title: clean_string(&input.work_title, WOODEN_FISH_TEMPLATE_NAME), + work_description: input.work_description.trim().to_string(), + theme_tags: tags.clone(), + hit_object_prompt: clean_string( + &input.hit_object_prompt, + "默认敲击物图案,圆润木质质感,透明背景", + ), + hit_object_reference_image_src: input + .hit_object_reference_image_src + .as_deref() + .and_then(clean_optional), + hit_sound_prompt: input.hit_sound_prompt.as_deref().and_then(clean_optional), + floating_words: floating_words.clone(), + hit_object_asset: hit_object_asset.clone(), + hit_sound_asset: hit_sound_asset.clone(), + cover_image_src: cover_image_src.clone(), + generation_status: input + .generation_status + .clone() + .unwrap_or_else(|| WOODEN_FISH_GENERATION_READY.to_string()), + }; + let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); + let row = WoodenFishWorkProfileRow { + profile_id: input.profile_id.clone(), + work_id: input.profile_id.clone(), + owner_user_id: input.owner_user_id.clone(), + source_session_id: input.session_id.clone(), + author_display_name: clean_string(&input.author_display_name, "敲木鱼玩家"), + work_title: draft.work_title.clone(), + work_description: draft.work_description.clone(), + theme_tags_json: to_json_string(&tags), + hit_object_prompt: draft.hit_object_prompt.clone(), + hit_object_reference_image_src: draft + .hit_object_reference_image_src + .clone() + .unwrap_or_default(), + hit_sound_prompt: draft.hit_sound_prompt.clone().unwrap_or_default(), + hit_object_asset_json: hit_object_asset + .as_ref() + .map(to_json_string) + .unwrap_or_default(), + hit_sound_asset_json: hit_sound_asset + .as_ref() + .map(to_json_string) + .unwrap_or_default(), + floating_words_json: to_json_string(&floating_words), + cover_image_src: cover_image_src.unwrap_or_default(), + generation_status: draft.generation_status.clone(), + publication_status: WOODEN_FISH_PUBLICATION_DRAFT.to_string(), + play_count: 0, + updated_at: compiled_at, + published_at: None, + }; + upsert_work(ctx, row); + let config = config_from_draft(&draft); + replace_session( + ctx, + &session, + WoodenFishAgentSessionRow { + progress_percent: 100, + stage: WOODEN_FISH_STAGE_DRAFT_COMPILED.to_string(), + config_json: to_json_string(&config), + draft_json: to_json_string(&draft), + published_profile_id: input.profile_id, + updated_at: compiled_at, + ..clone_session(&session) + }, + ); + + get_wooden_fish_agent_session_tx( + ctx, + WoodenFishAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn get_wooden_fish_work_profile_tx( + ctx: &ReducerContext, + input: WoodenFishWorkGetInput, +) -> Result { + let row = find_work(ctx, &input.profile_id)?; + if !input.owner_user_id.trim().is_empty() && row.owner_user_id != input.owner_user_id { + return Err("无权访问该 wooden_fish work".to_string()); + } + build_work_snapshot(&row) +} + +fn update_wooden_fish_work_tx( + ctx: &ReducerContext, + input: WoodenFishWorkUpdateInput, +) -> Result { + let row = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + let mut next = clone_work(&row); + next.work_title = clean_string(&input.work_title, &row.work_title); + next.work_description = input.work_description.trim().to_string(); + next.theme_tags_json = input.theme_tags_json.clone(); + if let Some(value) = input.hit_object_prompt.as_deref().and_then(clean_optional) { + next.hit_object_prompt = value; + } + if let Some(value) = input + .hit_object_reference_image_src + .as_deref() + .and_then(clean_optional) + { + next.hit_object_reference_image_src = value; + } + if let Some(value) = input.hit_sound_prompt.as_deref().and_then(clean_optional) { + next.hit_sound_prompt = value; + } + if let Some(value) = input + .hit_object_asset_json + .as_deref() + .and_then(clean_optional) + { + let asset = parse_json::(&value)?; + next.hit_object_asset_json = to_json_string(&asset); + next.cover_image_src = asset.image_src; + } + if let Some(value) = input + .hit_sound_asset_json + .as_deref() + .and_then(clean_optional) + { + let asset = parse_json::(&value)?; + next.hit_sound_asset_json = to_json_string(&asset); + } + if let Some(value) = input + .floating_words_json + .as_deref() + .and_then(clean_optional) + { + let words = parse_json::>(&value)?; + next.floating_words_json = to_json_string(&normalize_floating_words(&words)); + } + if let Some(value) = input.cover_image_src.as_deref().and_then(clean_optional) { + next.cover_image_src = value; + } + if let Some(value) = input.generation_status.as_deref().and_then(clean_optional) { + next.generation_status = value; + } + next.updated_at = updated_at; + replace_work(ctx, &row, next); + let updated = find_work(ctx, &row.profile_id)?; + sync_session_from_work_update(ctx, &updated, updated_at)?; + build_work_snapshot(&updated) +} + +fn publish_wooden_fish_work_tx( + ctx: &ReducerContext, + input: WoodenFishWorkPublishInput, +) -> Result { + let row = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; + if !is_publish_ready(&row) { + return Err("发布需要完整的敲击物图案、敲击音效和飘字配置".to_string()); + } + let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); + replace_work( + ctx, + &row, + WoodenFishWorkProfileRow { + publication_status: WOODEN_FISH_PUBLICATION_PUBLISHED.to_string(), + updated_at: published_at, + published_at: Some(published_at), + ..clone_work(&row) + }, + ); + if let Some(session) = ctx + .db + .wooden_fish_agent_session() + .session_id() + .find(&row.source_session_id) + { + replace_session( + ctx, + &session, + WoodenFishAgentSessionRow { + stage: WOODEN_FISH_STAGE_PUBLISHED.to_string(), + updated_at: published_at, + ..clone_session(&session) + }, + ); + } + let updated = find_work(ctx, &row.profile_id)?; + build_work_snapshot(&updated) +} + +fn list_wooden_fish_works_tx( + ctx: &ReducerContext, + input: WoodenFishWorksListInput, +) -> Result, String> { + let mut rows = if input.owner_user_id.trim().is_empty() { + ctx.db.wooden_fish_work_profile().iter().collect::>() + } else { + ctx.db + .wooden_fish_work_profile() + .by_wooden_fish_work_owner_user_id() + .filter(input.owner_user_id.as_str()) + .collect::>() + }; + if input.published_only { + rows.retain(|row| row.publication_status == WOODEN_FISH_PUBLICATION_PUBLISHED); + } + rows.sort_by(|left, right| { + right + .updated_at + .cmp(&left.updated_at) + .then_with(|| left.profile_id.cmp(&right.profile_id)) + }); + rows.into_iter() + .map(|row| build_work_snapshot(&row)) + .collect() +} + +fn start_wooden_fish_run_tx( + ctx: &ReducerContext, + input: WoodenFishRunStartInput, +) -> Result { + require_non_empty(&input.run_id, "wooden_fish run_id")?; + let work = find_work(ctx, &input.profile_id)?; + if !is_publish_ready(&work) { + return Err("敲木鱼运行态需要完整作品配置".to_string()); + } + let snapshot = WoodenFishRunSnapshot { + run_id: input.run_id.clone(), + profile_id: input.profile_id.clone(), + owner_user_id: input.owner_user_id.clone(), + status: WoodenFishRunStatus::Playing, + total_tap_count: 0, + word_counters: Vec::new(), + started_at_ms: input.started_at_ms.max(0) as u64, + updated_at_ms: input.started_at_ms.max(0) as u64, + finished_at_ms: None, + }; + upsert_run(ctx, &snapshot, input.started_at_ms); + increment_work_play_count(ctx, &work, input.started_at_ms); + insert_event( + ctx, + input.client_event_id, + input.owner_user_id, + input.profile_id, + input.run_id, + WOODEN_FISH_EVENT_RUN_STARTED, + None, + input.started_at_ms, + ); + Ok(snapshot) +} + +fn get_wooden_fish_run_tx( + ctx: &ReducerContext, + input: WoodenFishRunGetInput, +) -> Result { + let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + parse_json(&row.snapshot_json) +} + +fn checkpoint_wooden_fish_run_tx( + ctx: &ReducerContext, + input: WoodenFishRunCheckpointInput, +) -> Result { + let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + let current = parse_json::(&row.snapshot_json)?; + if current.status == WoodenFishRunStatus::Finished { + return Err("wooden_fish run 已结束,不能 checkpoint".to_string()); + } + let counters = parse_json::>(&input.word_counters_json)?; + let next = apply_run_checkpoint( + ¤t, + input.total_tap_count, + normalize_word_counters(counters), + input.checkpoint_at_ms.max(0) as u64, + ); + replace_run(ctx, &row, &next, input.checkpoint_at_ms); + insert_event( + ctx, + input.client_event_id, + input.owner_user_id, + next.profile_id.clone(), + input.run_id, + WOODEN_FISH_EVENT_RUN_CHECKPOINT, + Some(next.total_tap_count.to_string()), + input.checkpoint_at_ms, + ); + Ok(next) +} + +fn finish_wooden_fish_run_tx( + ctx: &ReducerContext, + input: WoodenFishRunFinishInput, +) -> Result { + let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + let current = parse_json::(&row.snapshot_json)?; + let counters = parse_json::>(&input.word_counters_json)?; + let next = finish_run( + ¤t, + input.total_tap_count, + normalize_word_counters(counters), + input.finished_at_ms.max(0) as u64, + ); + replace_run(ctx, &row, &next, input.finished_at_ms); + insert_event( + ctx, + input.client_event_id, + input.owner_user_id, + next.profile_id.clone(), + input.run_id, + WOODEN_FISH_EVENT_RUN_FINISHED, + Some(next.total_tap_count.to_string()), + input.finished_at_ms, + ); + Ok(next) +} + +fn build_gallery_view_row( + row: &WoodenFishWorkProfileRow, +) -> Result { + let work = build_work_snapshot(row)?; + Ok(WoodenFishGalleryViewRow { + public_work_code: build_wooden_fish_public_work_code(&work.profile_id), + work_id: work.work_id, + profile_id: work.profile_id, + owner_user_id: work.owner_user_id, + source_session_id: work.source_session_id, + author_display_name: work.author_display_name, + work_title: work.work_title, + work_description: work.work_description, + theme_tags: work.theme_tags, + hit_object_prompt: work.hit_object_prompt, + hit_object_reference_image_src: work.hit_object_reference_image_src, + hit_sound_prompt: work.hit_sound_prompt, + hit_object_asset: work.hit_object_asset, + hit_sound_asset: work.hit_sound_asset, + floating_words: work.floating_words, + cover_image_src: work.cover_image_src, + publication_status: work.publication_status, + publish_ready: work.publish_ready, + play_count: work.play_count, + generation_status: work.generation_status, + updated_at_micros: work.updated_at_micros, + published_at_micros: work.published_at_micros, + }) +} + +fn build_session_snapshot( + row: &WoodenFishAgentSessionRow, +) -> Result { + Ok(WoodenFishAgentSessionSnapshot { + session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), + current_turn: row.current_turn, + progress_percent: row.progress_percent, + stage: row.stage.clone(), + config: parse_config(&row.config_json)?, + draft: clean_optional(&row.draft_json) + .map(|value| parse_json(&value)) + .transpose()?, + published_profile_id: clean_optional(&row.published_profile_id), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + }) +} + +fn build_work_snapshot(row: &WoodenFishWorkProfileRow) -> Result { + Ok(WoodenFishWorkSnapshot { + work_id: row.work_id.clone(), + profile_id: row.profile_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: row.source_session_id.clone(), + author_display_name: row.author_display_name.clone(), + work_title: row.work_title.clone(), + work_description: row.work_description.clone(), + theme_tags: parse_tags(&row.theme_tags_json)?, + hit_object_prompt: row.hit_object_prompt.clone(), + hit_object_reference_image_src: clean_optional(&row.hit_object_reference_image_src), + hit_sound_prompt: clean_optional(&row.hit_sound_prompt), + hit_object_asset: clean_optional(&row.hit_object_asset_json) + .map(|value| parse_json(&value)) + .transpose()?, + hit_sound_asset: clean_optional(&row.hit_sound_asset_json) + .map(|value| parse_json(&value)) + .transpose()?, + floating_words: normalize_floating_words(&parse_json_or_default::>( + &row.floating_words_json, + )), + cover_image_src: row.cover_image_src.clone(), + publication_status: row.publication_status.clone(), + publish_ready: is_publish_ready(row), + play_count: row.play_count, + generation_status: row.generation_status.clone(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + published_at_micros: row + .published_at + .map(|value| value.to_micros_since_unix_epoch()), + }) +} + +fn sync_session_from_work_update( + ctx: &ReducerContext, + work: &WoodenFishWorkProfileRow, + updated_at: Timestamp, +) -> Result<(), String> { + let Some(session) = ctx + .db + .wooden_fish_agent_session() + .session_id() + .find(&work.source_session_id) + else { + return Ok(()); + }; + + let snapshot = build_work_snapshot(work)?; + let draft = draft_from_work_snapshot(&snapshot); + let config = config_from_draft(&draft); + replace_session( + ctx, + &session, + WoodenFishAgentSessionRow { + config_json: to_json_string(&config), + draft_json: to_json_string(&draft), + updated_at, + ..clone_session(&session) + }, + ); + Ok(()) +} + +fn find_owned_session( + ctx: &ReducerContext, + session_id: &str, + owner_user_id: &str, +) -> Result { + let row = ctx + .db + .wooden_fish_agent_session() + .session_id() + .find(&session_id.to_string()) + .ok_or_else(|| "wooden_fish_agent_session 不存在".to_string())?; + if row.owner_user_id != owner_user_id { + return Err("无权访问该 wooden_fish session".to_string()); + } + Ok(row) +} + +fn find_work(ctx: &ReducerContext, profile_id: &str) -> Result { + ctx.db + .wooden_fish_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .ok_or_else(|| "wooden_fish_work_profile 不存在".to_string()) +} + +fn find_owned_work( + ctx: &ReducerContext, + profile_id: &str, + owner_user_id: &str, +) -> Result { + let row = find_work(ctx, profile_id)?; + if row.owner_user_id != owner_user_id { + return Err("无权访问该 wooden_fish work".to_string()); + } + Ok(row) +} + +fn find_owned_run( + ctx: &ReducerContext, + run_id: &str, + owner_user_id: &str, +) -> Result { + let row = ctx + .db + .wooden_fish_runtime_run() + .run_id() + .find(&run_id.to_string()) + .ok_or_else(|| "wooden_fish_runtime_run 不存在".to_string())?; + if row.owner_user_id != owner_user_id { + return Err("无权访问该 wooden_fish run".to_string()); + } + Ok(row) +} + +fn upsert_work(ctx: &ReducerContext, row: WoodenFishWorkProfileRow) { + if let Some(old) = ctx + .db + .wooden_fish_work_profile() + .profile_id() + .find(&row.profile_id) + { + ctx.db.wooden_fish_work_profile().delete(old); + } + ctx.db.wooden_fish_work_profile().insert(row); +} + +fn replace_work( + ctx: &ReducerContext, + old: &WoodenFishWorkProfileRow, + next: WoodenFishWorkProfileRow, +) { + ctx.db.wooden_fish_work_profile().delete(clone_work(old)); + ctx.db.wooden_fish_work_profile().insert(next); +} + +fn replace_session( + ctx: &ReducerContext, + old: &WoodenFishAgentSessionRow, + next: WoodenFishAgentSessionRow, +) { + ctx.db + .wooden_fish_agent_session() + .delete(clone_session(old)); + ctx.db.wooden_fish_agent_session().insert(next); +} + +fn upsert_run(ctx: &ReducerContext, snapshot: &WoodenFishRunSnapshot, updated_at_ms: i64) { + if let Some(old) = ctx + .db + .wooden_fish_runtime_run() + .run_id() + .find(&snapshot.run_id) + { + ctx.db.wooden_fish_runtime_run().delete(old); + } + let created_at = Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000)); + ctx.db + .wooden_fish_runtime_run() + .insert(run_row_from_snapshot(snapshot, created_at, created_at)); +} + +fn replace_run( + ctx: &ReducerContext, + old: &WoodenFishRuntimeRunRow, + snapshot: &WoodenFishRunSnapshot, + updated_at_ms: i64, +) { + ctx.db.wooden_fish_runtime_run().delete(clone_run(old)); + ctx.db + .wooden_fish_runtime_run() + .insert(run_row_from_snapshot( + snapshot, + old.created_at, + Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000)), + )); +} + +fn run_row_from_snapshot( + snapshot: &WoodenFishRunSnapshot, + created_at: Timestamp, + updated_at: Timestamp, +) -> WoodenFishRuntimeRunRow { + WoodenFishRuntimeRunRow { + run_id: snapshot.run_id.clone(), + owner_user_id: snapshot.owner_user_id.clone(), + profile_id: snapshot.profile_id.clone(), + status: run_status_as_str(snapshot.status).to_string(), + total_tap_count: snapshot.total_tap_count, + word_counters_json: to_json_string(&snapshot.word_counters), + started_at_ms: snapshot.started_at_ms as i64, + updated_at_ms: snapshot.updated_at_ms as i64, + finished_at_ms: snapshot + .finished_at_ms + .map(|value| value as i64) + .unwrap_or(0), + snapshot_json: to_json_string(snapshot), + created_at, + updated_at, + } +} + +fn increment_work_play_count( + ctx: &ReducerContext, + row: &WoodenFishWorkProfileRow, + played_at_ms: i64, +) { + replace_work( + ctx, + row, + WoodenFishWorkProfileRow { + play_count: row.play_count.saturating_add(1), + updated_at: Timestamp::from_micros_since_unix_epoch(played_at_ms.saturating_mul(1000)), + ..clone_work(row) + }, + ); +} + +fn insert_event( + ctx: &ReducerContext, + event_id: String, + owner_user_id: String, + profile_id: String, + run_id: String, + event_type: &str, + result: Option, + occurred_at_ms: i64, +) { + let event_id = clean_optional(&event_id).unwrap_or_else(|| { + format!( + "wooden-fish-event-{}-{}-{}", + run_id, event_type, occurred_at_ms + ) + }); + if ctx + .db + .wooden_fish_event() + .event_id() + .find(&event_id) + .is_some() + { + return; + } + ctx.db.wooden_fish_event().insert(WoodenFishEventRow { + event_id, + owner_user_id, + profile_id, + run_id, + event_type: event_type.to_string(), + result: result.unwrap_or_default(), + occurred_at: Timestamp::from_micros_since_unix_epoch(occurred_at_ms.saturating_mul(1000)), + }); +} + +fn is_publish_ready(row: &WoodenFishWorkProfileRow) -> bool { + !row.work_title.trim().is_empty() + && !row.hit_object_asset_json.trim().is_empty() + && !row.hit_sound_asset_json.trim().is_empty() + && !row.floating_words_json.trim().is_empty() + && row.generation_status == WOODEN_FISH_GENERATION_READY +} + +fn default_config_from_input( + input: &WoodenFishAgentSessionCreateInput, +) -> WoodenFishCreatorConfigSnapshot { + WoodenFishCreatorConfigSnapshot { + work_title: clean_string(&input.work_title, WOODEN_FISH_TEMPLATE_NAME), + work_description: input.work_description.trim().to_string(), + theme_tags: parse_tags(input.theme_tags_json.as_deref().unwrap_or("[]")) + .unwrap_or_default(), + hit_object_prompt: "默认敲击物图案,圆润木质质感,透明背景".to_string(), + hit_object_reference_image_src: None, + hit_sound_prompt: Some("清脆短促的木鱼敲击声".to_string()), + floating_words: default_floating_words(), + } +} + +fn draft_from_config( + config: &WoodenFishCreatorConfigSnapshot, + profile_id: Option, + generation_status: &str, +) -> WoodenFishDraftSnapshot { + WoodenFishDraftSnapshot { + template_id: WOODEN_FISH_TEMPLATE_ID.to_string(), + template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(), + profile_id, + work_title: config.work_title.clone(), + work_description: config.work_description.clone(), + theme_tags: config.theme_tags.clone(), + hit_object_prompt: config.hit_object_prompt.clone(), + hit_object_reference_image_src: config.hit_object_reference_image_src.clone(), + hit_sound_prompt: config.hit_sound_prompt.clone(), + floating_words: normalize_floating_words(&config.floating_words), + hit_object_asset: None, + hit_sound_asset: None, + cover_image_src: None, + generation_status: generation_status.to_string(), + } +} + +fn draft_from_work_snapshot(work: &WoodenFishWorkSnapshot) -> WoodenFishDraftSnapshot { + WoodenFishDraftSnapshot { + template_id: WOODEN_FISH_TEMPLATE_ID.to_string(), + template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(), + profile_id: Some(work.profile_id.clone()), + work_title: work.work_title.clone(), + work_description: work.work_description.clone(), + theme_tags: work.theme_tags.clone(), + hit_object_prompt: work.hit_object_prompt.clone(), + hit_object_reference_image_src: work.hit_object_reference_image_src.clone(), + hit_sound_prompt: work.hit_sound_prompt.clone(), + floating_words: work.floating_words.clone(), + hit_object_asset: work.hit_object_asset.clone(), + hit_sound_asset: work.hit_sound_asset.clone(), + cover_image_src: clean_optional(&work.cover_image_src), + generation_status: work.generation_status.clone(), + } +} + +fn config_from_draft(draft: &WoodenFishDraftSnapshot) -> WoodenFishCreatorConfigSnapshot { + WoodenFishCreatorConfigSnapshot { + work_title: draft.work_title.clone(), + work_description: draft.work_description.clone(), + theme_tags: draft.theme_tags.clone(), + hit_object_prompt: draft.hit_object_prompt.clone(), + hit_object_reference_image_src: draft.hit_object_reference_image_src.clone(), + hit_sound_prompt: draft.hit_sound_prompt.clone(), + floating_words: normalize_floating_words(&draft.floating_words), + } +} + +fn build_wooden_fish_public_work_code(profile_id: &str) -> String { + let normalized = profile_id + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(|character| character.to_uppercase()) + .collect::(); + let fallback = if normalized.is_empty() { + "00000000".to_string() + } else { + normalized + }; + let suffix = if fallback.len() > 8 { + fallback[fallback.len() - 8..].to_string() + } else { + format!("{fallback:0>8}") + }; + format!("WF-{suffix}") +} + +fn run_status_as_str(status: WoodenFishRunStatus) -> &'static str { + match status { + WoodenFishRunStatus::Playing => "playing", + WoodenFishRunStatus::Finished => "finished", + } +} + +fn require_non_empty(value: &str, label: &str) -> Result<(), String> { + if value.trim().is_empty() { + Err(format!("{label} 不能为空")) + } else { + Ok(()) + } +} + +fn clean_optional(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} + +fn clean_string(value: &str, fallback: &str) -> String { + clean_optional(value).unwrap_or_else(|| fallback.to_string()) +} + +fn parse_config(value: &str) -> Result { + parse_json(value) +} + +fn parse_tags(value: &str) -> Result, String> { + Ok(parse_json_or_default::>(value) + .into_iter() + .map(|tag| tag.trim().to_string()) + .filter(|tag| !tag.is_empty()) + .take(8) + .collect()) +} + +fn parse_json(value: &str) -> Result +where + T: DeserializeOwned, +{ + serde_json::from_str(value).map_err(|error| error.to_string()) +} + +fn parse_json_or_default(value: &str) -> T +where + T: DeserializeOwned + Default, +{ + serde_json::from_str(value).unwrap_or_default() +} + +fn to_json_string(value: &T) -> String +where + T: Serialize, +{ + serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()) +} + +fn session_result( + session: WoodenFishAgentSessionSnapshot, +) -> WoodenFishAgentSessionProcedureResult { + WoodenFishAgentSessionProcedureResult { + ok: true, + session: Some(session), + error_message: None, + } +} + +fn session_error(message: String) -> WoodenFishAgentSessionProcedureResult { + WoodenFishAgentSessionProcedureResult { + ok: false, + session: None, + error_message: Some(message), + } +} + +fn work_result(work: WoodenFishWorkSnapshot) -> WoodenFishWorkProcedureResult { + WoodenFishWorkProcedureResult { + ok: true, + work: Some(work), + error_message: None, + } +} + +fn work_error(message: String) -> WoodenFishWorkProcedureResult { + WoodenFishWorkProcedureResult { + ok: false, + work: None, + error_message: Some(message), + } +} + +fn run_result(run: WoodenFishRunSnapshot) -> WoodenFishRunProcedureResult { + WoodenFishRunProcedureResult { + ok: true, + run: Some(run), + error_message: None, + } +} + +fn run_error(message: String) -> WoodenFishRunProcedureResult { + WoodenFishRunProcedureResult { + ok: false, + run: None, + error_message: Some(message), + } +} + +fn clone_session(row: &WoodenFishAgentSessionRow) -> WoodenFishAgentSessionRow { + WoodenFishAgentSessionRow { + session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), + current_turn: row.current_turn, + progress_percent: row.progress_percent, + stage: row.stage.clone(), + config_json: row.config_json.clone(), + draft_json: row.draft_json.clone(), + published_profile_id: row.published_profile_id.clone(), + created_at: row.created_at, + updated_at: row.updated_at, + } +} + +fn clone_work(row: &WoodenFishWorkProfileRow) -> WoodenFishWorkProfileRow { + WoodenFishWorkProfileRow { + profile_id: row.profile_id.clone(), + work_id: row.work_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: row.source_session_id.clone(), + author_display_name: row.author_display_name.clone(), + work_title: row.work_title.clone(), + work_description: row.work_description.clone(), + theme_tags_json: row.theme_tags_json.clone(), + hit_object_prompt: row.hit_object_prompt.clone(), + hit_object_reference_image_src: row.hit_object_reference_image_src.clone(), + hit_sound_prompt: row.hit_sound_prompt.clone(), + hit_object_asset_json: row.hit_object_asset_json.clone(), + hit_sound_asset_json: row.hit_sound_asset_json.clone(), + floating_words_json: row.floating_words_json.clone(), + cover_image_src: row.cover_image_src.clone(), + generation_status: row.generation_status.clone(), + publication_status: row.publication_status.clone(), + play_count: row.play_count, + updated_at: row.updated_at, + published_at: row.published_at, + } +} + +fn clone_run(row: &WoodenFishRuntimeRunRow) -> WoodenFishRuntimeRunRow { + WoodenFishRuntimeRunRow { + run_id: row.run_id.clone(), + owner_user_id: row.owner_user_id.clone(), + profile_id: row.profile_id.clone(), + status: row.status.clone(), + total_tap_count: row.total_tap_count, + word_counters_json: row.word_counters_json.clone(), + started_at_ms: row.started_at_ms, + updated_at_ms: row.updated_at_ms, + finished_at_ms: row.finished_at_ms, + snapshot_json: row.snapshot_json.clone(), + created_at: row.created_at, + updated_at: row.updated_at, + } +} diff --git a/server-rs/crates/spacetime-module/src/wooden_fish/tables.rs b/server-rs/crates/spacetime-module/src/wooden_fish/tables.rs new file mode 100644 index 00000000..e7f84193 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/wooden_fish/tables.rs @@ -0,0 +1,85 @@ +use crate::*; + +#[spacetimedb::table( + accessor = wooden_fish_agent_session, + index(accessor = by_wooden_fish_agent_session_owner_user_id, btree(columns = [owner_user_id])) +)] +pub struct WoodenFishAgentSessionRow { + #[primary_key] + pub(crate) session_id: String, + pub(crate) owner_user_id: String, + pub(crate) current_turn: u32, + pub(crate) progress_percent: u32, + pub(crate) stage: String, + pub(crate) config_json: String, + pub(crate) draft_json: String, + pub(crate) published_profile_id: String, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = wooden_fish_work_profile, + index(accessor = by_wooden_fish_work_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_wooden_fish_work_publication_status, btree(columns = [publication_status])) +)] +pub struct WoodenFishWorkProfileRow { + #[primary_key] + pub(crate) profile_id: String, + pub(crate) work_id: String, + pub(crate) owner_user_id: String, + pub(crate) source_session_id: String, + pub(crate) author_display_name: String, + pub(crate) work_title: String, + pub(crate) work_description: String, + pub(crate) theme_tags_json: String, + pub(crate) hit_object_prompt: String, + pub(crate) hit_object_reference_image_src: String, + pub(crate) hit_sound_prompt: String, + pub(crate) hit_object_asset_json: String, + pub(crate) hit_sound_asset_json: String, + pub(crate) floating_words_json: String, + pub(crate) cover_image_src: String, + pub(crate) generation_status: String, + pub(crate) publication_status: String, + pub(crate) play_count: u32, + pub(crate) updated_at: Timestamp, + pub(crate) published_at: Option, +} + +#[spacetimedb::table( + accessor = wooden_fish_runtime_run, + index(accessor = by_wooden_fish_run_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_wooden_fish_run_profile_id, btree(columns = [profile_id])) +)] +pub struct WoodenFishRuntimeRunRow { + #[primary_key] + pub(crate) run_id: String, + pub(crate) owner_user_id: String, + pub(crate) profile_id: String, + pub(crate) status: String, + pub(crate) total_tap_count: u32, + pub(crate) word_counters_json: String, + pub(crate) started_at_ms: i64, + pub(crate) updated_at_ms: i64, + pub(crate) finished_at_ms: i64, + pub(crate) snapshot_json: String, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = wooden_fish_event, + index(accessor = by_wooden_fish_event_profile_id, btree(columns = [profile_id])), + index(accessor = by_wooden_fish_event_run_id, btree(columns = [run_id])) +)] +pub struct WoodenFishEventRow { + #[primary_key] + pub(crate) event_id: String, + pub(crate) owner_user_id: String, + pub(crate) profile_id: String, + pub(crate) run_id: String, + pub(crate) event_type: String, + pub(crate) result: String, + pub(crate) occurred_at: Timestamp, +} diff --git a/server-rs/crates/spacetime-module/src/wooden_fish/types.rs b/server-rs/crates/spacetime-module/src/wooden_fish/types.rs new file mode 100644 index 00000000..8b8ea2ea --- /dev/null +++ b/server-rs/crates/spacetime-module/src/wooden_fish/types.rs @@ -0,0 +1,254 @@ +use crate::*; +use serde::{Deserialize, Serialize}; + +pub const WOODEN_FISH_TEMPLATE_ID: &str = "wooden-fish"; +pub const WOODEN_FISH_TEMPLATE_NAME: &str = "敲木鱼"; +pub const WOODEN_FISH_STAGE_COLLECTING: &str = "Collecting"; +pub const WOODEN_FISH_STAGE_DRAFT_COMPILED: &str = "DraftCompiled"; +pub const WOODEN_FISH_STAGE_PUBLISHED: &str = "Published"; +pub const WOODEN_FISH_PUBLICATION_DRAFT: &str = "Draft"; +pub const WOODEN_FISH_PUBLICATION_PUBLISHED: &str = "Published"; +pub const WOODEN_FISH_GENERATION_DRAFT: &str = "draft"; +pub const WOODEN_FISH_GENERATION_READY: &str = "ready"; +pub const WOODEN_FISH_EVENT_RUN_STARTED: &str = "run-started"; +pub const WOODEN_FISH_EVENT_RUN_CHECKPOINT: &str = "checkpoint"; +pub const WOODEN_FISH_EVENT_RUN_FINISHED: &str = "finish"; + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WoodenFishAgentSessionCreateInput { + pub session_id: String, + pub owner_user_id: String, + pub work_title: String, + pub work_description: String, + pub theme_tags_json: Option, + pub config_json: Option, + pub draft_json: Option, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WoodenFishAgentSessionGetInput { + pub session_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WoodenFishDraftCompileInput { + pub session_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub theme_tags_json: Option, + pub hit_object_prompt: String, + pub hit_object_reference_image_src: Option, + pub hit_sound_prompt: Option, + pub hit_object_asset_json: Option, + pub hit_sound_asset_json: Option, + pub floating_words_json: Option, + pub cover_image_src: Option, + pub generation_status: Option, + pub compiled_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WoodenFishWorkUpdateInput { + pub profile_id: String, + pub owner_user_id: String, + pub work_title: String, + pub work_description: String, + pub theme_tags_json: String, + pub hit_object_prompt: Option, + pub hit_object_reference_image_src: Option, + pub hit_sound_prompt: Option, + pub hit_object_asset_json: Option, + pub hit_sound_asset_json: Option, + pub floating_words_json: Option, + pub cover_image_src: Option, + pub generation_status: Option, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WoodenFishWorkPublishInput { + pub profile_id: String, + pub owner_user_id: String, + pub published_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WoodenFishWorksListInput { + pub owner_user_id: String, + pub published_only: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WoodenFishWorkGetInput { + pub profile_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WoodenFishRunStartInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub client_event_id: String, + pub started_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WoodenFishRunGetInput { + pub run_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WoodenFishRunCheckpointInput { + pub run_id: String, + pub owner_user_id: String, + pub total_tap_count: u32, + pub word_counters_json: String, + pub client_event_id: String, + pub checkpoint_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct WoodenFishRunFinishInput { + pub run_id: String, + pub owner_user_id: String, + pub total_tap_count: u32, + pub word_counters_json: String, + pub client_event_id: String, + pub finished_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct WoodenFishAgentSessionProcedureResult { + pub ok: bool, + pub session: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct WoodenFishWorkProcedureResult { + pub ok: bool, + pub work: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct WoodenFishWorksProcedureResult { + pub ok: bool, + pub items: Vec, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct WoodenFishRunProcedureResult { + pub ok: bool, + pub run: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishCreatorConfigSnapshot { + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub hit_object_prompt: String, + #[serde(default)] + pub hit_object_reference_image_src: Option, + #[serde(default)] + pub hit_sound_prompt: Option, + pub floating_words: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishImageAssetSnapshot { + pub asset_id: String, + pub image_src: String, + pub image_object_key: String, + pub asset_object_id: String, + pub generation_provider: String, + pub prompt: String, + pub width: u32, + pub height: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishAudioAssetSnapshot { + pub asset_id: String, + pub audio_src: String, + pub audio_object_key: String, + pub asset_object_id: String, + pub source: String, + #[serde(default)] + pub prompt: Option, + #[serde(default)] + pub duration_ms: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishDraftSnapshot { + pub template_id: String, + pub template_name: String, + pub profile_id: Option, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub hit_object_prompt: String, + pub hit_object_reference_image_src: Option, + pub hit_sound_prompt: Option, + pub floating_words: Vec, + pub hit_object_asset: Option, + pub hit_sound_asset: Option, + pub cover_image_src: Option, + pub generation_status: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishAgentSessionSnapshot { + pub session_id: String, + pub owner_user_id: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: String, + pub config: WoodenFishCreatorConfigSnapshot, + pub draft: Option, + pub published_profile_id: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishWorkSnapshot { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub hit_object_prompt: String, + pub hit_object_reference_image_src: Option, + pub hit_sound_prompt: Option, + pub hit_object_asset: Option, + pub hit_sound_asset: Option, + pub floating_words: Vec, + pub cover_image_src: String, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub generation_status: String, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} diff --git a/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx new file mode 100644 index 00000000..16e1b08e --- /dev/null +++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx @@ -0,0 +1,64 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { expect, test, vi } from 'vitest'; + +import type { CreationEntryConfig } from '../../services/creationEntryConfigService'; +import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal'; +import { derivePlatformCreationTypes } from './platformEntryCreationTypes'; + +const entryConfig = { + startCard: { + title: '新建作品', + description: '', + idleBadge: '模板', + busyBadge: '开启中', + }, + typeModal: { + title: '选择创作类型', + description: '', + }, + creationTypes: [ + { + id: 'wooden-fish', + title: '敲木鱼', + subtitle: '轻点积累功德', + badge: '可创建', + imageSrc: '/creation-type-references/puzzle.webp', + visible: true, + open: true, + sortOrder: 10, + updatedAtMicros: 1, + }, + ], +} satisfies CreationEntryConfig; + +test('dispatches wooden fish creation type selection', () => { + const onSelectWoodenFish = vi.fn(); + + render( + {}} + onSelectRpg={() => {}} + onSelectBigFish={() => {}} + onSelectMatch3D={() => {}} + onSelectSquareHole={() => {}} + onSelectJumpHop={() => {}} + onSelectWoodenFish={onSelectWoodenFish} + onSelectPuzzle={() => {}} + onSelectCreativeAgent={() => {}} + onSelectBarkBattle={() => {}} + onSelectVisualNovel={() => {}} + onSelectBabyObjectMatch={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: /敲木鱼/u })); + + expect(onSelectWoodenFish).toHaveBeenCalledTimes(1); +}); diff --git a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx index aa08bce6..8d6698aa 100644 --- a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx +++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx @@ -19,6 +19,7 @@ export interface PlatformEntryCreationTypeModalProps { onSelectMatch3D: () => void; onSelectSquareHole: () => void; onSelectJumpHop: () => void; + onSelectWoodenFish: () => void; onSelectPuzzle: () => void; onSelectCreativeAgent: () => void; onSelectBarkBattle: () => void; @@ -102,6 +103,7 @@ export function PlatformEntryCreationTypeModal({ onSelectMatch3D, onSelectSquareHole, onSelectJumpHop, + onSelectWoodenFish, onSelectPuzzle, onSelectCreativeAgent, onSelectBarkBattle, @@ -147,6 +149,9 @@ export function PlatformEntryCreationTypeModal({ if (item.id === 'jump-hop') { onSelectJumpHop(); } + if (item.id === 'wooden-fish') { + onSelectWoodenFish(); + } if (item.id === 'puzzle') { onSelectPuzzle(); } diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index eb79ef81..91954cb6 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -168,10 +168,10 @@ import { } from '../../services/edutainment-baby-object'; import { jumpHopClient, + type JumpHopGalleryCardResponse, type JumpHopRunResponse, type JumpHopSessionResponse, type JumpHopSessionSnapshotResponse, - type JumpHopGalleryCardResponse, type JumpHopWorkProfileResponse, type JumpHopWorkspaceCreateRequest, } from '../../services/jump-hop/jumpHopClient'; @@ -197,6 +197,7 @@ import { buildMiniGameDraftGenerationProgress, buildPuzzleGenerationAnchorEntries, buildSquareHoleGenerationAnchorEntries, + buildWoodenFishGenerationAnchorEntries, createMiniGameDraftGenerationState, type MiniGameDraftGenerationKind, type MiniGameDraftGenerationState, @@ -210,6 +211,7 @@ import { buildPuzzlePublicWorkCode, buildSquareHolePublicWorkCode, buildVisualNovelPublicWorkCode, + buildWoodenFishPublicWorkCode, isSameBabyObjectMatchPublicWorkCode, isSameBigFishPublicWorkCode, isSameJumpHopPublicWorkCode, @@ -217,6 +219,7 @@ import { isSamePuzzlePublicWorkCode, isSameSquareHolePublicWorkCode, isSameVisualNovelPublicWorkCode, + isSameWoodenFishPublicWorkCode, } from '../../services/publicWorkCode'; import { createPuzzleAgentSession, @@ -306,6 +309,15 @@ import { publishVisualNovelWork, updateVisualNovelWork, } from '../../services/visual-novel-works'; +import { + woodenFishClient, + type WoodenFishGalleryCardResponse, + type WoodenFishRunResponse, + type WoodenFishSessionResponse, + type WoodenFishSessionSnapshotResponse, + type WoodenFishWorkProfileResponse, + type WoodenFishWorkspaceCreateRequest, +} from '../../services/wooden-fish/woodenFishClient'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; import { PublishShareModal } from '../common/PublishShareModal'; @@ -325,6 +337,7 @@ import { isPuzzleGalleryEntry, isSquareHoleGalleryEntry, isVisualNovelGalleryEntry, + isWoodenFishGalleryEntry, mapBabyObjectMatchDraftToPlatformGalleryCard, mapBigFishWorkToPlatformGalleryCard, mapJumpHopWorkToPlatformGalleryCard, @@ -332,6 +345,7 @@ import { mapPuzzleWorkToPlatformGalleryCard, mapSquareHoleWorkToPlatformGalleryCard, mapVisualNovelWorkToPlatformGalleryCard, + mapWoodenFishWorkToPlatformGalleryCard, type PlatformPublicGalleryCard, } from '../rpg-entry/rpgEntryWorldPresentation'; import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling'; @@ -439,6 +453,7 @@ type RecommendRuntimeKind = | 'match3d' | 'puzzle' | 'square-hole' + | 'wooden-fish' | 'visual-novel' | 'rpg'; type SquareHoleRuntimeReturnStage = @@ -455,6 +470,10 @@ type BabyObjectMatchRuntimeReturnStage = | 'work-detail' | 'platform'; type JumpHopRuntimeReturnStage = 'jump-hop-result' | 'work-detail' | 'platform'; +type WoodenFishRuntimeReturnStage = + | 'wooden-fish-result' + | 'work-detail' + | 'platform'; type VisualNovelEntryGenerationPhase = 'generating' | 'ready' | 'failed'; type BabyObjectMatchGenerationPhase = 'generating' | 'ready' | 'failed'; @@ -467,6 +486,7 @@ type RecommendRuntimeState = { puzzleRun: PuzzleRunSnapshot | null; squareHoleRun: SquareHoleRunSnapshot | null; visualNovelRun: VisualNovelRunSnapshot | null; + woodenFishRun: WoodenFishRunResponse['run'] | null; }; type PuzzleSaveArchiveState = { @@ -525,15 +545,17 @@ function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) { ? 'puzzle' : isJumpHopGalleryEntry(entry) ? 'jump-hop' - : isMatch3DGalleryEntry(entry) - ? 'match3d' - : isSquareHoleGalleryEntry(entry) - ? 'square-hole' - : isVisualNovelGalleryEntry(entry) - ? 'visual-novel' - : isEdutainmentGalleryEntry(entry) - ? `edutainment:${entry.templateId}` - : 'rpg'; + : isWoodenFishGalleryEntry(entry) + ? 'wooden-fish' + : isMatch3DGalleryEntry(entry) + ? 'match3d' + : isSquareHoleGalleryEntry(entry) + ? 'square-hole' + : isVisualNovelGalleryEntry(entry) + ? 'visual-novel' + : isEdutainmentGalleryEntry(entry) + ? `edutainment:${entry.templateId}` + : 'rpg'; return `${kind}:${entry.ownerUserId}:${entry.profileId}`; } @@ -552,6 +574,10 @@ function getPlatformRecommendRuntimeKind( return 'jump-hop'; } + if (isWoodenFishGalleryEntry(entry)) { + return 'wooden-fish'; + } + if (isMatch3DGalleryEntry(entry)) { return 'match3d'; } @@ -586,6 +612,9 @@ function isRecommendRuntimeReadyForEntry( if (expectedKind === 'jump-hop') { return Boolean(state.jumpHopRun); } + if (expectedKind === 'wooden-fish') { + return Boolean(state.woodenFishRun); + } if (expectedKind === 'match3d') { return Boolean(state.match3dRun); } @@ -692,6 +721,12 @@ function mapJumpHopWorkToPublicWorkDetail( return mapJumpHopWorkToPlatformGalleryCard(item); } +function mapWoodenFishWorkToPublicWorkDetail( + item: WoodenFishGalleryCardResponse | WoodenFishWorkProfileResponse, +): PlatformPublicGalleryCard { + return mapWoodenFishWorkToPlatformGalleryCard(item); +} + function mapVisualNovelWorkDetailToSession( work: VisualNovelWorkDetail, ): VisualNovelAgentSessionSnapshot { @@ -2150,6 +2185,27 @@ const JumpHopRuntimeShell = lazy(async () => { }; }); +const WoodenFishWorkspace = lazy(async () => { + const module = await import('../wooden-fish-creation/WoodenFishWorkspace'); + return { + default: module.WoodenFishWorkspace, + }; +}); + +const WoodenFishResultView = lazy(async () => { + const module = await import('../wooden-fish-result/WoodenFishResultView'); + return { + default: module.WoodenFishResultView, + }; +}); + +const WoodenFishRuntimeShell = lazy(async () => { + const module = await import('../wooden-fish-runtime/WoodenFishRuntimeShell'); + return { + default: module.WoodenFishRuntimeShell, + }; +}); + const BarkBattleConfigEditor = lazy(async () => { const module = await import('../bark-battle-creation/BarkBattleConfigEditor'); return { @@ -2400,6 +2456,22 @@ export function PlatformEntryFlowShellImpl({ useState(null); const [jumpHopError, setJumpHopError] = useState(null); const [isJumpHopBusy, setIsJumpHopBusy] = useState(false); + const [woodenFishSession, setWoodenFishSession] = + useState(null); + const [woodenFishRun, setWoodenFishRun] = useState< + WoodenFishRunResponse['run'] | null + >(null); + const [woodenFishWork, setWoodenFishWork] = + useState(null); + const [woodenFishGalleryEntries, setWoodenFishGalleryEntries] = useState< + WoodenFishGalleryCardResponse[] + >([]); + const [woodenFishRuntimeReturnStage, setWoodenFishRuntimeReturnStage] = + useState('wooden-fish-result'); + const [woodenFishGenerationState, setWoodenFishGenerationState] = + useState(null); + const [woodenFishError, setWoodenFishError] = useState(null); + const [isWoodenFishBusy, setIsWoodenFishBusy] = useState(false); const [barkBattlePublishedConfig, setBarkBattlePublishedConfig] = useState(null); const [barkBattleError, setBarkBattleError] = useState(null); @@ -3031,6 +3103,17 @@ export function PlatformEntryFlowShellImpl({ } }, []); + const refreshWoodenFishGallery = useCallback(async () => { + try { + const galleryResponse = await woodenFishClient.listGallery(); + setWoodenFishGalleryEntries(galleryResponse.items); + return galleryResponse.items; + } catch { + setWoodenFishGalleryEntries([]); + return []; + } + }, []); + const refreshPuzzleShelf = useCallback(async () => { setIsPuzzleLoadingLibrary(true); @@ -3353,6 +3436,9 @@ export function PlatformEntryFlowShellImpl({ const jumpHopPublicEntries = jumpHopGalleryEntries.map( mapJumpHopWorkToPlatformGalleryCard, ); + const woodenFishPublicEntries = woodenFishGalleryEntries.map( + mapWoodenFishWorkToPlatformGalleryCard, + ); const visualNovelPublicEntries = visualNovelGalleryEntries.map( mapVisualNovelWorkToPlatformGalleryCard, ); @@ -3364,6 +3450,7 @@ export function PlatformEntryFlowShellImpl({ ...puzzlePublicEntries, ...squareHolePublicEntries, ...jumpHopPublicEntries, + ...woodenFishPublicEntries, ...(isVisualNovelCreationOpen ? visualNovelPublicEntries : []), ...babyObjectMatchPublicEntries, ], @@ -3380,6 +3467,7 @@ export function PlatformEntryFlowShellImpl({ puzzleGalleryEntries, squareHoleGalleryEntries, visualNovelGalleryEntries, + woodenFishGalleryEntries, ]); const latestGalleryEntries = useMemo( () => @@ -3392,6 +3480,9 @@ export function PlatformEntryFlowShellImpl({ ...match3dGalleryEntries.map(mapMatch3DWorkToPublicWorkDetail), ...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard), ...jumpHopGalleryEntries.map(mapJumpHopWorkToPlatformGalleryCard), + ...woodenFishGalleryEntries.map( + mapWoodenFishWorkToPlatformGalleryCard, + ), ...squareHoleGalleryEntries.map( mapSquareHoleWorkToPlatformGalleryCard, ), @@ -3419,6 +3510,7 @@ export function PlatformEntryFlowShellImpl({ puzzleGalleryEntries, squareHoleGalleryEntries, visualNovelGalleryEntries, + woodenFishGalleryEntries, ], ); const recommendRuntimeEntries = useMemo(() => { @@ -5748,6 +5840,18 @@ export function PlatformEntryFlowShellImpl({ return; } + if (type === 'wooden-fish') { + enterCreateTab(); + setShowCreationTypeModal(false); + setActiveCreationFormType('wooden-fish'); + setWoodenFishError(null); + setWoodenFishSession(null); + setWoodenFishWork(null); + setWoodenFishRun(null); + setWoodenFishGenerationState(null); + return; + } + if (type === 'puzzle') { enterCreateTab(); setShowCreationTypeModal(false); @@ -5804,6 +5908,11 @@ export function PlatformEntryFlowShellImpl({ setJumpHopRun, setJumpHopSession, setJumpHopWork, + setWoodenFishError, + setWoodenFishGenerationState, + setWoodenFishRun, + setWoodenFishSession, + setWoodenFishWork, setVisualNovelError, ], ); @@ -5843,6 +5952,16 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('platform'); }, [setSelectionStage]); + const leaveWoodenFishFlow = useCallback(() => { + setWoodenFishRun(null); + setWoodenFishWork(null); + setWoodenFishRuntimeReturnStage('wooden-fish-result'); + setWoodenFishGenerationState(null); + setWoodenFishSession(null); + setWoodenFishError(null); + setSelectionStage('platform'); + }, [setSelectionStage]); + const createReadyJumpHopGenerationState = useCallback( (state: MiniGameDraftGenerationState) => resolveFinishedMiniGameDraftGenerationState(state, 'ready', { @@ -5852,6 +5971,15 @@ export function PlatformEntryFlowShellImpl({ [], ); + const createReadyWoodenFishGenerationState = useCallback( + (state: MiniGameDraftGenerationState) => + resolveFinishedMiniGameDraftGenerationState(state, 'ready', { + completedAssetCount: 2, + totalAssetCount: 2, + }), + [], + ); + const leaveBarkBattleFlow = useCallback(() => { setBarkBattlePublishedConfig(null); setBarkBattleError(null); @@ -6844,6 +6972,325 @@ export function PlatformEntryFlowShellImpl({ [jumpHopRun?.runId], ); + const compileWoodenFishSession = useCallback( + async ( + created: WoodenFishSessionResponse, + payload?: WoodenFishWorkspaceCreateRequest, + ) => { + const generationState = createMiniGameDraftGenerationState('wooden-fish'); + setWoodenFishError(null); + setWoodenFishSession(created.session); + setWoodenFishWork(null); + setWoodenFishRun(null); + setWoodenFishGenerationState(generationState); + setIsWoodenFishBusy(true); + setSelectionStage('wooden-fish-generating'); + + try { + const response = await woodenFishClient.executeAction( + created.session.sessionId, + { + actionType: 'compile-draft', + workTitle: payload?.workTitle ?? created.session.draft?.workTitle, + workDescription: + payload?.workDescription ?? + created.session.draft?.workDescription, + themeTags: payload?.themeTags ?? created.session.draft?.themeTags, + hitObjectPrompt: + payload?.hitObjectPrompt ?? + created.session.draft?.hitObjectPrompt, + hitObjectReferenceImageSrc: + payload?.hitObjectReferenceImageSrc ?? + created.session.draft?.hitObjectReferenceImageSrc, + hitSoundPrompt: + payload?.hitSoundPrompt ?? created.session.draft?.hitSoundPrompt, + hitSoundAsset: + payload?.hitSoundAsset ?? created.session.draft?.hitSoundAsset, + floatingWords: + payload?.floatingWords ?? created.session.draft?.floatingWords, + }, + ); + setWoodenFishSession(response.session); + setWoodenFishWork(response.work ?? null); + setWoodenFishGenerationState( + createReadyWoodenFishGenerationState(generationState), + ); + setSelectionStage('wooden-fish-result'); + } catch (error) { + const errorMessage = resolveRpgCreationErrorMessage( + error, + '生成敲木鱼草稿失败。', + ); + setWoodenFishError(errorMessage); + setWoodenFishGenerationState( + resolveFinishedMiniGameDraftGenerationState( + generationState, + 'failed', + { error: errorMessage }, + ), + ); + try { + const latest = await woodenFishClient.getSession( + created.session.sessionId, + ); + setWoodenFishSession(latest.session); + setWoodenFishWork(null); + } catch { + setWoodenFishSession(created.session); + setWoodenFishWork(null); + } + } finally { + setIsWoodenFishBusy(false); + } + }, + [createReadyWoodenFishGenerationState, setSelectionStage], + ); + + const retryWoodenFishDraftGeneration = useCallback(() => { + if (!woodenFishSession) { + setSelectionStage('wooden-fish-workspace'); + return; + } + + void compileWoodenFishSession({ session: woodenFishSession }); + }, [compileWoodenFishSession, setSelectionStage, woodenFishSession]); + + const regenerateWoodenFishAsset = useCallback( + async (actionType: 'regenerate-hit-object' | 'generate-hit-sound') => { + if (!woodenFishSession?.sessionId) { + setSelectionStage('wooden-fish-workspace'); + return; + } + + const generationState = createMiniGameDraftGenerationState('wooden-fish'); + setWoodenFishError(null); + setWoodenFishGenerationState(generationState); + setIsWoodenFishBusy(true); + setSelectionStage('wooden-fish-generating'); + try { + const response = await woodenFishClient.executeAction( + woodenFishSession.sessionId, + { + actionType, + workTitle: woodenFishSession.draft?.workTitle, + workDescription: woodenFishSession.draft?.workDescription, + themeTags: woodenFishSession.draft?.themeTags, + hitObjectPrompt: woodenFishSession.draft?.hitObjectPrompt, + hitObjectReferenceImageSrc: + woodenFishSession.draft?.hitObjectReferenceImageSrc, + hitSoundPrompt: woodenFishSession.draft?.hitSoundPrompt, + hitSoundAsset: woodenFishSession.draft?.hitSoundAsset, + floatingWords: woodenFishSession.draft?.floatingWords, + }, + ); + setWoodenFishSession(response.session); + setWoodenFishWork(response.work ?? woodenFishWork); + setWoodenFishGenerationState( + createReadyWoodenFishGenerationState(generationState), + ); + setSelectionStage('wooden-fish-result'); + } catch (error) { + const errorMessage = resolveRpgCreationErrorMessage( + error, + actionType === 'regenerate-hit-object' + ? '重新生成敲击物图案失败。' + : '生成敲击音效失败。', + ); + setWoodenFishError(errorMessage); + setWoodenFishGenerationState( + resolveFinishedMiniGameDraftGenerationState( + generationState, + 'failed', + { error: errorMessage }, + ), + ); + } finally { + setIsWoodenFishBusy(false); + } + }, + [ + createReadyWoodenFishGenerationState, + setSelectionStage, + woodenFishSession?.draft, + woodenFishSession?.sessionId, + woodenFishWork, + ], + ); + + const publishWoodenFishDraft = useCallback(async () => { + const profileId = woodenFishWork?.summary.profileId?.trim(); + if (!profileId) { + setWoodenFishError('敲木鱼草稿尚未生成可发布作品。'); + setSelectionStage('wooden-fish-result'); + return; + } + + setIsWoodenFishBusy(true); + setWoodenFishError(null); + try { + const response = await woodenFishClient.publishWork(profileId); + setWoodenFishWork(response.item); + openPublishShareModal({ + title: response.item.summary.workTitle || '敲木鱼', + publicWorkCode: buildWoodenFishPublicWorkCode( + response.item.summary.profileId, + ), + stage: 'work-detail', + }); + } catch (error) { + setWoodenFishError( + resolveRpgCreationErrorMessage(error, '发布敲木鱼作品失败。'), + ); + setSelectionStage('wooden-fish-result'); + } finally { + setIsWoodenFishBusy(false); + } + }, [ + openPublishShareModal, + setSelectionStage, + woodenFishWork?.summary.profileId, + ]); + + const startWoodenFishTestRunFromProfile = useCallback(async () => { + const profileId = woodenFishWork?.summary.profileId?.trim(); + if (!profileId) { + setWoodenFishError('敲木鱼草稿尚未生成可试玩作品。'); + setSelectionStage('wooden-fish-result'); + return; + } + + setIsWoodenFishBusy(true); + setWoodenFishError(null); + setWoodenFishRuntimeReturnStage('wooden-fish-result'); + try { + const response = await woodenFishClient.startRun(profileId); + setWoodenFishRun(response.run); + setSelectionStage('wooden-fish-runtime'); + } catch (error) { + setWoodenFishError( + error instanceof Error ? error.message : '启动敲木鱼试玩失败。', + ); + setSelectionStage('wooden-fish-result'); + } finally { + setIsWoodenFishBusy(false); + } + }, [setSelectionStage, woodenFishWork?.summary.profileId]); + + const startWoodenFishRunFromProfile = useCallback( + async ( + profileId: string, + options: { + embedded?: boolean; + returnStage?: 'work-detail' | 'platform'; + } = {}, + ) => { + const normalizedProfileId = profileId.trim(); + if (!normalizedProfileId) { + setWoodenFishError('当前敲木鱼作品信息不完整,暂时无法进入玩法。'); + return false; + } + + setIsWoodenFishBusy(true); + setWoodenFishError(null); + setWoodenFishRuntimeReturnStage(options.returnStage ?? 'work-detail'); + try { + const [detail, runResponse] = await Promise.all([ + woodenFishClient.getWorkDetail(normalizedProfileId).catch(() => null), + woodenFishClient.startRun(normalizedProfileId), + ]); + if (detail?.item) { + setWoodenFishWork(detail.item); + } + setWoodenFishRun(runResponse.run); + if (!options.embedded) { + setSelectionStage('wooden-fish-runtime'); + pushAppHistoryPath( + buildPublicWorkStagePath( + 'wooden-fish-runtime', + buildWoodenFishPublicWorkCode(normalizedProfileId), + ), + ); + } + return true; + } catch (error) { + setWoodenFishError( + resolveRpgCreationErrorMessage(error, '启动敲木鱼玩法失败。'), + ); + return false; + } finally { + setIsWoodenFishBusy(false); + } + }, + [setSelectionStage], + ); + + const restartWoodenFishRuntimeRun = useCallback(async () => { + const profileId = + woodenFishRun?.profileId?.trim() ?? + woodenFishWork?.summary.profileId?.trim(); + if (!profileId) { + await startWoodenFishTestRunFromProfile(); + return; + } + + setIsWoodenFishBusy(true); + setWoodenFishError(null); + try { + const response = await woodenFishClient.startRun(profileId); + setWoodenFishRun(response.run); + } catch (error) { + setWoodenFishError( + resolveRpgCreationErrorMessage(error, '重新开始敲木鱼失败。'), + ); + } finally { + setIsWoodenFishBusy(false); + } + }, [ + startWoodenFishTestRunFromProfile, + woodenFishRun?.profileId, + woodenFishWork?.summary.profileId, + ]); + + const checkpointWoodenFishRuntimeRun = useCallback( + async (payload: { + totalTapCount: number; + wordCounters: WoodenFishRunResponse['run']['wordCounters']; + }) => { + const runId = woodenFishRun?.runId; + if (!runId) { + return; + } + const response = await woodenFishClient.checkpointRun(runId, payload); + setWoodenFishRun(response.run); + }, + [woodenFishRun?.runId], + ); + + const finishWoodenFishRuntimeRun = useCallback( + async (payload: { + totalTapCount: number; + wordCounters: WoodenFishRunResponse['run']['wordCounters']; + }) => { + const runId = woodenFishRun?.runId; + if (!runId) { + return; + } + setIsWoodenFishBusy(true); + setWoodenFishError(null); + try { + const response = await woodenFishClient.finishRun(runId, payload); + setWoodenFishRun(response.run); + } catch (error) { + setWoodenFishError( + resolveRpgCreationErrorMessage(error, '结束敲木鱼失败。'), + ); + } finally { + setIsWoodenFishBusy(false); + } + }, + [woodenFishRun?.runId], + ); + const executePuzzleAction = puzzleFlow.executeAction; const executePuzzleBackgroundAction = useCallback( @@ -9089,6 +9536,28 @@ export function PlatformEntryFlowShellImpl({ [openPublicWorkDetail, setJumpHopError, setSelectionStage], ); + const openWoodenFishPublicWorkDetail = useCallback( + async (profileId: string) => { + setIsPublicWorkDetailBusy(true); + setWoodenFishError(null); + setPublicWorkDetailError(null); + setSelectionStage('work-detail'); + + try { + const detail = await woodenFishClient.getWorkDetail(profileId); + setWoodenFishWork(detail.item); + openPublicWorkDetail(mapWoodenFishWorkToPublicWorkDetail(detail.item)); + } catch (error) { + setPublicWorkDetailError( + resolveRpgCreationErrorMessage(error, '读取敲木鱼详情失败。'), + ); + } finally { + setIsPublicWorkDetailBusy(false); + } + }, + [openPublicWorkDetail, setSelectionStage], + ); + const openPublicGalleryDetail = useCallback( (entry: PlatformPublicGalleryCard) => { if (isBigFishGalleryEntry(entry)) { @@ -9118,6 +9587,11 @@ export function PlatformEntryFlowShellImpl({ return; } + if (isWoodenFishGalleryEntry(entry)) { + void openWoodenFishPublicWorkDetail(entry.profileId); + return; + } + if (isVisualNovelGalleryEntry(entry)) { void openVisualNovelPublicWorkDetail(entry.profileId); return; @@ -9134,6 +9608,7 @@ export function PlatformEntryFlowShellImpl({ openPuzzlePublicWorkDetail, openPublicWorkDetail, openJumpHopPublicWorkDetail, + openWoodenFishPublicWorkDetail, openRpgPublicWorkDetail, openVisualNovelPublicWorkDetail, platformBootstrap.platformTab, @@ -9815,6 +10290,14 @@ export function PlatformEntryFlowShellImpl({ return; } + if (isWoodenFishGalleryEntry(selectedPublicWorkDetail)) { + setPublicWorkDetailError(null); + void startWoodenFishRunFromProfile(selectedPublicWorkDetail.profileId, { + returnStage: 'work-detail', + }); + return; + } + if (isMatch3DGalleryEntry(selectedPublicWorkDetail)) { const work = mapPublicWorkDetailToMatch3DWork(selectedPublicWorkDetail); if (!work) { @@ -9900,6 +10383,7 @@ export function PlatformEntryFlowShellImpl({ selectedPublicWorkDetail, startBigFishRunFromWork, startJumpHopRunFromProfile, + startWoodenFishRunFromProfile, startPuzzleRunFromProfile, startMatch3DRunFromProfile, startSquareHoleRunFromProfile, @@ -9959,6 +10443,11 @@ export function PlatformEntryFlowShellImpl({ embedded: true, returnStage: 'platform', }); + } else if (isWoodenFishGalleryEntry(entry)) { + started = await startWoodenFishRunFromProfile(entry.profileId, { + embedded: true, + returnStage: 'platform', + }); } else if (isMatch3DGalleryEntry(entry)) { const work = mapPublicWorkDetailToMatch3DWork(entry); if (!work) { @@ -10037,6 +10526,7 @@ export function PlatformEntryFlowShellImpl({ setSquareHoleError, startBigFishRunFromWork, startJumpHopRunFromProfile, + startWoodenFishRunFromProfile, startMatch3DRunFromProfile, startPuzzleRunFromProfile, startSquareHoleRunFromProfile, @@ -10285,6 +10775,25 @@ export function PlatformEntryFlowShellImpl({ ); } + if (activeRecommendRuntimeKind === 'wooden-fish') { + return ( + { + setActiveRecommendRuntimeKind(null); + }} + onRestart={() => { + void restartWoodenFishRuntimeRun(); + }} + onCheckpoint={checkpointWoodenFishRuntimeRun} + onFinish={finishWoodenFishRuntimeRun} + /> + ); + } + if (activeRecommendRuntimeKind === 'square-hole') { return ( { @@ -10487,6 +11003,7 @@ export function PlatformEntryFlowShellImpl({ puzzleRun, squareHoleRun, visualNovelRun, + woodenFishRun, }); if ( (activeRecommendEntry !== null && isActiveRecommendRuntimeReady) || @@ -10518,6 +11035,7 @@ export function PlatformEntryFlowShellImpl({ selectionStage, squareHoleRun, visualNovelRun, + woodenFishRun, ]); const remixPublicWork = useCallback( @@ -10586,6 +11104,12 @@ export function PlatformEntryFlowShellImpl({ return; } + if (isWoodenFishGalleryEntry(entry)) { + setPublicWorkDetailError('敲木鱼作品改造将在后续版本开放。'); + setIsPublicWorkDetailBusy(false); + return; + } + if (isVisualNovelGalleryEntry(entry)) { setPublicWorkDetailError('视觉小说作品改造将在后续版本开放。'); setIsPublicWorkDetailBusy(false); @@ -10701,6 +11225,11 @@ export function PlatformEntryFlowShellImpl({ return; } + if (isWoodenFishGalleryEntry(entry)) { + setPublicWorkDetailError('这份敲木鱼作品暂时请从作品架编辑。'); + return; + } + if (isVisualNovelGalleryEntry(entry)) { const matchedWork = visualNovelWorks.find( (work) => work.profileId === entry.profileId, @@ -10787,6 +11316,7 @@ export function PlatformEntryFlowShellImpl({ const shouldSearchBigFishFirst = upperKeyword.startsWith('BF'); const shouldSearchBabyObjectFirst = upperKeyword.startsWith('BO'); const shouldSearchJumpHopFirst = upperKeyword.startsWith('JH'); + const shouldSearchWoodenFishFirst = upperKeyword.startsWith('WF'); const shouldSearchMatch3DFirst = upperKeyword.startsWith('M3'); const shouldSearchPuzzleFirst = upperKeyword.startsWith('PZ'); const shouldSearchSquareHoleFirst = upperKeyword.startsWith('SH'); @@ -10796,6 +11326,7 @@ export function PlatformEntryFlowShellImpl({ !shouldSearchBabyObjectFirst && !shouldSearchBigFishFirst && !shouldSearchJumpHopFirst && + !shouldSearchWoodenFishFirst && !shouldSearchMatch3DFirst && !shouldSearchPuzzleFirst && !shouldSearchSquareHoleFirst && @@ -10808,6 +11339,7 @@ export function PlatformEntryFlowShellImpl({ !shouldSearchBigFishFirst && !shouldSearchBabyObjectFirst && !shouldSearchJumpHopFirst && + !shouldSearchWoodenFishFirst && !shouldSearchMatch3DFirst && !shouldSearchPuzzleFirst && !shouldSearchSquareHoleFirst && @@ -10904,6 +11436,25 @@ export function PlatformEntryFlowShellImpl({ openPublicWorkDetail(mapJumpHopWorkToPublicWorkDetail(matchedEntry)); }; + const tryOpenWoodenFishGalleryEntry = async () => { + const entries = + woodenFishGalleryEntries.length > 0 + ? woodenFishGalleryEntries + : await refreshWoodenFishGallery(); + const matchedEntry = entries.find((entry) => { + const detailEntry = mapWoodenFishWorkToPublicWorkDetail(entry); + return ( + canExposePublicWork(detailEntry) && + isSameWoodenFishPublicWorkCode(normalizedKeyword, entry.profileId) + ); + }); + + if (!matchedEntry) { + throw new Error('未找到敲木鱼作品。'); + } + + openPublicWorkDetail(mapWoodenFishWorkToPublicWorkDetail(matchedEntry)); + }; const tryOpenMatch3DGalleryEntry = async () => { const entries = match3dGalleryEntries.length > 0 @@ -11011,6 +11562,11 @@ export function PlatformEntryFlowShellImpl({ return; } + if (shouldSearchWoodenFishFirst) { + await tryOpenWoodenFishGalleryEntry(); + return; + } + if (shouldSearchBabyObjectFirst) { await tryOpenBabyObjectMatchGalleryEntry(); return; @@ -11093,6 +11649,7 @@ export function PlatformEntryFlowShellImpl({ puzzleGalleryEntries, refreshBigFishGallery, refreshJumpHopGallery, + refreshWoodenFishGallery, refreshPuzzleGallery, refreshSquareHoleGallery, refreshVisualNovelGallery, @@ -11102,6 +11659,7 @@ export function PlatformEntryFlowShellImpl({ setPuzzleError, setSelectionStage, visualNovelGalleryEntries, + woodenFishGalleryEntries, ], ); @@ -11176,6 +11734,19 @@ export function PlatformEntryFlowShellImpl({ return; } + if ( + worldType === 'wooden-fish' || + worldType === 'wooden_fish' || + work.worldKey.startsWith('wooden-fish:') + ) { + const profileId = + work.profileId ?? work.worldKey.replace(/^wooden-fish:/u, ''); + if (profileId) { + void openWoodenFishPublicWorkDetail(profileId); + } + return; + } + if ( worldType === 'big_fish' || worldType === 'big-fish' || @@ -11257,6 +11828,7 @@ export function PlatformEntryFlowShellImpl({ openPuzzlePublicWorkDetail, openPublicWorkDetail, openJumpHopPublicWorkDetail, + openWoodenFishPublicWorkDetail, openRpgPublicWorkDetail, openSquareHolePublicWorkDetail, refreshBigFishGallery, @@ -11284,6 +11856,7 @@ export function PlatformEntryFlowShellImpl({ void refreshBigFishGallery(); } void refreshJumpHopGallery(); + void refreshWoodenFishGallery(); void refreshMatch3DGallery(); void refreshPuzzleGallery(); if (isSquareHoleCreationVisible) { @@ -11299,6 +11872,7 @@ export function PlatformEntryFlowShellImpl({ isVisualNovelCreationOpen, refreshBigFishGallery, refreshJumpHopGallery, + refreshWoodenFishGallery, refreshMatch3DGallery, refreshPuzzleGallery, refreshSquareHoleGallery, @@ -11409,6 +11983,7 @@ export function PlatformEntryFlowShellImpl({ setPuzzleError(null); setVisualNovelError(null); setBabyObjectMatchError(null); + setWoodenFishError(null); void platformBootstrap.refreshCustomWorldWorks().catch((error) => { platformBootstrap.setPlatformError( resolveRpgCreationErrorMessage(error, '读取创作作品列表失败。'), @@ -11433,6 +12008,7 @@ export function PlatformEntryFlowShellImpl({ bigFishError ?? match3dError ?? (isSquareHoleCreationVisible ? squareHoleError : null) ?? + woodenFishError ?? puzzleCreationError ?? puzzleError ?? (isVisualNovelCreationOpen ? visualNovelError : null) ?? @@ -11446,6 +12022,7 @@ export function PlatformEntryFlowShellImpl({ isBigFishBusy || isMatch3DBusy || (isSquareHoleCreationVisible && isSquareHoleBusy) || + isWoodenFishBusy || isPuzzleBusy || (isVisualNovelCreationOpen && isVisualNovelBusy) || (isVisualNovelCreationOpen && isVisualNovelStreamingReply) || @@ -11718,6 +12295,19 @@ export function PlatformEntryFlowShellImpl({ }} /> + ) : activeCreationFormType === 'wooden-fish' ? ( + } + > + { + void compileWoodenFishSession(result, payload); + }} + /> + ) : ( } @@ -11821,7 +12411,8 @@ export function PlatformEntryFlowShellImpl({ isPuzzleBusy || isMatch3DBusy || isSquareHoleBusy || - isVisualNovelBusy + isVisualNovelBusy || + isWoodenFishBusy } recommendRuntimeError={activeRecommendRuntimeError} onSelectNextRecommendEntry={() => @@ -12973,6 +13564,137 @@ export function PlatformEntryFlowShellImpl({ )} + {selectionStage === 'wooden-fish-workspace' && ( + + } + > + { + void compileWoodenFishSession(result, payload); + }} + /> + + + )} + + {selectionStage === 'wooden-fish-generating' && ( + + } + > + { + setSelectionStage('wooden-fish-workspace'); + }} + onRetry={retryWoodenFishDraftGeneration} + onInterrupt={undefined} + backLabel="返回创作中心" + settingActionLabel={null} + retryLabel="重新生成草稿" + settingTitle="当前敲木鱼信息" + settingDescription={null} + progressTitle="敲木鱼草稿生成进度" + activeBadgeLabel="素材生成中" + pausedBadgeLabel="素材生成已暂停" + idleBadgeLabel="等待返回工作区" + /> + + + )} + + {selectionStage === 'wooden-fish-result' && + woodenFishSession?.draft && ( + + } + > + { + setSelectionStage('wooden-fish-workspace'); + }} + onStartTestRun={startWoodenFishTestRunFromProfile} + onPublish={publishWoodenFishDraft} + onRegenerateHitObject={() => { + void regenerateWoodenFishAsset('regenerate-hit-object'); + }} + onGenerateHitSound={() => { + void regenerateWoodenFishAsset('generate-hit-sound'); + }} + /> + + + )} + + {selectionStage === 'wooden-fish-runtime' && ( + + } + > + { + setSelectionStage(woodenFishRuntimeReturnStage); + }} + onRestart={() => { + void restartWoodenFishRuntimeRun(); + }} + onCheckpoint={checkpointWoodenFishRuntimeRun} + onFinish={finishWoodenFishRuntimeRun} + /> + + + )} + {selectionStage === 'creative-agent-workspace' && ( { handleCreationHubCreateType('jump-hop'); }} + onSelectWoodenFish={() => { + handleCreationHubCreateType('wooden-fish'); + }} onSelectCreativeAgent={() => { runProtectedAction(() => { void openCreativeAgentWorkspace(); diff --git a/src/components/platform-entry/platformEntryTypes.ts b/src/components/platform-entry/platformEntryTypes.ts index 938c1f5a..67c426c4 100644 --- a/src/components/platform-entry/platformEntryTypes.ts +++ b/src/components/platform-entry/platformEntryTypes.ts @@ -36,6 +36,10 @@ export type SelectionStage = | 'jump-hop-result' | 'jump-hop-runtime' | 'jump-hop-gallery-detail' + | 'wooden-fish-workspace' + | 'wooden-fish-generating' + | 'wooden-fish-result' + | 'wooden-fish-runtime' | 'bark-battle-runtime' | 'creative-agent-workspace' | 'visual-novel-agent-workspace' diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 782ddf4e..36684ae9 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -133,6 +133,7 @@ import { isPuzzleGalleryEntry, isSquareHoleGalleryEntry, isVisualNovelGalleryEntry, + isWoodenFishGalleryEntry, type PlatformPublicGalleryCard, type PlatformWorldCardLike, resolvePlatformPublicWorkCode, @@ -1843,22 +1844,31 @@ async function getPublicWorkAuthorSummary( } function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) { - const kind = isBigFishGalleryEntry(entry) - ? '大鱼' - : isPuzzleGalleryEntry(entry) - ? '拼图' - : isMatch3DGalleryEntry(entry) - ? '抓鹅' - : isSquareHoleGalleryEntry(entry) - ? '方洞' - : isJumpHopGalleryEntry(entry) - ? '跳一跳' - : isVisualNovelGalleryEntry(entry) - ? '视觉' - : isEdutainmentGalleryEntry(entry) - ? entry.templateName - : describePlatformThemeLabel(entry.themeMode); - return formatPlatformWorkDisplayTag(kind); + if (isBigFishGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('大鱼'); + } + if (isPuzzleGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('拼图'); + } + if (isMatch3DGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('抓鹅'); + } + if (isSquareHoleGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('方洞'); + } + if (isJumpHopGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('跳一跳'); + } + if (isWoodenFishGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('敲木鱼'); + } + if (isVisualNovelGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('视觉'); + } + if (isEdutainmentGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag(entry.templateName); + } + return formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode)); } function getPublicAuthorAvatarLabel(authorDisplayName: string) { diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts index 8abc1fe3..07f7d1c1 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts @@ -10,8 +10,10 @@ import { formatPlatformWorldTime, isEdutainmentGalleryEntry, isVisualNovelGalleryEntry, + isWoodenFishGalleryEntry, mapBabyObjectMatchDraftToPlatformGalleryCard, mapVisualNovelWorkToPlatformGalleryCard, + mapWoodenFishWorkToPlatformGalleryCard, type PlatformEdutainmentGalleryCard, type PlatformPuzzleGalleryCard, resolvePlatformPublicWorkCode, @@ -165,6 +167,34 @@ test('maps visual novel work to platform gallery card with VN public code', () = expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['悬疑', '列车']); }); +test('maps wooden fish work to platform gallery card with WF public code', () => { + const card = mapWoodenFishWorkToPlatformGalleryCard({ + publicWorkCode: '', + workId: 'wooden-fish-work-1', + profileId: 'wooden-fish-profile-12345678', + ownerUserId: 'user-1', + authorDisplayName: '玩家', + workTitle: '每日一敲', + workDescription: '敲一下,好事发生。', + coverImageSrc: '/generated-wooden-fish-assets/profile/hit-object.png', + themeTags: [], + publicationStatus: 'published', + playCount: 12, + updatedAt: '2026-05-20T00:00:00.000Z', + publishedAt: '2026-05-20T00:00:00.000Z', + generationStatus: 'ready', + }); + + expect(isWoodenFishGalleryEntry(card)).toBe(true); + expect(card.sourceType).toBe('wooden-fish'); + expect(card.publicWorkCode).toBe('WF-12345678'); + expect(resolvePlatformPublicWorkCode(card)).toBe('WF-12345678'); + expect(resolvePlatformWorldFallbackCoverImage(card)).toBe( + '/wooden-fish/default-hit-object.png', + ); + expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['敲木鱼']); +}); + test('keeps baby object match public card code and template label intact', () => { const card: PlatformEdutainmentGalleryCard = { sourceType: 'edutainment', diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.ts index 48871876..621703dd 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.ts @@ -22,6 +22,10 @@ import type { SquareHoleWorkSummary, } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; +import type { + WoodenFishGalleryCardResponse, + WoodenFishWorkProfileResponse, +} from '../../../packages/shared/src/contracts/woodenFish'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals'; import { @@ -32,7 +36,9 @@ import { buildPuzzlePublicWorkCode, buildSquareHolePublicWorkCode, buildVisualNovelPublicWorkCode, + buildWoodenFishPublicWorkCode, } from '../../services/publicWorkCode'; +import { WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC } from '../../services/wooden-fish/woodenFishDefaults'; import type { CustomWorldProfile } from '../../types'; export const PLATFORM_WORK_NAME_DISPLAY_LIMIT = 8; @@ -48,6 +54,7 @@ export type PlatformWorldCardLike = | PlatformSquareHoleGalleryCard | PlatformPuzzleGalleryCard | PlatformJumpHopGalleryCard + | PlatformWoodenFishGalleryCard | PlatformVisualNovelGalleryCard | PlatformEdutainmentGalleryCard; @@ -202,6 +209,28 @@ export type PlatformJumpHopGalleryCard = { stylePreset?: string; }; +export type PlatformWoodenFishGalleryCard = { + sourceType: 'wooden-fish'; + workId: string; + profileId: string; + sourceSessionId?: string | null; + publicWorkCode: string; + ownerUserId: string; + authorDisplayName: string; + worldName: string; + subtitle: string; + summaryText: string; + coverImageSrc: string | null; + themeTags: string[]; + playCount?: number; + remixCount?: number; + likeCount?: number; + recentPlayCount7d?: number; + visibility: 'published'; + publishedAt: string | null; + updatedAt: string; +}; + export type PlatformEdutainmentGalleryCard = { sourceType: 'edutainment'; templateId: typeof EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID; @@ -233,6 +262,7 @@ export type PlatformPublicGalleryCard = | PlatformSquareHoleGalleryCard | PlatformPuzzleGalleryCard | PlatformJumpHopGalleryCard + | PlatformWoodenFishGalleryCard | PlatformVisualNovelGalleryCard | PlatformEdutainmentGalleryCard; @@ -278,6 +308,12 @@ export function isJumpHopGalleryEntry( return 'sourceType' in entry && entry.sourceType === 'jump-hop'; } +export function isWoodenFishGalleryEntry( + entry: PlatformWorldCardLike, +): entry is PlatformWoodenFishGalleryCard { + return 'sourceType' in entry && entry.sourceType === 'wooden-fish'; +} + export function isEdutainmentGalleryEntry( entry: PlatformWorldCardLike, ): entry is PlatformEdutainmentGalleryCard { @@ -472,6 +508,39 @@ export function mapJumpHopWorkToPlatformGalleryCard( }; } +export function mapWoodenFishWorkToPlatformGalleryCard( + work: WoodenFishGalleryCardResponse | WoodenFishWorkProfileResponse, +): PlatformWoodenFishGalleryCard { + const summary = 'summary' in work ? work.summary : work; + + return { + sourceType: 'wooden-fish', + workId: summary.workId, + profileId: summary.profileId, + sourceSessionId: + 'sourceSessionId' in summary ? (summary.sourceSessionId ?? null) : null, + publicWorkCode: + 'publicWorkCode' in summary && summary.publicWorkCode.trim() + ? summary.publicWorkCode + : buildWoodenFishPublicWorkCode(summary.profileId), + ownerUserId: summary.ownerUserId, + authorDisplayName: + 'authorDisplayName' in summary ? summary.authorDisplayName : '玩家', + worldName: summary.workTitle, + subtitle: '敲木鱼', + summaryText: summary.workDescription, + coverImageSrc: summary.coverImageSrc ?? null, + themeTags: summary.themeTags.length > 0 ? summary.themeTags : ['敲木鱼'], + playCount: summary.playCount ?? 0, + remixCount: 0, + likeCount: 0, + recentPlayCount7d: 0, + visibility: 'published', + publishedAt: summary.publishedAt ?? null, + updatedAt: summary.updatedAt, + }; +} + export function mapBabyObjectMatchDraftToPlatformGalleryCard( draft: BabyObjectMatchDraft, ): PlatformEdutainmentGalleryCard { @@ -553,6 +622,10 @@ export function resolvePlatformWorldFallbackCoverImage( return '/creation-type-references/jump-hop.webp'; } + if (isWoodenFishGalleryEntry(entry)) { + return WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC; + } + if (isBigFishGalleryEntry(entry)) { return '/creation-type-references/big-fish.webp'; } @@ -722,6 +795,12 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) { : ['跳一跳']; } + if (isWoodenFishGalleryEntry(entry)) { + return entry.themeTags.length > 0 + ? entry.themeTags.slice(0, 3) + : ['敲木鱼']; + } + if (isEdutainmentGalleryEntry(entry)) { return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) @@ -818,6 +897,10 @@ export function resolvePlatformPublicWorkCode( return entry.publicWorkCode; } + if (isWoodenFishGalleryEntry(entry)) { + return entry.publicWorkCode; + } + if (isEdutainmentGalleryEntry(entry)) { return entry.publicWorkCode; } diff --git a/src/components/wooden-fish-creation/WoodenFishWorkspace.test.tsx b/src/components/wooden-fish-creation/WoodenFishWorkspace.test.tsx new file mode 100644 index 00000000..600f5708 --- /dev/null +++ b/src/components/wooden-fish-creation/WoodenFishWorkspace.test.tsx @@ -0,0 +1,25 @@ +/* @vitest-environment jsdom */ + +import { render, screen, within } from '@testing-library/react'; +import { expect, test } from 'vitest'; + +import { WoodenFishWorkspace } from './WoodenFishWorkspace'; + +test('功德有什么默认只显示基础词条,不显示运行态 +1 后缀', () => { + render( + {}} + onSubmitted={() => {}} + />, + ); + + const sectionTitle = screen.getByText('功德有什么'); + const section = sectionTitle.closest('section'); + + expect(section).not.toBeNull(); + expect(within(section as HTMLElement).getByDisplayValue('幸运')).toBeTruthy(); + expect(within(section as HTMLElement).getByDisplayValue('健康')).toBeTruthy(); + expect(within(section as HTMLElement).getByDisplayValue('财富')).toBeTruthy(); + expect(within(section as HTMLElement).queryByDisplayValue('幸运+1')).toBeNull(); + expect(within(section as HTMLElement).queryByDisplayValue('功德+1')).toBeNull(); +}); diff --git a/src/components/wooden-fish-creation/WoodenFishWorkspace.tsx b/src/components/wooden-fish-creation/WoodenFishWorkspace.tsx new file mode 100644 index 00000000..8a863496 --- /dev/null +++ b/src/components/wooden-fish-creation/WoodenFishWorkspace.tsx @@ -0,0 +1,534 @@ +import { + ArrowLeft, + Loader2, + Mic, + Pause, + Send, + Upload, +} from 'lucide-react'; +import { useMemo, useRef, useState } from 'react'; + +import type { + WoodenFishAudioAsset, + WoodenFishSessionResponse, + WoodenFishWorkspaceCreateRequest, +} from '../../../packages/shared/src/contracts/woodenFish'; +import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage'; +import { woodenFishClient } from '../../services/wooden-fish/woodenFishClient'; +import { WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT } from '../../services/wooden-fish/woodenFishDefaults'; +import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel'; + +type WoodenFishWorkspaceProps = { + isBusy?: boolean; + error?: string | null; + onBack: () => void; + onSubmitted: ( + result: WoodenFishSessionResponse, + payload: WoodenFishWorkspaceCreateRequest, + ) => void; +}; + +type WoodenFishWorkspaceFormState = { + workTitle: string; + workDescription: string; + themeTags: string; + hitObjectPrompt: string; + hitObjectReferenceImageSrc: string; + hitSoundPrompt: string; + hitSoundAsset: WoodenFishAudioAsset | null; + floatingWords: string[]; +}; + +const DEFAULT_FLOATING_WORDS = [ + '幸运', + '健康', + '财富', + '姻缘', + '幸福', + '事业', + '成功', + '功德', +]; + +const DEFAULT_FORM_STATE: WoodenFishWorkspaceFormState = { + workTitle: '今日敲木鱼', + workDescription: '', + themeTags: '敲木鱼 解压', + hitObjectPrompt: WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT, + hitObjectReferenceImageSrc: '', + hitSoundPrompt: '清脆短促的木鱼敲击声', + hitSoundAsset: null, + floatingWords: DEFAULT_FLOATING_WORDS, +}; + +function splitTags(value: string) { + return value + .split(/[,,、\s]+/u) + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, 6); +} + +function normalizeFloatingWords(words: string[]) { + const seen = new Set(); + const normalized: string[] = []; + for (const word of words) { + const trimmed = word.trim().replace(/[++]\s*1$/u, '').trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + normalized.push(trimmed); + if (normalized.length >= 8) { + break; + } + } + return normalized.length > 0 ? normalized : DEFAULT_FLOATING_WORDS; +} + +function readAudioFileAsAsset(file: File, source: 'uploaded' | 'recorded') { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error('音频读取失败,请重试。')); + reader.onload = () => { + if (typeof reader.result !== 'string') { + reject(new Error('音频读取失败,请重试。')); + return; + } + resolve({ + assetId: `local-${source}-${Date.now()}`, + audioSrc: reader.result, + audioObjectKey: '', + assetObjectId: '', + source, + prompt: file.name, + durationMs: null, + }); + }; + reader.readAsDataURL(file); + }); +} + +function WoodenFishAudioInputPanel({ + disabled, + prompt, + asset, + onPromptChange, + onAssetChange, + onError, +}: { + disabled: boolean; + prompt: string; + asset: WoodenFishAudioAsset | null; + onPromptChange: (value: string) => void; + onAssetChange: (asset: WoodenFishAudioAsset | null) => void; + onError: (message: string | null) => void; +}) { + const [isRecording, setIsRecording] = useState(false); + const recorderRef = useRef(null); + const chunksRef = useRef([]); + + const startRecording = async () => { + if (disabled || isRecording) { + return; + } + + try { + if ( + typeof navigator === 'undefined' || + !navigator.mediaDevices?.getUserMedia || + typeof MediaRecorder === 'undefined' + ) { + throw new Error('当前浏览器不支持录音。'); + } + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const recorder = new MediaRecorder(stream); + chunksRef.current = []; + recorder.ondataavailable = (event) => { + if (event.data.size > 0) { + chunksRef.current.push(event.data); + } + }; + recorder.onstop = () => { + const blob = new Blob(chunksRef.current, { + type: recorder.mimeType || 'audio/webm', + }); + stream.getTracks().forEach((track) => track.stop()); + const file = new File([blob], `wooden-fish-hit-${Date.now()}.webm`, { + type: blob.type, + }); + void readAudioFileAsAsset(file, 'recorded') + .then(onAssetChange) + .catch((caughtError) => { + onError( + caughtError instanceof Error + ? caughtError.message + : '录音保存失败。', + ); + }); + }; + recorderRef.current = recorder; + recorder.start(); + setIsRecording(true); + onError(null); + } catch (caughtError) { + onError( + caughtError instanceof Error ? caughtError.message : '录音启动失败。', + ); + } + }; + + const stopRecording = () => { + recorderRef.current?.stop(); + recorderRef.current = null; + setIsRecording(false); + }; + + return ( +
+
+
+ 敲击音效 +
+ {asset ? ( + + ) : null} +
+