Add user played work stats for puzzle and big fish #2

Merged
kdletters merged 3 commits from codex/user-play-stats-played-works into master 2026-04-28 15:58:10 +08:00
80 changed files with 3609 additions and 434 deletions
Showing only changes of commit e7bd85e0b3 - Show all commits

View File

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

View File

@@ -210,3 +210,23 @@ npm.cmd run build
- 没有人手逐个点击整局游戏所有 function 的视觉回放,本轮重点是自动化测试、服务端测试、内容校验与 smoke 门禁。
因此,本审计可以说明“当前 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 发布后首页 / 分类页公开作品列表刷新链路。
- [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_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. 启动测试运行态
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. 本轮明确不做
1. 不在本文件内展开正式图片模型链、OSS 真相链和占位兼容层的细节;相关正式出图方案以 `BIG_FISH_FORMAL_IMAGE_GENERATION_2026-04-23.md` 为准。

View File

@@ -395,6 +395,17 @@ Node 侧入口位于:
这些都等 `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.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-28 更新:为开发期本地/测试服联调新增服务端环境变量 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED`,默认 `false`。仅当该变量显式为 `true` 时,`POST /api/auth/entry` 可对未知手机号用本次密码直接创建账号并登录;默认关闭时仍严格保持未知手机号返回 `401` 的生产语义。该开关不得用于生产环境,也不新增任何前端规则说明文案。
日期:`2026-04-21`
@@ -166,6 +168,13 @@
2. 不创建账号。
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 未设置密码
当账号存在但 `password_login_enabled = false` 时:
@@ -233,6 +242,8 @@
4. 邮箱、用户名或叙世号作为密码登录标识返回 `400`
5. 登录成功时返回 access token。
6. 登录成功时写回 refresh cookie。
7. `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED` 默认关闭时行为不变。
8. 开关开启时,未知手机号可通过 `/api/auth/entry` 创建账号并登录;同手机号后续用相同密码登录复用同一用户,错误密码仍返回 `401`
## 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 相关进程。
- [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 实例的根因、脚本修复和现场处理方式。

View File

@@ -96,6 +96,9 @@
4. 前端 `storyChoiceContinuation` / `useRpgRuntimeNpcInteraction`
- `fight_defeat` 不能再被当成“本地 NPC 战斗胜利”进入战后收束
- 玩家死亡后必须直接走死亡复活链,且复活时重置到开局场景第一幕,不能先推进下一幕
5. 前端 `postBattleFlow`
- 复活回到开局场景时,必须重新走首幕 encounter preview 恢复链
- 第一幕主交互 NPC 与同幕陪衬 NPC 要继续沿用既有场景槽位,不能退化成全部站成一排
## 继续收口2026-04-28
@@ -118,6 +121,24 @@
- 非 NPC 通用敌对战斗 `!inBattle`
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 主链的胜负判定口径变为:

View File

@@ -200,3 +200,47 @@
1. 自定义世界角色型敌人在战斗态不会再重复叠加场景立绘下沉偏移;
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` |
| 运行时档案 | `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` |
| 世界创作 | `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` |
@@ -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;
```
### `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`
- 作用:记录用户玩过的世界及最后游玩时间,用于个人页历史和继续游戏入口。

View File

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

View File

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

View File

@@ -94,9 +94,10 @@ use crate::{
runtime_chat::stream_runtime_npc_chat_turn,
runtime_inventory::get_runtime_inventory_state,
runtime_profile::{
admin_disable_profile_redeem_code, admin_upsert_profile_redeem_code,
create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats,
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::{
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
@@ -143,6 +144,20 @@ pub fn build_router(state: AppState) -> Router {
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(
"/healthz",
get(|Extension(request_context): Extension<_>| async move {
@@ -581,6 +596,13 @@ pub fn build_router(state: AppState) -> Router {
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(
"/api/runtime/puzzle/agent/sessions",
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,
)),
)
.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(
"/api/runtime/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
@@ -1360,6 +1396,36 @@ mod tests {
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]
async fn password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie() {
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")?;
let session = state
let items = state
.spacetime_client()
.record_big_fish_play(BigFishPlayReportRecordInput {
session_id,
@@ -226,8 +226,11 @@ pub async fn record_big_fish_play(
Ok(json_success_body(
Some(&request_context),
BigFishSessionResponse {
session: map_big_fish_session_response(session),
BigFishWorksResponse {
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_motion_ready_count: item.level_motion_ready_count,
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_session_ttl_days: u32,
pub auth_store_path: PathBuf,
pub dev_password_entry_auto_register_enabled: bool,
pub sms_auth_enabled: bool,
pub sms_auth_provider: String,
pub sms_endpoint: String,
@@ -118,6 +119,7 @@ impl Default for AppConfig {
refresh_cookie_same_site: "Lax".to_string(),
refresh_session_ttl_days: 30,
auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH),
dev_password_entry_auto_register_enabled: false,
sms_auth_enabled: false,
sms_auth_provider: "mock".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"]) {
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"]) {
config.sms_auth_enabled = sms_auth_enabled;

View File

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

View File

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

View File

@@ -7,30 +7,36 @@ use axum::{
use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType,
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord,
RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
RuntimeReferralRedeemRecord,
};
use serde_json::{Value, json};
use shared_contracts::runtime::{
AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileRedeemCodeRequest,
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_CONSUME,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse,
ProfileRechargeProductResponse, ProfileReferralInviteCenterResponse,
ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse,
ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest,
RedeemProfileRewardCodeResponse,
};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
admin::AuthenticatedAdmin, api_response::json_success_body, auth::AuthenticatedAccessToken,
http_error::AppError, request_context::RequestContext, state::AppState,
};
pub async fn get_profile_dashboard(
@@ -118,6 +124,9 @@ fn format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::AssetGenerationRefund => {
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(
State(state): State<AppState>,
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)]
mod tests {
use module_runtime::RuntimeProfileWalletLedgerSourceType;

View File

@@ -486,6 +486,38 @@ impl PasswordEntryService {
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(
&self,
user_id: &str,
@@ -1336,6 +1368,53 @@ impl InMemoryAuthStore {
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(
&self,
profile: WechatIdentityProfile,
@@ -2474,6 +2553,39 @@ mod tests {
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]
async fn phone_user_can_set_password_then_login() {
let store = build_store();

View File

@@ -221,6 +221,7 @@ pub struct BigFishWorkSummarySnapshot {
pub level_main_image_ready_count: u32,
pub level_motion_ready_count: u32,
pub background_ready: bool,
pub play_count: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -318,11 +319,11 @@ pub struct BigFishPublishInput {
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishPlayReportInput {
pub struct BigFishPlayRecordInput {
pub session_id: String,
pub user_id: String,
pub elapsed_ms: u64,
pub reported_at_micros: i64,
pub played_at_micros: i64,
}
#[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)
}
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() {
return Err(BigFishFieldError::MissingSessionId);
}

View File

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

View File

@@ -261,6 +261,15 @@ pub enum RuntimeProfileWalletLedgerSourceType {
PointsRecharge,
AssetGenerationConsume,
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))]
@@ -424,6 +433,75 @@ pub struct RuntimeProfileWalletAdjustmentInput {
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))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeReferralInviteCenterSnapshot {
@@ -537,6 +615,9 @@ pub enum RuntimeProfileFieldError {
MissingLedgerId,
InvalidWalletAmount,
MissingInviteCode,
MissingRedeemCode,
InvalidRedeemCodeReward,
InvalidRedeemCodeMaxUses,
MissingProductId,
MissingWorldKey,
MissingBottomTab,
@@ -812,6 +893,29 @@ pub struct RuntimeProfileRechargeCenterRecord {
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)]
pub struct RuntimeReferralInviteCenterRecord {
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(
user_id: String,
) -> 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(
snapshot: RuntimeProfilePlayedWorldSnapshot,
) -> RuntimeProfilePlayedWorldRecord {
@@ -1508,6 +1708,17 @@ impl RuntimeProfileWalletLedgerSourceType {
Self::PointsRecharge => "points_recharge",
Self::AssetGenerationConsume => "asset_generation_consume",
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 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -1743,6 +1958,9 @@ impl std::fmt::Display for RuntimeProfileFieldError {
Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"),
Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"),
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::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
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_motion_ready_count: u32,
pub background_ready: bool,
#[serde(default)]
pub play_count: u32,
}
#[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";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_GENERATION_REFUND: &str =
"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_ARCANE: &str = "arcane";
pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina";
@@ -258,6 +259,60 @@ pub struct RedeemProfileReferralInviteCodeResponse {
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)]
#[serde(rename_all = "camelCase")]
pub struct ProfilePlayedWorkSummaryResponse {

View File

@@ -1,6 +1,7 @@
use super::*;
use crate::mapper::*;
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 {
pub async fn create_big_fish_session(
@@ -80,12 +81,22 @@ impl SpacetimeClient {
procedure_input: BigFishWorksListInput,
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
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
.procedures()
.list_big_fish_works_then(procedure_input, move |_, result| {
let mapped = result
.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);
});
})
@@ -103,12 +114,18 @@ impl SpacetimeClient {
};
self.call_after_connect(move |connection, sender| {
let fallback_owner_user_id = Some(procedure_input.owner_user_id.clone());
connection
.procedures()
.delete_big_fish_work_then(procedure_input, move |_, result| {
let mapped = result
.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);
});
})
@@ -254,12 +271,12 @@ impl SpacetimeClient {
pub async fn record_big_fish_play(
&self,
input: BigFishPlayReportRecordInput,
) -> Result<BigFishSessionRecord, SpacetimeClientError> {
let procedure_input = BigFishPlayReportInput {
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
let procedure_input = BigFishPlayRecordInput {
session_id: input.session_id,
user_id: input.user_id,
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| {
@@ -268,7 +285,7 @@ impl SpacetimeClient {
.record_big_fish_play_then(procedure_input, move |_, result| {
let mapped = result
.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);
});
})

View File

@@ -121,6 +121,8 @@ use module_runtime::{
RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme,
RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
RuntimeProfileSaveArchiveRecord, RuntimeProfileWalletLedgerEntryRecord,
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeSettingsRecord,
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_recharge_center_record,
build_runtime_profile_recharge_order_create_input,
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_redeem_code_admin_disable_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_list_input, build_runtime_referral_invite_center_get_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>
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(
result: RuntimeProfilePlayStatsProcedureResult,
) -> 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(
result: BigFishWorksProcedureResult,
fallback_owner_user_id: Option<&str>,
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
@@ -1292,9 +1377,15 @@ pub(crate) fn map_big_fish_works_procedure_result(
"SpacetimeDB procedure 未返回 big fish works 快照".to_string(),
)
})?;
serde_json::from_str::<Vec<BigFishWorkSummaryRecord>>(&items_json).map_err(|error| {
SpacetimeClientError::Runtime(format!("big fish works items_json 非法: {error}"))
})
let items = serde_json::from_str::<Vec<CompatibleBigFishWorkSummaryRecord>>(&items_json)
.map_err(|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(
@@ -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(
snapshot: RuntimeProfilePlayedWorldSnapshot,
) -> module_runtime::RuntimeProfilePlayedWorldSnapshot {
@@ -3277,6 +3395,41 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back(
crate::module_bindings::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_motion_ready_count: u32,
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)]

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 last_assistant_reply: Option<String>,
pub publish_ready: bool,
pub play_count: u32,
pub created_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 last_assistant_reply: __sdk::__query_builder::Col<BigFishCreationSession, Option<String>>,
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 updated_at: __sdk::__query_builder::Col<BigFishCreationSession, __sdk::Timestamp>,
}
@@ -68,6 +70,7 @@ impl __sdk::__query_builder::HasCols for BigFishCreationSession {
"last_assistant_reply",
),
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"),
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)]
#[sats(crate = __lib)]
pub struct BigFishPlayReportInput {
pub struct BigFishPlayRecordInput {
pub session_id: String,
pub user_id: String,
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;
}

View File

@@ -8,6 +8,8 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
pub mod accept_quest_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 ai_result_reference_input_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_message_finalize_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_runtime_params_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_played_world_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_save_archive_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 record_big_fish_play_procedure;
pub mod redeem_profile_referral_invite_code_procedure;
pub mod redeem_profile_reward_code_procedure;
pub mod refresh_session_type;
pub mod refund_profile_wallet_points_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_product_kind_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_procedure_result_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 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 ai_result_reference_input_type::AiResultReferenceInput;
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_message_finalize_input_type::BigFishMessageFinalizeInput;
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_runtime_params_type::BigFishRuntimeParams;
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_played_world_type::ProfilePlayedWorld;
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_save_archive_type::ProfileSaveArchive;
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 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_reward_code_procedure::redeem_profile_reward_code;
pub use refresh_session_type::RefreshSession;
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;
@@ -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_product_kind_type::RuntimeProfileRechargeProductKind;
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_procedure_result_type::RuntimeProfileSaveArchiveProcedureResult;
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)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::big_fish_play_report_input_type::BigFishPlayReportInput;
use super::big_fish_session_procedure_result_type::BigFishSessionProcedureResult;
use super::big_fish_play_record_input_type::BigFishPlayRecordInput;
use super::big_fish_works_procedure_result_type::BigFishWorksProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct RecordBigFishPlayArgs {
pub input: BigFishPlayReportInput,
pub input: BigFishPlayRecordInput,
}
impl __sdk::InModule for RecordBigFishPlayArgs {
@@ -22,17 +22,17 @@ impl __sdk::InModule for RecordBigFishPlayArgs {
///
/// Implemented for [`super::RemoteProcedures`].
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, |_, _| {});
}
fn record_big_fish_play_then(
&self,
input: BigFishPlayReportInput,
input: BigFishPlayRecordInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<BigFishSessionProcedureResult, __sdk::InternalError>,
Result<BigFishWorksProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
@@ -41,16 +41,16 @@ pub trait record_big_fish_play {
impl record_big_fish_play for super::RemoteProcedures {
fn record_big_fish_play_then(
&self,
input: BigFishPlayReportInput,
input: BigFishPlayRecordInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<BigFishSessionProcedureResult, __sdk::InternalError>,
Result<BigFishWorksProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, BigFishSessionProcedureResult>(
.invoke_procedure_with_callback::<_, BigFishWorksProcedureResult>(
"record_big_fish_play",
RecordBigFishPlayArgs { 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_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,
AssetGenerationRefund,
RedeemCodeReward,
}
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {

View File

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

View File

@@ -255,6 +255,97 @@ impl SpacetimeClient {
.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(
&self,
user_id: String,

View File

@@ -108,6 +108,7 @@ pub(crate) fn generate_big_fish_asset_tx(
.map_err(|error| error.to_string())?,
last_assistant_reply: Some(reply.clone()),
publish_ready: coverage.publish_ready,
play_count: session.play_count,
created_at: session.created_at,
updated_at,
};
@@ -164,6 +165,7 @@ pub(crate) fn publish_big_fish_game_tx(
.map_err(|error| error.to_string())?,
last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()),
publish_ready: true,
play_count: session.play_count,
created_at: session.created_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]
pub fn submit_big_fish_message(
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(
ctx: &ReducerContext,
input: BigFishSessionCreateInput,
@@ -216,6 +223,7 @@ pub(crate) fn create_big_fish_session_tx(
.map_err(|error| error.to_string())?,
last_assistant_reply: Some(input.welcome_message_text.clone()),
publish_ready: false,
play_count: 0,
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(),
last_assistant_reply: session.last_assistant_reply.clone(),
publish_ready: session.publish_ready,
play_count: session.play_count,
created_at: session.created_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(),
last_assistant_reply: session.last_assistant_reply.clone(),
publish_ready: session.publish_ready,
play_count: session.play_count,
created_at: session.created_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(),
last_assistant_reply: Some(assistant_reply_text),
publish_ready: session.publish_ready,
play_count: session.play_count,
created_at: session.created_at,
updated_at,
};
@@ -552,6 +563,7 @@ pub(crate) fn compile_big_fish_draft_tx(
.map_err(|error| error.to_string())?,
last_assistant_reply: Some(reply.clone()),
publish_ready: coverage.publish_ready,
play_count: session.play_count,
created_at: session.created_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(
ctx: &ReducerContext,
input: BigFishPlayReportInput,
) -> Result<BigFishSessionSnapshot, String> {
validate_play_report_input(&input).map_err(|error| error.to_string())?;
input: BigFishPlayRecordInput,
) -> Result<Vec<BigFishWorkSummarySnapshot>, String> {
validate_play_record_input(&input).map_err(|error| error.to_string())?;
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.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
.draft_json
.as_deref()
@@ -613,7 +626,7 @@ pub(crate) fn record_big_fish_play_tx(
world_type: Some("BIG_FISH".to_string()),
world_title: title,
world_subtitle: subtitle,
played_at_micros: input.reported_at_micros,
played_at_micros: input.played_at_micros,
},
)?;
add_profile_observed_play_time(
@@ -621,10 +634,34 @@ pub(crate) fn record_big_fish_play_tx(
&input.user_id,
&world_key,
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(
@@ -740,6 +777,7 @@ pub(crate) fn build_big_fish_work_summary(
level_main_image_ready_count: coverage.level_main_image_ready_count,
level_motion_ready_count: coverage.level_motion_ready_count,
background_ready: coverage.background_ready,
play_count: row.play_count,
})
}
@@ -776,6 +814,7 @@ mod tests {
asset_coverage_json: "{}".to_string(),
last_assistant_reply: Some("欢迎来到大鱼吃小鱼共创。".to_string()),
publish_ready: false,
play_count: 0,
created_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) last_assistant_reply: Option<String>,
pub(crate) publish_ready: bool,
pub(crate) play_count: u32,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
}

View File

@@ -109,6 +109,8 @@ macro_rules! migration_tables {
user_browse_history,
profile_dashboard_state,
profile_wallet_ledger,
profile_redeem_code,
profile_redeem_code_usage,
profile_invite_code,
profile_referral_relation,
profile_played_world,
@@ -659,6 +661,19 @@ where
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(
ctx: &ReducerContext,
table: &MigrationTable,
@@ -672,7 +687,8 @@ fn insert_migration_table_rows(
let mut imported = 0u64;
let mut skipped = 0u64;
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)))?;
let insert_result = ctx.db
.$table()

View File

@@ -28,6 +28,39 @@ pub struct ProfileWalletLedger {
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)]
pub struct ProfileInviteCode {
#[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(
ctx: &ReducerContext,
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(
ctx: &ReducerContext,
user_id: &str,
@@ -1756,6 +2026,74 @@ fn latest_profile_recharge_order(
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(
row: &ProfileWalletLedger,
) -> RuntimeProfileWalletLedgerEntrySnapshot {

View File

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

View File

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

View File

@@ -1,16 +1,18 @@
// @vitest-environment jsdom
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} 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 { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
import * as puzzleWorksService from '../../services/puzzle-works';
import { PuzzleResultView } from './PuzzleResultView';
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(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
@@ -149,6 +160,39 @@ function createSession(
}
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', () => {
render(
<PuzzleResultView
@@ -168,9 +212,13 @@ describe('PuzzleResultView', () => {
});
test('edits theme tags with chips instead of a persistent tag input', () => {
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
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', () => {
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 { PuzzleResultDraft } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { updatePuzzleWork } from '../../services/puzzle-works';
import {
puzzleAssetClient,
type PuzzleHistoryAsset,
@@ -24,6 +25,7 @@ import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleResultViewProps = {
session: PuzzleAgentSessionSnapshot;
profileId?: string | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
@@ -32,6 +34,7 @@ type PuzzleResultViewProps = {
};
type PuzzleResultTab = 'basic' | 'images';
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type DraftEditState = {
levelName: string;
@@ -39,6 +42,10 @@ type DraftEditState = {
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) {
return [
...new Set(
@@ -84,7 +91,16 @@ function publishBlockedReason(session: PuzzleAgentSessionSnapshot) {
if (!session.resultPreview) {
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(
@@ -96,7 +112,10 @@ function buildPublishReady(
const blockers = [
...publishBlockedReason(session),
...(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 ? [] : ['请先选择一张正式拼图图片。']),
];
@@ -105,7 +124,8 @@ function buildPublishReady(
publishReady:
Boolean(session.resultPreview?.publishReady) &&
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),
};
}
@@ -130,12 +150,29 @@ function resolvePuzzleFormalImageSrc(draft: PuzzleResultDraft) {
}
function PuzzleResultHeader({
autoSaveState,
isBusy,
onBack,
}: {
autoSaveState: PuzzleAutoSaveState;
isBusy: boolean;
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 (
<div className="mb-4 flex items-center justify-between gap-3">
<button
@@ -149,6 +186,7 @@ function PuzzleResultHeader({
</span>
</button>
{autoSaveBadge}
</div>
);
}
@@ -871,6 +909,7 @@ function PuzzleResultActionBar({
*/
export function PuzzleResultView({
session,
profileId = null,
isBusy = false,
error = null,
onBack,
@@ -884,15 +923,77 @@ export function PuzzleResultView({
const [editState, setEditState] = useState<DraftEditState | null>(
draft ? createDraftEditState(draft) : null,
);
const [autoSaveState, setAutoSaveState] = useState<PuzzleAutoSaveState>('idle');
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
useEffect(() => {
if (!draft) {
setEditState(null);
setAutoSaveState('idle');
setAutoSaveError(null);
return;
}
setEditState(createDraftEditState(draft));
setAutoSaveState('idle');
setAutoSaveError(null);
}, [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(() => {
if (!draft || !editState) {
return {
@@ -915,7 +1016,11 @@ export function PuzzleResultView({
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)]">
<PuzzleResultHeader isBusy={isBusy} onBack={onBack} />
<PuzzleResultHeader
autoSaveState={autoSaveState}
isBusy={isBusy}
onBack={onBack}
/>
<PuzzleResultTabs
activeTab={activeTab}
@@ -953,6 +1058,11 @@ export function PuzzleResultView({
{error}
</div>
) : null}
{!error && autoSaveError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{autoSaveError}
</div>
) : null}
<PuzzleResultActionBar
draft={draft}

View File

@@ -8,7 +8,6 @@ import {
Clock3,
Coins,
Copy,
Crown,
House,
LogIn,
MessageCircle,
@@ -36,19 +35,17 @@ import type {
ProfileDashboardSummary,
ProfilePlayedWorkSummary,
ProfilePlayStatsResponse,
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
ProfileReferralInviteCenterResponse,
ProfileSaveArchiveSummary,
RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeResponse,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { AuthUser } from '../../services/authService';
import {
createRpgProfileRechargeOrder,
getRpgProfileRechargeCenter,
getRpgProfileReferralInviteCenter,
redeemRpgProfileReferralInviteCode,
redeemRpgProfileRewardCode,
} from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
@@ -933,206 +930,68 @@ function ProfileShortcutButton({
);
}
function formatRechargePrice(priceCents: number) {
const yuan = priceCents / 100;
return Number.isInteger(yuan) ? `¥${yuan}` : `¥${yuan.toFixed(2)}`;
}
function formatMembershipDuration(days: number) {
if (days >= 365) {
return '365天';
}
return `${days}`;
}
function AccountRechargeModal({
center,
activeTab,
isLoading,
function RewardCodeRedeemModal({
value,
isSubmitting,
error,
onTabChange,
success,
onChange,
onSubmit,
onClose,
onSelectProduct,
}: {
center: ProfileRechargeCenterResponse | null;
activeTab: 'points' | 'membership';
isLoading: boolean;
isSubmitting: string | null;
value: string;
isSubmitting: boolean;
error: string | null;
onTabChange: (tab: 'points' | 'membership') => void;
success: string | null;
onChange: (value: string) => void;
onSubmit: () => void;
onClose: () => void;
onSelectProduct: (product: ProfileRechargeProduct) => void;
}) {
const visibleProducts =
activeTab === 'points'
? (center?.pointProducts ?? [])
: (center?.membershipProducts ?? []);
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
<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">
<button
type="button"
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"
aria-label="关闭账户充值"
>
×
</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 className="mt-1 text-2xl font-black"></div>
<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">
<Coins className="h-3.5 w-3.5 text-[#ff4056]" />
<span>
{center ? `${center.walletBalance}叙世币` : '叙世币账户'}
</span>
</div>
</div>
<div className="mt-4 grid grid-cols-2 rounded-2xl bg-zinc-100/90 p-1">
<button
type="button"
onClick={() => onTabChange('points')}
className={`rounded-xl px-4 py-3 text-sm font-black transition ${
activeTab === 'points'
? 'bg-white text-[#ff4056] shadow-sm'
: 'text-zinc-500'
}`}
>
</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>
<div className="platform-modal-backdrop fixed inset-0 z-50 flex items-center justify-center px-4 py-6">
<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
type="button"
aria-label="关闭兑换码"
onClick={onClose}
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
>
×
</button>
</div>
<div className="space-y-3 px-5 py-5">
<input
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onSubmit();
}
}}
className="platform-profile-input w-full rounded-2xl px-4 py-3 text-sm font-semibold uppercase tracking-normal"
placeholder="输入兑换码"
autoFocus
/>
<button
type="button"
onClick={onSubmit}
disabled={isSubmitting || !value.trim()}
className="platform-primary-button w-full rounded-2xl px-4 py-3 text-sm font-black disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? '兑换中' : '兑换'}
</button>
{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}
</div>
) : null}
{isLoading ? (
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
{Array.from({ length: activeTab === 'points' ? 6 : 3 }).map(
(_, index) => (
<div
key={index}
className="h-24 animate-pulse rounded-xl bg-zinc-100"
/>
),
)}
{success ? (
<div className="platform-profile-success rounded-2xl px-3 py-2 text-xs font-semibold">
{success}
</div>
) : activeTab === 'points' ? (
<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>
</>
)}
) : null}
</div>
</div>
</div>
@@ -1425,16 +1284,13 @@ export function RpgEntryHomeView({
const authUi = useAuthUi();
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
const [isRechargeOpen, setIsRechargeOpen] = useState(false);
const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>(
'points',
const [isRewardCodeOpen, setIsRewardCodeOpen] = useState(false);
const [rewardCodeInput, setRewardCodeInput] = useState('');
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] =
useState<ProfilePopupPanel | null>(null);
const [referralCenter, setReferralCenter] =
@@ -1532,36 +1388,6 @@ export function RpgEntryHomeView({
}
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) => {
setProfilePopupPanel(panel);
setReferralError(null);
@@ -1617,6 +1443,30 @@ export function RpgEntryHomeView({
})
.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 keyword = desktopSearchKeyword.trim();
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
@@ -1964,17 +1814,13 @@ export function RpgEntryHomeView({
<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"
>
<Crown className="h-4 w-4" />
<Ticket className="h-4 w-4" />
<div>
<div className="text-xs font-bold"></div>
<div className="text-[10px] opacity-80">
{rechargeCenter?.membership.status === 'active'
? '叙世会员'
: '普通用户'}
</div>
<div className="text-xs font-bold"></div>
<div className="text-[10px] opacity-80"></div>
</div>
<ChevronRight className="h-4 w-4 opacity-80" />
</button>
@@ -2422,18 +2268,6 @@ export function RpgEntryHomeView({
))}
</div>
</div>
{isRechargeOpen ? (
<AccountRechargeModal
center={rechargeCenter}
activeTab={rechargeTab}
isLoading={isLoadingRecharge}
isSubmitting={submittingRechargeProductId}
error={rechargeError}
onTabChange={setRechargeTab}
onClose={() => setIsRechargeOpen(false)}
onSelectProduct={submitRechargeProduct}
/>
) : null}
{profilePopupPanel ? (
<ProfileReferralModal
panel={profilePopupPanel}
@@ -2535,16 +2369,15 @@ export function RpgEntryHomeView({
</div>
</div>
</div>
{isRechargeOpen ? (
<AccountRechargeModal
center={rechargeCenter}
activeTab={rechargeTab}
isLoading={isLoadingRecharge}
isSubmitting={submittingRechargeProductId}
error={rechargeError}
onTabChange={setRechargeTab}
onClose={() => setIsRechargeOpen(false)}
onSelectProduct={submitRechargeProduct}
{isRewardCodeOpen ? (
<RewardCodeRedeemModal
value={rewardCodeInput}
isSubmitting={isSubmittingRewardCode}
error={rewardCodeError}
success={rewardCodeSuccess}
onChange={setRewardCodeInput}
onSubmit={submitRewardCode}
onClose={() => setIsRewardCodeOpen(false)}
/>
) : null}
{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={() => {
const acceptedQuestId = npcChatQuestOfferUi.acceptPendingOffer();
if (!acceptedQuestId) return null;
// 中文注释:待领取任务详情弹层走的是异步服务端接取链路,
// 这里先记录 questId等 quest 真正进入日志后再由 effect 统一收口面板状态。
setPendingAcceptedQuestId(acceptedQuestId);
setSelectedQuestId(null);
return acceptedQuestId;
}}

View File

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

View File

@@ -240,7 +240,9 @@ function buildResolvedNpcBattleState(state: GameState, encounter: Encounter) {
currentBattleNpcId: battleNpcId,
currentNpcBattleMode: 'fight' as const,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
// 中文注释NPC 开战后要保留战前原始遭遇,供战斗收尾时恢复和平态站位。
// 这里复用现有 sparReturnEncounter 存槽,避免战后误把 battle encounter 的临时坐标带回场景。
sparReturnEncounter: encounter,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: 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 {
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 = (
encounter: GameState['currentEncounter'],
): 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 () => {
vi.useFakeTimers();
const firstScene = getScenePresetsByWorld(WorldType.WUXIA)[0]!;
const customWorldProfile = createCustomWorldProfileForSceneAct(firstScene.id);
const state = {
...createBaseState(),
customWorldProfile,
currentScenePreset: firstScene,
storyEngineMemory: {
discoveredFactIds: [],
@@ -735,7 +682,6 @@ describe('createStoryChoiceActions', () => {
}));
const setCurrentStory = vi.fn();
const setGameState = vi.fn();
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory: createFallbackStory(),
@@ -763,7 +709,7 @@ describe('createStoryChoiceActions', () => {
skillCooldowns: {},
})),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
buildFallbackStoryForState: vi.fn(() => createFallbackStory('fallback')),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
@@ -809,22 +755,24 @@ describe('createStoryChoiceActions', () => {
id: firstScene.id,
}),
playerHp: 100,
playerMana: 20,
inBattle: false,
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.objectContaining({
text: expect.stringContaining('重新醒来'),
}),
);
vi.useRealTimers();
});
it('settles escape locally without ai continuation', async () => {

View File

@@ -721,6 +721,59 @@ describe('npcEncounterActions', () => {
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 () => {
const encounter = createEncounter();
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 { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
import {
advanceSceneActRuntimeState,
buildInitialSceneActRuntimeState,
@@ -169,7 +170,7 @@ export function buildRevivedFirstSceneState(state: GameState): GameState {
storyEngineMemory: undefined,
});
return {
const revivedBaseState = {
...state,
currentScenePreset: firstScene,
currentEncounter: null,
@@ -195,19 +196,34 @@ export function buildRevivedFirstSceneState(state: GameState): GameState {
...storyEngineMemory,
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 =
state.worldType
? getScenePresetsByWorld(state.worldType)[0]?.name
: state.currentScenePreset?.name;
return {
text: firstSceneName
? `你在战斗中倒下,随后在${firstSceneName}重新醒来。`
: '你在战斗中倒下,随后重新醒来。',
options: [buildContinueOption()],
// 中文注释:复活后的“继续前进”只负责揭示已经准备好的首场景入口,
// 不能再次触发普通剧情推演,否则容易直接被推进到主 NPC 执行态。
deferredOptions:
deferredOptions && deferredOptions.length > 0
? deferredOptions
: undefined,
streaming: false,
};
}

View File

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

View File

@@ -937,6 +937,9 @@ describe('runtimeStoryCoordinator', () => {
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 () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import type {
ProfileSaveArchiveResumeResponse,
ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeResponse,
RuntimeSettings,
} from '../../../packages/shared/src/contracts/runtime';
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 = {}) {
return requestRpgRuntimeJson<ProfilePlayStatsResponse>(
'/profile/play-stats',