Merge master into user play stats branch
Some checks failed
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-04-28 15:52:27 +08:00
80 changed files with 3609 additions and 434 deletions

View File

@@ -48,6 +48,8 @@ AUTH_REFRESH_COOKIE_SAME_SITE="Lax"
AUTH_REFRESH_COOKIE_SECURE="false" AUTH_REFRESH_COOKIE_SECURE="false"
# Rust 鉴权快照路径;包含 password_hash 与 refresh token hash只能放服务端私有目录。 # Rust 鉴权快照路径;包含 password_hash 与 refresh token hash只能放服务端私有目录。
GENARRATIVE_AUTH_STORE_PATH="server-rs/.data/auth-store.json" GENARRATIVE_AUTH_STORE_PATH="server-rs/.data/auth-store.json"
# 开发期便捷开关true 时允许 /api/auth/entry 对未知手机号用本次密码直接创建账号;生产必须保持 false。
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED="false"
# 手机号验证码登录配置(阿里云 PNVS # 手机号验证码登录配置(阿里云 PNVS
# 正式环境请改成你自己的 AccessKey 和短信签名/模板。 # 正式环境请改成你自己的 AccessKey 和短信签名/模板。

View File

@@ -210,3 +210,23 @@ npm.cmd run build
- 没有人手逐个点击整局游戏所有 function 的视觉回放,本轮重点是自动化测试、服务端测试、内容校验与 smoke 门禁。 - 没有人手逐个点击整局游戏所有 function 的视觉回放,本轮重点是自动化测试、服务端测试、内容校验与 smoke 门禁。
因此,本审计可以说明“当前 function 系统的自动化测试层状况”,但不等于“所有视觉演出与在线模型联动都已人工验证完毕”。 因此,本审计可以说明“当前 function 系统的自动化测试层状况”,但不等于“所有视觉演出与在线模型联动都已人工验证完毕”。
## 8. 执行回填2026-04-28修复聊天任务领取入口
- 问题现象:
- NPC 聊天中的待领取任务,点击“查看任务”进入详情后,再点“领取任务”没有把面板切到正式已接任务状态,表现上像“无法领取”。
- 根因:
- `npcChatQuestOfferUi.acceptPendingOffer()` 会异步把 `npc_quest_accept` 发到服务端。
- 但 [`src/components/rpg-runtime-panels/RpgAdventurePanel.tsx`](../../src/components/rpg-runtime-panels/RpgAdventurePanel.tsx) 里“待领取任务详情弹层”的 `onAcceptPendingNpcQuestOffer` 只返回了 `questId`,没有把它写入共享的 `pendingAcceptedQuestId`
- 结果是本来负责等待 quest 真正进入 `quests` 后再统一收口面板状态的 `useEffect` 根本不会触发。
- 修复:
-`onAcceptPendingNpcQuestOffer` 中补写 `setPendingAcceptedQuestId(acceptedQuestId)`,让待领取任务详情弹层复用普通 `npc_quest_accept` 已有的异步收口链。
- 新增 [`src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx`](../../src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx),覆盖“查看任务 -> 领取任务 -> 服务端异步写回 quest log -> 面板切到正式任务状态”的回归路径。
- 本次回归验证:
- `src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx`
- `src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx`
- `src/hooks/rpg-runtime-story/npcEncounterActions.test.ts`
- `src/hooks/rpg-runtime-story/choiceActions.test.ts`
- `src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts`
- `src/hooks/rpg-runtime-story/sessionActions.test.ts`
-`57` 条测试通过。

View File

@@ -0,0 +1,48 @@
# RPG 复活后继续冒险链路修复记录 2026-04-28
## 问题现象
RPG 运行态里,角色在战斗死亡并复活后,面板会显示一个“继续前进”入口。
此前这一步只有 `story_continue_adventure` 控制项,没有同步挂出复活后首场景应该展示的 `deferredOptions`。因此玩家点击继续后,系统会把它当作一次普通剧情续推入口,而不是单纯展示“复活后的下一批可选动作”。
在带有场景章节主 NPC 的自定义世界里,这会让玩家看起来像是“刚复活就直接和对面主 NPC 聊天”,造成复活后第一拍体验被主 NPC 对话链抢走。
## 根因结论
根因不在 NPC 聊天函数本身,而在死亡复活链没有沿用 `story_continue_adventure -> deferredOptions` 的延迟展示协议。
- 战后胜利链已经使用 `story_continue_adventure + deferredOptions`
- 死亡复活链此前只保留了 `story_continue_adventure`
- 结果是“继续前进”点击后无法走纯展示分支,只能落回普通续推
## 本次修复
本次在 `src/hooks/rpg-runtime-story/postBattleFlow.ts` 与复活调用链中补齐:
- `buildDeathStory(...)` 现在支持在复活文案上同步挂出 `deferredOptions`
- 这些 `deferredOptions` 复用 `buildFallbackStoryForState(...)` 产出的复活后可用入口
- 点击“继续前进”时只揭示这些入口,不再额外触发一次普通剧情推演
## 当前行为规则
角色死亡复活后:
1. 先显示“你在战斗中倒下,随后重新醒来”
2. 面板只展示一个 `story_continue_adventure`
3. 点击后展示复活后首场景已有的后续动作
4. 不应直接自动推进到主 NPC 聊天执行态
## 回归覆盖
已补两条测试:
- `src/hooks/rpg-runtime-story/choiceActions.test.ts`
- 覆盖本地战斗失败后的复活链
- `src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts`
- 覆盖服务端战斗失败后的复活链
两条测试都要求复活文案返回:
- `story_continue_adventure`
- 非空 `deferredOptions`

View File

@@ -0,0 +1,65 @@
# 大鱼作品列表 `items_json` 兼容修复 2026-04-28
## 背景
大鱼吃小鱼作品列表在 `server-rs` 链路里由 SpacetimeDB procedure 返回 `items_json`,再由 `spacetime-client` 反序列化成 `BigFishWorkSummaryRecord`
本轮出现的线上报错为:
```text
big fish works items_json 非法: missing field `owner_user_id`
```
这说明:
1. 客户端 record 结构已经把 `owner_user_id` 当成必填字段。
2. 某些历史 `items_json` 仍是旧字段集,没有带上 `owner_user_id`
3. 一旦直接按新结构强反序列化,整个 works 列表接口都会失败,而不是只丢失单字段。
## 根因判断
这不是前端展示问题,也不是 Axum 路由参数问题,而是:
1. SpacetimeDB procedure 输出 JSON 的结构发生过升级。
2. `spacetime-client` 映射层没有为旧 JSON 做向后兼容。
3. 作品列表是聚合读模型,一条旧记录就可能拖垮整批列表读取。
## 本次落地口径
本次只做最小风险修复,不改前端契约,不改现有表结构:
1. `server-rs/crates/spacetime-client/src/mapper.rs`
- 大鱼 works 反序列化改为先读兼容结构。
- `owner_user_id` 改为兼容层里的可缺省字段。
2. 私有 works 列表
- 若旧 JSON 缺 `owner_user_id`,用当前查询的 `owner_user_id` 回填。
- 这样不会破坏创作中心里依赖 `ownerUserId` 的恢复、归属和 key 逻辑。
3. 公开 gallery 列表
- 若旧 JSON 缺 `owner_user_id`,先回填空串,保证列表接口不再整体失败。
- 后续若公开画廊明确需要作者归属真相,再补模块端回填或数据修复。
4. 新增定向测试
- 覆盖“私有 works 旧 JSON 缺字段仍可回填”
- 覆盖“公开 works 旧 JSON 缺字段不再报错”
## 经验结论
以后只要是 `procedure -> items_json -> client record` 这类链路,都要默认遵守下面两条:
1. 聚合读模型的 JSON 字段升级不能假设全量历史数据同步完成。
2. `spacetime-client` 的映射层必须承担兼容旧 JSON 的责任,不能把结构升级风险直接抛给上层接口。
尤其是 works / gallery / library 这种平台入口级接口:
1. 允许单字段降级
2. 不允许整批列表因单字段缺失而 500 / 400
## 后续建议
如果后面继续演进大鱼 works 字段,推荐优先遵守:
1. 新增字段优先 `Option` 或兼容层解析。
2. 聚合 JSON 升级时同步补回归测试。
3. 如果字段已经进入前端关键逻辑,再决定是在模块端回填、客户端兜底,还是补历史数据迁移。

View File

@@ -31,3 +31,4 @@
- [RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md](./RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md):记录 RPG 发布后首页 / 分类页公开作品列表刷新链路。 - [RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md](./RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md):记录 RPG 发布后首页 / 分类页公开作品列表刷新链路。
- [AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md](./AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md):记录 Agent 空会话不应进入作品草稿列表的后端判定规则。 - [AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md](./AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md):记录 Agent 空会话不应进入作品草稿列表的后端判定规则。
- [BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md](./BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md):记录大鱼吃小鱼发布成功后结果页反馈与作品列表刷新的修复口径。 - [BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md](./BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md):记录大鱼吃小鱼发布成功后结果页反馈与作品列表刷新的修复口径。
- [BIG_FISH_WORKS_JSON_COMPAT_FIX_2026-04-28.md](./BIG_FISH_WORKS_JSON_COMPAT_FIX_2026-04-28.md):记录大鱼作品列表 `items_json` 字段升级后的向后兼容修复口径,避免旧 JSON 直接打崩 works 接口。

View File

@@ -16,6 +16,16 @@
6. 启动测试运行态 6. 启动测试运行态
7. 后端推进摇杆输入、刷怪、吞噬收编、三合一、屏外清理和胜负裁决 7. 后端推进摇杆输入、刷怪、吞噬收编、三合一、屏外清理和胜负裁决
### 1.1 2026-04-27 公开游玩次数补充
正式发布的大鱼吃小鱼作品需要记录公开游玩次数,落地口径如下:
1. `big_fish_creation_session.play_count` 保存该作品被正式启动的次数,默认值为 `0`
2. 只有平台作品详情、作品架等正式入口启动已发布作品时递增;创作结果页内的测试运行不计入。
3. 前端作品摘要 contract 暴露 `playCount`,作品架展示与拼图一致使用该后端值。
4. 本轮仅记录“进入玩法”次数,不记录大鱼吃小鱼总时长;个人 profile 的 RPG 时长统计仍由 runtime snapshot 负责。
5. schema 变更需要同步 `migration.rs` 已纳入的 `big_fish_creation_session` 导入导出结构。
## 2. 本轮明确不做 ## 2. 本轮明确不做
1. 不在本文件内展开正式图片模型链、OSS 真相链和占位兼容层的细节;相关正式出图方案以 `BIG_FISH_FORMAL_IMAGE_GENERATION_2026-04-23.md` 为准。 1. 不在本文件内展开正式图片模型链、OSS 真相链和占位兼容层的细节;相关正式出图方案以 `BIG_FISH_FORMAL_IMAGE_GENERATION_2026-04-23.md` 为准。

View File

@@ -395,6 +395,17 @@ Node 侧入口位于:
这些都等 `runtime_snapshot / save archive` 主链文档冻结后继续推进。 这些都等 `runtime_snapshot / save archive` 主链文档冻结后继续推进。
## 10.1 2026-04-27 统计写链修正
`runtime_snapshot / save archive` 主链已接入后profile projection 的写入语义补充冻结如下:
1. 正式 RPG 游玩只通过 `PUT /api/runtime/save/snapshot` 刷新 `profile_dashboard_state``profile_played_world`
2. `runtimeMode = "preview"``runtimeMode = "test"``runtimePersistenceDisabled = true` 的快照不刷新 profile projection。
3. 前端发起自动保存与手动保存前,必须先把 `runtimeStats.lastPlayTickAt` 到当前时间的 live 时长同步进 `runtimeStats.playTimeMs`,避免 15 秒内进入又退出时保存 0。
4. `profile_played_world` 的一行表示“当前用户玩过这个世界”,不是全站作品热度计数;`playedWorldCount` 读取当前用户的去重世界数。
5. `profile_dashboard_state.total_play_time_ms` 通过同一用户同一世界的 `runtimeStats.playTimeMs - last_observed_play_time_ms` 增量累积,后端使用 `saturating_sub` 防止旧快照回退导致负增量。
6. 作品卡上的公开热度计数如果需要覆盖 RPG 作品,应另立公开作品统计方案;不能把个人 `profile_played_world` 误当成全站作品 `playCount`
## 11. 测试策略 ## 11. 测试策略
### 11.1 必跑 ### 11.1 必跑

View File

@@ -1,6 +1,8 @@
# 密码登录入口历史落地设计 # 密码登录入口历史落地设计
> 2026-04-25 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经登录后设置过密码的手机号账号。`POST /api/auth/entry` 只接受 `phone + password`,不支持邮箱、用户名或叙世号登录,也不承担自动建号能力。本文原有“密码自动建号”内容仅作为历史背景保留,当前落地以本更新和 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准。 > 2026-04-25 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经登录后设置过密码的手机号账号。`POST /api/auth/entry` 只接受 `phone + password`,不支持邮箱、用户名或叙世号登录,也不承担自动建号能力。本文原有“密码自动建号”内容仅作为历史背景保留,当前落地以本更新和 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准。
>
> 2026-04-28 更新:为开发期本地/测试服联调新增服务端环境变量 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED`,默认 `false`。仅当该变量显式为 `true` 时,`POST /api/auth/entry` 可对未知手机号用本次密码直接创建账号并登录;默认关闭时仍严格保持未知手机号返回 `401` 的生产语义。该开关不得用于生产环境,也不新增任何前端规则说明文案。
日期:`2026-04-21` 日期:`2026-04-21`
@@ -166,6 +168,13 @@
2. 不创建账号。 2. 不创建账号。
3. 不写 `password_hash` 3. 不写 `password_hash`
开发期例外:
1.`GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true` 时,未知手机号会创建手机号账号。
2. 新账号立即写入本次密码的 `password_hash`,并将 `password_login_enabled` 置为 `true`
3. 成功响应沿用密码登录响应体,`created` 只保留在领域结果中,不额外暴露到当前 HTTP contract。
4. 手机号格式和密码长度校验仍完全沿用正式密码入口规则。
### 8.2 未设置密码 ### 8.2 未设置密码
当账号存在但 `password_login_enabled = false` 时: 当账号存在但 `password_login_enabled = false` 时:
@@ -233,6 +242,8 @@
4. 邮箱、用户名或叙世号作为密码登录标识返回 `400` 4. 邮箱、用户名或叙世号作为密码登录标识返回 `400`
5. 登录成功时返回 access token。 5. 登录成功时返回 access token。
6. 登录成功时写回 refresh cookie。 6. 登录成功时写回 refresh cookie。
7. `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED` 默认关闭时行为不变。
8. 开关开启时,未知手机号可通过 `/api/auth/entry` 创建账号并登录;同手机号后续用相同密码登录复用同一用户,错误密码仍返回 `401`
## 13. 完成定义 ## 13. 完成定义

View File

@@ -0,0 +1,131 @@
# 资料兑换码模块落地设计
## 1. 目标
本轮在现有“我的”资料与钱包 projection 上新增兑换码能力。用户兑换成功后直接增加叙世币余额,写入 `profile_wallet_ledger`,并同步刷新 `profile_dashboard_state.wallet_balance`
管理侧本轮只提供后端 API不新增管理后台页面。私有兑换码创建时支持内部 `userId` 与公开叙世号两类输入,后端创建阶段统一解析成内部 `userId` 存储。
## 2. 兑换码类型
`RuntimeProfileRedeemCodeMode` 固定为三种:
| 类型 | 规则 |
| --- | --- |
| `Public` | 任意用户可兑换,`max_uses` 按用户独立计算。 |
| `Unique` | 任意用户可兑换,`max_uses` 全局共用。 |
| `Private` | 仅 `allowed_user_ids` 中的用户可兑换,`max_uses` 全局共用。 |
兑换码入库前必须 `trim + uppercase`。空兑换码、奖励为 0、次数为 0 均拒绝。
## 3. 表结构
### 3.1 `profile_redeem_code`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `code` | `String` | 主键,标准化后的兑换码。 |
| `mode` | `RuntimeProfileRedeemCodeMode` | 兑换码模式。 |
| `reward_points` | `u64` | 单次到账叙世币。 |
| `max_uses` | `u32` | 公共码为单用户上限,唯一码/私有码为全局上限。 |
| `global_used_count` | `u32` | 全局已使用次数。公共码也记录总使用次数,但不参与公共码上限判断。 |
| `enabled` | `bool` | 是否启用。 |
| `allowed_user_ids` | `Vec<String>` | 私有码允许用户;公共/唯一码存空数组。 |
| `created_by` | `String` | 管理员用户 ID。 |
| `created_at` | `Timestamp` | 创建时间。 |
| `updated_at` | `Timestamp` | 更新时间。 |
### 3.2 `profile_redeem_code_usage`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `usage_id` | `String` | 主键,格式 `redeem:{code}:{user_id}:{micros}:{sequence}`。 |
| `code` | `String` | 兑换码。 |
| `user_id` | `String` | 兑换用户。 |
| `amount_granted` | `u64` | 到账叙世币。 |
| `created_at` | `Timestamp` | 兑换时间。 |
索引:`code``user_id``(code, user_id)`
## 4. SpacetimeDB 过程
### 4.1 用户兑换
`redeem_profile_reward_code(input: RuntimeProfileRewardCodeRedeemInput) -> RuntimeProfileRewardCodeRedeemProcedureResult`
流程:
1. 标准化 code。
2. 校验兑换码存在、启用、奖励大于 0。
3. 按模式校验使用范围与次数。
4. 同一事务内写入 `profile_redeem_code_usage`、增加钱包余额、写入 `profile_wallet_ledger`,最后更新 `profile_redeem_code.global_used_count`
5. 返回 `walletBalance``amountGranted` 与本次 `ledgerEntry`
### 4.2 管理创建/更新
`admin_upsert_profile_redeem_code(input: RuntimeProfileRedeemCodeAdminUpsertInput) -> RuntimeProfileRedeemCodeAdminProcedureResult`
私有码必须至少解析出一个内部用户 ID。公共码与唯一码忽略 allowed 列表并存空数组。
### 4.3 管理停用
`admin_disable_profile_redeem_code(input: RuntimeProfileRedeemCodeAdminDisableInput) -> RuntimeProfileRedeemCodeAdminProcedureResult`
只更新 `enabled=false``updated_at`,不存在时返回“兑换码不存在”。
## 5. Axum API
用户接口:
- `POST /api/profile/redeem-codes/redeem`
- `POST /api/runtime/profile/redeem-codes/redeem`
请求:`{ "code": "WELCOME2026" }`
成功返回:
```json
{
"walletBalance": 130,
"amountGranted": 100,
"ledgerEntry": {
"id": "redeem:WELCOME2026:user:1777392000000000:0",
"amountDelta": 100,
"balanceAfter": 130,
"sourceType": "redeem_code_reward",
"createdAt": "2026-04-28T00:00:00Z"
}
}
```
管理员接口:
- `POST /admin/api/profile/redeem-codes`
- `POST /admin/api/profile/redeem-codes/disable`
管理员接口复用现有 `require_admin_auth`
## 6. 错误文案
| 场景 | message |
| --- | --- |
| 空 code | `兑换码不能为空` |
| 不存在 | `兑换码不存在` |
| 停用 | `兑换码已停用` |
| 奖励为 0 | `兑换码奖励无效` |
| 次数耗尽 | `兑换次数已用完` |
| 私有码账号不匹配 | `该兑换码不适用于当前账号` |
| 私有码无允许用户 | `私有兑换码必须指定可兑换用户` |
## 7. 前端交互
“我的”页头像右侧入口由 `会员充值` 改为 `兑换码`。点击打开独立模态窗口,窗口内只保留输入框、兑换按钮和后端返回提示,不展示兑换规则说明。
成功后展示 `已到账 X 叙世币`,并刷新 profile dashboard。失败后直接展示后端 `message`
## 8. 测试矩阵
- Rust/module-runtime覆盖公共码、唯一码、私有码、失败场景、流水来源和余额累加。
- Axum覆盖用户鉴权、管理员鉴权、runtime error 到 400 的映射和兼容路径。
- 前端:覆盖入口替换、独立 modal、成功刷新余额和失败展示后端 message。
- 验证命令:`cargo test`、目标前端测试、`npm run api-server:maincloud``npm run check:encoding`

View File

@@ -0,0 +1,58 @@
# 拼图结果页自动保存与标签发布门槛修复
## 背景
拼图结果页此前存在两个串联问题:
1. 创作者在结果页修改 `关卡名`、新增标签、删除标签,只会改前端本地 `editState`,不会立即写回拼图作品 profile。
2. 发布弹窗同时混用了旧 session 内的 `publishReady` 与前端本地编辑态,导致标签已经在界面里补够,但发布校验仍然盯着旧草稿里的标签数量,用户无法通过发布检验。
这会直接破坏拼图创作主链的可用性:用户明明已经在结果页补齐正式标签,却因为没有自动保存、也没有按当前编辑态重算门槛而卡在发布前。
## 修复目标
1. 拼图结果页中的 `关卡名``添加标签``删除标签` 统一接入自动保存。
2. 自动保存复用现有 `PUT /api/runtime/puzzle/works/:profileId`,不新增新系统。
3. 前端发布门槛与后端 `module-puzzle` 规则显式对齐,统一采用 `3~6` 个正式标签。
4. 发布弹窗要基于“当前可编辑态”判断是否通过,不再被旧 session 中可由本地编辑修复的 blocker 卡死。
## 实现口径
### 1. 结果页自动保存
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
- 为拼图结果页显式透传稳定 `profileId`
- 草稿结果页默认按 `puzzle-session-* -> puzzle-profile-*` 规则推导 profileId已发布作品优先复用 `publishedProfileId`
- `src/components/puzzle-result/PuzzleResultView.tsx`
-`levelName / summary / themeTags` 相对当前 `draft` 发生变化时,触发防抖自动保存。
- 自动保存复用 `updatePuzzleWork(...)`,同步写回:
- `levelName`
- `summary`
- `themeTags`
- 当前正式图 `coverImageSrc / coverAssetId`
- 顶部只展示轻量保存状态角标:`保存中 / 已自动保存 / 保存失败`
### 2. 发布门槛统一
- 前端结果页发布判定不再使用“至少 1 个标签”的旧口径。
- 统一改为和后端 `module-puzzle` 一致的规则:正式标签数量必须在 `3 到 6` 之间。
### 3. 结果页 blocker 重算策略
`session.resultPreview.blockers` 中与可编辑字段直接相关的 blocker
- `MISSING_LEVEL_NAME`
- `INVALID_TAG_COUNT`
- `MISSING_COVER_IMAGE`
这些项不再原样阻断前端发布按钮,而是改由结果页基于当前 `editState + formalImageSrc` 重新计算。
其余后端 blocker 仍继续保留,避免前端绕过真正不可编辑的发布门禁。
## 验收点
1. 修改拼图关卡名后,不点发布也会自动写回作品 profile。
2. 添加标签、删除标签后,不点发布也会自动写回作品 profile。
3. 标签少于 `3` 个时,发布弹窗明确提示“正式标签数量必须在 3 到 6 之间”。
4. 标签补到 `3~6` 个后,无需刷新页面即可通过前端发布校验。
5. 结果页顶部能看到轻量自动保存状态,不额外堆叠说明文案。

View File

@@ -4,6 +4,7 @@
## 文档列表 ## 文档列表
- [PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md](./PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md):记录拼图结果页名称与标签编辑自动保存、发布门槛统一到 `3~6` 标签,以及前端发布校验不再被旧 session blocker 卡死的修复口径。
- [SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md](./SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md):记录发布包 `start.sh` 只输出“SpacetimeDB 进程在就绪前退出”时的诊断补强,启动失败或超时时自动回显 `logs/spacetimedb.log``server ping`、端口监听和 root-dir 相关进程。 - [SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md](./SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md):记录发布包 `start.sh` 只输出“SpacetimeDB 进程在就绪前退出”时的诊断补强,启动失败或超时时自动回显 `logs/spacetimedb.log``server ping`、端口监听和 root-dir 相关进程。
- [RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md](./RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md):记录 RPG 运行时 NPC 聊天、RPG/自定义世界 Agent 与大鱼 Agent 从“拼完整 SSE 字符串后一次性返回”改为 `mpsc + Sse<Event>` 真流式输出的后端落地口径。 - [RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md](./RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md):记录 RPG 运行时 NPC 聊天、RPG/自定义世界 Agent 与大鱼 Agent 从“拼完整 SSE 字符串后一次性返回”改为 `mpsc + Sse<Event>` 真流式输出的后端落地口径。
- [SPACETIMEDB_START_SH_ROOT_OWNER_FALSE_POSITIVE_FIX_2026-04-27.md](./SPACETIMEDB_START_SH_ROOT_OWNER_FALSE_POSITIVE_FIX_2026-04-27.md):记录发布包 `start.sh` root-dir 占用检测把 `grep -F .../.spacetimedb` 误判为 SpacetimeDB 实例的根因、脚本修复和现场处理方式。 - [SPACETIMEDB_START_SH_ROOT_OWNER_FALSE_POSITIVE_FIX_2026-04-27.md](./SPACETIMEDB_START_SH_ROOT_OWNER_FALSE_POSITIVE_FIX_2026-04-27.md):记录发布包 `start.sh` root-dir 占用检测把 `grep -F .../.spacetimedb` 误判为 SpacetimeDB 实例的根因、脚本修复和现场处理方式。

View File

@@ -96,6 +96,9 @@
4. 前端 `storyChoiceContinuation` / `useRpgRuntimeNpcInteraction` 4. 前端 `storyChoiceContinuation` / `useRpgRuntimeNpcInteraction`
- `fight_defeat` 不能再被当成“本地 NPC 战斗胜利”进入战后收束 - `fight_defeat` 不能再被当成“本地 NPC 战斗胜利”进入战后收束
- 玩家死亡后必须直接走死亡复活链,且复活时重置到开局场景第一幕,不能先推进下一幕 - 玩家死亡后必须直接走死亡复活链,且复活时重置到开局场景第一幕,不能先推进下一幕
5. 前端 `postBattleFlow`
- 复活回到开局场景时,必须重新走首幕 encounter preview 恢复链
- 第一幕主交互 NPC 与同幕陪衬 NPC 要继续沿用既有场景槽位,不能退化成全部站成一排
## 继续收口2026-04-28 ## 继续收口2026-04-28
@@ -118,6 +121,24 @@
- 非 NPC 通用敌对战斗 `!inBattle` - 非 NPC 通用敌对战斗 `!inBattle`
5. 这样可以保证作品测试、幕预览与正式游戏在“死亡后回开局第一幕”这一口径上继续对齐 5. 这样可以保证作品测试、幕预览与正式游戏在“死亡后回开局第一幕”这一口径上继续对齐
## 继续收口:复活后首幕 NPC 与站位恢复2026-04-28
在继续复测后,又确认死亡复活链还有一层表现问题:
1. 角色虽然已经回到开局场景第一幕;
2. 但复活态旧实现只是重置 `currentSceneActState`,没有重新恢复第一幕 encounter preview
3. 于是画布只能把第一幕 NPC 都按普通 ambient 角色绘制;
4. 视觉上就会表现为:
- 主交互 NPC 没有按首幕重新成为前景目标
- 同幕 NPC 失去原本的前后排关系
- 最终看起来像“所有人站成一排”
本轮补充修正如下:
1. `buildRevivedFirstSceneState(...)` 在重置到首幕之后,立即复用 `ensureSceneEncounterPreview(...)`
2. 这样复活链与“开局进入世界 / 场景正常进场”继续共用同一套首幕恢复逻辑
3. 第一幕主交互 NPC、同幕陪衬 NPC 与既有槽位会一起恢复,不再额外发明一套复活专用站位规则
## 结论 ## 结论
本次修复后RPG 战斗 compat 主链的胜负判定口径变为: 本次修复后RPG 战斗 compat 主链的胜负判定口径变为:

View File

@@ -200,3 +200,47 @@
1. 自定义世界角色型敌人在战斗态不会再重复叠加场景立绘下沉偏移; 1. 自定义世界角色型敌人在战斗态不会再重复叠加场景立绘下沉偏移;
2. 相关战斗编队、runtime gateway 与 battle plan 既有回归继续通过。 2. 相关战斗编队、runtime gateway 与 battle plan 既有回归继续通过。
## 9. 本轮继续修正:战斗结束后和平态站位被战斗坐标污染
在前面解决“开战瞬间跳位”后,用户继续反馈战斗结束时敌对角色站位仍会被改掉。继续顺着 NPC 战斗收尾链核对后,确认这次的问题不在画布层,而在“战后恢复使用了哪一份 encounter 真相”。
### 9.1 根因梳理
此前 `fight_victory` 收尾时,恢复 `currentEncounter` 的优先级仍可能落到:
1. 战斗中的 `currentEncounter`
2. `activeBattleHostiles[0]?.encounter`
这两份 encounter 都已经是战斗态里被压到前排中心位后的数据,`xMeters` 往往已经变成 `3.2`。因此即使战前和平态 NPC 原本站在更靠后的场景位置,战斗结束后也会被错误恢复到战斗中心位,表现为“打完架后角色站位被改掉”。
### 9.2 本次收口
这次修正把“战前原始 encounter 保存”和“战后 encounter 恢复”两端一起收口:
1. `sceneEncounterPreviews.ts`
- NPC 自动开战时立即保存战前原始 encounter
- 复用现有 `sparReturnEncounter` 存槽,避免新增一套并行状态
2. `rpgRuntimeStoryGateway.ts`
- 若服务端战斗快照未带回 `sparReturnEncounter`
- 网关自动沿用进入战斗前的原始 NPC encounter 回填
3. `useRpgRuntimeNpcInteraction.ts`
- `fight_victory` 恢复和平态时,优先使用保存下来的战前 encounter
- 只在缺失时才退回到 battle encounter / fallback encounter
### 9.3 效果
这样处理后:
1. 战斗结束后恢复到场景中的 NPC会回到战前那份 encounter 对应的位置;
2. 不会再把战斗前排中心位误带回和平态;
3. `fight``spar` 两条 NPC 战斗收尾链恢复口径保持一致;
4. 作品测试、幕预览与正式运行的战后站位表现继续对齐。
### 9.4 验证
本轮新增并通过了以下回归验证:
1. 负好感 NPC 自动开战后会保存战前 encounter
2. `npc_fight` 服务端空战场快照桥接后会保留战前 encounter
3. `fight_victory` 收尾时会恢复战前 encounter而不是战斗态 encounter。

View File

@@ -23,7 +23,7 @@ spacetime sql <db> "SELECT * FROM custom_world_gallery_entry"
| 领域 | 表 | | 领域 | 表 |
| --- | --- | | --- | --- |
| 认证 | `auth_store_snapshot`, `user_account`, `auth_identity`, `refresh_session` | | 认证 | `auth_store_snapshot`, `user_account`, `auth_identity`, `refresh_session` |
| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `profile_played_world`, `profile_save_archive` | | 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_played_world`, `profile_save_archive` |
| RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` | | RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` |
| 世界创作 | `custom_world_profile`, `custom_world_session`, `custom_world_agent_session`, `custom_world_agent_message`, `custom_world_agent_operation`, `custom_world_draft_card`, `custom_world_gallery_entry` | | 世界创作 | `custom_world_profile`, `custom_world_session`, `custom_world_agent_session`, `custom_world_agent_message`, `custom_world_agent_operation`, `custom_world_draft_card`, `custom_world_gallery_entry` |
| 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_runtime_run` | | 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_runtime_run` |
@@ -133,6 +133,27 @@ SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>';
SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>' ORDER BY created_at DESC; SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>' ORDER BY created_at DESC;
``` ```
### `profile_redeem_code`
- 作用:运营发放的叙世币兑换码,支持公共码、唯一码和私有码。
- 结构:`code PK: String`, `mode: RuntimeProfileRedeemCodeMode`, `reward_points: u64`, `max_uses: u32`, `global_used_count: u32`, `enabled: bool`, `allowed_user_ids: Vec<String>`, `created_by: String`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:主键 `code`
```sql
SELECT * FROM profile_redeem_code WHERE code = '<CODE>';
```
### `profile_redeem_code_usage`
- 作用:记录每一次兑换行为,为公共码用户维度计次、唯一/私有码全局计次提供依据。
- 结构:`usage_id PK: String`, `code: String`, `user_id: String`, `amount_granted: u64`, `created_at: Timestamp`
- 索引:`code`, `user_id`, `(code, user_id)`
```sql
SELECT * FROM profile_redeem_code_usage WHERE code = '<CODE>';
SELECT * FROM profile_redeem_code_usage WHERE user_id = '<user_id>';
```
### `profile_played_world` ### `profile_played_world`
- 作用:记录用户玩过的世界及最后游玩时间,用于个人页历史和继续游戏入口。 - 作用:记录用户玩过的世界及最后游玩时间,用于个人页历史和继续游戏入口。

View File

@@ -15,6 +15,7 @@ export interface BigFishWorkSummary {
levelMainImageReadyCount: number; levelMainImageReadyCount: number;
levelMotionReadyCount: number; levelMotionReadyCount: number;
backgroundReady: boolean; backgroundReady: boolean;
playCount?: number;
} }
export interface BigFishWorksResponse { export interface BigFishWorksResponse {

View File

@@ -57,7 +57,8 @@ export type ProfileWalletLedgerEntry = {
| 'invite_invitee_reward' | 'invite_invitee_reward'
| 'points_recharge' | 'points_recharge'
| 'asset_generation_consume' | 'asset_generation_consume'
| 'asset_generation_refund'; | 'asset_generation_refund'
| 'redeem_code_reward';
createdAt: string; createdAt: string;
}; };
@@ -159,6 +160,16 @@ export type RedeemProfileReferralInviteCodeResponse = {
inviterBalanceAfter: number; inviterBalanceAfter: number;
}; };
export type RedeemProfileRewardCodeRequest = {
code: string;
};
export type RedeemProfileRewardCodeResponse = {
walletBalance: number;
amountGranted: number;
ledgerEntry: ProfileWalletLedgerEntry;
};
export type ProfilePlayedWorkSummary = { export type ProfilePlayedWorkSummary = {
worldKey: string; worldKey: string;
ownerUserId: string | null; ownerUserId: string | null;

View File

@@ -94,9 +94,10 @@ use crate::{
runtime_chat::stream_runtime_npc_chat_turn, runtime_chat::stream_runtime_npc_chat_turn,
runtime_inventory::get_runtime_inventory_state, runtime_inventory::get_runtime_inventory_state,
runtime_profile::{ runtime_profile::{
admin_disable_profile_redeem_code, admin_upsert_profile_redeem_code,
create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats, create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats,
get_profile_recharge_center, get_profile_referral_invite_center, get_profile_wallet_ledger, get_profile_recharge_center, get_profile_referral_invite_center, get_profile_wallet_ledger,
redeem_profile_referral_invite_code, redeem_profile_referral_invite_code, redeem_profile_reward_code,
}, },
runtime_save::{ runtime_save::{
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives, delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
@@ -143,6 +144,20 @@ pub fn build_router(state: AppState) -> Router {
require_admin_auth, require_admin_auth,
)), )),
) )
.route(
"/admin/api/profile/redeem-codes",
post(admin_upsert_profile_redeem_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/profile/redeem-codes/disable",
post(admin_disable_profile_redeem_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route( .route(
"/healthz", "/healthz",
get(|Extension(request_context): Extension<_>| async move { get(|Extension(request_context): Extension<_>| async move {
@@ -581,6 +596,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth, require_bearer_auth,
)), )),
) )
.route(
"/api/runtime/big-fish/works/{session_id}/play",
post(record_big_fish_play).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route( .route(
"/api/runtime/puzzle/agent/sessions", "/api/runtime/puzzle/agent/sessions",
post(create_puzzle_agent_session).route_layer(middleware::from_fn_with_state( post(create_puzzle_agent_session).route_layer(middleware::from_fn_with_state(
@@ -854,6 +876,20 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth, require_bearer_auth,
)), )),
) )
.route(
"/api/runtime/profile/redeem-codes/redeem",
post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/redeem-codes/redeem",
post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route( .route(
"/api/runtime/profile/play-stats", "/api/runtime/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state( get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
@@ -1360,6 +1396,36 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[tokio::test]
async fn password_entry_dev_auto_register_creates_unknown_phone_when_enabled() {
let config = AppConfig {
dev_password_entry_auto_register_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let first_response =
password_login_request(app.clone(), "13800138023", TEST_PASSWORD).await;
let first_status = first_response.status();
let first_body = first_response
.into_body()
.collect()
.await
.expect("first response body should collect")
.to_bytes();
let first_payload: Value =
serde_json::from_slice(&first_body).expect("first response body should be valid json");
let second_response = password_login_request(app, "13800138023", TEST_PASSWORD).await;
assert_eq!(first_status, StatusCode::OK);
assert!(first_payload["token"].as_str().is_some());
assert_eq!(
first_payload["user"]["loginMethod"],
Value::String("password".to_string())
);
assert_eq!(second_response.status(), StatusCode::OK);
}
#[tokio::test] #[tokio::test]
async fn password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie() { async fn password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie() {
let state = AppState::new(AppConfig::default()).expect("state should build"); let state = AppState::new(AppConfig::default()).expect("state should build");

View File

@@ -211,7 +211,7 @@ pub async fn record_big_fish_play(
})?; })?;
ensure_non_empty(&request_context, &session_id, "sessionId")?; ensure_non_empty(&request_context, &session_id, "sessionId")?;
let session = state let items = state
.spacetime_client() .spacetime_client()
.record_big_fish_play(BigFishPlayReportRecordInput { .record_big_fish_play(BigFishPlayReportRecordInput {
session_id, session_id,
@@ -226,8 +226,11 @@ pub async fn record_big_fish_play(
Ok(json_success_body( Ok(json_success_body(
Some(&request_context), Some(&request_context),
BigFishSessionResponse { BigFishWorksResponse {
session: map_big_fish_session_response(session), items: items
.into_iter()
.map(map_big_fish_work_summary_response)
.collect(),
}, },
)) ))
} }
@@ -965,6 +968,7 @@ fn map_big_fish_work_summary_response(
level_main_image_ready_count: item.level_main_image_ready_count, level_main_image_ready_count: item.level_main_image_ready_count,
level_motion_ready_count: item.level_motion_ready_count, level_motion_ready_count: item.level_motion_ready_count,
background_ready: item.background_ready, background_ready: item.background_ready,
play_count: item.play_count,
} }
} }

View File

@@ -29,6 +29,7 @@ pub struct AppConfig {
pub refresh_cookie_same_site: String, pub refresh_cookie_same_site: String,
pub refresh_session_ttl_days: u32, pub refresh_session_ttl_days: u32,
pub auth_store_path: PathBuf, pub auth_store_path: PathBuf,
pub dev_password_entry_auto_register_enabled: bool,
pub sms_auth_enabled: bool, pub sms_auth_enabled: bool,
pub sms_auth_provider: String, pub sms_auth_provider: String,
pub sms_endpoint: String, pub sms_endpoint: String,
@@ -118,6 +119,7 @@ impl Default for AppConfig {
refresh_cookie_same_site: "Lax".to_string(), refresh_cookie_same_site: "Lax".to_string(),
refresh_session_ttl_days: 30, refresh_session_ttl_days: 30,
auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH), auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH),
dev_password_entry_auto_register_enabled: false,
sms_auth_enabled: false, sms_auth_enabled: false,
sms_auth_provider: "mock".to_string(), sms_auth_provider: "mock".to_string(),
sms_endpoint: "dypnsapi.aliyuncs.com".to_string(), sms_endpoint: "dypnsapi.aliyuncs.com".to_string(),
@@ -273,6 +275,11 @@ impl AppConfig {
if let Some(auth_store_path) = read_first_non_empty_env(&["GENARRATIVE_AUTH_STORE_PATH"]) { if let Some(auth_store_path) = read_first_non_empty_env(&["GENARRATIVE_AUTH_STORE_PATH"]) {
config.auth_store_path = PathBuf::from(auth_store_path); config.auth_store_path = PathBuf::from(auth_store_path);
} }
if let Some(enabled) =
read_first_bool_env(&["GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED"])
{
config.dev_password_entry_auto_register_enabled = enabled;
}
if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) { if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) {
config.sms_auth_enabled = sms_auth_enabled; config.sms_auth_enabled = sms_auth_enabled;

View File

@@ -94,19 +94,19 @@
}, },
{ {
"templateId": "big_fish", "templateId": "big_fish",
"displayName": "大鱼吃小鱼共创", "displayName": "大鱼吃小鱼",
"creationGoal": "收束成可直接编译为竖屏大鱼吃小鱼玩法草稿的成长、生态、节奏方案。", "creationGoal": "收束成可直接编译为竖屏大鱼吃小鱼玩法草稿的成长、生态、节奏方案。",
"anchorQuestions": [ "anchorQuestions": [
{ {
"key": "gameplayPromise", "key": "gameplayPromise",
"label": "玩法承诺", "label": "玩法爽点",
"question": "这版大鱼吃小鱼最核心的吞噬成长爽点是什么?", "question": "核心的吞噬成长爽点是什么?",
"requiredEffect": "明确玩家为什么要持续吞噬、升级和冒险。" "requiredEffect": "明确玩家为什么要持续吞噬、升级和冒险。"
}, },
{ {
"key": "ecologyVisualTheme", "key": "ecologyVisualTheme",
"label": "生态视觉主题", "label": "视觉主题",
"question": "鱼群、场景和敌我生态的视觉主题是什么?", "question": "场景和形象的视觉主题是什么?",
"requiredEffect": "提供后续角色图、动作图和背景图的一致视觉方向。" "requiredEffect": "提供后续角色图、动作图和背景图的一致视觉方向。"
}, },
{ {

View File

@@ -26,13 +26,18 @@ pub async fn password_entry(
headers: HeaderMap, headers: HeaderMap,
Json(payload): Json<PasswordEntryRequest>, Json(payload): Json<PasswordEntryRequest>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let result = state let input = PasswordEntryInput {
.password_entry_service()
.execute(PasswordEntryInput {
phone_number: payload.phone, phone_number: payload.phone,
password: payload.password, password: payload.password,
}) };
let result = if state.config.dev_password_entry_auto_register_enabled {
state
.password_entry_service()
.execute_with_dev_registration(input)
.await .await
} else {
state.password_entry_service().execute(input).await
}
.map_err(map_password_entry_error)?; .map_err(map_password_entry_error)?;
let session_client = resolve_session_client_context(&headers); let session_client = resolve_session_client_context(&headers);
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?; let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;

View File

@@ -7,30 +7,36 @@ use axum::{
use module_runtime::{ use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType, RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
RuntimeReferralRedeemRecord,
}; };
use serde_json::{Value, json}; use serde_json::{Value, json};
use shared_contracts::runtime::{ use shared_contracts::runtime::{
AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileRedeemCodeRequest,
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse,
ProfileRechargeProductResponse, ProfileReferralInviteCenterResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse, ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse, ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest,
RedeemProfileRewardCodeResponse,
}; };
use spacetime_client::SpacetimeClientError; use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime; use time::OffsetDateTime;
use crate::{ use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, admin::AuthenticatedAdmin, api_response::json_success_body, auth::AuthenticatedAccessToken,
request_context::RequestContext, state::AppState, http_error::AppError, request_context::RequestContext, state::AppState,
}; };
pub async fn get_profile_dashboard( pub async fn get_profile_dashboard(
@@ -118,6 +124,9 @@ fn format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => { RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND
} }
RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD
}
} }
} }
@@ -228,6 +237,99 @@ pub async fn redeem_profile_referral_invite_code(
)) ))
} }
pub async fn redeem_profile_reward_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RedeemProfileRewardCodeRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let redeemed_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.redeem_profile_reward_code(user_id, payload.code, redeemed_at_micros as i64)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_redeem_profile_reward_code_response(record),
))
}
pub async fn admin_upsert_profile_redeem_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertProfileRedeemCodeRequest>,
) -> Result<Json<Value>, Response> {
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let mode = parse_profile_redeem_code_mode(&payload.mode).map_err(|error| {
runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
let record = state
.spacetime_client()
.admin_upsert_profile_redeem_code(
admin.session().subject.clone(),
payload.code,
mode,
payload.reward_points,
payload.max_uses,
payload.enabled,
payload.allowed_user_ids,
payload.allowed_public_user_codes,
updated_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_redeem_code_admin_response(record),
))
}
pub async fn admin_disable_profile_redeem_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminDisableProfileRedeemCodeRequest>,
) -> Result<Json<Value>, Response> {
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.admin_disable_profile_redeem_code(
admin.session().subject.clone(),
payload.code,
updated_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_redeem_code_admin_response(record),
))
}
pub async fn get_profile_play_stats( pub async fn get_profile_play_stats(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
@@ -396,6 +498,49 @@ fn build_redeem_profile_referral_invite_code_response(
} }
} }
fn build_redeem_profile_reward_code_response(
record: RuntimeProfileRewardCodeRedeemRecord,
) -> RedeemProfileRewardCodeResponse {
RedeemProfileRewardCodeResponse {
wallet_balance: record.wallet_balance,
amount_granted: record.amount_granted,
ledger_entry: ProfileWalletLedgerEntryResponse {
id: record.ledger_entry.wallet_ledger_id,
amount_delta: record.ledger_entry.amount_delta,
balance_after: record.ledger_entry.balance_after,
source_type: format_profile_wallet_ledger_source_type(record.ledger_entry.source_type)
.to_string(),
created_at: record.ledger_entry.created_at,
},
}
}
fn parse_profile_redeem_code_mode(raw: &str) -> Result<RuntimeProfileRedeemCodeMode, String> {
match raw.trim().to_ascii_lowercase().as_str() {
"public" => Ok(RuntimeProfileRedeemCodeMode::Public),
"unique" => Ok(RuntimeProfileRedeemCodeMode::Unique),
"private" => Ok(RuntimeProfileRedeemCodeMode::Private),
_ => Err("兑换码类型无效".to_string()),
}
}
fn build_profile_redeem_code_admin_response(
record: RuntimeProfileRedeemCodeRecord,
) -> ProfileRedeemCodeAdminResponse {
ProfileRedeemCodeAdminResponse {
code: record.code,
mode: record.mode.as_str().to_string(),
reward_points: record.reward_points,
max_uses: record.max_uses,
global_used_count: record.global_used_count,
enabled: record.enabled,
allowed_user_ids: record.allowed_user_ids,
created_by: record.created_by,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use module_runtime::RuntimeProfileWalletLedgerSourceType; use module_runtime::RuntimeProfileWalletLedgerSourceType;

View File

@@ -486,6 +486,38 @@ impl PasswordEntryService {
verify_stored_password_user(existing_user, &input.password).await verify_stored_password_user(existing_user, &input.password).await
} }
pub async fn execute_with_dev_registration(
&self,
input: PasswordEntryInput,
) -> Result<PasswordEntryResult, PasswordEntryError> {
validate_password(&input.password)?;
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)
.map_err(|_| PasswordEntryError::InvalidPhoneNumber)?;
if let Some(existing_user) = self
.store
.find_by_phone_number_for_password(&normalized_phone.e164)?
{
return verify_stored_password_user(existing_user, &input.password).await;
}
let password_hash = hash_password(&input.password)
.await
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
let user = self.store.create_dev_password_phone_user(
normalized_phone.clone(),
normalized_phone.masked_national_number,
password_hash,
)?;
Ok(PasswordEntryResult {
user: AuthUser {
login_method: AuthLoginMethod::Password,
..user
},
created: true,
})
}
pub fn get_user_by_id( pub fn get_user_by_id(
&self, &self,
user_id: &str, user_id: &str,
@@ -1336,6 +1368,53 @@ impl InMemoryAuthStore {
Ok(user) Ok(user)
} }
fn create_dev_password_phone_user(
&self,
phone_number: PhoneNumberSnapshot,
display_name: String,
password_hash: String,
) -> Result<AuthUser, PasswordEntryError> {
let mut state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
if state.phone_to_user_id.contains_key(&phone_number.e164) {
return Err(PasswordEntryError::InvalidCredentials);
}
let sequence = state.next_user_id;
let user_id = format!("user_{sequence:08}");
let public_user_code = build_public_user_code(sequence);
state.next_user_id += 1;
let username = build_system_username("phone", state.next_user_id);
let user = AuthUser {
id: user_id.clone(),
public_user_code,
username: username.clone(),
display_name,
phone_number_masked: Some(phone_number.masked_national_number.clone()),
login_method: AuthLoginMethod::Password,
binding_status: AuthBindingStatus::Active,
wechat_bound: false,
token_version: 1,
};
state
.phone_to_user_id
.insert(phone_number.e164.clone(), user_id);
state.users_by_username.insert(
username,
StoredPasswordUser {
user: user.clone(),
password_hash,
password_login_enabled: true,
phone_number: Some(phone_number.e164),
},
);
self.persist_password_state(&state)?;
Ok(user)
}
fn create_pending_wechat_user( fn create_pending_wechat_user(
&self, &self,
profile: WechatIdentityProfile, profile: WechatIdentityProfile,
@@ -2474,6 +2553,39 @@ mod tests {
assert_eq!(error, PasswordEntryError::InvalidCredentials); assert_eq!(error, PasswordEntryError::InvalidCredentials);
} }
#[tokio::test]
async fn password_entry_dev_registration_creates_unknown_phone_user() {
let service = build_password_service(build_store());
let created = service
.execute_with_dev_registration(PasswordEntryInput {
phone_number: "13800138009".to_string(),
password: "secret123".to_string(),
})
.await
.expect("dev registration should create user");
let reused = service
.execute_with_dev_registration(PasswordEntryInput {
phone_number: "13800138009".to_string(),
password: "secret123".to_string(),
})
.await
.expect("same password should reuse created user");
let wrong_password = service
.execute_with_dev_registration(PasswordEntryInput {
phone_number: "13800138009".to_string(),
password: "secret999".to_string(),
})
.await
.expect_err("existing user still requires the right password");
assert!(created.created);
assert_eq!(created.user.login_method, AuthLoginMethod::Password);
assert!(!reused.created);
assert_eq!(created.user.id, reused.user.id);
assert_eq!(wrong_password, PasswordEntryError::InvalidCredentials);
}
#[tokio::test] #[tokio::test]
async fn phone_user_can_set_password_then_login() { async fn phone_user_can_set_password_then_login() {
let store = build_store(); let store = build_store();

View File

@@ -221,6 +221,7 @@ pub struct BigFishWorkSummarySnapshot {
pub level_main_image_ready_count: u32, pub level_main_image_ready_count: u32,
pub level_motion_ready_count: u32, pub level_motion_ready_count: u32,
pub background_ready: bool, pub background_ready: bool,
pub play_count: u32,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -318,11 +319,11 @@ pub struct BigFishPublishInput {
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishPlayReportInput { pub struct BigFishPlayRecordInput {
pub session_id: String, pub session_id: String,
pub user_id: String, pub user_id: String,
pub elapsed_ms: u64, pub elapsed_ms: u64,
pub reported_at_micros: i64, pub played_at_micros: i64,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@@ -663,7 +664,7 @@ pub fn validate_publish_input(input: &BigFishPublishInput) -> Result<(), BigFish
validate_session_owner(&input.session_id, &input.owner_user_id) validate_session_owner(&input.session_id, &input.owner_user_id)
} }
pub fn validate_play_report_input(input: &BigFishPlayReportInput) -> Result<(), BigFishFieldError> { pub fn validate_play_record_input(input: &BigFishPlayRecordInput) -> Result<(), BigFishFieldError> {
if normalize_required_string(&input.session_id).is_none() { if normalize_required_string(&input.session_id).is_none() {
return Err(BigFishFieldError::MissingSessionId); return Err(BigFishFieldError::MissingSessionId);
} }

View File

@@ -1964,13 +1964,17 @@ fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) ->
if current_level.status != PuzzleRuntimeLevelStatus::Cleared && is_cleared { if current_level.status != PuzzleRuntimeLevelStatus::Cleared && is_cleared {
let cleared_at_ms = current_unix_ms(); let cleared_at_ms = current_unix_ms();
current_level.cleared_at_ms = Some(cleared_at_ms); current_level.cleared_at_ms = Some(cleared_at_ms);
current_level.elapsed_ms = current_level.elapsed_ms = Some(
Some(cleared_at_ms.saturating_sub(current_level.started_at_ms).max(1_000)); cleared_at_ms
.saturating_sub(current_level.started_at_ms)
.max(1_000),
);
} }
current_level.status = next_level_status; current_level.status = next_level_status;
} }
if is_cleared && run.current_level.as_ref().map(|level| level.status) if is_cleared
&& run.current_level.as_ref().map(|level| level.status)
!= Some(PuzzleRuntimeLevelStatus::Cleared) != Some(PuzzleRuntimeLevelStatus::Cleared)
{ {
next_run.cleared_level_count += 1; next_run.cleared_level_count += 1;

View File

@@ -261,6 +261,15 @@ pub enum RuntimeProfileWalletLedgerSourceType {
PointsRecharge, PointsRecharge,
AssetGenerationConsume, AssetGenerationConsume,
AssetGenerationRefund, AssetGenerationRefund,
RedeemCodeReward,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeProfileRedeemCodeMode {
Public,
Unique,
Private,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -424,6 +433,75 @@ pub struct RuntimeProfileWalletAdjustmentInput {
pub created_at_micros: i64, pub created_at_micros: i64,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRewardCodeRedeemInput {
pub user_id: String,
pub code: String,
pub redeemed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRewardCodeRedeemSnapshot {
pub wallet_balance: u64,
pub amount_granted: u64,
pub ledger_entry: RuntimeProfileWalletLedgerEntrySnapshot,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRewardCodeRedeemProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileRewardCodeRedeemSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRedeemCodeAdminUpsertInput {
pub admin_user_id: String,
pub code: String,
pub mode: RuntimeProfileRedeemCodeMode,
pub reward_points: u64,
pub max_uses: u32,
pub enabled: bool,
pub allowed_user_ids: Vec<String>,
pub allowed_public_user_codes: Vec<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRedeemCodeAdminDisableInput {
pub admin_user_id: String,
pub code: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRedeemCodeSnapshot {
pub code: String,
pub mode: RuntimeProfileRedeemCodeMode,
pub reward_points: u64,
pub max_uses: u32,
pub global_used_count: u32,
pub enabled: bool,
pub allowed_user_ids: Vec<String>,
pub created_by: String,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRedeemCodeAdminProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileRedeemCodeSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeReferralInviteCenterSnapshot { pub struct RuntimeReferralInviteCenterSnapshot {
@@ -537,6 +615,9 @@ pub enum RuntimeProfileFieldError {
MissingLedgerId, MissingLedgerId,
InvalidWalletAmount, InvalidWalletAmount,
MissingInviteCode, MissingInviteCode,
MissingRedeemCode,
InvalidRedeemCodeReward,
InvalidRedeemCodeMaxUses,
MissingProductId, MissingProductId,
MissingWorldKey, MissingWorldKey,
MissingBottomTab, MissingBottomTab,
@@ -812,6 +893,29 @@ pub struct RuntimeProfileRechargeCenterRecord {
pub has_points_recharged: bool, pub has_points_recharged: bool,
} }
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileRewardCodeRedeemRecord {
pub wallet_balance: u64,
pub amount_granted: u64,
pub ledger_entry: RuntimeProfileWalletLedgerEntryRecord,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileRedeemCodeRecord {
pub code: String,
pub mode: RuntimeProfileRedeemCodeMode,
pub reward_points: u64,
pub max_uses: u32,
pub global_used_count: u32,
pub enabled: bool,
pub allowed_user_ids: Vec<String>,
pub created_by: String,
pub created_at: String,
pub created_at_micros: i64,
pub updated_at: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct RuntimeReferralInviteCenterRecord { pub struct RuntimeReferralInviteCenterRecord {
pub user_id: String, pub user_id: String,
@@ -970,6 +1074,73 @@ pub fn build_runtime_referral_redeem_input(
}) })
} }
pub fn build_runtime_profile_reward_code_redeem_input(
user_id: String,
code: String,
redeemed_at_micros: i64,
) -> Result<RuntimeProfileRewardCodeRedeemInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?;
Ok(RuntimeProfileRewardCodeRedeemInput {
user_id,
code,
redeemed_at_micros,
})
}
pub fn build_runtime_profile_redeem_code_admin_upsert_input(
admin_user_id: String,
code: String,
mode: RuntimeProfileRedeemCodeMode,
reward_points: u64,
max_uses: u32,
enabled: bool,
allowed_user_ids: Vec<String>,
allowed_public_user_codes: Vec<String>,
updated_at_micros: i64,
) -> Result<RuntimeProfileRedeemCodeAdminUpsertInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?;
if reward_points == 0 {
return Err(RuntimeProfileFieldError::InvalidRedeemCodeReward);
}
if max_uses == 0 {
return Err(RuntimeProfileFieldError::InvalidRedeemCodeMaxUses);
}
Ok(RuntimeProfileRedeemCodeAdminUpsertInput {
admin_user_id,
code,
mode,
reward_points,
max_uses,
enabled,
allowed_user_ids: allowed_user_ids
.into_iter()
.filter_map(|value| normalize_optional_string(Some(value)))
.collect(),
allowed_public_user_codes: allowed_public_user_codes
.into_iter()
.filter_map(|value| normalize_optional_string(Some(value)))
.collect(),
updated_at_micros,
})
}
pub fn build_runtime_profile_redeem_code_admin_disable_input(
admin_user_id: String,
code: String,
updated_at_micros: i64,
) -> Result<RuntimeProfileRedeemCodeAdminDisableInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?;
Ok(RuntimeProfileRedeemCodeAdminDisableInput {
admin_user_id,
code,
updated_at_micros,
})
}
pub fn build_runtime_profile_play_stats_get_input( pub fn build_runtime_profile_play_stats_get_input(
user_id: String, user_id: String,
) -> Result<RuntimeProfilePlayStatsGetInput, RuntimeProfileFieldError> { ) -> Result<RuntimeProfilePlayStatsGetInput, RuntimeProfileFieldError> {
@@ -1323,6 +1494,35 @@ pub fn build_runtime_referral_redeem_record(
} }
} }
pub fn build_runtime_profile_reward_code_redeem_record(
snapshot: RuntimeProfileRewardCodeRedeemSnapshot,
) -> RuntimeProfileRewardCodeRedeemRecord {
RuntimeProfileRewardCodeRedeemRecord {
wallet_balance: snapshot.wallet_balance,
amount_granted: snapshot.amount_granted,
ledger_entry: build_runtime_profile_wallet_ledger_entry_record(snapshot.ledger_entry),
}
}
pub fn build_runtime_profile_redeem_code_record(
snapshot: RuntimeProfileRedeemCodeSnapshot,
) -> RuntimeProfileRedeemCodeRecord {
RuntimeProfileRedeemCodeRecord {
code: snapshot.code,
mode: snapshot.mode,
reward_points: snapshot.reward_points,
max_uses: snapshot.max_uses,
global_used_count: snapshot.global_used_count,
enabled: snapshot.enabled,
allowed_user_ids: snapshot.allowed_user_ids,
created_by: snapshot.created_by,
created_at: format_utc_micros(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_played_world_record( pub fn build_runtime_profile_played_world_record(
snapshot: RuntimeProfilePlayedWorldSnapshot, snapshot: RuntimeProfilePlayedWorldSnapshot,
) -> RuntimeProfilePlayedWorldRecord { ) -> RuntimeProfilePlayedWorldRecord {
@@ -1508,6 +1708,17 @@ impl RuntimeProfileWalletLedgerSourceType {
Self::PointsRecharge => "points_recharge", Self::PointsRecharge => "points_recharge",
Self::AssetGenerationConsume => "asset_generation_consume", Self::AssetGenerationConsume => "asset_generation_consume",
Self::AssetGenerationRefund => "asset_generation_refund", Self::AssetGenerationRefund => "asset_generation_refund",
Self::RedeemCodeReward => "redeem_code_reward",
}
}
}
impl RuntimeProfileRedeemCodeMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Public => "public",
Self::Unique => "unique",
Self::Private => "private",
} }
} }
} }
@@ -1736,6 +1947,10 @@ pub fn normalize_invite_code(value: String) -> Option<String> {
} }
} }
pub fn normalize_redeem_code(value: String) -> Option<String> {
normalize_invite_code(value)
}
impl std::fmt::Display for RuntimeProfileFieldError { impl std::fmt::Display for RuntimeProfileFieldError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
@@ -1743,6 +1958,9 @@ impl std::fmt::Display for RuntimeProfileFieldError {
Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"), Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"),
Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"), Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"),
Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"), Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"),
Self::MissingRedeemCode => f.write_str("兑换码不能为空"),
Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"),
Self::InvalidRedeemCodeMaxUses => f.write_str("兑换次数必须大于 0"),
Self::MissingProductId => f.write_str("recharge.product_id 不能为空"), Self::MissingProductId => f.write_str("recharge.product_id 不能为空"),
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"), Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"), Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"),

View File

@@ -18,6 +18,8 @@ pub struct BigFishWorkSummaryResponse {
pub level_main_image_ready_count: u32, pub level_main_image_ready_count: u32,
pub level_motion_ready_count: u32, pub level_motion_ready_count: u32,
pub background_ready: bool, pub background_ready: bool,
#[serde(default)]
pub play_count: u32,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]

View File

@@ -11,6 +11,7 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME: &str =
"asset_generation_consume"; "asset_generation_consume";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND: &str = pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND: &str =
"asset_generation_refund"; "asset_generation_refund";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD: &str = "redeem_code_reward";
pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial"; pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial";
pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane"; pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane";
pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina"; pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina";
@@ -258,6 +259,60 @@ pub struct RedeemProfileReferralInviteCodeResponse {
pub inviter_balance_after: u64, pub inviter_balance_after: u64,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RedeemProfileRewardCodeRequest {
pub code: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RedeemProfileRewardCodeResponse {
pub wallet_balance: u64,
pub amount_granted: u64,
pub ledger_entry: ProfileWalletLedgerEntryResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminUpsertProfileRedeemCodeRequest {
pub code: String,
pub mode: String,
pub reward_points: u64,
pub max_uses: u32,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub allowed_user_ids: Vec<String>,
#[serde(default)]
pub allowed_public_user_codes: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDisableProfileRedeemCodeRequest {
pub code: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileRedeemCodeAdminResponse {
pub code: String,
pub mode: String,
pub reward_points: u64,
pub max_uses: u32,
pub global_used_count: u32,
pub enabled: bool,
pub allowed_user_ids: Vec<String>,
pub created_by: String,
pub created_at: String,
pub updated_at: String,
}
fn default_true() -> bool {
true
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ProfilePlayedWorkSummaryResponse { pub struct ProfilePlayedWorkSummaryResponse {

View File

@@ -1,6 +1,7 @@
use super::*; use super::*;
use crate::mapper::*; use crate::mapper::*;
use crate::module_bindings::delete_big_fish_work_procedure::delete_big_fish_work; use crate::module_bindings::delete_big_fish_work_procedure::delete_big_fish_work;
use crate::module_bindings::record_big_fish_play_procedure::record_big_fish_play;
impl SpacetimeClient { impl SpacetimeClient {
pub async fn create_big_fish_session( pub async fn create_big_fish_session(
@@ -80,12 +81,22 @@ impl SpacetimeClient {
procedure_input: BigFishWorksListInput, procedure_input: BigFishWorksListInput,
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> { ) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
self.call_after_connect(move |connection, sender| { self.call_after_connect(move |connection, sender| {
let fallback_owner_user_id = if procedure_input.published_only {
None
} else {
Some(procedure_input.owner_user_id.clone())
};
connection connection
.procedures() .procedures()
.list_big_fish_works_then(procedure_input, move |_, result| { .list_big_fish_works_then(procedure_input, move |_, result| {
let mapped = result let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_big_fish_works_procedure_result); .and_then(|result| {
map_big_fish_works_procedure_result(
result,
fallback_owner_user_id.as_deref(),
)
});
send_once(&sender, mapped); send_once(&sender, mapped);
}); });
}) })
@@ -103,12 +114,18 @@ impl SpacetimeClient {
}; };
self.call_after_connect(move |connection, sender| { self.call_after_connect(move |connection, sender| {
let fallback_owner_user_id = Some(procedure_input.owner_user_id.clone());
connection connection
.procedures() .procedures()
.delete_big_fish_work_then(procedure_input, move |_, result| { .delete_big_fish_work_then(procedure_input, move |_, result| {
let mapped = result let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_big_fish_works_procedure_result); .and_then(|result| {
map_big_fish_works_procedure_result(
result,
fallback_owner_user_id.as_deref(),
)
});
send_once(&sender, mapped); send_once(&sender, mapped);
}); });
}) })
@@ -254,12 +271,12 @@ impl SpacetimeClient {
pub async fn record_big_fish_play( pub async fn record_big_fish_play(
&self, &self,
input: BigFishPlayReportRecordInput, input: BigFishPlayReportRecordInput,
) -> Result<BigFishSessionRecord, SpacetimeClientError> { ) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
let procedure_input = BigFishPlayReportInput { let procedure_input = BigFishPlayRecordInput {
session_id: input.session_id, session_id: input.session_id,
user_id: input.user_id, user_id: input.user_id,
elapsed_ms: input.elapsed_ms, elapsed_ms: input.elapsed_ms,
reported_at_micros: input.reported_at_micros, played_at_micros: input.reported_at_micros,
}; };
self.call_after_connect(move |connection, sender| { self.call_after_connect(move |connection, sender| {
@@ -268,7 +285,7 @@ impl SpacetimeClient {
.record_big_fish_play_then(procedure_input, move |_, result| { .record_big_fish_play_then(procedure_input, move |_, result| {
let mapped = result let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_big_fish_session_procedure_result); .and_then(|result| map_big_fish_works_procedure_result(result, None));
send_once(&sender, mapped); send_once(&sender, mapped);
}); });
}) })

View File

@@ -121,6 +121,8 @@ use module_runtime::{
RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme, RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme,
RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord, RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
RuntimeProfileSaveArchiveRecord, RuntimeProfileWalletLedgerEntryRecord, RuntimeProfileSaveArchiveRecord, RuntimeProfileWalletLedgerEntryRecord,
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeSettingsRecord, RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeSettingsRecord,
RuntimeSnapshotRecord, build_runtime_browse_history_clear_input, RuntimeSnapshotRecord, build_runtime_browse_history_clear_input,
@@ -130,8 +132,12 @@ use module_runtime::{
build_runtime_profile_play_stats_record, build_runtime_profile_recharge_center_get_input, build_runtime_profile_play_stats_record, build_runtime_profile_recharge_center_get_input,
build_runtime_profile_recharge_center_record, build_runtime_profile_recharge_center_record,
build_runtime_profile_recharge_order_create_input, build_runtime_profile_recharge_order_create_input,
build_runtime_profile_save_archive_list_input, build_runtime_profile_save_archive_record, build_runtime_profile_redeem_code_admin_disable_input,
build_runtime_profile_save_archive_resume_input, build_runtime_profile_wallet_adjustment_input, build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record,
build_runtime_profile_reward_code_redeem_input,
build_runtime_profile_reward_code_redeem_record, build_runtime_profile_save_archive_list_input,
build_runtime_profile_save_archive_record, build_runtime_profile_save_archive_resume_input,
build_runtime_profile_wallet_adjustment_input,
build_runtime_profile_wallet_ledger_entry_record, build_runtime_profile_wallet_ledger_entry_record,
build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input, build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input,
build_runtime_referral_invite_center_record, build_runtime_referral_redeem_input, build_runtime_referral_invite_center_record, build_runtime_referral_redeem_input,

View File

@@ -161,6 +161,48 @@ impl From<module_runtime::RuntimeProfileRechargeOrderCreateInput>
} }
} }
impl From<module_runtime::RuntimeProfileRewardCodeRedeemInput>
for RuntimeProfileRewardCodeRedeemInput
{
fn from(input: module_runtime::RuntimeProfileRewardCodeRedeemInput) -> Self {
Self {
user_id: input.user_id,
code: input.code,
redeemed_at_micros: input.redeemed_at_micros,
}
}
}
impl From<module_runtime::RuntimeProfileRedeemCodeAdminUpsertInput>
for RuntimeProfileRedeemCodeAdminUpsertInput
{
fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminUpsertInput) -> Self {
Self {
admin_user_id: input.admin_user_id,
code: input.code,
mode: map_runtime_profile_redeem_code_mode(input.mode),
reward_points: input.reward_points,
max_uses: input.max_uses,
enabled: input.enabled,
allowed_user_ids: input.allowed_user_ids,
allowed_public_user_codes: input.allowed_public_user_codes,
updated_at_micros: input.updated_at_micros,
}
}
}
impl From<module_runtime::RuntimeProfileRedeemCodeAdminDisableInput>
for RuntimeProfileRedeemCodeAdminDisableInput
{
fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminDisableInput) -> Self {
Self {
admin_user_id: input.admin_user_id,
code: input.code,
updated_at_micros: input.updated_at_micros,
}
}
}
impl From<module_runtime::RuntimeReferralInviteCenterGetInput> impl From<module_runtime::RuntimeReferralInviteCenterGetInput>
for RuntimeReferralInviteCenterGetInput for RuntimeReferralInviteCenterGetInput
{ {
@@ -802,6 +844,48 @@ pub(crate) fn map_runtime_referral_redeem_procedure_result(
)) ))
} }
pub(crate) fn map_runtime_profile_reward_code_redeem_procedure_result(
result: RuntimeProfileRewardCodeRedeemProcedureResult,
) -> Result<RuntimeProfileRewardCodeRedeemRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let snapshot = result.record.ok_or_else(|| {
SpacetimeClientError::Procedure(
"SpacetimeDB procedure 未返回 reward redeem 快照".to_string(),
)
})?;
Ok(build_runtime_profile_reward_code_redeem_record(
map_runtime_profile_reward_code_redeem_snapshot(snapshot),
))
}
pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result(
result: RuntimeProfileRedeemCodeAdminProcedureResult,
) -> Result<RuntimeProfileRedeemCodeRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let snapshot = result.record.ok_or_else(|| {
SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 redeem code 快照".to_string())
})?;
Ok(build_runtime_profile_redeem_code_record(
map_runtime_profile_redeem_code_snapshot(snapshot),
))
}
pub(crate) fn map_runtime_profile_play_stats_procedure_result( pub(crate) fn map_runtime_profile_play_stats_procedure_result(
result: RuntimeProfilePlayStatsProcedureResult, result: RuntimeProfilePlayStatsProcedureResult,
) -> Result<RuntimeProfilePlayStatsRecord, SpacetimeClientError> { ) -> Result<RuntimeProfilePlayStatsRecord, SpacetimeClientError> {
@@ -1278,6 +1362,7 @@ pub(crate) fn map_big_fish_session_procedure_result(
pub(crate) fn map_big_fish_works_procedure_result( pub(crate) fn map_big_fish_works_procedure_result(
result: BigFishWorksProcedureResult, result: BigFishWorksProcedureResult,
fallback_owner_user_id: Option<&str>,
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> { ) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
if !result.ok { if !result.ok {
return Err(SpacetimeClientError::Procedure( return Err(SpacetimeClientError::Procedure(
@@ -1292,9 +1377,15 @@ pub(crate) fn map_big_fish_works_procedure_result(
"SpacetimeDB procedure 未返回 big fish works 快照".to_string(), "SpacetimeDB procedure 未返回 big fish works 快照".to_string(),
) )
})?; })?;
serde_json::from_str::<Vec<BigFishWorkSummaryRecord>>(&items_json).map_err(|error| { let items = serde_json::from_str::<Vec<CompatibleBigFishWorkSummaryRecord>>(&items_json)
.map_err(|error| {
SpacetimeClientError::Runtime(format!("big fish works items_json 非法: {error}")) SpacetimeClientError::Runtime(format!("big fish works items_json 非法: {error}"))
}) })?;
Ok(items
.into_iter()
.map(|item| item.into_record(fallback_owner_user_id))
.collect())
} }
pub(crate) fn map_story_session_procedure_result( pub(crate) fn map_story_session_procedure_result(
@@ -1666,6 +1757,33 @@ pub(crate) fn map_runtime_referral_redeem_snapshot(
} }
} }
pub(crate) fn map_runtime_profile_reward_code_redeem_snapshot(
snapshot: RuntimeProfileRewardCodeRedeemSnapshot,
) -> module_runtime::RuntimeProfileRewardCodeRedeemSnapshot {
module_runtime::RuntimeProfileRewardCodeRedeemSnapshot {
wallet_balance: snapshot.wallet_balance,
amount_granted: snapshot.amount_granted,
ledger_entry: map_runtime_profile_wallet_ledger_entry_snapshot(snapshot.ledger_entry),
}
}
pub(crate) fn map_runtime_profile_redeem_code_snapshot(
snapshot: RuntimeProfileRedeemCodeSnapshot,
) -> module_runtime::RuntimeProfileRedeemCodeSnapshot {
module_runtime::RuntimeProfileRedeemCodeSnapshot {
code: snapshot.code,
mode: map_runtime_profile_redeem_code_mode_back(snapshot.mode),
reward_points: snapshot.reward_points,
max_uses: snapshot.max_uses,
global_used_count: snapshot.global_used_count,
enabled: snapshot.enabled,
allowed_user_ids: snapshot.allowed_user_ids,
created_by: snapshot.created_by,
created_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
}
pub(crate) fn map_runtime_profile_played_world_snapshot( pub(crate) fn map_runtime_profile_played_world_snapshot(
snapshot: RuntimeProfilePlayedWorldSnapshot, snapshot: RuntimeProfilePlayedWorldSnapshot,
) -> module_runtime::RuntimeProfilePlayedWorldSnapshot { ) -> module_runtime::RuntimeProfilePlayedWorldSnapshot {
@@ -3277,6 +3395,41 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back(
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => { crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => {
module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund module_runtime::RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund
} }
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => {
module_runtime::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward
}
}
}
pub(crate) fn map_runtime_profile_redeem_code_mode(
value: module_runtime::RuntimeProfileRedeemCodeMode,
) -> crate::module_bindings::RuntimeProfileRedeemCodeMode {
match value {
module_runtime::RuntimeProfileRedeemCodeMode::Public => {
crate::module_bindings::RuntimeProfileRedeemCodeMode::Public
}
module_runtime::RuntimeProfileRedeemCodeMode::Unique => {
crate::module_bindings::RuntimeProfileRedeemCodeMode::Unique
}
module_runtime::RuntimeProfileRedeemCodeMode::Private => {
crate::module_bindings::RuntimeProfileRedeemCodeMode::Private
}
}
}
pub(crate) fn map_runtime_profile_redeem_code_mode_back(
value: crate::module_bindings::RuntimeProfileRedeemCodeMode,
) -> module_runtime::RuntimeProfileRedeemCodeMode {
match value {
crate::module_bindings::RuntimeProfileRedeemCodeMode::Public => {
module_runtime::RuntimeProfileRedeemCodeMode::Public
}
crate::module_bindings::RuntimeProfileRedeemCodeMode::Unique => {
module_runtime::RuntimeProfileRedeemCodeMode::Unique
}
crate::module_bindings::RuntimeProfileRedeemCodeMode::Private => {
module_runtime::RuntimeProfileRedeemCodeMode::Private
}
} }
} }
@@ -4607,6 +4760,124 @@ pub struct BigFishWorkSummaryRecord {
pub level_main_image_ready_count: u32, pub level_main_image_ready_count: u32,
pub level_motion_ready_count: u32, pub level_motion_ready_count: u32,
pub background_ready: bool, pub background_ready: bool,
pub play_count: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
struct CompatibleBigFishWorkSummaryRecord {
work_id: String,
source_session_id: String,
#[serde(default)]
owner_user_id: Option<String>,
title: String,
subtitle: String,
summary: String,
cover_image_src: Option<String>,
status: String,
updated_at_micros: i64,
publish_ready: bool,
level_count: u32,
level_main_image_ready_count: u32,
level_motion_ready_count: u32,
background_ready: bool,
#[serde(default)]
play_count: u32,
}
impl CompatibleBigFishWorkSummaryRecord {
fn into_record(self, fallback_owner_user_id: Option<&str>) -> BigFishWorkSummaryRecord {
BigFishWorkSummaryRecord {
work_id: self.work_id,
source_session_id: self.source_session_id,
// 中文注释:兼容旧 works JSON 没有 owner_user_id 的历史数据,避免一次字段升级把整个作品列表打崩。
owner_user_id: self.owner_user_id.unwrap_or_else(|| {
fallback_owner_user_id
.map(str::to_string)
.unwrap_or_default()
}),
title: self.title,
subtitle: self.subtitle,
summary: self.summary,
cover_image_src: self.cover_image_src,
status: self.status,
updated_at_micros: self.updated_at_micros,
publish_ready: self.publish_ready,
level_count: self.level_count,
level_main_image_ready_count: self.level_main_image_ready_count,
level_motion_ready_count: self.level_motion_ready_count,
background_ready: self.background_ready,
play_count: self.play_count,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn big_fish_works_mapper_backfills_missing_owner_user_id_for_private_lists() {
let result = BigFishWorksProcedureResult {
ok: true,
items_json: Some(
r#"[{
"work_id":"big-fish-work-session-1",
"source_session_id":"session-1",
"title":"深海草稿",
"subtitle":"副标题",
"summary":"摘要",
"cover_image_src":null,
"status":"draft",
"updated_at_micros":123,
"publish_ready":false,
"level_count":8,
"level_main_image_ready_count":0,
"level_motion_ready_count":0,
"background_ready":false
}]"#
.to_string(),
),
error_message: None,
};
let items = map_big_fish_works_procedure_result(result, Some("user-1"))
.expect("旧 works JSON 应能被兼容解析");
assert_eq!(items.len(), 1);
assert_eq!(items[0].owner_user_id, "user-1");
}
#[test]
fn big_fish_works_mapper_keeps_empty_owner_when_gallery_legacy_json_lacks_field() {
let result = BigFishWorksProcedureResult {
ok: true,
items_json: Some(
r#"[{
"work_id":"big-fish-work-session-2",
"source_session_id":"session-2",
"title":"公开作品",
"subtitle":"副标题",
"summary":"摘要",
"cover_image_src":null,
"status":"published",
"updated_at_micros":456,
"publish_ready":true,
"level_count":8,
"level_main_image_ready_count":8,
"level_motion_ready_count":16,
"background_ready":true
}]"#
.to_string(),
),
error_message: None,
};
let items = map_big_fish_works_procedure_result(result, None)
.expect("公开 works 旧 JSON 也不应因缺字段报错");
assert_eq!(items.len(), 1);
assert!(items[0].owner_user_id.is_empty());
}
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]

View File

@@ -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::runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput;
use super::runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AdminDisableProfileRedeemCodeArgs {
pub input: RuntimeProfileRedeemCodeAdminDisableInput,
}
impl __sdk::InModule for AdminDisableProfileRedeemCodeArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `admin_disable_profile_redeem_code`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait admin_disable_profile_redeem_code {
fn admin_disable_profile_redeem_code(&self, input: RuntimeProfileRedeemCodeAdminDisableInput) {
self.admin_disable_profile_redeem_code_then(input, |_, _| {});
}
fn admin_disable_profile_redeem_code_then(
&self,
input: RuntimeProfileRedeemCodeAdminDisableInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl admin_disable_profile_redeem_code for super::RemoteProcedures {
fn admin_disable_profile_redeem_code_then(
&self,
input: RuntimeProfileRedeemCodeAdminDisableInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>(
"admin_disable_profile_redeem_code",
AdminDisableProfileRedeemCodeArgs { input },
__callback,
);
}
}

View File

@@ -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::runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult;
use super::runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AdminUpsertProfileRedeemCodeArgs {
pub input: RuntimeProfileRedeemCodeAdminUpsertInput,
}
impl __sdk::InModule for AdminUpsertProfileRedeemCodeArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `admin_upsert_profile_redeem_code`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait admin_upsert_profile_redeem_code {
fn admin_upsert_profile_redeem_code(&self, input: RuntimeProfileRedeemCodeAdminUpsertInput) {
self.admin_upsert_profile_redeem_code_then(input, |_, _| {});
}
fn admin_upsert_profile_redeem_code_then(
&self,
input: RuntimeProfileRedeemCodeAdminUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl admin_upsert_profile_redeem_code for super::RemoteProcedures {
fn admin_upsert_profile_redeem_code_then(
&self,
input: RuntimeProfileRedeemCodeAdminUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>(
"admin_upsert_profile_redeem_code",
AdminUpsertProfileRedeemCodeArgs { input },
__callback,
);
}
}

View File

@@ -20,6 +20,7 @@ pub struct BigFishCreationSession {
pub asset_coverage_json: String, pub asset_coverage_json: String,
pub last_assistant_reply: Option<String>, pub last_assistant_reply: Option<String>,
pub publish_ready: bool, pub publish_ready: bool,
pub play_count: u32,
pub created_at: __sdk::Timestamp, pub created_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp,
} }
@@ -43,6 +44,7 @@ pub struct BigFishCreationSessionCols {
pub asset_coverage_json: __sdk::__query_builder::Col<BigFishCreationSession, String>, pub asset_coverage_json: __sdk::__query_builder::Col<BigFishCreationSession, String>,
pub last_assistant_reply: __sdk::__query_builder::Col<BigFishCreationSession, Option<String>>, pub last_assistant_reply: __sdk::__query_builder::Col<BigFishCreationSession, Option<String>>,
pub publish_ready: __sdk::__query_builder::Col<BigFishCreationSession, bool>, pub publish_ready: __sdk::__query_builder::Col<BigFishCreationSession, bool>,
pub play_count: __sdk::__query_builder::Col<BigFishCreationSession, u32>,
pub created_at: __sdk::__query_builder::Col<BigFishCreationSession, __sdk::Timestamp>, pub created_at: __sdk::__query_builder::Col<BigFishCreationSession, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<BigFishCreationSession, __sdk::Timestamp>, pub updated_at: __sdk::__query_builder::Col<BigFishCreationSession, __sdk::Timestamp>,
} }
@@ -68,6 +70,7 @@ impl __sdk::__query_builder::HasCols for BigFishCreationSession {
"last_assistant_reply", "last_assistant_reply",
), ),
publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"), publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"),
play_count: __sdk::__query_builder::Col::new(table_name, "play_count"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
} }

View File

@@ -6,13 +6,13 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)] #[sats(crate = __lib)]
pub struct BigFishPlayReportInput { pub struct BigFishPlayRecordInput {
pub session_id: String, pub session_id: String,
pub user_id: String, pub user_id: String,
pub elapsed_ms: u64, pub elapsed_ms: u64,
pub reported_at_micros: i64, pub played_at_micros: i64,
} }
impl __sdk::InModule for BigFishPlayReportInput { impl __sdk::InModule for BigFishPlayRecordInput {
type Module = super::RemoteModule; type Module = super::RemoteModule;
} }

View File

@@ -8,6 +8,8 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
pub mod accept_quest_reducer; pub mod accept_quest_reducer;
pub mod acknowledge_quest_completion_reducer; pub mod acknowledge_quest_completion_reducer;
pub mod admin_disable_profile_redeem_code_procedure;
pub mod admin_upsert_profile_redeem_code_procedure;
pub mod advance_puzzle_next_level_procedure; pub mod advance_puzzle_next_level_procedure;
pub mod ai_result_reference_input_type; pub mod ai_result_reference_input_type;
pub mod ai_result_reference_kind_type; pub mod ai_result_reference_kind_type;
@@ -89,7 +91,7 @@ pub mod big_fish_game_draft_type;
pub mod big_fish_level_blueprint_type; pub mod big_fish_level_blueprint_type;
pub mod big_fish_message_finalize_input_type; pub mod big_fish_message_finalize_input_type;
pub mod big_fish_message_submit_input_type; pub mod big_fish_message_submit_input_type;
pub mod big_fish_play_report_input_type; pub mod big_fish_play_record_input_type;
pub mod big_fish_publish_input_type; pub mod big_fish_publish_input_type;
pub mod big_fish_runtime_params_type; pub mod big_fish_runtime_params_type;
pub mod big_fish_session_create_input_type; pub mod big_fish_session_create_input_type;
@@ -278,6 +280,8 @@ pub mod profile_invite_code_type;
pub mod profile_membership_type; pub mod profile_membership_type;
pub mod profile_played_world_type; pub mod profile_played_world_type;
pub mod profile_recharge_order_type; pub mod profile_recharge_order_type;
pub mod profile_redeem_code_type;
pub mod profile_redeem_code_usage_type;
pub mod profile_referral_relation_type; pub mod profile_referral_relation_type;
pub mod profile_save_archive_type; pub mod profile_save_archive_type;
pub mod profile_wallet_ledger_type; pub mod profile_wallet_ledger_type;
@@ -346,6 +350,7 @@ pub mod quest_treasure_inspected_signal_type;
pub mod quest_turn_in_input_type; pub mod quest_turn_in_input_type;
pub mod record_big_fish_play_procedure; pub mod record_big_fish_play_procedure;
pub mod redeem_profile_referral_invite_code_procedure; pub mod redeem_profile_referral_invite_code_procedure;
pub mod redeem_profile_reward_code_procedure;
pub mod refresh_session_type; pub mod refresh_session_type;
pub mod refund_profile_wallet_points_and_return_procedure; pub mod refund_profile_wallet_points_and_return_procedure;
pub mod resolve_combat_action_and_return_procedure; pub mod resolve_combat_action_and_return_procedure;
@@ -405,6 +410,14 @@ pub mod runtime_profile_recharge_order_snapshot_type;
pub mod runtime_profile_recharge_order_status_type; pub mod runtime_profile_recharge_order_status_type;
pub mod runtime_profile_recharge_product_kind_type; pub mod runtime_profile_recharge_product_kind_type;
pub mod runtime_profile_recharge_product_snapshot_type; pub mod runtime_profile_recharge_product_snapshot_type;
pub mod runtime_profile_redeem_code_admin_disable_input_type;
pub mod runtime_profile_redeem_code_admin_procedure_result_type;
pub mod runtime_profile_redeem_code_admin_upsert_input_type;
pub mod runtime_profile_redeem_code_mode_type;
pub mod runtime_profile_redeem_code_snapshot_type;
pub mod runtime_profile_reward_code_redeem_input_type;
pub mod runtime_profile_reward_code_redeem_procedure_result_type;
pub mod runtime_profile_reward_code_redeem_snapshot_type;
pub mod runtime_profile_save_archive_list_input_type; pub mod runtime_profile_save_archive_list_input_type;
pub mod runtime_profile_save_archive_procedure_result_type; pub mod runtime_profile_save_archive_procedure_result_type;
pub mod runtime_profile_save_archive_resume_input_type; pub mod runtime_profile_save_archive_resume_input_type;
@@ -479,6 +492,8 @@ pub mod user_browse_history_type;
pub use accept_quest_reducer::accept_quest; pub use accept_quest_reducer::accept_quest;
pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion; pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion;
pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code;
pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code;
pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level; pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level;
pub use ai_result_reference_input_type::AiResultReferenceInput; pub use ai_result_reference_input_type::AiResultReferenceInput;
pub use ai_result_reference_kind_type::AiResultReferenceKind; pub use ai_result_reference_kind_type::AiResultReferenceKind;
@@ -560,7 +575,7 @@ pub use big_fish_game_draft_type::BigFishGameDraft;
pub use big_fish_level_blueprint_type::BigFishLevelBlueprint; pub use big_fish_level_blueprint_type::BigFishLevelBlueprint;
pub use big_fish_message_finalize_input_type::BigFishMessageFinalizeInput; pub use big_fish_message_finalize_input_type::BigFishMessageFinalizeInput;
pub use big_fish_message_submit_input_type::BigFishMessageSubmitInput; pub use big_fish_message_submit_input_type::BigFishMessageSubmitInput;
pub use big_fish_play_report_input_type::BigFishPlayReportInput; pub use big_fish_play_record_input_type::BigFishPlayRecordInput;
pub use big_fish_publish_input_type::BigFishPublishInput; pub use big_fish_publish_input_type::BigFishPublishInput;
pub use big_fish_runtime_params_type::BigFishRuntimeParams; pub use big_fish_runtime_params_type::BigFishRuntimeParams;
pub use big_fish_session_create_input_type::BigFishSessionCreateInput; pub use big_fish_session_create_input_type::BigFishSessionCreateInput;
@@ -749,6 +764,8 @@ pub use profile_invite_code_type::ProfileInviteCode;
pub use profile_membership_type::ProfileMembership; pub use profile_membership_type::ProfileMembership;
pub use profile_played_world_type::ProfilePlayedWorld; pub use profile_played_world_type::ProfilePlayedWorld;
pub use profile_recharge_order_type::ProfileRechargeOrder; pub use profile_recharge_order_type::ProfileRechargeOrder;
pub use profile_redeem_code_type::ProfileRedeemCode;
pub use profile_redeem_code_usage_type::ProfileRedeemCodeUsage;
pub use profile_referral_relation_type::ProfileReferralRelation; pub use profile_referral_relation_type::ProfileReferralRelation;
pub use profile_save_archive_type::ProfileSaveArchive; pub use profile_save_archive_type::ProfileSaveArchive;
pub use profile_wallet_ledger_type::ProfileWalletLedger; pub use profile_wallet_ledger_type::ProfileWalletLedger;
@@ -817,6 +834,7 @@ pub use quest_treasure_inspected_signal_type::QuestTreasureInspectedSignal;
pub use quest_turn_in_input_type::QuestTurnInInput; pub use quest_turn_in_input_type::QuestTurnInInput;
pub use record_big_fish_play_procedure::record_big_fish_play; pub use record_big_fish_play_procedure::record_big_fish_play;
pub use redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_code; pub use redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_code;
pub use redeem_profile_reward_code_procedure::redeem_profile_reward_code;
pub use refresh_session_type::RefreshSession; pub use refresh_session_type::RefreshSession;
pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return; pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return;
pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return; pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return;
@@ -876,6 +894,14 @@ pub use runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrde
pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus; pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus;
pub use runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; pub use runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind;
pub use runtime_profile_recharge_product_snapshot_type::RuntimeProfileRechargeProductSnapshot; pub use runtime_profile_recharge_product_snapshot_type::RuntimeProfileRechargeProductSnapshot;
pub use runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput;
pub use runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult;
pub use runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput;
pub use runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode;
pub use runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot;
pub use runtime_profile_reward_code_redeem_input_type::RuntimeProfileRewardCodeRedeemInput;
pub use runtime_profile_reward_code_redeem_procedure_result_type::RuntimeProfileRewardCodeRedeemProcedureResult;
pub use runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot;
pub use runtime_profile_save_archive_list_input_type::RuntimeProfileSaveArchiveListInput; pub use runtime_profile_save_archive_list_input_type::RuntimeProfileSaveArchiveListInput;
pub use runtime_profile_save_archive_procedure_result_type::RuntimeProfileSaveArchiveProcedureResult; pub use runtime_profile_save_archive_procedure_result_type::RuntimeProfileSaveArchiveProcedureResult;
pub use runtime_profile_save_archive_resume_input_type::RuntimeProfileSaveArchiveResumeInput; pub use runtime_profile_save_archive_resume_input_type::RuntimeProfileSaveArchiveResumeInput;

View File

@@ -0,0 +1,78 @@
// 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::runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct ProfileRedeemCode {
pub code: String,
pub mode: RuntimeProfileRedeemCodeMode,
pub reward_points: u64,
pub max_uses: u32,
pub global_used_count: u32,
pub enabled: bool,
pub allowed_user_ids: Vec<String>,
pub created_by: String,
pub created_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for ProfileRedeemCode {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `ProfileRedeemCode`.
///
/// Provides typed access to columns for query building.
pub struct ProfileRedeemCodeCols {
pub code: __sdk::__query_builder::Col<ProfileRedeemCode, String>,
pub mode: __sdk::__query_builder::Col<ProfileRedeemCode, RuntimeProfileRedeemCodeMode>,
pub reward_points: __sdk::__query_builder::Col<ProfileRedeemCode, u64>,
pub max_uses: __sdk::__query_builder::Col<ProfileRedeemCode, u32>,
pub global_used_count: __sdk::__query_builder::Col<ProfileRedeemCode, u32>,
pub enabled: __sdk::__query_builder::Col<ProfileRedeemCode, bool>,
pub allowed_user_ids: __sdk::__query_builder::Col<ProfileRedeemCode, Vec<String>>,
pub created_by: __sdk::__query_builder::Col<ProfileRedeemCode, String>,
pub created_at: __sdk::__query_builder::Col<ProfileRedeemCode, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<ProfileRedeemCode, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for ProfileRedeemCode {
type Cols = ProfileRedeemCodeCols;
fn cols(table_name: &'static str) -> Self::Cols {
ProfileRedeemCodeCols {
code: __sdk::__query_builder::Col::new(table_name, "code"),
mode: __sdk::__query_builder::Col::new(table_name, "mode"),
reward_points: __sdk::__query_builder::Col::new(table_name, "reward_points"),
max_uses: __sdk::__query_builder::Col::new(table_name, "max_uses"),
global_used_count: __sdk::__query_builder::Col::new(table_name, "global_used_count"),
enabled: __sdk::__query_builder::Col::new(table_name, "enabled"),
allowed_user_ids: __sdk::__query_builder::Col::new(table_name, "allowed_user_ids"),
created_by: __sdk::__query_builder::Col::new(table_name, "created_by"),
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 `ProfileRedeemCode`.
///
/// Provides typed access to indexed columns for query building.
pub struct ProfileRedeemCodeIxCols {
pub code: __sdk::__query_builder::IxCol<ProfileRedeemCode, String>,
}
impl __sdk::__query_builder::HasIxCols for ProfileRedeemCode {
type IxCols = ProfileRedeemCodeIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
ProfileRedeemCodeIxCols {
code: __sdk::__query_builder::IxCol::new(table_name, "code"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for ProfileRedeemCode {}

View File

@@ -0,0 +1,65 @@
// 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 ProfileRedeemCodeUsage {
pub usage_id: String,
pub code: String,
pub user_id: String,
pub amount_granted: u64,
pub created_at: __sdk::Timestamp,
}
impl __sdk::InModule for ProfileRedeemCodeUsage {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `ProfileRedeemCodeUsage`.
///
/// Provides typed access to columns for query building.
pub struct ProfileRedeemCodeUsageCols {
pub usage_id: __sdk::__query_builder::Col<ProfileRedeemCodeUsage, String>,
pub code: __sdk::__query_builder::Col<ProfileRedeemCodeUsage, String>,
pub user_id: __sdk::__query_builder::Col<ProfileRedeemCodeUsage, String>,
pub amount_granted: __sdk::__query_builder::Col<ProfileRedeemCodeUsage, u64>,
pub created_at: __sdk::__query_builder::Col<ProfileRedeemCodeUsage, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for ProfileRedeemCodeUsage {
type Cols = ProfileRedeemCodeUsageCols;
fn cols(table_name: &'static str) -> Self::Cols {
ProfileRedeemCodeUsageCols {
usage_id: __sdk::__query_builder::Col::new(table_name, "usage_id"),
code: __sdk::__query_builder::Col::new(table_name, "code"),
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
amount_granted: __sdk::__query_builder::Col::new(table_name, "amount_granted"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
}
}
}
/// Indexed column accessor struct for the table `ProfileRedeemCodeUsage`.
///
/// Provides typed access to indexed columns for query building.
pub struct ProfileRedeemCodeUsageIxCols {
pub code: __sdk::__query_builder::IxCol<ProfileRedeemCodeUsage, String>,
pub usage_id: __sdk::__query_builder::IxCol<ProfileRedeemCodeUsage, String>,
pub user_id: __sdk::__query_builder::IxCol<ProfileRedeemCodeUsage, String>,
}
impl __sdk::__query_builder::HasIxCols for ProfileRedeemCodeUsage {
type IxCols = ProfileRedeemCodeUsageIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
ProfileRedeemCodeUsageIxCols {
code: __sdk::__query_builder::IxCol::new(table_name, "code"),
usage_id: __sdk::__query_builder::IxCol::new(table_name, "usage_id"),
user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for ProfileRedeemCodeUsage {}

View File

@@ -4,13 +4,13 @@
#![allow(unused, clippy::all)] #![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::big_fish_play_report_input_type::BigFishPlayReportInput; use super::big_fish_play_record_input_type::BigFishPlayRecordInput;
use super::big_fish_session_procedure_result_type::BigFishSessionProcedureResult; use super::big_fish_works_procedure_result_type::BigFishWorksProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)] #[sats(crate = __lib)]
struct RecordBigFishPlayArgs { struct RecordBigFishPlayArgs {
pub input: BigFishPlayReportInput, pub input: BigFishPlayRecordInput,
} }
impl __sdk::InModule for RecordBigFishPlayArgs { impl __sdk::InModule for RecordBigFishPlayArgs {
@@ -22,17 +22,17 @@ impl __sdk::InModule for RecordBigFishPlayArgs {
/// ///
/// Implemented for [`super::RemoteProcedures`]. /// Implemented for [`super::RemoteProcedures`].
pub trait record_big_fish_play { pub trait record_big_fish_play {
fn record_big_fish_play(&self, input: BigFishPlayReportInput) { fn record_big_fish_play(&self, input: BigFishPlayRecordInput) {
self.record_big_fish_play_then(input, |_, _| {}); self.record_big_fish_play_then(input, |_, _| {});
} }
fn record_big_fish_play_then( fn record_big_fish_play_then(
&self, &self,
input: BigFishPlayReportInput, input: BigFishPlayRecordInput,
__callback: impl FnOnce( __callback: impl FnOnce(
&super::ProcedureEventContext, &super::ProcedureEventContext,
Result<BigFishSessionProcedureResult, __sdk::InternalError>, Result<BigFishWorksProcedureResult, __sdk::InternalError>,
) + Send ) + Send
+ 'static, + 'static,
); );
@@ -41,16 +41,16 @@ pub trait record_big_fish_play {
impl record_big_fish_play for super::RemoteProcedures { impl record_big_fish_play for super::RemoteProcedures {
fn record_big_fish_play_then( fn record_big_fish_play_then(
&self, &self,
input: BigFishPlayReportInput, input: BigFishPlayRecordInput,
__callback: impl FnOnce( __callback: impl FnOnce(
&super::ProcedureEventContext, &super::ProcedureEventContext,
Result<BigFishSessionProcedureResult, __sdk::InternalError>, Result<BigFishWorksProcedureResult, __sdk::InternalError>,
) + Send ) + Send
+ 'static, + 'static,
) { ) {
self.imp self.imp
.invoke_procedure_with_callback::<_, BigFishSessionProcedureResult>( .invoke_procedure_with_callback::<_, BigFishWorksProcedureResult>(
"record_big_fish_play", "record_big_fish_play",
RecordBigFishPlayArgs { input }, RecordBigFishPlayArgs { input },
__callback, __callback,

View File

@@ -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::runtime_profile_reward_code_redeem_input_type::RuntimeProfileRewardCodeRedeemInput;
use super::runtime_profile_reward_code_redeem_procedure_result_type::RuntimeProfileRewardCodeRedeemProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct RedeemProfileRewardCodeArgs {
pub input: RuntimeProfileRewardCodeRedeemInput,
}
impl __sdk::InModule for RedeemProfileRewardCodeArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `redeem_profile_reward_code`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait redeem_profile_reward_code {
fn redeem_profile_reward_code(&self, input: RuntimeProfileRewardCodeRedeemInput) {
self.redeem_profile_reward_code_then(input, |_, _| {});
}
fn redeem_profile_reward_code_then(
&self,
input: RuntimeProfileRewardCodeRedeemInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRewardCodeRedeemProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl redeem_profile_reward_code for super::RemoteProcedures {
fn redeem_profile_reward_code_then(
&self,
input: RuntimeProfileRewardCodeRedeemInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRewardCodeRedeemProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileRewardCodeRedeemProcedureResult>(
"redeem_profile_reward_code",
RedeemProfileRewardCodeArgs { input },
__callback,
);
}
}

View File

@@ -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 RuntimeProfileRedeemCodeAdminDisableInput {
pub admin_user_id: String,
pub code: String,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileRedeemCodeAdminDisableInput {
type Module = super::RemoteModule;
}

View File

@@ -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::runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRedeemCodeAdminProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileRedeemCodeSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeProfileRedeemCodeAdminProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,25 @@
// 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::runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRedeemCodeAdminUpsertInput {
pub admin_user_id: String,
pub code: String,
pub mode: RuntimeProfileRedeemCodeMode,
pub reward_points: u64,
pub max_uses: u32,
pub enabled: bool,
pub allowed_user_ids: Vec<String>,
pub allowed_public_user_codes: Vec<String>,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileRedeemCodeAdminUpsertInput {
type Module = super::RemoteModule;
}

View File

@@ -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)]
#[derive(Copy, Eq, Hash)]
pub enum RuntimeProfileRedeemCodeMode {
Public,
Unique,
Private,
}
impl __sdk::InModule for RuntimeProfileRedeemCodeMode {
type Module = super::RemoteModule;
}

View File

@@ -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::runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRedeemCodeSnapshot {
pub code: String,
pub mode: RuntimeProfileRedeemCodeMode,
pub reward_points: u64,
pub max_uses: u32,
pub global_used_count: u32,
pub enabled: bool,
pub allowed_user_ids: Vec<String>,
pub created_by: String,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileRedeemCodeSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -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 RuntimeProfileRewardCodeRedeemInput {
pub user_id: String,
pub code: String,
pub redeemed_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileRewardCodeRedeemInput {
type Module = super::RemoteModule;
}

View File

@@ -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::runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRewardCodeRedeemProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileRewardCodeRedeemSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeProfileRewardCodeRedeemProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -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::runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletLedgerEntrySnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRewardCodeRedeemSnapshot {
pub wallet_balance: u64,
pub amount_granted: u64,
pub ledger_entry: RuntimeProfileWalletLedgerEntrySnapshot,
}
impl __sdk::InModule for RuntimeProfileRewardCodeRedeemSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -19,6 +19,8 @@ pub enum RuntimeProfileWalletLedgerSourceType {
AssetGenerationConsume, AssetGenerationConsume,
AssetGenerationRefund, AssetGenerationRefund,
RedeemCodeReward,
} }
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType { impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {

View File

@@ -478,15 +478,14 @@ impl SpacetimeClient {
}; };
self.call_after_connect(move |connection, sender| { self.call_after_connect(move |connection, sender| {
connection.procedures().submit_puzzle_leaderboard_entry_then( connection
procedure_input, .procedures()
move |_, result| { .submit_puzzle_leaderboard_entry_then(procedure_input, move |_, result| {
let mapped = result let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_puzzle_run_procedure_result); .and_then(map_puzzle_run_procedure_result);
send_once(&sender, mapped); send_once(&sender, mapped);
}, });
);
}) })
.await .await
} }

View File

@@ -255,6 +255,97 @@ impl SpacetimeClient {
.await .await
} }
pub async fn redeem_profile_reward_code(
&self,
user_id: String,
code: String,
redeemed_at_micros: i64,
) -> Result<RuntimeProfileRewardCodeRedeemRecord, SpacetimeClientError> {
let procedure_input =
build_runtime_profile_reward_code_redeem_input(user_id, code, redeemed_at_micros)
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
.into();
self.call_after_connect(move |connection, sender| {
connection.procedures().redeem_profile_reward_code_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_runtime_profile_reward_code_redeem_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn admin_upsert_profile_redeem_code(
&self,
admin_user_id: String,
code: String,
mode: DomainRuntimeProfileRedeemCodeMode,
reward_points: u64,
max_uses: u32,
enabled: bool,
allowed_user_ids: Vec<String>,
allowed_public_user_codes: Vec<String>,
updated_at_micros: i64,
) -> Result<RuntimeProfileRedeemCodeRecord, SpacetimeClientError> {
let procedure_input = build_runtime_profile_redeem_code_admin_upsert_input(
admin_user_id,
code,
mode,
reward_points,
max_uses,
enabled,
allowed_user_ids,
allowed_public_user_codes,
updated_at_micros,
)
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.admin_upsert_profile_redeem_code_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_runtime_profile_redeem_code_admin_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn admin_disable_profile_redeem_code(
&self,
admin_user_id: String,
code: String,
updated_at_micros: i64,
) -> Result<RuntimeProfileRedeemCodeRecord, SpacetimeClientError> {
let procedure_input = build_runtime_profile_redeem_code_admin_disable_input(
admin_user_id,
code,
updated_at_micros,
)
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.admin_disable_profile_redeem_code_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_runtime_profile_redeem_code_admin_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn get_profile_play_stats( pub async fn get_profile_play_stats(
&self, &self,
user_id: String, user_id: String,

View File

@@ -108,6 +108,7 @@ pub(crate) fn generate_big_fish_asset_tx(
.map_err(|error| error.to_string())?, .map_err(|error| error.to_string())?,
last_assistant_reply: Some(reply.clone()), last_assistant_reply: Some(reply.clone()),
publish_ready: coverage.publish_ready, publish_ready: coverage.publish_ready,
play_count: session.play_count,
created_at: session.created_at, created_at: session.created_at,
updated_at, updated_at,
}; };
@@ -164,6 +165,7 @@ pub(crate) fn publish_big_fish_game_tx(
.map_err(|error| error.to_string())?, .map_err(|error| error.to_string())?,
last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()), last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()),
publish_ready: true, publish_ready: true,
play_count: session.play_count,
created_at: session.created_at, created_at: session.created_at,
updated_at: published_at, updated_at: published_at,
}; };

View File

@@ -96,6 +96,32 @@ pub fn delete_big_fish_work(
} }
} }
#[spacetimedb::procedure]
pub fn record_big_fish_play(
ctx: &mut ProcedureContext,
input: BigFishPlayRecordInput,
) -> BigFishWorksProcedureResult {
match ctx.try_with_tx(|tx| record_big_fish_play_tx(tx, input.clone())) {
Ok(items) => match serde_json::to_string(&items) {
Ok(items_json) => BigFishWorksProcedureResult {
ok: true,
items_json: Some(items_json),
error_message: None,
},
Err(error) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
error_message: Some(error.to_string()),
},
},
Err(message) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure] #[spacetimedb::procedure]
pub fn submit_big_fish_message( pub fn submit_big_fish_message(
ctx: &mut ProcedureContext, ctx: &mut ProcedureContext,
@@ -153,25 +179,6 @@ pub fn compile_big_fish_draft(
} }
} }
#[spacetimedb::procedure]
pub fn record_big_fish_play(
ctx: &mut ProcedureContext,
input: BigFishPlayReportInput,
) -> BigFishSessionProcedureResult {
match ctx.try_with_tx(|tx| record_big_fish_play_tx(tx, input.clone())) {
Ok(session) => BigFishSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => BigFishSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
pub(crate) fn create_big_fish_session_tx( pub(crate) fn create_big_fish_session_tx(
ctx: &ReducerContext, ctx: &ReducerContext,
input: BigFishSessionCreateInput, input: BigFishSessionCreateInput,
@@ -216,6 +223,7 @@ pub(crate) fn create_big_fish_session_tx(
.map_err(|error| error.to_string())?, .map_err(|error| error.to_string())?,
last_assistant_reply: Some(input.welcome_message_text.clone()), last_assistant_reply: Some(input.welcome_message_text.clone()),
publish_ready: false, publish_ready: false,
play_count: 0,
created_at, created_at,
updated_at: created_at, updated_at: created_at,
}); });
@@ -405,6 +413,7 @@ pub(crate) fn submit_big_fish_message_tx(
asset_coverage_json: session.asset_coverage_json.clone(), asset_coverage_json: session.asset_coverage_json.clone(),
last_assistant_reply: session.last_assistant_reply.clone(), last_assistant_reply: session.last_assistant_reply.clone(),
publish_ready: session.publish_ready, publish_ready: session.publish_ready,
play_count: session.play_count,
created_at: session.created_at, created_at: session.created_at,
updated_at: submitted_at, updated_at: submitted_at,
}; };
@@ -451,6 +460,7 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx(
asset_coverage_json: session.asset_coverage_json.clone(), asset_coverage_json: session.asset_coverage_json.clone(),
last_assistant_reply: session.last_assistant_reply.clone(), last_assistant_reply: session.last_assistant_reply.clone(),
publish_ready: session.publish_ready, publish_ready: session.publish_ready,
play_count: session.play_count,
created_at: session.created_at, created_at: session.created_at,
updated_at, updated_at,
}; };
@@ -505,6 +515,7 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx(
asset_coverage_json: session.asset_coverage_json.clone(), asset_coverage_json: session.asset_coverage_json.clone(),
last_assistant_reply: Some(assistant_reply_text), last_assistant_reply: Some(assistant_reply_text),
publish_ready: session.publish_ready, publish_ready: session.publish_ready,
play_count: session.play_count,
created_at: session.created_at, created_at: session.created_at,
updated_at, updated_at,
}; };
@@ -552,6 +563,7 @@ pub(crate) fn compile_big_fish_draft_tx(
.map_err(|error| error.to_string())?, .map_err(|error| error.to_string())?,
last_assistant_reply: Some(reply.clone()), last_assistant_reply: Some(reply.clone()),
publish_ready: coverage.publish_ready, publish_ready: coverage.publish_ready,
play_count: session.play_count,
created_at: session.created_at, created_at: session.created_at,
updated_at: compiled_at, updated_at: compiled_at,
}; };
@@ -568,16 +580,17 @@ pub(crate) fn compile_big_fish_draft_tx(
pub(crate) fn record_big_fish_play_tx( pub(crate) fn record_big_fish_play_tx(
ctx: &ReducerContext, ctx: &ReducerContext,
input: BigFishPlayReportInput, input: BigFishPlayRecordInput,
) -> Result<BigFishSessionSnapshot, String> { ) -> Result<Vec<BigFishWorkSummarySnapshot>, String> {
validate_play_report_input(&input).map_err(|error| error.to_string())?; validate_play_record_input(&input).map_err(|error| error.to_string())?;
let session = ctx let session = ctx
.db .db
.big_fish_creation_session() .big_fish_creation_session()
.session_id() .session_id()
.find(&input.session_id) .find(&input.session_id)
.filter(|row| row.stage == BigFishCreationStage::Published) .filter(|row| row.stage == BigFishCreationStage::Published)
.ok_or_else(|| "big_fish_creation_session 不存在或尚未发布".to_string())?; .ok_or_else(|| "big_fish 已发布作品不存在".to_string())?;
let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros);
let draft = session let draft = session
.draft_json .draft_json
.as_deref() .as_deref()
@@ -613,7 +626,7 @@ pub(crate) fn record_big_fish_play_tx(
world_type: Some("BIG_FISH".to_string()), world_type: Some("BIG_FISH".to_string()),
world_title: title, world_title: title,
world_subtitle: subtitle, world_subtitle: subtitle,
played_at_micros: input.reported_at_micros, played_at_micros: input.played_at_micros,
}, },
)?; )?;
add_profile_observed_play_time( add_profile_observed_play_time(
@@ -621,10 +634,34 @@ pub(crate) fn record_big_fish_play_tx(
&input.user_id, &input.user_id,
&world_key, &world_key,
input.elapsed_ms, input.elapsed_ms,
input.reported_at_micros, input.played_at_micros,
)?; )?;
let next_session = BigFishCreationSession {
session_id: session.session_id.clone(),
owner_user_id: session.owner_user_id.clone(),
seed_text: session.seed_text.clone(),
current_turn: session.current_turn,
progress_percent: session.progress_percent,
stage: session.stage,
anchor_pack_json: session.anchor_pack_json.clone(),
draft_json: session.draft_json.clone(),
asset_coverage_json: session.asset_coverage_json.clone(),
last_assistant_reply: session.last_assistant_reply.clone(),
publish_ready: session.publish_ready,
// 中文注释:正式进入已发布作品时同时累加作品播放数,用户侧去重由 profile_played_world 保证。
play_count: session.play_count.saturating_add(1),
created_at: session.created_at,
updated_at: played_at,
};
replace_big_fish_session(ctx, &session, next_session);
build_big_fish_session_snapshot(ctx, &session) list_big_fish_works_tx(
ctx,
BigFishWorksListInput {
owner_user_id: String::new(),
published_only: true,
},
)
} }
pub(crate) fn build_big_fish_session_snapshot( pub(crate) fn build_big_fish_session_snapshot(
@@ -740,6 +777,7 @@ pub(crate) fn build_big_fish_work_summary(
level_main_image_ready_count: coverage.level_main_image_ready_count, level_main_image_ready_count: coverage.level_main_image_ready_count,
level_motion_ready_count: coverage.level_motion_ready_count, level_motion_ready_count: coverage.level_motion_ready_count,
background_ready: coverage.background_ready, background_ready: coverage.background_ready,
play_count: row.play_count,
}) })
} }
@@ -776,6 +814,7 @@ mod tests {
asset_coverage_json: "{}".to_string(), asset_coverage_json: "{}".to_string(),
last_assistant_reply: Some("欢迎来到大鱼吃小鱼共创。".to_string()), last_assistant_reply: Some("欢迎来到大鱼吃小鱼共创。".to_string()),
publish_ready: false, publish_ready: false,
play_count: 0,
created_at: Timestamp::from_micros_since_unix_epoch(1), created_at: Timestamp::from_micros_since_unix_epoch(1),
updated_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1),
} }

View File

@@ -17,6 +17,7 @@ pub struct BigFishCreationSession {
pub(crate) asset_coverage_json: String, pub(crate) asset_coverage_json: String,
pub(crate) last_assistant_reply: Option<String>, pub(crate) last_assistant_reply: Option<String>,
pub(crate) publish_ready: bool, pub(crate) publish_ready: bool,
pub(crate) play_count: u32,
pub(crate) created_at: Timestamp, pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp, pub(crate) updated_at: Timestamp,
} }

View File

@@ -109,6 +109,8 @@ macro_rules! migration_tables {
user_browse_history, user_browse_history,
profile_dashboard_state, profile_dashboard_state,
profile_wallet_ledger, profile_wallet_ledger,
profile_redeem_code,
profile_redeem_code_usage,
profile_invite_code, profile_invite_code,
profile_referral_relation, profile_referral_relation,
profile_played_world, profile_played_world,
@@ -659,6 +661,19 @@ where
Ok(wrapped.0) Ok(wrapped.0)
} }
fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde_json::Value {
let mut next_value = value.clone();
if table_name == "big_fish_creation_session" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。
object
.entry("play_count".to_string())
.or_insert_with(|| serde_json::Value::from(0));
}
}
next_value
}
fn insert_migration_table_rows( fn insert_migration_table_rows(
ctx: &ReducerContext, ctx: &ReducerContext,
table: &MigrationTable, table: &MigrationTable,
@@ -672,7 +687,8 @@ fn insert_migration_table_rows(
let mut imported = 0u64; let mut imported = 0u64;
let mut skipped = 0u64; let mut skipped = 0u64;
for value in &table.rows { for value in &table.rows {
let row = row_from_json(value) let normalized_value = normalize_migration_row(stringify!($table), value);
let row = row_from_json(&normalized_value)
.map_err(|error| format!("{}: {error}", stringify!($table)))?; .map_err(|error| format!("{}: {error}", stringify!($table)))?;
let insert_result = ctx.db let insert_result = ctx.db
.$table() .$table()

View File

@@ -28,6 +28,39 @@ pub struct ProfileWalletLedger {
pub(crate) created_at: Timestamp, pub(crate) created_at: Timestamp,
} }
#[spacetimedb::table(accessor = profile_redeem_code)]
pub struct ProfileRedeemCode {
#[primary_key]
pub(crate) code: String,
pub(crate) mode: RuntimeProfileRedeemCodeMode,
pub(crate) reward_points: u64,
pub(crate) max_uses: u32,
pub(crate) global_used_count: u32,
pub(crate) enabled: bool,
pub(crate) allowed_user_ids: Vec<String>,
pub(crate) created_by: String,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_redeem_code_usage,
index(accessor = by_profile_redeem_code_usage_code, btree(columns = [code])),
index(accessor = by_profile_redeem_code_usage_user_id, btree(columns = [user_id])),
index(
accessor = by_profile_redeem_code_usage_code_user_id,
btree(columns = [code, user_id])
)
)]
pub struct ProfileRedeemCodeUsage {
#[primary_key]
pub(crate) usage_id: String,
pub(crate) code: String,
pub(crate) user_id: String,
pub(crate) amount_granted: u64,
pub(crate) created_at: Timestamp,
}
#[spacetimedb::table(accessor = profile_invite_code)] #[spacetimedb::table(accessor = profile_invite_code)]
pub struct ProfileInviteCode { pub struct ProfileInviteCode {
#[primary_key] #[primary_key]
@@ -407,6 +440,64 @@ pub fn redeem_profile_referral_invite_code(
} }
} }
// 兑换码奖励、usage 与钱包流水必须在同一事务内落库,避免到账和计次分离。
#[spacetimedb::procedure]
pub fn redeem_profile_reward_code(
ctx: &mut ProcedureContext,
input: RuntimeProfileRewardCodeRedeemInput,
) -> RuntimeProfileRewardCodeRedeemProcedureResult {
match ctx.try_with_tx(|tx| redeem_profile_reward_code_record(tx, input.clone())) {
Ok(record) => RuntimeProfileRewardCodeRedeemProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileRewardCodeRedeemProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_upsert_profile_redeem_code(
ctx: &mut ProcedureContext,
input: RuntimeProfileRedeemCodeAdminUpsertInput,
) -> RuntimeProfileRedeemCodeAdminProcedureResult {
match ctx.try_with_tx(|tx| admin_upsert_profile_redeem_code_record(tx, input.clone())) {
Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_disable_profile_redeem_code(
ctx: &mut ProcedureContext,
input: RuntimeProfileRedeemCodeAdminDisableInput,
) -> RuntimeProfileRedeemCodeAdminProcedureResult {
match ctx.try_with_tx(|tx| admin_disable_profile_redeem_code_record(tx, input.clone())) {
Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
pub(crate) fn list_profile_save_archive_rows( pub(crate) fn list_profile_save_archive_rows(
ctx: &ReducerContext, ctx: &ReducerContext,
input: RuntimeProfileSaveArchiveListInput, input: RuntimeProfileSaveArchiveListInput,
@@ -1371,6 +1462,185 @@ fn redeem_profile_referral_invite_code_record(
}) })
} }
fn redeem_profile_reward_code_record(
ctx: &ReducerContext,
input: RuntimeProfileRewardCodeRedeemInput,
) -> Result<RuntimeProfileRewardCodeRedeemSnapshot, String> {
let validated_input = build_runtime_profile_reward_code_redeem_input(
input.user_id,
input.code,
input.redeemed_at_micros,
)
.map_err(|error| error.to_string())?;
let redeemed_at = Timestamp::from_micros_since_unix_epoch(validated_input.redeemed_at_micros);
let user_id = validated_input.user_id;
let code = validated_input.code;
let redeem_code = ctx
.db
.profile_redeem_code()
.code()
.find(&code)
.ok_or_else(|| "兑换码不存在".to_string())?;
if !redeem_code.enabled {
return Err("兑换码已停用".to_string());
}
if redeem_code.reward_points == 0 {
return Err("兑换码奖励无效".to_string());
}
let user_used_count = count_profile_redeem_code_user_usage(ctx, &code, &user_id);
match redeem_code.mode {
RuntimeProfileRedeemCodeMode::Public if user_used_count >= redeem_code.max_uses => {
return Err("兑换次数已用完".to_string());
}
RuntimeProfileRedeemCodeMode::Unique
if redeem_code.global_used_count >= redeem_code.max_uses =>
{
return Err("兑换次数已用完".to_string());
}
RuntimeProfileRedeemCodeMode::Private => {
if !redeem_code
.allowed_user_ids
.iter()
.any(|item| item == &user_id)
{
return Err("该兑换码不适用于当前账号".to_string());
}
if redeem_code.global_used_count >= redeem_code.max_uses {
return Err("兑换次数已用完".to_string());
}
}
_ => {}
}
let usage_id = build_profile_redeem_code_usage_id(
ctx,
&code,
&user_id,
validated_input.redeemed_at_micros,
);
let wallet_ledger_id = format!("{}:ledger", usage_id);
let wallet_balance = apply_profile_wallet_delta(
ctx,
&user_id,
redeem_code.reward_points,
RuntimeProfileWalletLedgerSourceType::RedeemCodeReward,
&wallet_ledger_id,
redeemed_at,
)?;
ctx.db
.profile_redeem_code_usage()
.insert(ProfileRedeemCodeUsage {
usage_id,
code: code.clone(),
user_id,
amount_granted: redeem_code.reward_points,
created_at: redeemed_at,
});
let next_code = ProfileRedeemCode {
global_used_count: redeem_code.global_used_count.saturating_add(1),
updated_at: redeemed_at,
..redeem_code
};
ctx.db.profile_redeem_code().code().delete(&code);
ctx.db.profile_redeem_code().insert(next_code);
let ledger_entry = ctx
.db
.profile_wallet_ledger()
.wallet_ledger_id()
.find(&wallet_ledger_id)
.ok_or_else(|| "兑换码钱包流水写入失败".to_string())?;
Ok(RuntimeProfileRewardCodeRedeemSnapshot {
wallet_balance,
amount_granted: ledger_entry.amount_delta.max(0) as u64,
ledger_entry: build_profile_wallet_ledger_snapshot_from_row(&ledger_entry),
})
}
fn admin_upsert_profile_redeem_code_record(
ctx: &ReducerContext,
input: RuntimeProfileRedeemCodeAdminUpsertInput,
) -> Result<RuntimeProfileRedeemCodeSnapshot, String> {
let validated_input = build_runtime_profile_redeem_code_admin_upsert_input(
input.admin_user_id,
input.code,
input.mode,
input.reward_points,
input.max_uses,
input.enabled,
input.allowed_user_ids,
input.allowed_public_user_codes,
input.updated_at_micros,
)
.map_err(|error| error.to_string())?;
let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
let allowed_user_ids = resolve_profile_redeem_code_allowed_user_ids(ctx, &validated_input)?;
let existing = ctx
.db
.profile_redeem_code()
.code()
.find(&validated_input.code);
let created_at = existing
.as_ref()
.map(|row| row.created_at)
.unwrap_or(updated_at);
let global_used_count = existing
.as_ref()
.map(|row| row.global_used_count)
.unwrap_or(0);
if let Some(existing) = existing {
ctx.db.profile_redeem_code().code().delete(&existing.code);
}
let row = ProfileRedeemCode {
code: validated_input.code,
mode: validated_input.mode,
reward_points: validated_input.reward_points,
max_uses: validated_input.max_uses,
global_used_count,
enabled: validated_input.enabled,
allowed_user_ids,
created_by: validated_input.admin_user_id,
created_at,
updated_at,
};
let inserted = ctx.db.profile_redeem_code().insert(row);
Ok(build_profile_redeem_code_snapshot_from_row(&inserted))
}
fn admin_disable_profile_redeem_code_record(
ctx: &ReducerContext,
input: RuntimeProfileRedeemCodeAdminDisableInput,
) -> Result<RuntimeProfileRedeemCodeSnapshot, String> {
let validated_input = build_runtime_profile_redeem_code_admin_disable_input(
input.admin_user_id,
input.code,
input.updated_at_micros,
)
.map_err(|error| error.to_string())?;
let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
let existing = ctx
.db
.profile_redeem_code()
.code()
.find(&validated_input.code)
.ok_or_else(|| "兑换码不存在".to_string())?;
ctx.db.profile_redeem_code().code().delete(&existing.code);
let inserted = ctx.db.profile_redeem_code().insert(ProfileRedeemCode {
enabled: false,
updated_at,
..existing
});
Ok(build_profile_redeem_code_snapshot_from_row(&inserted))
}
fn build_profile_referral_invite_center_snapshot( fn build_profile_referral_invite_center_snapshot(
ctx: &ReducerContext, ctx: &ReducerContext,
user_id: &str, user_id: &str,
@@ -1756,6 +2026,74 @@ fn latest_profile_recharge_order(
orders.into_iter().next() orders.into_iter().next()
} }
fn count_profile_redeem_code_user_usage(ctx: &ReducerContext, code: &str, user_id: &str) -> u32 {
ctx.db
.profile_redeem_code_usage()
.by_profile_redeem_code_usage_code_user_id()
.filter((code, user_id))
.count() as u32
}
fn build_profile_redeem_code_usage_id(
ctx: &ReducerContext,
code: &str,
user_id: &str,
redeemed_at_micros: i64,
) -> String {
let sequence = count_profile_redeem_code_user_usage(ctx, code, user_id);
format!(
"redeem:{}:{}:{}:{}",
code, user_id, redeemed_at_micros, sequence
)
}
fn resolve_profile_redeem_code_allowed_user_ids(
ctx: &ReducerContext,
input: &RuntimeProfileRedeemCodeAdminUpsertInput,
) -> Result<Vec<String>, String> {
if input.mode != RuntimeProfileRedeemCodeMode::Private {
return Ok(Vec::new());
}
let mut allowed_user_ids = input.allowed_user_ids.clone();
for public_user_code in &input.allowed_public_user_codes {
if let Some(account) = ctx
.db
.user_account()
.by_user_account_public_code()
.filter(public_user_code)
.next()
{
allowed_user_ids.push(account.user_id);
}
}
allowed_user_ids.sort();
allowed_user_ids.dedup();
if allowed_user_ids.is_empty() {
return Err("私有兑换码必须指定可兑换用户".to_string());
}
Ok(allowed_user_ids)
}
fn build_profile_redeem_code_snapshot_from_row(
row: &ProfileRedeemCode,
) -> RuntimeProfileRedeemCodeSnapshot {
RuntimeProfileRedeemCodeSnapshot {
code: row.code.clone(),
mode: row.mode,
reward_points: row.reward_points,
max_uses: row.max_uses,
global_used_count: row.global_used_count,
enabled: row.enabled,
allowed_user_ids: row.allowed_user_ids.clone(),
created_by: row.created_by.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_profile_wallet_ledger_snapshot_from_row( fn build_profile_wallet_ledger_snapshot_from_row(
row: &ProfileWalletLedger, row: &ProfileWalletLedger,
) -> RuntimeProfileWalletLedgerEntrySnapshot { ) -> RuntimeProfileWalletLedgerEntrySnapshot {

View File

@@ -200,6 +200,7 @@ function mapBigFishWorkToShelfItem(
id: 'level-motion-ready-count', id: 'level-motion-ready-count',
label: `动作 ${item.levelMotionReadyCount}`, label: `动作 ${item.levelMotionReadyCount}`,
}, },
{ id: 'play-count', label: `游玩 ${item.playCount ?? 0}` },
...(item.backgroundReady ...(item.backgroundReady
? [ ? [
{ {

View File

@@ -94,6 +94,7 @@ import {
} from '../../services/puzzle-gallery'; } from '../../services/puzzle-gallery';
import { import {
advanceLocalPuzzleNextLevel, advanceLocalPuzzleNextLevel,
startPuzzleRun,
submitPuzzleLeaderboard, submitPuzzleLeaderboard,
} from '../../services/puzzle-runtime'; } from '../../services/puzzle-runtime';
import { import {
@@ -322,6 +323,17 @@ function buildAgentResultPublishGateView(
}; };
} }
function buildPuzzleResultProfileId(sessionId: string | null | undefined) {
const normalizedSessionId = sessionId?.trim();
if (!normalizedSessionId) {
return null;
}
const stableSuffix = normalizedSessionId.startsWith('puzzle-session-')
? normalizedSessionId.slice('puzzle-session-'.length)
: normalizedSessionId;
return `puzzle-profile-${stableSuffix}`;
}
const CustomWorldGenerationView = lazy(async () => { const CustomWorldGenerationView = lazy(async () => {
const module = await import('../CustomWorldGenerationView'); const module = await import('../CustomWorldGenerationView');
return { return {
@@ -1178,7 +1190,13 @@ export function PlatformEntryFlowShellImpl({
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'), resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
); );
}); });
}, [bigFishSession, resolveBigFishErrorMessage, setSelectionStage]); void refreshBigFishShelf();
}, [
bigFishSession,
refreshBigFishShelf,
resolveBigFishErrorMessage,
setSelectionStage,
]);
const restartBigFishRun = useCallback(() => { const restartBigFishRun = useCallback(() => {
if (!bigFishSession && !bigFishRun) { if (!bigFishSession && !bigFishRun) {
@@ -1227,8 +1245,9 @@ export function PlatformEntryFlowShellImpl({
try { try {
const { item } = await getPuzzleGalleryDetail(profileId); const { item } = await getPuzzleGalleryDetail(profileId);
const { run } = await startPuzzleRun({ profileId: item.profileId });
setSelectedPuzzleDetail(item); setSelectedPuzzleDetail(item);
setPuzzleRun(startLocalPuzzleRun(item)); setPuzzleRun(run);
setPuzzleRuntimeReturnStage('puzzle-gallery-detail'); setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
setSelectionStage('puzzle-runtime'); setSelectionStage('puzzle-runtime');
pushAppHistoryPath( pushAppHistoryPath(
@@ -2672,6 +2691,10 @@ export function PlatformEntryFlowShellImpl({
> >
<PuzzleResultView <PuzzleResultView
session={puzzleSession} session={puzzleSession}
profileId={
puzzleSession.publishedProfileId ??
buildPuzzleResultProfileId(puzzleSession.sessionId)
}
isBusy={isPuzzleBusy} isBusy={isPuzzleBusy}
error={puzzleError} error={puzzleError}
onBack={() => { onBack={() => {

View File

@@ -1,16 +1,18 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { import {
act,
fireEvent, fireEvent,
render, render,
screen, screen,
waitFor, waitFor,
within, within,
} from '@testing-library/react'; } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient'; import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
import * as puzzleWorksService from '../../services/puzzle-works';
import { PuzzleResultView } from './PuzzleResultView'; import { PuzzleResultView } from './PuzzleResultView';
vi.mock('../ResolvedAssetImage', () => ({ vi.mock('../ResolvedAssetImage', () => ({
@@ -31,6 +33,15 @@ vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
}, },
})); }));
vi.mock('../../services/puzzle-works', () => ({
updatePuzzleWork: vi.fn(),
}));
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
function createSession( function createSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {}, overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot { ): PuzzleAgentSessionSnapshot {
@@ -149,6 +160,39 @@ function createSession(
} }
describe('PuzzleResultView', () => { describe('PuzzleResultView', () => {
test('auto saves renamed title to the puzzle work profile', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.change(screen.getByDisplayValue('雨夜猫街'), {
target: { value: '暖灯猫街' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
levelName: '暖灯猫街',
summary: '屋檐下的猫与暖灯街角。',
themeTags: ['猫咪', '雨夜'],
}),
);
});
test('uses two tabs without author preview or persistent publish validation', () => { test('uses two tabs without author preview or persistent publish validation', () => {
render( render(
<PuzzleResultView <PuzzleResultView
@@ -168,9 +212,13 @@ describe('PuzzleResultView', () => {
}); });
test('edits theme tags with chips instead of a persistent tag input', () => { test('edits theme tags with chips instead of a persistent tag input', () => {
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
render( render(
<PuzzleResultView <PuzzleResultView
session={createSession()} session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}} onBack={() => {}}
onExecuteAction={() => {}} onExecuteAction={() => {}}
/>, />,
@@ -256,6 +304,78 @@ describe('PuzzleResultView', () => {
); );
}); });
test('requires at least three theme tags before publish can pass', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByLabelText('删除标签 猫咪'));
fireEvent.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '发布拼图作品' });
expect(
within(dialog).getByText('正式标签数量必须在 3 到 6 之间。'),
).toBeTruthy();
expect(
(
within(dialog).getByRole('button', {
name: '发布到广场',
}) as HTMLButtonElement
).disabled,
).toBe(true);
});
test('auto saves added and removed theme tags', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByLabelText('新增题材标签'));
fireEvent.change(screen.getByLabelText('新题材标签'), {
target: { value: '暖灯' },
});
fireEvent.click(screen.getByRole('button', { name: '添加' }));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
themeTags: ['猫咪', '雨夜', '暖灯'],
}),
);
fireEvent.click(screen.getByLabelText('删除标签 猫咪'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
themeTags: ['雨夜', '暖灯'],
}),
);
});
test('generates one image from the picture description and replaces current image', () => { test('generates one image from the picture description and replaces current image', () => {
const onExecuteAction = vi.fn(); const onExecuteAction = vi.fn();

View File

@@ -15,6 +15,7 @@ import { createPortal } from 'react-dom';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions'; import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type { PuzzleResultDraft } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; import type { PuzzleResultDraft } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { updatePuzzleWork } from '../../services/puzzle-works';
import { import {
puzzleAssetClient, puzzleAssetClient,
type PuzzleHistoryAsset, type PuzzleHistoryAsset,
@@ -24,6 +25,7 @@ import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleResultViewProps = { type PuzzleResultViewProps = {
session: PuzzleAgentSessionSnapshot; session: PuzzleAgentSessionSnapshot;
profileId?: string | null;
isBusy?: boolean; isBusy?: boolean;
error?: string | null; error?: string | null;
onBack: () => void; onBack: () => void;
@@ -32,6 +34,7 @@ type PuzzleResultViewProps = {
}; };
type PuzzleResultTab = 'basic' | 'images'; type PuzzleResultTab = 'basic' | 'images';
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type DraftEditState = { type DraftEditState = {
levelName: string; levelName: string;
@@ -39,6 +42,10 @@ type DraftEditState = {
themeTags: string[]; themeTags: string[];
}; };
const PUZZLE_MIN_THEME_TAG_COUNT = 3;
const PUZZLE_MAX_THEME_TAG_COUNT = 6;
const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
function normalizeThemeTagInput(value: string) { function normalizeThemeTagInput(value: string) {
return [ return [
...new Set( ...new Set(
@@ -84,7 +91,16 @@ function publishBlockedReason(session: PuzzleAgentSessionSnapshot) {
if (!session.resultPreview) { if (!session.resultPreview) {
return ['等待结果页草稿完成后再发布。']; return ['等待结果页草稿完成后再发布。'];
} }
return session.resultPreview.blockers.map((entry) => entry.message); return session.resultPreview.blockers
.filter(
(entry) =>
![
'MISSING_LEVEL_NAME',
'INVALID_TAG_COUNT',
'MISSING_COVER_IMAGE',
].includes(entry.code),
)
.map((entry) => entry.message);
} }
function buildPublishReady( function buildPublishReady(
@@ -96,7 +112,10 @@ function buildPublishReady(
const blockers = [ const blockers = [
...publishBlockedReason(session), ...publishBlockedReason(session),
...(editState.levelName.trim() ? [] : ['关卡名不能为空。']), ...(editState.levelName.trim() ? [] : ['关卡名不能为空。']),
...(editState.themeTags.length > 0 ? [] : ['至少需要 1 个题材标签。']), ...(editState.themeTags.length >= PUZZLE_MIN_THEME_TAG_COUNT &&
editState.themeTags.length <= PUZZLE_MAX_THEME_TAG_COUNT
? []
: [`正式标签数量必须在 ${PUZZLE_MIN_THEME_TAG_COUNT}${PUZZLE_MAX_THEME_TAG_COUNT} 之间。`]),
...(formalImageSrc ? [] : ['请先选择一张正式拼图图片。']), ...(formalImageSrc ? [] : ['请先选择一张正式拼图图片。']),
]; ];
@@ -105,7 +124,8 @@ function buildPublishReady(
publishReady: publishReady:
Boolean(session.resultPreview?.publishReady) && Boolean(session.resultPreview?.publishReady) &&
Boolean(editState.levelName.trim()) && Boolean(editState.levelName.trim()) &&
editState.themeTags.length > 0 && editState.themeTags.length >= PUZZLE_MIN_THEME_TAG_COUNT &&
editState.themeTags.length <= PUZZLE_MAX_THEME_TAG_COUNT &&
Boolean(formalImageSrc), Boolean(formalImageSrc),
}; };
} }
@@ -130,12 +150,29 @@ function resolvePuzzleFormalImageSrc(draft: PuzzleResultDraft) {
} }
function PuzzleResultHeader({ function PuzzleResultHeader({
autoSaveState,
isBusy, isBusy,
onBack, onBack,
}: { }: {
autoSaveState: PuzzleAutoSaveState;
isBusy: boolean; isBusy: boolean;
onBack: () => void; onBack: () => void;
}) { }) {
const autoSaveBadge =
autoSaveState === 'saving' ? (
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'saved' ? (
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'error' ? (
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
</div>
) : null;
return ( return (
<div className="mb-4 flex items-center justify-between gap-3"> <div className="mb-4 flex items-center justify-between gap-3">
<button <button
@@ -149,6 +186,7 @@ function PuzzleResultHeader({
</span> </span>
</button> </button>
{autoSaveBadge}
</div> </div>
); );
} }
@@ -871,6 +909,7 @@ function PuzzleResultActionBar({
*/ */
export function PuzzleResultView({ export function PuzzleResultView({
session, session,
profileId = null,
isBusy = false, isBusy = false,
error = null, error = null,
onBack, onBack,
@@ -884,15 +923,77 @@ export function PuzzleResultView({
const [editState, setEditState] = useState<DraftEditState | null>( const [editState, setEditState] = useState<DraftEditState | null>(
draft ? createDraftEditState(draft) : null, draft ? createDraftEditState(draft) : null,
); );
const [autoSaveState, setAutoSaveState] = useState<PuzzleAutoSaveState>('idle');
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!draft) { if (!draft) {
setEditState(null); setEditState(null);
setAutoSaveState('idle');
setAutoSaveError(null);
return; return;
} }
setEditState(createDraftEditState(draft)); setEditState(createDraftEditState(draft));
setAutoSaveState('idle');
setAutoSaveError(null);
}, [draft]); }, [draft]);
useEffect(() => {
if (!draft || !editState || !profileId) {
return;
}
const normalizedLevelName = editState.levelName.trim();
const normalizedSummary = editState.summary.trim();
const normalizedTags = normalizeThemeTagInput(editState.themeTags.join(''));
const draftLevelName = draft.levelName.trim();
const draftSummary = draft.summary.trim();
const draftTags = normalizeThemeTagInput(draft.themeTags.join(''));
const levelNameChanged = normalizedLevelName !== draftLevelName;
const summaryChanged = normalizedSummary !== draftSummary;
const tagsChanged =
normalizedTags.length !== draftTags.length ||
normalizedTags.some((tag, index) => tag !== draftTags[index]);
if (!levelNameChanged && !summaryChanged && !tagsChanged) {
return;
}
setAutoSaveState('saving');
setAutoSaveError(null);
let cancelled = false;
const timer = window.setTimeout(() => {
void updatePuzzleWork(profileId, {
levelName: normalizedLevelName,
summary: normalizedSummary,
themeTags: normalizedTags,
coverImageSrc: formalImageSrc || null,
coverAssetId: draft.coverAssetId ?? null,
})
.then(() => {
if (cancelled) {
return;
}
setAutoSaveState('saved');
})
.catch((saveError) => {
if (cancelled) {
return;
}
setAutoSaveState('error');
setAutoSaveError(
saveError instanceof Error ? saveError.message : '自动保存失败。',
);
});
}, PUZZLE_AUTOSAVE_DEBOUNCE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [draft, editState, formalImageSrc, profileId]);
const publishState = useMemo(() => { const publishState = useMemo(() => {
if (!draft || !editState) { if (!draft || !editState) {
return { return {
@@ -915,7 +1016,11 @@ export function PuzzleResultView({
return ( return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,98rem)] xl:px-1 2xl:max-w-[min(100%,112rem)]"> <div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,98rem)] xl:px-1 2xl:max-w-[min(100%,112rem)]">
<PuzzleResultHeader isBusy={isBusy} onBack={onBack} /> <PuzzleResultHeader
autoSaveState={autoSaveState}
isBusy={isBusy}
onBack={onBack}
/>
<PuzzleResultTabs <PuzzleResultTabs
activeTab={activeTab} activeTab={activeTab}
@@ -953,6 +1058,11 @@ export function PuzzleResultView({
{error} {error}
</div> </div>
) : null} ) : null}
{!error && autoSaveError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{autoSaveError}
</div>
) : null}
<PuzzleResultActionBar <PuzzleResultActionBar
draft={draft} draft={draft}

View File

@@ -8,7 +8,6 @@ import {
Clock3, Clock3,
Coins, Coins,
Copy, Copy,
Crown,
House, House,
LogIn, LogIn,
MessageCircle, MessageCircle,
@@ -36,19 +35,17 @@ import type {
ProfileDashboardSummary, ProfileDashboardSummary,
ProfilePlayedWorkSummary, ProfilePlayedWorkSummary,
ProfilePlayStatsResponse, ProfilePlayStatsResponse,
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
ProfileReferralInviteCenterResponse, ProfileReferralInviteCenterResponse,
ProfileSaveArchiveSummary, ProfileSaveArchiveSummary,
RedeemProfileReferralInviteCodeResponse, RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeResponse,
} from '../../../packages/shared/src/contracts/runtime'; } from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { AuthUser } from '../../services/authService'; import type { AuthUser } from '../../services/authService';
import { import {
createRpgProfileRechargeOrder,
getRpgProfileRechargeCenter,
getRpgProfileReferralInviteCenter, getRpgProfileReferralInviteCenter,
redeemRpgProfileReferralInviteCode, redeemRpgProfileReferralInviteCode,
redeemRpgProfileRewardCode,
} from '../../services/rpg-entry/rpgProfileClient'; } from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types'; import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext'; import { useAuthUi } from '../auth/AuthUiContext';
@@ -933,206 +930,68 @@ function ProfileShortcutButton({
); );
} }
function formatRechargePrice(priceCents: number) { function RewardCodeRedeemModal({
const yuan = priceCents / 100; value,
return Number.isInteger(yuan) ? `¥${yuan}` : `¥${yuan.toFixed(2)}`;
}
function formatMembershipDuration(days: number) {
if (days >= 365) {
return '365天';
}
return `${days}`;
}
function AccountRechargeModal({
center,
activeTab,
isLoading,
isSubmitting, isSubmitting,
error, error,
onTabChange, success,
onChange,
onSubmit,
onClose, onClose,
onSelectProduct,
}: { }: {
center: ProfileRechargeCenterResponse | null; value: string;
activeTab: 'points' | 'membership'; isSubmitting: boolean;
isLoading: boolean;
isSubmitting: string | null;
error: string | null; error: string | null;
onTabChange: (tab: 'points' | 'membership') => void; success: string | null;
onChange: (value: string) => void;
onSubmit: () => void;
onClose: () => void; onClose: () => void;
onSelectProduct: (product: ProfileRechargeProduct) => void;
}) { }) {
const visibleProducts =
activeTab === 'points'
? (center?.pointProducts ?? [])
: (center?.membershipProducts ?? []);
return ( return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5"> <div className="platform-modal-backdrop fixed inset-0 z-50 flex items-center justify-center px-4 py-6">
<div className="relative max-h-[min(92vh,46rem)] w-full max-w-[32rem] overflow-hidden rounded-[1.35rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_34%,#f8fafc_100%)] text-zinc-950 shadow-2xl"> <div className="platform-recharge-modal w-full max-w-sm overflow-hidden rounded-[1.4rem]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div className="text-base font-black"></div>
<button <button
type="button" type="button"
aria-label="关闭兑换码"
onClick={onClose} onClick={onClose}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/78 text-[#ff4056] shadow-sm" className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
aria-label="关闭账户充值"
> >
× ×
</button> </button>
<div className="max-h-[min(92vh,46rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
<div className="pr-10">
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
WALLET
</div> </div>
<div className="mt-1 text-2xl font-black"></div> <div className="space-y-3 px-5 py-5">
<div className="mt-2 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-3 py-1.5 text-xs font-bold text-zinc-600"> <input
<Coins className="h-3.5 w-3.5 text-[#ff4056]" /> value={value}
<span> onChange={(event) => onChange(event.target.value)}
{center ? `${center.walletBalance}叙世币` : '叙世币账户'} onKeyDown={(event) => {
</span> if (event.key === 'Enter') {
</div> onSubmit();
</div> }
}}
<div className="mt-4 grid grid-cols-2 rounded-2xl bg-zinc-100/90 p-1"> className="platform-profile-input w-full rounded-2xl px-4 py-3 text-sm font-semibold uppercase tracking-normal"
placeholder="输入兑换码"
autoFocus
/>
<button <button
type="button" type="button"
onClick={() => onTabChange('points')} onClick={onSubmit}
className={`rounded-xl px-4 py-3 text-sm font-black transition ${ disabled={isSubmitting || !value.trim()}
activeTab === 'points' className="platform-primary-button w-full rounded-2xl px-4 py-3 text-sm font-black disabled:cursor-not-allowed disabled:opacity-50"
? 'bg-white text-[#ff4056] shadow-sm'
: 'text-zinc-500'
}`}
> >
{isSubmitting ? '兑换中' : '兑换'}
</button> </button>
<button
type="button"
onClick={() => onTabChange('membership')}
className={`rounded-xl px-4 py-3 text-sm font-black transition ${
activeTab === 'membership'
? 'bg-white text-[#ff4056] shadow-sm'
: 'text-zinc-500'
}`}
>
</button>
</div>
{error ? ( {error ? (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700"> <div className="platform-profile-error rounded-2xl px-3 py-2 text-xs font-semibold">
{error} {error}
</div> </div>
) : null} ) : null}
{success ? (
{isLoading ? ( <div className="platform-profile-success rounded-2xl px-3 py-2 text-xs font-semibold">
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3"> {success}
{Array.from({ length: activeTab === 'points' ? 6 : 3 }).map(
(_, index) => (
<div
key={index}
className="h-24 animate-pulse rounded-xl bg-zinc-100"
/>
),
)}
</div> </div>
) : activeTab === 'points' ? ( ) : null}
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
{visibleProducts.map((product) => (
<button
type="button"
key={product.productId}
disabled={Boolean(isSubmitting)}
onClick={() => onSelectProduct(product)}
className="relative min-h-[8.45rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white text-center shadow-sm transition hover:border-[#ff4056] disabled:opacity-70"
>
<div className="h-8 bg-[#ff4056] px-2 py-1.5 text-xs font-black text-white">
{product.badgeLabel}
</div>
<div className="px-2 py-3">
<div className="text-xl font-black">
{product.pointsAmount}
</div>
<div className="mt-1 text-xs text-zinc-500">
{formatRechargePrice(product.priceCents)}
</div>
<div className="my-2 h-px bg-zinc-100" />
<div className="text-sm text-zinc-800">
{isSubmitting === product.productId
? '处理中'
: product.description}
</div>
</div>
</button>
))}
</div>
) : (
<>
<div className="mt-5 grid gap-3 sm:grid-cols-3">
{visibleProducts.map((product) => (
<button
type="button"
key={product.productId}
disabled={Boolean(isSubmitting)}
onClick={() => onSelectProduct(product)}
className="group relative min-h-[7.75rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-left shadow-sm transition hover:border-[#ff4056] hover:shadow-md disabled:opacity-70"
>
<div className="absolute right-0 top-0 h-16 w-16 rounded-bl-[2rem] bg-[#ff4056]/10 transition group-hover:bg-[#ff4056]/16" />
<div className="relative">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-lg font-black">
{product.title}
</div>
<div className="mt-1 text-xs font-bold text-zinc-500">
{formatMembershipDuration(product.durationDays)}
</div>
</div>
<Crown className="h-5 w-5 shrink-0 text-[#ff4056]" />
</div>
<div className="mt-4 text-2xl font-black text-[#ff4056]">
{formatRechargePrice(product.priceCents)}
</div>
<div className="mt-2 text-xs font-semibold text-zinc-500">
{isSubmitting === product.productId
? '处理中'
: product.description}
</div>
</div>
</button>
))}
</div>
<div className="mt-5 overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm">
<div className="border-b border-zinc-200 px-4 py-3 text-sm font-black">
</div>
<div className="overflow-x-auto">
<div className="grid min-w-[30rem] grid-cols-5 text-center text-sm">
{center?.benefits.map((benefit) => (
<div key={benefit.benefitName} className="contents">
<div className="border-b border-zinc-100 bg-zinc-50 px-2 py-3 text-left text-zinc-600">
{benefit.benefitName}
</div>
<div className="border-b border-zinc-100 px-2 py-3 text-zinc-500">
{benefit.normalValue}
</div>
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-emerald-700">
{benefit.monthValue}
</div>
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-rose-500">
{benefit.seasonValue}
</div>
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-amber-600">
{benefit.yearValue}
</div>
</div>
))}
</div>
</div>
</div>
</>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -1425,16 +1284,13 @@ export function RpgEntryHomeView({
const authUi = useAuthUi(); const authUi = useAuthUi();
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState(''); const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
const [mobileSearchKeyword, setMobileSearchKeyword] = useState(''); const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
const [isRechargeOpen, setIsRechargeOpen] = useState(false); const [isRewardCodeOpen, setIsRewardCodeOpen] = useState(false);
const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>( const [rewardCodeInput, setRewardCodeInput] = useState('');
'points', const [isSubmittingRewardCode, setIsSubmittingRewardCode] = useState(false);
const [rewardCodeError, setRewardCodeError] = useState<string | null>(null);
const [rewardCodeSuccess, setRewardCodeSuccess] = useState<string | null>(
null,
); );
const [rechargeCenter, setRechargeCenter] =
useState<ProfileRechargeCenterResponse | null>(null);
const [rechargeError, setRechargeError] = useState<string | null>(null);
const [isLoadingRecharge, setIsLoadingRecharge] = useState(false);
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
useState<string | null>(null);
const [profilePopupPanel, setProfilePopupPanel] = const [profilePopupPanel, setProfilePopupPanel] =
useState<ProfilePopupPanel | null>(null); useState<ProfilePopupPanel | null>(null);
const [referralCenter, setReferralCenter] = const [referralCenter, setReferralCenter] =
@@ -1532,36 +1388,6 @@ export function RpgEntryHomeView({
} }
authUi?.openLoginModal(); authUi?.openLoginModal();
}; };
const openRechargePanel = () => {
setIsRechargeOpen(true);
setRechargeError(null);
setIsLoadingRecharge(true);
void getRpgProfileRechargeCenter()
.then(setRechargeCenter)
.catch((error: unknown) => {
setRechargeCenter(null);
setRechargeError(
error instanceof Error ? error.message : '读取账户充值失败',
);
})
.finally(() => setIsLoadingRecharge(false));
};
const submitRechargeProduct = (product: ProfileRechargeProduct) => {
if (submittingRechargeProductId) {
return;
}
setSubmittingRechargeProductId(product.productId);
setRechargeError(null);
void createRpgProfileRechargeOrder(product.productId)
.then((response) => {
setRechargeCenter(response.center);
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
setRechargeError(error instanceof Error ? error.message : '充值失败');
})
.finally(() => setSubmittingRechargeProductId(null));
};
const openProfilePopupPanel = (panel: ProfilePopupPanel) => { const openProfilePopupPanel = (panel: ProfilePopupPanel) => {
setProfilePopupPanel(panel); setProfilePopupPanel(panel);
setReferralError(null); setReferralError(null);
@@ -1617,6 +1443,30 @@ export function RpgEntryHomeView({
}) })
.finally(() => setIsSubmittingReferral(false)); .finally(() => setIsSubmittingReferral(false));
}; };
const openRewardCodeModal = () => {
setIsRewardCodeOpen(true);
setRewardCodeError(null);
setRewardCodeSuccess(null);
};
const submitRewardCode = () => {
if (isSubmittingRewardCode || !rewardCodeInput.trim()) {
return;
}
setIsSubmittingRewardCode(true);
setRewardCodeError(null);
setRewardCodeSuccess(null);
void redeemRpgProfileRewardCode(rewardCodeInput)
.then((response: RedeemProfileRewardCodeResponse) => {
setRewardCodeInput('');
setRewardCodeSuccess(`已到账 ${response.amountGranted} 叙世币`);
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
setRewardCodeError(error instanceof Error ? error.message : '兑换失败');
})
.finally(() => setIsSubmittingRewardCode(false));
};
const submitDesktopSearch = () => { const submitDesktopSearch = () => {
const keyword = desktopSearchKeyword.trim(); const keyword = desktopSearchKeyword.trim();
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) { if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
@@ -1964,17 +1814,13 @@ export function RpgEntryHomeView({
<button <button
type="button" type="button"
onClick={openRechargePanel} onClick={openRewardCodeModal}
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left" className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
> >
<Crown className="h-4 w-4" /> <Ticket className="h-4 w-4" />
<div> <div>
<div className="text-xs font-bold"></div> <div className="text-xs font-bold"></div>
<div className="text-[10px] opacity-80"> <div className="text-[10px] opacity-80"></div>
{rechargeCenter?.membership.status === 'active'
? '叙世会员'
: '普通用户'}
</div>
</div> </div>
<ChevronRight className="h-4 w-4 opacity-80" /> <ChevronRight className="h-4 w-4 opacity-80" />
</button> </button>
@@ -2422,18 +2268,6 @@ export function RpgEntryHomeView({
))} ))}
</div> </div>
</div> </div>
{isRechargeOpen ? (
<AccountRechargeModal
center={rechargeCenter}
activeTab={rechargeTab}
isLoading={isLoadingRecharge}
isSubmitting={submittingRechargeProductId}
error={rechargeError}
onTabChange={setRechargeTab}
onClose={() => setIsRechargeOpen(false)}
onSelectProduct={submitRechargeProduct}
/>
) : null}
{profilePopupPanel ? ( {profilePopupPanel ? (
<ProfileReferralModal <ProfileReferralModal
panel={profilePopupPanel} panel={profilePopupPanel}
@@ -2535,16 +2369,15 @@ export function RpgEntryHomeView({
</div> </div>
</div> </div>
</div> </div>
{isRechargeOpen ? ( {isRewardCodeOpen ? (
<AccountRechargeModal <RewardCodeRedeemModal
center={rechargeCenter} value={rewardCodeInput}
activeTab={rechargeTab} isSubmitting={isSubmittingRewardCode}
isLoading={isLoadingRecharge} error={rewardCodeError}
isSubmitting={submittingRechargeProductId} success={rewardCodeSuccess}
error={rechargeError} onChange={setRewardCodeInput}
onTabChange={setRechargeTab} onSubmit={submitRewardCode}
onClose={() => setIsRechargeOpen(false)} onClose={() => setIsRewardCodeOpen(false)}
onSelectProduct={submitRechargeProduct}
/> />
) : null} ) : null}
{profilePopupPanel ? ( {profilePopupPanel ? (

View File

@@ -0,0 +1,236 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeAll, expect, test, vi } from 'vitest';
import {
AnimationState,
type Character,
type QuestLogEntry,
type StoryMoment,
type StoryOption,
WorldType,
} from '../../types';
import { RpgAdventurePanel } from './RpgAdventurePanel';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '测试主角',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 8,
spirit: 9,
},
personality: 'calm',
skills: [],
adventureOpenings: {},
} as Character;
}
function createOption(functionId: string, actionText: string): StoryOption {
return {
functionId,
actionText,
text: actionText,
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
};
}
function createPendingQuest(): QuestLogEntry {
return {
id: 'quest-liu-1',
issuerNpcId: 'npc-liu',
issuerNpcName: '柳无声',
sceneId: 'scene-bamboo',
title: '竹林密信',
description: '替柳无声查清竹林中的密信来源。',
summary: '去竹林查清密信来源。',
objective: {
kind: 'inspect_treasure',
requiredCount: 1,
},
progress: 0,
status: 'active',
reward: {
affinityBonus: 5,
currency: 10,
items: [],
},
rewardText: '完成后可获得报酬。',
};
}
function createPendingQuestStory(quest: QuestLogEntry): StoryMoment {
const viewOption = createOption('npc_chat_quest_offer_view', '查看任务');
viewOption.runtimePayload = {
npcChatQuestOfferAction: 'view',
};
const replaceOption = createOption('npc_chat_quest_offer_replace', '更换任务');
replaceOption.runtimePayload = {
npcChatQuestOfferAction: 'replace',
};
const abandonOption = createOption('npc_chat_quest_offer_abandon', '放弃任务');
abandonOption.runtimePayload = {
npcChatQuestOfferAction: 'abandon',
};
return {
text: '柳无声把真正的委托说了出来。',
displayMode: 'dialogue',
dialogue: [
{ speaker: 'npc', speakerName: '柳无声', text: '这件事我只想正式托付给你。' },
],
options: [viewOption, replaceOption, abandonOption],
npcChatState: {
npcId: 'npc-liu',
npcName: '柳无声',
turnCount: 2,
customInputPlaceholder: '输入你想对 TA 说的话',
pendingQuestOffer: {
quest,
},
},
};
}
function createAcceptedQuestStory(quest: QuestLogEntry): StoryMoment {
return {
text: '柳无声把接下来的线索正式交给了你。',
displayMode: 'dialogue',
dialogue: [
{ speaker: 'npc', speakerName: '柳无声', text: '这件事我只想正式托付给你。' },
{ speaker: 'player', text: '这件事我愿意接下,你把关键要点交给我。' },
{ speaker: 'npc', speakerName: '柳无声', text: '先去竹林查清密信来源。' },
],
options: [
createOption('npc_chat', '这件事里你最担心哪一步'),
createOption('npc_chat', '我回来时你最想先知道什么'),
],
npcChatState: {
npcId: 'npc-liu',
npcName: '柳无声',
turnCount: 2,
customInputPlaceholder: '输入你想对 TA 说的话',
pendingQuestOffer: null,
},
};
}
function QuestOfferHarness() {
const pendingQuest = createPendingQuest();
const [currentStory, setCurrentStory] = useState<StoryMoment>(
createPendingQuestStory(pendingQuest),
);
const [quests, setQuests] = useState<QuestLogEntry[]>([]);
const acceptPendingOffer = vi.fn(() => {
queueMicrotask(() => {
setQuests([pendingQuest]);
setCurrentStory(createAcceptedQuestStory(pendingQuest));
});
return pendingQuest.id;
});
return (
<RpgAdventurePanel
aiError={null}
currentStory={currentStory}
isLoading={false}
displayedOptions={currentStory.options}
hideOptions={false}
canRefreshOptions={false}
onRefreshOptions={() => undefined}
onChoice={() => undefined}
onSubmitNpcChatInput={() => true}
onExitNpcChat={() => true}
onOpenCharacter={() => undefined}
onOpenInventory={() => undefined}
playerCharacter={createCharacter()}
worldType={WorldType.WUXIA}
quests={quests}
questUi={{
acknowledgeQuestCompletion: () => undefined,
claimQuestReward: () => null,
}}
npcChatQuestOfferUi={{
replacePendingOffer: () => false,
abandonPendingOffer: () => false,
acceptPendingOffer,
}}
goalStack={{
northStarGoal: null,
activeGoal: null,
immediateStepGoal: null,
supportGoals: [],
}}
goalPulse={null}
onDismissGoalPulse={() => undefined}
battleRewardUi={{
reward: null,
dismiss: () => undefined,
}}
playerHp={100}
playerMaxHp={100}
playerMana={20}
playerMaxMana={20}
playerSkillCooldowns={{}}
inBattle={false}
currentNpcBattleMode={null}
statistics={{
playTimeMs: 0,
hostileNpcsDefeated: 0,
questsAccepted: 0,
questsCompleted: 0,
questsTurnedIn: 0,
itemsUsed: 0,
scenesTraveled: 0,
currentSceneName: '竹林古道',
playerCurrency: 0,
inventoryItemCount: 0,
inventoryStackCount: 0,
activeCompanionCount: 0,
rosterCompanionCount: 0,
}}
musicVolume={0.6}
onMusicVolumeChange={() => undefined}
onSaveAndExit={() => undefined}
/>
);
}
beforeAll(() => {
if (!HTMLElement.prototype.scrollTo) {
HTMLElement.prototype.scrollTo = () => undefined;
}
});
test('quest offer accept button reuses the shared accepted-quest follow-up chain', async () => {
const user = userEvent.setup();
render(<QuestOfferHarness />);
await user.click(screen.getByRole('button', { name: //u }));
await user.click(await screen.findByRole('button', { name: '领取任务' }));
expect(await screen.findByText('任务进度0/1')).toBeTruthy();
expect(screen.getAllByText('竹林密信').length).toBeGreaterThan(0);
expect(screen.queryByText('待领取')).toBeNull();
expect(screen.getByText('这件事里你最担心哪一步')).toBeTruthy();
});

View File

@@ -1724,6 +1724,9 @@ export function RpgAdventurePanel({
onAcceptPendingNpcQuestOffer={() => { onAcceptPendingNpcQuestOffer={() => {
const acceptedQuestId = npcChatQuestOfferUi.acceptPendingOffer(); const acceptedQuestId = npcChatQuestOfferUi.acceptPendingOffer();
if (!acceptedQuestId) return null; if (!acceptedQuestId) return null;
// 中文注释:待领取任务详情弹层走的是异步服务端接取链路,
// 这里先记录 questId等 quest 真正进入日志后再由 effect 统一收口面板状态。
setPendingAcceptedQuestId(acceptedQuestId);
setSelectedQuestId(null); setSelectedQuestId(null);
return acceptedQuestId; return acceptedQuestId;
}} }}

View File

@@ -137,6 +137,7 @@ describe('sceneEncounterPreviews', () => {
expect(resolved.currentEncounter).toBeNull(); expect(resolved.currentEncounter).toBeNull();
expect(resolved.currentBattleNpcId).toBe('npc-trader'); expect(resolved.currentBattleNpcId).toBe('npc-trader');
expect(resolved.currentNpcBattleMode).toBe('fight'); expect(resolved.currentNpcBattleMode).toBe('fight');
expect(resolved.sparReturnEncounter).toEqual(state.currentEncounter);
expect(resolved.sceneHostileNpcs).toHaveLength(1); expect(resolved.sceneHostileNpcs).toHaveLength(1);
expect(resolved.sceneHostileNpcs[0]?.encounter?.npcName).toBe('Trader Lin'); expect(resolved.sceneHostileNpcs[0]?.encounter?.npcName).toBe('Trader Lin');
}); });

View File

@@ -240,7 +240,9 @@ function buildResolvedNpcBattleState(state: GameState, encounter: Encounter) {
currentBattleNpcId: battleNpcId, currentBattleNpcId: battleNpcId,
currentNpcBattleMode: 'fight' as const, currentNpcBattleMode: 'fight' as const,
currentNpcBattleOutcome: null, currentNpcBattleOutcome: null,
sparReturnEncounter: null, // 中文注释NPC 开战后要保留战前原始遭遇,供战斗收尾时恢复和平态站位。
// 这里复用现有 sparReturnEncounter 存槽,避免战后误把 battle encounter 的临时坐标带回场景。
sparReturnEncounter: encounter,
sparPlayerHpBefore: null, sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null, sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null, sparStoryHistoryBefore: null,

View File

@@ -132,67 +132,16 @@ function createBattleOption(functionId = 'battle_all_in_crush'): StoryOption {
}; };
} }
function createFallbackStory(text = 'fallback'): StoryMoment { function createFallbackStory(
text = 'fallback',
options: StoryOption[] = [],
): StoryMoment {
return { return {
text, text,
options: [], options,
}; };
} }
function createCustomWorldProfileForSceneAct(sceneId: string) {
return {
id: 'custom-world-test',
name: '场景幕重置测试',
summary: '用于验证战败后回到首幕。',
playableNpcs: [],
storyNpcs: [],
sceneChapterBlueprints: [
{
id: `${sceneId}-chapter`,
sceneId,
title: '测试章节',
summary: '测试章节摘要',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: `${sceneId}-act-1`,
sceneId,
title: '第一幕',
summary: '开场第一幕',
stageCoverage: ['opening'],
backgroundImageSrc: '/act-1.png',
encounterNpcIds: [],
primaryNpcId: null,
oppositeNpcId: null,
eventDescription: '第一幕事件',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '完成第一幕目标',
transitionHook: '第一幕过渡',
},
{
id: `${sceneId}-act-2`,
sceneId,
title: '第二幕',
summary: '推进第二幕',
stageCoverage: ['expansion'],
backgroundImageSrc: '/act-2.png',
encounterNpcIds: [],
primaryNpcId: null,
oppositeNpcId: null,
eventDescription: '第二幕事件',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '完成第二幕目标',
transitionHook: '第二幕过渡',
},
],
},
],
} as NonNullable<GameState['customWorldProfile']>;
}
const neverNpcEncounter = ( const neverNpcEncounter = (
encounter: GameState['currentEncounter'], encounter: GameState['currentEncounter'],
): encounter is Encounter => false; ): encounter is Encounter => false;
@@ -692,10 +641,8 @@ describe('createStoryChoiceActions', () => {
it('keeps local npc defeat on the death revive chain and resets to the first scene act', async () => { it('keeps local npc defeat on the death revive chain and resets to the first scene act', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const firstScene = getScenePresetsByWorld(WorldType.WUXIA)[0]!; const firstScene = getScenePresetsByWorld(WorldType.WUXIA)[0]!;
const customWorldProfile = createCustomWorldProfileForSceneAct(firstScene.id);
const state = { const state = {
...createBaseState(), ...createBaseState(),
customWorldProfile,
currentScenePreset: firstScene, currentScenePreset: firstScene,
storyEngineMemory: { storyEngineMemory: {
discoveredFactIds: [], discoveredFactIds: [],
@@ -735,7 +682,6 @@ describe('createStoryChoiceActions', () => {
})); }));
const setCurrentStory = vi.fn(); const setCurrentStory = vi.fn();
const setGameState = vi.fn(); const setGameState = vi.fn();
const { handleChoice } = createStoryChoiceActions({ const { handleChoice } = createStoryChoiceActions({
gameState: state, gameState: state,
currentStory: createFallbackStory(), currentStory: createFallbackStory(),
@@ -763,7 +709,7 @@ describe('createStoryChoiceActions', () => {
skillCooldowns: {}, skillCooldowns: {},
})), })),
buildStoryFromResponse: vi.fn((_, __, response) => response), buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()), buildFallbackStoryForState: vi.fn(() => createFallbackStory('fallback')),
generateStoryForState: vi.fn(), generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => null), getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []), getStoryGenerationHostileNpcs: vi.fn(() => []),
@@ -809,22 +755,24 @@ describe('createStoryChoiceActions', () => {
id: firstScene.id, id: firstScene.id,
}), }),
playerHp: 100, playerHp: 100,
playerMana: 20,
inBattle: false, inBattle: false,
currentNpcBattleOutcome: null, currentNpcBattleOutcome: null,
storyEngineMemory: expect.objectContaining({
currentSceneActState: expect.objectContaining({
sceneId: firstScene.id,
currentActId: `${firstScene.id}-act-1`,
currentActIndex: 0,
}),
}),
}), }),
); );
const revivedState = setGameState.mock.calls[1]?.[0] as GameState;
expect(revivedState.currentBattleNpcId).toBeNull();
expect(revivedState.currentNpcBattleMode).toBeNull();
expect(revivedState.currentNpcBattleOutcome).toBeNull();
expect(
revivedState.currentEncounter !== null || revivedState.sceneHostileNpcs.length > 0,
).toBe(true);
expect(setCurrentStory).toHaveBeenCalledWith( expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
text: expect.stringContaining('重新醒来'), text: expect.stringContaining('重新醒来'),
}), }),
); );
vi.useRealTimers();
}); });
it('settles escape locally without ai continuation', async () => { it('settles escape locally without ai continuation', async () => {

View File

@@ -721,6 +721,59 @@ describe('npcEncounterActions', () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it('restores the pre-battle encounter after fight_victory instead of using the battle encounter position', () => {
const preBattleEncounter = {
...createEncounter(),
xMeters: 12,
context: '断桥外侧',
};
const battleEncounter = {
...createEncounter(),
xMeters: 3.2,
context: '战斗中心位',
};
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: battleEncounter,
inBattle: true,
currentBattleNpcId: 'npc-rival',
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: 'fight_victory',
sparReturnEncounter: preBattleEncounter,
sceneHostileNpcs: [
{
id: 'npc-rival',
name: '断桥客',
action: '逼近',
description: '拦路旧敌',
animation: 'idle',
xMeters: 3.2,
yOffset: 0,
facing: 'left',
attackRange: 1.4,
speed: 7,
hp: 0,
maxHp: 12,
renderKind: 'npc',
encounter: battleEncounter,
},
],
}),
});
const result = actions.finalizeNpcBattleResult(
actions.gameState,
actions.gameState.playerCharacter!,
'fight',
'fight_victory',
);
expect(result).not.toBeNull();
expect(result?.nextState.currentEncounter).toEqual(preBattleEncounter);
expect(result?.nextState.currentEncounter?.xMeters).toBe(12);
expect(result?.nextState.sparReturnEncounter).toBeNull();
});
it('streams a model-driven npc-initiated opening on first meaningful contact', async () => { it('streams a model-driven npc-initiated opening on first meaningful contact', async () => {
const encounter = createEncounter(); const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({ streamNpcChatTurnMock.mockResolvedValueOnce({

View File

@@ -0,0 +1,327 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const { ensureSceneEncounterPreviewMock } = vi.hoisted(() => ({
ensureSceneEncounterPreviewMock: vi.fn(),
}));
vi.mock('../../data/sceneEncounterPreviews', () => ({
ensureSceneEncounterPreview: ensureSceneEncounterPreviewMock,
}));
import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime';
import { getScenePresetsByWorld } from '../../data/scenePresets';
import { AnimationState, type GameState, WorldType } from '../../types';
import { buildRevivedFirstSceneState } from './postBattleFlow';
function createBackstoryReveal(label: string) {
return {
publicSummary: `${label}的公开背景`,
chapters: [
{
id: `${label}-surface`,
title: '表层来意',
affinityRequired: 15,
teaser: `${label}先收着话。`,
content: `${label}把真正目的藏在后面。`,
contextSnippet: `${label}表面上仍在试探。`,
},
{
id: `${label}-scar`,
title: '旧事裂痕',
affinityRequired: 30,
teaser: `${label}提到旧事会迟疑。`,
content: `${label}背后压着旧伤。`,
contextSnippet: `${label}仍被旧事牵制。`,
},
{
id: `${label}-hidden`,
title: '隐藏执念',
affinityRequired: 60,
teaser: `${label}真正执念并不在表面。`,
content: `${label}真正想守住的是另一条暗线。`,
contextSnippet: `${label}另有没说出口的理由。`,
},
{
id: `${label}-final`,
title: '最终底牌',
affinityRequired: 90,
teaser: `${label}手里还扣着底牌。`,
content: `${label}掌握能改写局势的最后证据。`,
contextSnippet: `${label}最后底牌还没翻出。`,
},
],
};
}
function createStoryRole(id: string, name: string, hostile = false) {
return {
id,
name,
title: `${name}的头衔`,
role: hostile ? '敌对角色' : '同幕角色',
description: `${name}的测试描述`,
backstory: `${name}的测试背景`,
personality: '冷静克制',
motivation: hostile ? '阻拦玩家继续向前' : '观察局势变化',
combatStyle: hostile ? '正面压制' : '后排支援',
initialAffinity: hostile ? -20 : 12,
relationshipHooks: [],
tags: [],
backstoryReveal: createBackstoryReveal(name),
skills: [],
initialItems: [],
};
}
function createReviveState(): GameState {
const customWorldProfile = {
id: 'custom-revive-test',
name: '复活回场测试世界',
subtitle: '首幕站位恢复',
summary: '用于验证复活后第一幕 NPC 会按既有 encounter preview 恢复。',
settingText: '围绕开局营地与第一幕对峙角色展开的自定义世界。',
tone: '紧张、克制',
playerGoal: '复活后重新回到第一幕并面对主交互角色。',
templateWorldType: WorldType.WUXIA,
majorFactions: [],
coreConflicts: [],
attributeSchema: {
id: 'schema:test',
worldId: 'CUSTOM',
schemaVersion: 1,
schemaName: '测试属性',
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '复活回场测试世界',
settingSummary: '首幕站位恢复',
tone: '紧张、克制',
conflictCore: '复活后重新面对主交互角色',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [
createStoryRole('npc-front', '正面对手', true),
createStoryRole('npc-back-1', '后排甲'),
createStoryRole('npc-back-2', '后排乙'),
],
items: [],
landmarks: [],
camp: {
id: 'custom-scene-camp',
name: '开局营地',
description: '用于复活回场测试。',
visualDescription: '营地火光映着即将重开的第一幕。',
imageSrc: '/camp.png',
sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
connections: [],
narrativeResidues: null,
},
sceneChapterBlueprints: [
{
id: 'custom-scene-camp-chapter',
sceneId: 'custom-scene-camp',
title: '开局章节',
summary: '复活后应回到这里的第一幕。',
sceneTaskDescription: '',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'custom-scene-camp-act-1',
sceneId: 'custom-scene-camp',
title: '第一幕',
summary: '主交互角色与后排角色一同出现。',
stageCoverage: ['opening'],
backgroundImageSrc: '/act-1.png',
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
primaryNpcId: 'npc-front',
oppositeNpcId: 'npc-front',
eventDescription: '第一幕事件',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '重新进入首幕',
transitionHook: '首幕回场',
},
{
id: 'custom-scene-camp-act-2',
sceneId: 'custom-scene-camp',
title: '第二幕',
summary: '这是死亡前已经推进到的幕。',
stageCoverage: ['expansion'],
backgroundImageSrc: '/act-2.png',
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
primaryNpcId: 'npc-front',
oppositeNpcId: 'npc-front',
eventDescription: '第二幕事件',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '推进第二幕',
transitionHook: '第二幕推进',
},
],
},
],
} as NonNullable<GameState['customWorldProfile']>;
setRuntimeCustomWorldProfile(customWorldProfile);
const firstScene = getScenePresetsByWorld(WorldType.CUSTOM)[0]!;
return {
worldType: WorldType.CUSTOM,
customWorldProfile,
playerCharacter: {
id: 'hero',
name: '测试主角',
title: '旅人',
description: '测试角色',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 10,
spirit: 10,
},
personality: 'calm',
skills: [],
adventureOpenings: {},
},
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.DIE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: firstScene,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 0,
playerMaxHp: 100,
playerMana: 0,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {
'npc-front': {
affinity: -20,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
'npc-back-1': {
affinity: 8,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
'npc-back-2': {
affinity: 6,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: 'npc-front',
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: 'fight_defeat',
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
storyEngineMemory: {
discoveredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
currentSceneActState: {
sceneId: 'custom-scene-camp',
chapterId: 'custom-scene-camp-chapter',
currentActId: 'custom-scene-camp-act-2',
currentActIndex: 1,
completedActIds: ['custom-scene-camp-act-1'],
visitedActIds: ['custom-scene-camp-act-1', 'custom-scene-camp-act-2'],
},
},
} as GameState;
}
describe('postBattleFlow', () => {
afterEach(() => {
ensureSceneEncounterPreviewMock.mockReset();
setRuntimeCustomWorldProfile(null);
});
it('rebuilds revived first-scene state through encounter preview restoration', () => {
const reviveState = createReviveState();
const previewRestoredState = {
...reviveState,
currentEncounter: {
id: 'npc-front',
kind: 'npc' as const,
characterId: 'npc-front',
npcName: '正面对手',
npcDescription: '正面对手的测试描述',
npcAvatar: '正',
context: '敌对角色',
xMeters: 12,
},
};
ensureSceneEncounterPreviewMock.mockReturnValue(previewRestoredState);
const revived = buildRevivedFirstSceneState(reviveState);
expect(ensureSceneEncounterPreviewMock).toHaveBeenCalledWith(
expect.objectContaining({
currentScenePreset: expect.objectContaining({
id: 'custom-scene-camp',
}),
currentEncounter: null,
sceneHostileNpcs: [],
playerHp: 100,
playerMana: 20,
inBattle: false,
currentNpcBattleOutcome: null,
storyEngineMemory: expect.objectContaining({
currentSceneActState: expect.objectContaining({
currentActId: 'custom-scene-camp-act-1',
currentActIndex: 0,
}),
}),
}),
);
expect(revived).toBe(previewRestoredState);
});
});

View File

@@ -1,4 +1,5 @@
import { getScenePresetById, getScenePresetsByWorld } from '../../data/scenePresets'; import { getScenePresetById, getScenePresetsByWorld } from '../../data/scenePresets';
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
import { import {
advanceSceneActRuntimeState, advanceSceneActRuntimeState,
buildInitialSceneActRuntimeState, buildInitialSceneActRuntimeState,
@@ -169,7 +170,7 @@ export function buildRevivedFirstSceneState(state: GameState): GameState {
storyEngineMemory: undefined, storyEngineMemory: undefined,
}); });
return { const revivedBaseState = {
...state, ...state,
currentScenePreset: firstScene, currentScenePreset: firstScene,
currentEncounter: null, currentEncounter: null,
@@ -195,19 +196,34 @@ export function buildRevivedFirstSceneState(state: GameState): GameState {
...storyEngineMemory, ...storyEngineMemory,
currentSceneActState: firstActState, currentSceneActState: firstActState,
}, },
}; } satisfies GameState;
// 中文注释:角色复活后要回到“开局进入世界”同一套首幕 encounter preview
// 构建链,而不是只清空战斗态。这样第一幕主交互 NPC 与同幕陪衬 NPC
// 会按既有槽位一起恢复,避免退化成所有人站成一排。
return ensureSceneEncounterPreview(revivedBaseState);
} }
export function buildDeathStory(state: GameState): StoryMoment { export function buildDeathStory(
state: GameState,
deferredOptions?: StoryOption[],
): StoryMoment {
const firstSceneName = const firstSceneName =
state.worldType state.worldType
? getScenePresetsByWorld(state.worldType)[0]?.name ? getScenePresetsByWorld(state.worldType)[0]?.name
: state.currentScenePreset?.name; : state.currentScenePreset?.name;
return { return {
text: firstSceneName text: firstSceneName
? `你在战斗中倒下,随后在${firstSceneName}重新醒来。` ? `你在战斗中倒下,随后在${firstSceneName}重新醒来。`
: '你在战斗中倒下,随后重新醒来。', : '你在战斗中倒下,随后重新醒来。',
options: [buildContinueOption()], options: [buildContinueOption()],
// 中文注释:复活后的“继续前进”只负责揭示已经准备好的首场景入口,
// 不能再次触发普通剧情推演,否则容易直接被推进到主 NPC 执行态。
deferredOptions:
deferredOptions && deferredOptions.length > 0
? deferredOptions
: undefined,
streaming: false, streaming: false,
}; };
} }

View File

@@ -301,6 +301,13 @@ function bridgeServerNpcBattleSnapshot(params: {
sceneHostileNpcs: resolvedBattleFormation, sceneHostileNpcs: resolvedBattleFormation,
currentEncounter: null, currentEncounter: null,
npcInteractionActive: false, npcInteractionActive: false,
// 中文注释:服务端兼容链路若未带回战前遭遇,则沿用进入战斗前的原始 encounter
// 让后续 fight_victory / spar_complete 都能恢复到正确站位,而不是战斗中的临时坐标。
sparReturnEncounter:
snapshotState.sparReturnEncounter ??
(previousState.currentEncounter?.kind === 'npc'
? previousState.currentEncounter
: null),
}, },
} satisfies HydratedSavedGameSnapshot; } satisfies HydratedSavedGameSnapshot;
} }

View File

@@ -937,6 +937,9 @@ describe('runtimeStoryCoordinator', () => {
yOffset: 62, yOffset: 62,
}, },
]); ]);
expect(result.hydratedSnapshot.gameState.sparReturnEncounter).toEqual(
gameState.currentEncounter,
);
}); });
it('realigns non-empty npc_fight battle snapshots back to the visible pre-battle formation', async () => { it('realigns non-empty npc_fight battle snapshots back to the visible pre-battle formation', async () => {

View File

@@ -446,8 +446,12 @@ export async function runLocalStoryChoiceContinuation(params: {
], ],
}; };
fallbackState = revivedState; fallbackState = revivedState;
const revivedDeferredOptions =
params.buildFallbackStoryForState(revivedState, params.character).options;
params.setGameState(revivedState); params.setGameState(revivedState);
params.setCurrentStory(buildDeathStory(revivedState)); params.setCurrentStory(
buildDeathStory(revivedState, revivedDeferredOptions),
);
return; return;
} }

View File

@@ -527,7 +527,10 @@ describe('storyChoiceRuntime', () => {
setIsLoading: vi.fn(), setIsLoading: vi.fn(),
setGameState, setGameState,
setCurrentStory: setCurrentStory as (story: StoryMoment) => void, setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
buildFallbackStoryForState: () => createStory('fallback'), buildFallbackStoryForState: () =>
createStory('fallback', [
createOption('idle_explore_forward'),
]),
turnVisualMs: 1, turnVisualMs: 1,
}); });
@@ -541,6 +544,11 @@ describe('storyChoiceRuntime', () => {
expect(setCurrentStory).toHaveBeenCalledWith( expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
text: expect.stringContaining('重新醒来'), text: expect.stringContaining('重新醒来'),
options: [
expect.objectContaining({
functionId: 'story_continue_adventure',
}),
],
}), }),
); );
expect(setCurrentStory).not.toHaveBeenCalledWith( expect(setCurrentStory).not.toHaveBeenCalledWith(

View File

@@ -350,8 +350,12 @@ export async function runServerRuntimeChoiceAction(params: {
params.setGameState(deathState); params.setGameState(deathState);
await sleep(PLAYER_REVIVE_DELAY_MS); await sleep(PLAYER_REVIVE_DELAY_MS);
const revivedState = buildRevivedFirstSceneState(deathState); const revivedState = buildRevivedFirstSceneState(deathState);
const revivedDeferredOptions =
params.buildFallbackStoryForState(revivedState, params.character).options;
params.setGameState(revivedState); params.setGameState(revivedState);
params.setCurrentStory(buildDeathStory(revivedState)); params.setCurrentStory(
buildDeathStory(revivedState, revivedDeferredOptions),
);
return; return;
} }

View File

@@ -490,6 +490,7 @@ export function createStoryNpcEncounterActions({
(hostileNpc) => hostileNpc.id, (hostileNpc) => hostileNpc.id,
); );
const restoredEncounter = const restoredEncounter =
state.sparReturnEncounter ??
(state.currentEncounter?.kind === 'npc' ? state.currentEncounter : null) ?? (state.currentEncounter?.kind === 'npc' ? state.currentEncounter : null) ??
activeBattleHostiles[0]?.encounter ?? activeBattleHostiles[0]?.encounter ??
({ ({

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { syncGameStatePlayTime } from '../../data/runtimeStats';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { isAbortError } from '../../services/apiClient'; import { isAbortError } from '../../services/apiClient';
import { rpgSnapshotClient } from '../../services/rpg-runtime'; import { rpgSnapshotClient } from '../../services/rpg-runtime';
@@ -37,6 +38,10 @@ function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) {
}; };
} }
function buildPersistedGameState(gameState: GameState) {
return syncGameStatePlayTime(gameState);
}
export type UseRpgSessionPersistenceParams = { export type UseRpgSessionPersistenceParams = {
authenticatedUserId: string | null; authenticatedUserId: string | null;
gameState: GameState; gameState: GameState;
@@ -208,9 +213,10 @@ export function useRpgSessionPersistence({
if (!canPersist) return; if (!canPersist) return;
const timeoutId = window.setTimeout(() => { const timeoutId = window.setTimeout(() => {
const persistedGameState = buildPersistedGameState(gameState);
void persistSnapshot({ void persistSnapshot({
payload: { payload: {
gameState, gameState: persistedGameState,
bottomTab, bottomTab,
currentStory, currentStory,
}, },
@@ -235,9 +241,10 @@ export function useRpgSessionPersistence({
return false; return false;
} }
const persistedGameState = buildPersistedGameState(nextGameState);
const snapshot = await persistSnapshot({ const snapshot = await persistSnapshot({
payload: { payload: {
gameState: nextGameState, gameState: persistedGameState,
bottomTab: nextBottomTab, bottomTab: nextBottomTab,
currentStory: nextStory, currentStory: nextStory,
}, },

View File

@@ -161,3 +161,68 @@ test('unauthenticated runtime skips remote snapshot hydration', async () => {
expect(screen.getByTestId('saved-game').textContent).toBe('no'); expect(screen.getByTestId('saved-game').textContent).toBe('no');
expect(storageMocks.getSaveSnapshot).not.toHaveBeenCalled(); expect(storageMocks.getSaveSnapshot).not.toHaveBeenCalled();
}); });
test('authenticated runtime autosave syncs live play time before remote snapshot upload', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-27T10:00:02.000Z'));
storageMocks.putSaveSnapshot.mockResolvedValue({
gameState: {},
bottomTab: 'adventure',
currentStory: null,
});
const gameState = {
runtimePersistenceDisabled: false,
runtimeMode: 'play',
currentScene: 'Story',
worldType: 'CUSTOM',
playerCharacter: { id: 'hero-1' },
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: '2026-04-27T10:00:00.000Z',
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
} as GameState;
function AutosaveHarness() {
useRpgSessionPersistence({
authenticatedUserId: 'user-1',
gameState,
bottomTab: 'adventure' as BottomTab,
currentStory: { streaming: false } as StoryMoment,
isLoading: false,
setGameState: () => {},
setBottomTab: () => {},
hydrateStoryState: () => {},
resetStoryState: () => {},
});
return null;
}
render(<AutosaveHarness />);
await act(async () => {
vi.advanceTimersByTime(400);
await Promise.resolve();
await Promise.resolve();
});
expect(storageMocks.putSaveSnapshot).toHaveBeenCalledTimes(1);
expect(storageMocks.putSaveSnapshot).toHaveBeenCalledWith(
expect.objectContaining({
gameState: expect.objectContaining({
runtimeStats: expect.objectContaining({
playTimeMs: 2400,
lastPlayTickAt: '2026-04-27T10:00:02.400Z',
}),
}),
}),
expect.objectContaining({
signal: expect.any(AbortSignal),
}),
);
});

View File

@@ -46,7 +46,24 @@ export async function deleteBigFishWork(sessionId: string) {
); );
} }
/**
* 记录已发布大鱼吃小鱼作品的一次正式进入。
*/
export async function recordBigFishWorkPlay(sessionId: string) {
return requestJson<BigFishWorksResponse>(
`${BIG_FISH_WORKS_API_BASE}/${encodeURIComponent(sessionId)}/play`,
{
method: 'POST',
},
'记录大鱼吃小鱼游玩次数失败',
{
retry: BIG_FISH_WORKS_WRITE_RETRY,
},
);
}
export const bigFishWorksClient = { export const bigFishWorksClient = {
delete: deleteBigFishWork, delete: deleteBigFishWork,
list: listBigFishWorks, list: listBigFishWorks,
recordPlay: recordBigFishWorkPlay,
}; };

View File

@@ -2,4 +2,5 @@ export {
bigFishWorksClient, bigFishWorksClient,
deleteBigFishWork, deleteBigFishWork,
listBigFishWorks, listBigFishWorks,
recordBigFishWorkPlay,
} from './bigFishWorksClient'; } from './bigFishWorksClient';

View File

@@ -11,6 +11,7 @@ import type {
ProfileSaveArchiveResumeResponse, ProfileSaveArchiveResumeResponse,
ProfileWalletLedgerResponse, ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeResponse, RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeResponse,
RuntimeSettings, RuntimeSettings,
} from '../../../packages/shared/src/contracts/runtime'; } from '../../../packages/shared/src/contracts/runtime';
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot'; import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
@@ -125,6 +126,22 @@ export function redeemRpgProfileReferralInviteCode(
); );
} }
export function redeemRpgProfileRewardCode(
code: string,
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<RedeemProfileRewardCodeResponse>(
'/profile/redeem-codes/redeem',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
},
'兑换失败',
options,
);
}
export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) { export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) {
return requestRpgRuntimeJson<ProfilePlayStatsResponse>( return requestRpgRuntimeJson<ProfilePlayStatsResponse>(
'/profile/play-stats', '/profile/play-stats',