This commit is contained in:
2026-05-01 01:30:02 +08:00
parent aabad6407f
commit 2e9d0f4640
92 changed files with 4548 additions and 248 deletions
@@ -82,7 +82,7 @@
5. 玩家从广场进入某个作品时,第 1 关必须先显示当前作品本身。
6. 第 2 关及以后必须按照“标签相似度权重 `70%` + 同作者权重 `30%`”选择下一关。
7. 游戏运行时必须全屏展示拼图画布。
8. 新游戏进入时难度必须从 `3*3` 开始,完成 `3`后切为 `4*4`,后续持续为 `4*4`
8. 新游戏进入时难度必须从`1` 关的 `3*3` 开始,并按关卡配置推进到 `4*4``5*5``6*6``7*7`;第 `11`起每 `6` 关循环复用第 `5~10` 关配置
9. 拼图运行时必须支持:
- 点击选择两块并交换
- 正确相邻后自动合并
@@ -517,21 +517,35 @@ tagSimilarityScore =
本次建议同时显示:
1. 当前关卡序号
2. 当前网格规格,例如 `3x3``4x4`
2. 当前网格规格,例如 `3x3``5x5``7x7`
## 9.3 难度与关卡推进规则
每次新 run 都必须从最低难度开始:
每次新 run 都必须从`1` 关配置开始:
1.`1~3` 关固定为 `3x3`
2.`4` 关开始固定为 `4x4`
3. 后续全部关卡保持 `4x4`
| 关卡 | 切割规格 | 限时 |
| ---------- | -------- | -------------- |
| 第 `1` 关 | `3x3` | `5` 分钟 |
| 第 `2` 关 | `4x4` | `5` 分钟 |
| 第 `3` 关 | `5x5` | `5` 分钟 |
| 第 `4` 关 | `5x5` | `3``30` 秒 |
| 第 `5` 关 | `5x5` | `3``30` 秒 |
| 第 `6` 关 | `6x6` | `4` 分钟 |
| 第 `7` 关 | `5x5` | `3``30` 秒 |
| 第 `8` 关 | `7x7` | `4``30` 秒 |
| 第 `9` 关 | `5x5` | `4` 分钟 |
| 第 `10` 关 | `7x7` | `4``30` 秒 |
`11` 关开始,每 `6` 关循环复用第 `5~10` 关配置。
对应函数建议:
```ts
function resolvePuzzleGridSize(clearedLevelCount: number): 3 | 4 {
return clearedLevelCount >= 3 ? 4 : 3;
function resolvePuzzleLevelConfig(levelIndex: number): {
gridSize: 3 | 4 | 5 | 6 | 7;
timeLimitMs: number;
} {
// 统一从关卡序号解析切割规格和倒计时。
}
```
@@ -646,8 +660,8 @@ V1 规则如下:
`2026-04-29` 起,拼图运行时加入倒计时:
1. `3x3` 关卡限时 `180`
2. `4x4` 关卡限时 `300`
1. 倒计时必须使用第 `9.3` 节的关卡配置函数,不允许在 UI 或本地兜底里按网格规模另写一套时间表
2. `1~10` 关按配置表执行;第 `11` 关起每 `6` 关循环复用第 `5~10` 关配置
3. 规定时间内未完成拼图,关卡状态变为 `failed`
4. 弹窗、查看原图覆盖、冻结时间生效期间不消耗倒计时。
5. 通关成绩只统计有效消耗时间,不统计暂停与冻结时间。
@@ -693,7 +707,7 @@ interface PuzzleProfile {
interface PuzzleRuntimeLevelSnapshot {
runId: string;
levelIndex: number;
gridSize: 3 | 4;
gridSize: 3 | 4 | 5 | 6 | 7;
profileId: string;
levelName: string;
authorDisplayName: string;
@@ -738,7 +752,7 @@ interface PuzzleRunSnapshot {
entryProfileId: string;
clearedLevelCount: number;
currentLevelIndex: number;
currentGridSize: 3 | 4;
currentGridSize: 3 | 4 | 5 | 6 | 7;
playedProfileIds: string[];
previousLevelTags: string[];
currentLevel: PuzzleRuntimeLevelSnapshot | null;
@@ -1167,7 +1181,7 @@ interface PuzzleRunSnapshot {
先做:
1. `3x3 / 4x4` 切图
1. `3x3 / 4x4 / 5x5 / 6x6 / 7x7` 切图
2. 点击两块交换
3. 正确连接自动合并
4. 合并块整体拖动
@@ -1202,7 +1216,7 @@ interface PuzzleRunSnapshot {
4. 发布后的拼图作品能进入平台广场。
5. 玩家从广场进入时,第 `1` 关必定是当前作品本身。
6.`2` 关及以后按照“标签相似度 `70%` + 同作者 `30%`”计算下一关。
7. 新 run `3` 关为 `3x3`,之后固定为 `4x4`
7. 新 run 的关卡切割和倒计时符合第 `9.3` 节配置,并且第 `11` 关起按第 `5~10` 关配置循环
8. 运行时支持点击两块交换。
9. 交换后正确相邻的块会自动合并。
10. 合并块可以整体拖动。
@@ -93,8 +93,9 @@
1. 数字过大时做单位缩略展示
2. “游戏时长”卡固定以小时为单位展示,短时长不切换成分钟,长时长不切换成天
3. 进入页面先展示骨架屏
4. 数据请求失败时展示降级文案,不展示假数字
3. “玩过”卡展示值始终带 `个` 单位,例如 `0个``1个``1.2万个`
4. 进入页面先展示骨架屏
5. 数据请求失败时展示降级文案,不展示假数字
---
@@ -16,7 +16,7 @@
1. 拼图生成图固定使用 `1024*1024`
2. 文生图和参考图生图共用同一个尺寸常量,禁止一条链路仍生成竖屏或横版图。
3. 拼图图片提示词明确写入 `1:1 正方形画布`,继续保留 `3x3 4x4 拼图切块`、主体清晰、层次明确、无文字水印等约束。
3. 拼图图片提示词明确写入 `1:1 正方形画布`,继续保留适配 `3x3 / 4x4 / 5x5 / 6x6 / 7x7` 拼图切块、主体清晰、层次明确、无文字水印等约束。
4. 文生图正向 prompt 必须由后端压缩到 `500` 字符以内,优先保留玩家画面描述开头与固定拼图约束,避免 DashScope 旧 text2image 协议把超长 prompt 判为“请求参数不合法”。
5. DashScope 上游失败时,api-server 必须在错误 details 中保留业务 message、`upstreamStatus` 和截断后的 `rawExcerpt`,日志也要记录同样的摘要,避免生成进度页只能看到通用 HTTP 文案。
6. 图片生成仍由 `api-server` 执行。SpacetimeDB reducer 不做网络 I/O。
@@ -47,7 +47,7 @@
1. 点击拼图草稿生成或重新生成画面时,后端请求 DashScope 的 `size``1024*1024`
2. 图片提示词包含 `1:1 正方形拼图关卡`
3. 图片提示词长度不超过 `500` 字符,超长画面描述会被截断,但 `3x3 4x4``避免文字、水印、边框和 UI 元素` 等玩法约束不能丢。
3. 图片提示词长度不超过 `500` 字符,超长画面描述会被截断,但适配 `3x3 / 4x4 / 5x5 / 6x6 / 7x7` 拼图切块`避免文字、水印、边框和 UI 元素` 等玩法约束不能丢。
4. DashScope 返回参数错误、任务失败或非 2xx 时,前端错误优先展示后端 details.message,后端日志能看到 `upstreamStatus``rawExcerpt`
5. 正式拼图 run 中拖动拼块后,前端立即更新棋盘、合并块和通关状态,不再等待 `/drag`
6. 移动端运行时棋盘为正方形,并尽量贴近屏幕两侧边缘。
@@ -13,7 +13,7 @@
1. 通关后默认点击“下一关”,优先加载当前拼图作品的下一关。
2. 当前作品没有下一关时,后端按标签语义相似度选出相似度最高的三个已发布作品。
3. 用户在通关弹窗里点击候选作品后,进入该作品并从第 1 关重新开始。
3. 用户在通关弹窗里点击候选作品后,进入该作品并从第 `1` 关重新开始。
4. 移动端优先,候选卡片要紧凑,不写玩法说明类文案。
## 数据契约
@@ -51,8 +51,8 @@
- 返回最高的 3 个候选
4. `advance_puzzle_next_level`
- `nextLevelMode = sameWork` 时加载当前作品的下一关,并继续当前 run。
- `nextLevelMode = similarWorks` 时默认加载候选第一项,并从该作品第 1 关重新开始
5. `local-next-level` 兼容接口同样优先找同作品下一关;没有时返回相似作品候选或旧草稿兜底。
- `nextLevelMode = similarWorks` 时默认加载候选第一项,并`entryProfileId / clearedLevelCount / currentLevelIndex` 重置到目标作品第 `1`
5. `local-next-level` 兼容接口同样优先找同作品下一关;没有时返回 `similarWorks` 候选并保持当前通关 run,只有候选池为空时才进入旧草稿兜底。
## 前端规则
@@ -64,11 +64,12 @@
- `sameWork` 保留“下一关”。
- `similarWorks` 显示“换个作品”,点击后打开结算弹窗供选择。
3. 所有正式相似度计算只信任后端返回,不在 UI 里重新算。
4. 本地/草稿 run 通关提交本地排行榜后,会异步调用 `local-next-level` 刷新 handoff;若拿到 `similarWorks`,只合并候选字段,不把已通关弹窗改成新的 playing 关卡。
## 验收
1. 当前作品有下一关时,点击“下一关”进入当前作品下一关。
2. 当前作品没有下一关时,通关弹窗显示最多 3 个相似作品。
3. 点击相似作品后进入该作品第 1 关
3. 点击相似作品后进入该作品第 `1` 关,HUD 关卡序号、切割规格和倒计时都按第 `1` 关显示
4.`recommendedNextProfileId` 为空时,只要 `nextLevelMode = sameWork`,按钮仍可用。
5. 拼图 runtime 单测、Rust 拼图模块测试和编码检查通过。
@@ -24,12 +24,28 @@
## 难度限时
第一版按网格规模定义限时
拼图关卡切割规格和倒计时由统一关卡配置函数解析,不再按网格规模单独推导时间
1. `3x3``180000ms`
2. `4x4``300000ms`
| 关卡 | 切割规格 | 限时 |
| -------- | -------- | ---------- |
| 第 1 关 | `3x3` | `300000ms` |
| 第 2 关 | `4x4` | `300000ms` |
| 第 3 关 | `5x5` | `300000ms` |
| 第 4 关 | `5x5` | `210000ms` |
| 第 5 关 | `5x5` | `210000ms` |
| 第 6 关 | `6x6` | `240000ms` |
| 第 7 关 | `5x5` | `210000ms` |
| 第 8 关 | `7x7` | `270000ms` |
| 第 9 关 | `5x5` | `240000ms` |
| 第 10 关 | `7x7` | `270000ms` |
后续若扩展更多难度,只能通过同一个难度解析函数扩展,不允许在 UI 里写死另一套时间
第 11 关开始,每 6 关循环复用第 5 关到第 10 关的配置,即 `5x5/210000ms``6x6/240000ms``5x5/210000ms``7x7/270000ms``5x5/240000ms``7x7/270000ms`
同作品下一关必须使用同一个运行时关卡序号继续推进。跨作品相似推荐代表进入新作品,必须从目标作品第 `1` 关重新开始。
失败状态点击“重新开始”时,不进入作品第 `1` 关,而是重开当前失败关卡:前端需要传当前关 `levelId`,服务端按该 `levelId` 在作品内的位置恢复 `currentLevelIndex`、切割规格和倒计时。
后续若扩展更多难度,只能通过同一个关卡配置解析函数扩展,不允许在 UI 里写死另一套时间。
## 计时规则
@@ -0,0 +1,59 @@
# 拼图作品积分激励链路设计
更新时间:`2026-05-01`
## 1. 目标
1. 拼图草稿页“新增关卡”按钮下方显示一行小字:“获得更多积分激励”。
2. 创作页的已发布拼图作品卡展示当前作品的积分激励总数、待领取积分数和领取按钮。
3. 用户在他人已发布拼图作品中消耗陶泥币时,作品作者获得消耗陶泥币数量的一半作为积分激励。
4. 作者领取时只能领取整数个陶泥币,待领取值向下取整;未满 1 个陶泥币的半数余额继续保留。
## 2. 数据模型
拼图作品激励归属到 `puzzle_work_profile`
1. `point_incentive_total_half_points: u64`
- 记录该作品累计获得的激励,单位为“半个陶泥币”。
- 每消耗 `N` 个陶泥币,增加 `N` 个 half points;当前拼图道具每次消耗 1 个陶泥币,因此每次为作者增加 0.5。
2. `point_incentive_claimed_points: u64`
- 记录作者已领取的整数陶泥币数量。
3. 前端展示:
- 激励总数 = `pointIncentiveTotalHalfPoints / 2`,允许展示一位小数。
- 待领取积分 = `floor(pointIncentiveTotalHalfPoints / 2) - pointIncentiveClaimedPoints`
- 领取按钮仅在待领取积分大于 0 时可用。
## 3. 后端事务
1. 拼图运行道具扣费成功、道具效果成功落库后,后端根据 run 的当前作品 `profile_id` 查找作者。
2. 若使用者不是作品作者,则给该作品累积 `consumed_points` 个 half points。
3. 若使用者是作者本人,视为作者自测,不产生积分激励。
4. 若后续业务操作失败并触发扣费退款,不写入激励。
5. 领取接口:
- 只允许作品作者领取。
- 计算可领取整数 `claimable = total_half_points / 2 - claimed_points`
- `claimable <= 0` 时拒绝领取。
- 同一事务内更新作品 `claimed_points += claimable`,并向作者钱包增加 `claimable` 陶泥币,钱包流水来源使用 `puzzle_author_incentive_claim`
## 4. API 与前端
1. `PuzzleWorkSummary` / `PuzzleWorkProfile` 增加:
- `pointIncentiveTotalHalfPoints`
- `pointIncentiveClaimedPoints`
- `pointIncentiveTotalPoints`
- `pointIncentiveClaimablePoints`
2. 新增领取接口:
- `POST /api/runtime/puzzle/works/{profile_id}/point-incentive/claim`
- 返回更新后的 `PuzzleWorkProfile`
3. 创作页仅对已发布拼图作品显示积分激励块;RPG、大鱼和草稿卡不显示。
4. 领取成功后刷新对应拼图作品列表状态,按钮立即禁用或显示新的待领取数。
5. `spacetime-client` 映射层继续兼容历史拼图运行快照:旧 `run_json` 若缺少 `started_at_ms`,API 记录回填为非 0 值,避免前端计时器拿到无效开始时间。
## 5. 验收点
1. 拼图草稿页新增关卡按钮下方显示“获得更多积分激励”。
2. 已发布拼图作品卡展示“积分激励总数”和“待领取”两个数值。
3. 待领取积分为 0 时领取按钮禁用。
4. 非作者游玩他人拼图并使用付费道具后,该作品累计 half points 增加。
5. 作者领取后钱包增加向下取整后的整数陶泥币,作品待领取数归零或保留不足 1 的小数余额。
6. 修改后运行编码检查、SpacetimeDB 绑定生成、Rust 检查和必要前端测试。
+2 -1
View File
@@ -24,8 +24,9 @@
- [PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md](./PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md):记录拼图生成图片回到 1:1,运行时拖动、交换、合并与拆分由前端即时裁决,以及移动端棋盘贴近屏幕边缘的落地边界。
- [PUZZLE_FORM_CREATION_FLOW_2026-04-29.md](./PUZZLE_FORM_CREATION_FLOW_2026-04-29.md):冻结拼图填表式创作入口、初始表单自动保存草稿、生成前退出后的表单恢复,以及草稿编译/首图生成的前后端边界。
- [PUZZLE_LEADERBOARD_FRONTEND_LEVEL_AND_RPG_COMING_SOON_2026-04-30.md](./PUZZLE_LEADERBOARD_FRONTEND_LEVEL_AND_RPG_COMING_SOON_2026-04-30.md):记录拼图第二关排行榜提交以前端当前关卡为准、不被 SpacetimeDB 旧 run 快照误杀,以及 RPG 创作入口改为敬请期待的落地边界。
- [PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md](./PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md):记录拼图通关后优先同作品下一关、无下一关时按 RPG/build 标签语义相似度返回三个候选作品并从第 1 关接续的落地规则。
- [PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md](./PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md):记录拼图通关后优先同作品下一关、无下一关时按 RPG/build 标签语义相似度返回三个候选作品,并在跨作品时只切换到候选作品第 1 张图、运行时关卡序号继续累进的落地规则。
- [PUZZLE_FAILURE_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md](./PUZZLE_FAILURE_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md):记录拼图失败后重新开始/付费续时,以及进入作品与过关后同步存档页投影的落地规则。
- [PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md](./PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md):记录拼图关卡切割、倒计时、失败态和三个运行时道具的统一规则;2026-05-01 起关卡切割与限时按第 1-10 关配置,并从第 11 关按第 5-10 关六关循环。
- [RPG_SCENE_ACT_PREVIEW_BOOTSTRAP_FIX_2026-04-30.md](./RPG_SCENE_ACT_PREVIEW_BOOTSTRAP_FIX_2026-04-30.md):记录编辑器幕预览卡在“正在载入这一幕”时的启动态根因,收口预览本地运行态装配与禁持久化首段 story 注入。
- [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 卡死的修复口径。
- [WORK_AUTHOR_ID_RESOLUTION_2026-04-30.md](./WORK_AUTHOR_ID_RESOLUTION_2026-04-30.md):记录作品作者以 `owner_user_id` 为真相源,API 按用户 ID 解析最新昵称与公开用户码,历史 `author_display_name` 仅作为兼容回退。
@@ -1,4 +1,4 @@
export type PuzzleGridSize = 3 | 4;
export type PuzzleGridSize = 3 | 4 | 5 | 6 | 7;
export interface PuzzleCellPosition {
row: number;
@@ -23,6 +23,10 @@ export interface PuzzleWorkSummary {
remixCount?: number;
likeCount?: number;
recentPlayCount7d?: number;
pointIncentiveTotalHalfPoints?: number;
pointIncentiveClaimedPoints?: number;
pointIncentiveTotalPoints?: number;
pointIncentiveClaimablePoints?: number;
publishReady: boolean;
levels?: PuzzleDraftLevel[];
}
+2 -1
View File
@@ -64,7 +64,8 @@ export type ProfileWalletLedgerEntry = {
| 'points_recharge'
| 'asset_operation_consume'
| 'asset_operation_refund'
| 'redeem_code_reward';
| 'redeem_code_reward'
| 'puzzle_author_incentive_claim';
createdAt: string;
};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"choices":[{"message":{"content":"{\"name\":\"雾港归航\",\"subtitle\":\"失灯旧案\",\"summary\":\"守灯人与群岛议会围绕沉船旧案对峙。\",\"tone\":\"海雾悬疑\",\"playerGoal\":\"查清父亲沉船真相\",\"templateWorldType\":\"WUXIA\",\"majorFactions\":[\"群岛议会\",\"灯塔署\"],\"coreConflicts\":[\"守灯塔的旧档案被人改写。\"],\"attributeSchema\":{\"slots\":[{\"name\":\"灯骨\"},{\"name\":\"潮步\"},{\"name\":\"灯识\"},{\"name\":\"雾魄\"},{\"name\":\"旧约\"},{\"name\":\"回澜\"}]},\"camp\":{\"name\":\"旧灯塔归舍\",\"description\":\"海雾边缘的守灯人旧居。\"}}"}}],"id":"resp_01"}
+14 -7
View File
@@ -83,13 +83,13 @@ use crate::{
phone_auth::{phone_login, send_phone_code},
profile_identity::update_profile_identity,
puzzle::{
advance_local_puzzle_next_level, advance_puzzle_next_level, create_puzzle_agent_session,
delete_puzzle_work, execute_puzzle_agent_action, get_puzzle_agent_session,
get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works,
list_puzzle_gallery, put_puzzle_work, record_puzzle_gallery_like,
remix_puzzle_gallery_work, start_puzzle_run, stream_puzzle_agent_message,
submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces,
update_puzzle_run_pause, use_puzzle_runtime_prop,
advance_local_puzzle_next_level, advance_puzzle_next_level,
claim_puzzle_work_point_incentive, create_puzzle_agent_session, delete_puzzle_work,
execute_puzzle_agent_action, get_puzzle_agent_session, get_puzzle_gallery_detail,
get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery,
put_puzzle_work, record_puzzle_gallery_like, remix_puzzle_gallery_work, start_puzzle_run,
stream_puzzle_agent_message, submit_puzzle_agent_message, submit_puzzle_leaderboard,
swap_puzzle_pieces, update_puzzle_run_pause, use_puzzle_runtime_prop,
},
refresh_session::refresh_session,
request_context::{attach_request_context, resolve_request_id},
@@ -764,6 +764,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/works/{profile_id}/point-incentive/claim",
post(claim_puzzle_work_point_incentive).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route("/api/runtime/puzzle/gallery", get(list_puzzle_gallery))
.route(
"/api/runtime/puzzle/gallery/{profile_id}",
+334 -40
View File
@@ -17,7 +17,10 @@ use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use module_puzzle::{PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus};
use module_puzzle::{
PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus,
PuzzleWorkProfile, resolve_puzzle_level_config,
};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
OssSignedGetObjectUrlRequest,
@@ -61,8 +64,9 @@ use spacetime_client::{
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput, PuzzleWorkProfileRecord,
PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, SpacetimeClientError,
PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput,
PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
};
use std::convert::Infallible;
use tokio::time::sleep;
@@ -966,6 +970,43 @@ pub async fn delete_puzzle_work(
))
}
pub async fn claim_puzzle_work_point_incentive(
State(state): State<AppState>,
AxumPath(profile_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(
&request_context,
PUZZLE_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let item = state
.spacetime_client()
.claim_puzzle_work_point_incentive(PuzzleWorkPointIncentiveClaimRecordInput {
profile_id,
owner_user_id: authenticated.claims().user_id().to_string(),
claimed_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_WORKS_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleWorkMutationResponse {
item: map_puzzle_work_profile_response(&state, item),
},
))
}
pub async fn list_puzzle_gallery(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -1370,6 +1411,7 @@ pub async fn use_puzzle_runtime_prop(
owner_user_id: reducer_owner_user_id,
prop_kind,
used_at_micros: current_utc_micros(),
spent_points: crate::asset_billing::ASSET_OPERATION_POINTS_COST,
})
.await
.map_err(map_puzzle_client_error)
@@ -1689,6 +1731,13 @@ fn map_puzzle_work_summary_response(
remix_count: item.remix_count,
like_count: item.like_count,
recent_play_count_7d: item.recent_play_count_7d,
point_incentive_total_half_points: item.point_incentive_total_half_points,
point_incentive_claimed_points: item.point_incentive_claimed_points,
point_incentive_total_points: item.point_incentive_total_half_points as f64 / 2.0,
point_incentive_claimable_points: item
.point_incentive_total_half_points
.saturating_div(2)
.saturating_sub(item.point_incentive_claimed_points),
publish_ready: item.publish_ready,
levels: Vec::new(),
}
@@ -1898,7 +1947,8 @@ fn map_puzzle_board_request_record(board: PuzzleBoardSnapshotResponse) -> Puzzle
fn map_puzzle_runtime_level_response(
level: spacetime_client::PuzzleRuntimeLevelRecord,
) -> PuzzleRuntimeLevelSnapshotResponse {
let timer_defaults = build_puzzle_runtime_timer_response_defaults(level.grid_size);
let timer_defaults =
build_puzzle_runtime_timer_response_defaults(level.level_index, level.grid_size);
let time_limit_ms = if level.time_limit_ms == 0 {
timer_defaults.time_limit_ms
} else {
@@ -1945,9 +1995,14 @@ struct PuzzleRuntimeTimerResponseDefaults {
}
fn build_puzzle_runtime_timer_response_defaults(
level_index: u32,
grid_size: u32,
) -> PuzzleRuntimeTimerResponseDefaults {
let time_limit_ms = module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size);
let time_limit_ms = if level_index > 0 {
module_puzzle::resolve_puzzle_level_time_limit_ms_by_index(level_index)
} else {
module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size)
};
PuzzleRuntimeTimerResponseDefaults { time_limit_ms }
}
@@ -2697,8 +2752,11 @@ async fn build_local_next_puzzle_run(
return Ok(next_run);
}
if let Some(gallery_item) = resolve_gallery_next_puzzle_work(state, &run).await? {
return Ok(build_next_run_from_puzzle_work(state, run, gallery_item));
let current_work = fetch_local_current_work_detail(state, &run).await?;
let similar_works =
resolve_gallery_similar_puzzle_works(state, &run, current_work.as_ref()).await?;
if !similar_works.is_empty() {
return Ok(build_local_similar_works_handoff(run, similar_works));
}
if source_session_id.trim().is_empty() {
@@ -2886,23 +2944,187 @@ async fn fetch_local_current_work_detail(
}
}
async fn resolve_gallery_next_puzzle_work(
async fn resolve_gallery_similar_puzzle_works(
state: &AppState,
run: &PuzzleRunRecord,
) -> Result<Option<PuzzleWorkProfileRecord>, AppError> {
current_work: Option<&PuzzleWorkProfileRecord>,
) -> Result<Vec<PuzzleRecommendedNextWorkRecord>, AppError> {
let Some(current_profile) = build_recommendation_current_profile(run, current_work) else {
return Ok(Vec::new());
};
let items = state
.spacetime_client()
.list_puzzle_gallery()
.await
.map_err(map_puzzle_client_error)?;
Ok(items.into_iter().find(|item| {
item.publication_status == "published"
&& item
.cover_image_src
.as_ref()
.is_some_and(|value| !value.is_empty())
&& !run.played_profile_ids.contains(&item.profile_id)
}))
let candidates = items
.iter()
.map(map_puzzle_work_profile_domain)
.collect::<Vec<_>>();
Ok(module_puzzle::select_next_profiles(
&current_profile,
&run.played_profile_ids,
&candidates,
3,
)
.into_iter()
.map(|candidate| build_recommended_next_work_record(&current_profile, candidate))
.collect())
}
fn build_local_similar_works_handoff(
mut run: PuzzleRunRecord,
recommended_next_works: Vec<PuzzleRecommendedNextWorkRecord>,
) -> PuzzleRunRecord {
let next_profile_id = recommended_next_works
.first()
.map(|item| item.profile_id.clone());
run.recommended_next_profile_id = next_profile_id.clone();
run.next_level_mode = module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS.to_string();
run.next_level_profile_id = next_profile_id;
run.next_level_id = None;
run.recommended_next_works = recommended_next_works;
run
}
fn build_recommendation_current_profile(
run: &PuzzleRunRecord,
current_work: Option<&PuzzleWorkProfileRecord>,
) -> Option<PuzzleWorkProfile> {
if let Some(work) = current_work {
return Some(map_puzzle_work_profile_domain(work));
}
let level = run.current_level.as_ref()?;
Some(PuzzleWorkProfile {
work_id: format!("runtime-work-{}", level.profile_id),
profile_id: level.profile_id.clone(),
owner_user_id: String::new(),
source_session_id: None,
author_display_name: level.author_display_name.clone(),
work_title: level.level_name.clone(),
work_description: String::new(),
level_name: level.level_name.clone(),
summary: String::new(),
theme_tags: level.theme_tags.clone(),
cover_image_src: level.cover_image_src.clone(),
cover_asset_id: None,
levels: Vec::new(),
publication_status: module_puzzle::PuzzlePublicationStatus::Published,
updated_at_micros: 0,
published_at_micros: None,
play_count: 0,
remix_count: 0,
like_count: 0,
recent_play_count_7d: 0,
point_incentive_total_half_points: 0,
point_incentive_claimed_points: 0,
publish_ready: true,
anchor_pack: module_puzzle::empty_anchor_pack(),
})
}
fn map_puzzle_work_profile_domain(item: &PuzzleWorkProfileRecord) -> PuzzleWorkProfile {
PuzzleWorkProfile {
work_id: item.work_id.clone(),
profile_id: item.profile_id.clone(),
owner_user_id: item.owner_user_id.clone(),
source_session_id: item.source_session_id.clone(),
author_display_name: item.author_display_name.clone(),
work_title: item.work_title.clone(),
work_description: item.work_description.clone(),
level_name: item.level_name.clone(),
summary: item.summary.clone(),
theme_tags: item.theme_tags.clone(),
cover_image_src: item.cover_image_src.clone(),
cover_asset_id: item.cover_asset_id.clone(),
levels: item
.levels
.iter()
.map(map_puzzle_draft_level_domain)
.collect(),
publication_status: match item.publication_status.as_str() {
"published" => module_puzzle::PuzzlePublicationStatus::Published,
_ => module_puzzle::PuzzlePublicationStatus::Draft,
},
updated_at_micros: parse_puzzle_record_timestamp_micros(&item.updated_at),
published_at_micros: item
.published_at
.as_deref()
.map(parse_puzzle_record_timestamp_micros),
play_count: item.play_count,
remix_count: item.remix_count,
like_count: item.like_count,
recent_play_count_7d: item.recent_play_count_7d,
point_incentive_total_half_points: item.point_incentive_total_half_points,
point_incentive_claimed_points: item.point_incentive_claimed_points,
publish_ready: item.publish_ready,
anchor_pack: module_puzzle::empty_anchor_pack(),
}
}
fn map_puzzle_draft_level_domain(
level: &PuzzleDraftLevelRecord,
) -> module_puzzle::PuzzleDraftLevel {
module_puzzle::PuzzleDraftLevel {
level_id: level.level_id.clone(),
level_name: level.level_name.clone(),
picture_description: level.picture_description.clone(),
candidates: level
.candidates
.iter()
.map(map_puzzle_generated_image_candidate_domain)
.collect(),
selected_candidate_id: level.selected_candidate_id.clone(),
cover_image_src: level.cover_image_src.clone(),
cover_asset_id: level.cover_asset_id.clone(),
generation_status: level.generation_status.clone(),
}
}
fn map_puzzle_generated_image_candidate_domain(
candidate: &PuzzleGeneratedImageCandidateRecord,
) -> PuzzleGeneratedImageCandidate {
PuzzleGeneratedImageCandidate {
candidate_id: candidate.candidate_id.clone(),
image_src: candidate.image_src.clone(),
asset_id: candidate.asset_id.clone(),
prompt: candidate.prompt.clone(),
actual_prompt: candidate.actual_prompt.clone(),
source_type: candidate.source_type.clone(),
selected: candidate.selected,
}
}
fn build_recommended_next_work_record(
current_profile: &PuzzleWorkProfile,
candidate: &PuzzleWorkProfile,
) -> PuzzleRecommendedNextWorkRecord {
PuzzleRecommendedNextWorkRecord {
profile_id: candidate.profile_id.clone(),
level_name: candidate.level_name.clone(),
author_display_name: candidate.author_display_name.clone(),
theme_tags: candidate.theme_tags.clone(),
cover_image_src: candidate.cover_image_src.clone(),
similarity_score: module_puzzle::tag_similarity_score(
&current_profile.theme_tags,
&candidate.theme_tags,
),
}
}
fn parse_puzzle_record_timestamp_micros(value: &str) -> i64 {
let Some((seconds, rest)) = value.split_once('.') else {
return 0;
};
let micros = rest.strip_suffix('Z').unwrap_or(rest);
let Ok(seconds) = seconds.parse::<i64>() else {
return 0;
};
let Ok(micros) = micros.parse::<i64>() else {
return 0;
};
seconds.saturating_mul(1_000_000).saturating_add(micros)
}
fn pick_unused_puzzle_candidate<'a>(
@@ -2987,27 +3209,6 @@ fn resolve_level_cover_image_src(level: &PuzzleDraftLevelRecord) -> Option<Strin
})
}
fn build_next_run_from_puzzle_work(
state: &AppState,
run: PuzzleRunRecord,
item: PuzzleWorkProfileRecord,
) -> PuzzleRunRecord {
let author = resolve_work_author_by_user_id(
state,
&item.owner_user_id,
Some(&item.author_display_name),
None,
);
build_next_run_from_parts(
run,
item.profile_id,
item.level_name,
author.display_name,
item.theme_tags,
item.cover_image_src,
)
}
fn build_next_run_from_candidate(
run: PuzzleRunRecord,
session: &PuzzleAgentSessionRecord,
@@ -3089,8 +3290,9 @@ fn build_next_run_from_parts_with_handoff(
next_after_level_id: Option<String>,
) -> PuzzleRunRecord {
let next_level_index = run.current_level_index + 1;
let grid_size = if run.cleared_level_count >= 3 { 4 } else { 3 };
let time_limit_ms = module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size);
let level_config = resolve_puzzle_level_config(next_level_index);
let grid_size = level_config.grid_size;
let time_limit_ms = level_config.time_limit_ms;
let mut played_profile_ids = run.played_profile_ids.clone();
let current_level_id = run.next_level_id.clone();
if !played_profile_ids.contains(&profile_id) {
@@ -3250,6 +3452,98 @@ mod tests {
assert!(!has_original_neighbor_pair(&third));
}
fn test_recommended_work(profile_id: &str, score: f32) -> PuzzleRecommendedNextWorkRecord {
PuzzleRecommendedNextWorkRecord {
profile_id: profile_id.to_string(),
level_name: format!("{profile_id} 关"),
author_display_name: "作者".to_string(),
theme_tags: vec!["奇幻".to_string()],
cover_image_src: Some(format!("/{profile_id}.png")),
similarity_score: score,
}
}
#[test]
fn local_similar_works_handoff_keeps_cleared_run_for_user_choice() {
let run = PuzzleRunRecord {
run_id: "local-puzzle-run-a".to_string(),
entry_profile_id: "profile-current".to_string(),
cleared_level_count: 1,
current_level_index: 1,
current_grid_size: 3,
played_profile_ids: vec!["profile-current".to_string()],
previous_level_tags: vec!["奇幻".to_string()],
current_level: Some(PuzzleRuntimeLevelRecord {
run_id: "local-puzzle-run-a".to_string(),
level_index: 1,
level_id: Some("puzzle-level-1".to_string()),
grid_size: 3,
profile_id: "profile-current".to_string(),
level_name: "当前拼图".to_string(),
author_display_name: "当前作者".to_string(),
theme_tags: vec!["奇幻".to_string()],
cover_image_src: Some("/current.png".to_string()),
board: build_local_puzzle_board(3, "local-puzzle-run-a", "profile-current", 1),
status: "cleared".to_string(),
started_at_ms: 1_000,
cleared_at_ms: Some(2_000),
elapsed_ms: Some(1_000),
time_limit_ms: 300_000,
remaining_ms: 0,
paused_accumulated_ms: 0,
pause_started_at_ms: None,
freeze_accumulated_ms: 0,
freeze_started_at_ms: None,
freeze_until_ms: None,
leaderboard_entries: Vec::new(),
}),
recommended_next_profile_id: None,
next_level_mode: module_puzzle::PUZZLE_NEXT_LEVEL_MODE_NONE.to_string(),
next_level_profile_id: None,
next_level_id: None,
recommended_next_works: Vec::new(),
leaderboard_entries: Vec::new(),
};
let next_run = build_local_similar_works_handoff(
run,
vec![
test_recommended_work("profile-a", 0.9),
test_recommended_work("profile-b", 0.8),
test_recommended_work("profile-c", 0.7),
],
);
assert_eq!(
next_run.next_level_mode,
module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS
);
assert_eq!(
next_run.recommended_next_profile_id.as_deref(),
Some("profile-a")
);
assert_eq!(next_run.next_level_profile_id.as_deref(), Some("profile-a"));
assert_eq!(next_run.next_level_id, None);
assert_eq!(next_run.recommended_next_works.len(), 3);
assert_eq!(next_run.current_level_index, 1);
assert_eq!(
next_run
.current_level
.as_ref()
.map(|level| level.status.as_str()),
Some("cleared")
);
}
#[test]
fn puzzle_record_timestamp_parser_matches_shared_format() {
assert_eq!(
parse_puzzle_record_timestamp_micros("1713686401.234567Z"),
1_713_686_401_234_567
);
assert_eq!(parse_puzzle_record_timestamp_micros("bad-value"), 0);
}
#[test]
fn puzzle_generated_image_size_is_square_1_1() {
assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "1024*1024");
@@ -21,6 +21,7 @@ use shared_contracts::runtime::{
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_PUZZLE_AUTHOR_INCENTIVE_CLAIM,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
@@ -127,6 +128,9 @@ fn format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD
}
RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM
}
}
}
@@ -562,7 +566,7 @@ mod tests {
use crate::{app::build_router, config::AppConfig, state::AppState};
#[test]
fn profile_wallet_ledger_source_type_formats_asset_operation_values() {
fn profile_wallet_ledger_source_type_formats_backend_values() {
assert_eq!(
format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume
@@ -575,6 +579,12 @@ mod tests {
),
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND
);
assert_eq!(
format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim
),
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM
);
}
#[tokio::test]
File diff suppressed because it is too large Load Diff
@@ -262,6 +262,7 @@ pub enum RuntimeProfileWalletLedgerSourceType {
AssetOperationConsume,
AssetOperationRefund,
RedeemCodeReward,
PuzzleAuthorIncentiveClaim,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -1709,6 +1710,7 @@ impl RuntimeProfileWalletLedgerSourceType {
Self::AssetOperationConsume => "asset_operation_consume",
Self::AssetOperationRefund => "asset_operation_refund",
Self::RedeemCodeReward => "redeem_code_reward",
Self::PuzzleAuthorIncentiveClaim => "puzzle_author_incentive_claim",
}
}
}
@@ -2233,6 +2235,10 @@ mod tests {
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund.as_str(),
"asset_operation_refund"
);
assert_eq!(
RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim.as_str(),
"puzzle_author_incentive_claim"
);
}
#[test]
@@ -47,11 +47,61 @@ pub struct PuzzleWorkSummaryResponse {
pub like_count: u32,
#[serde(default)]
pub recent_play_count_7d: u32,
#[serde(default)]
pub point_incentive_total_half_points: u64,
#[serde(default)]
pub point_incentive_claimed_points: u64,
#[serde(default)]
pub point_incentive_total_points: f64,
#[serde(default)]
pub point_incentive_claimable_points: u64,
pub publish_ready: bool,
#[serde(default)]
pub levels: Vec<PuzzleDraftLevelResponse>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn puzzle_work_summary_response_uses_point_incentive_fields() {
let payload = serde_json::to_value(PuzzleWorkSummaryResponse {
work_id: "work-1".to_string(),
profile_id: "profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: None,
author_display_name: "作者".to_string(),
work_title: "作品".to_string(),
work_description: "描述".to_string(),
level_name: "第一关".to_string(),
summary: "画面".to_string(),
theme_tags: vec!["拼图".to_string(), "夜色".to_string(), "灯光".to_string()],
cover_image_src: None,
cover_asset_id: None,
publication_status: "published".to_string(),
updated_at: "2026-05-01T00:00:00Z".to_string(),
published_at: Some("2026-05-01T00:00:00Z".to_string()),
play_count: 1,
remix_count: 0,
like_count: 0,
recent_play_count_7d: 1,
point_incentive_total_half_points: 3,
point_incentive_claimed_points: 1,
point_incentive_total_points: 1.5,
point_incentive_claimable_points: 0,
publish_ready: true,
levels: Vec::new(),
})
.expect("payload should serialize");
assert_eq!(payload["pointIncentiveTotalHalfPoints"], 3);
assert_eq!(payload["pointIncentiveClaimedPoints"], 1);
assert_eq!(payload["pointIncentiveTotalPoints"], 1.5);
assert_eq!(payload["pointIncentiveClaimablePoints"], 0);
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleWorkProfileResponse {
@@ -11,6 +11,8 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME: &str =
"asset_operation_consume";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND: &str = "asset_operation_refund";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD: &str = "redeem_code_reward";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM: &str =
"puzzle_author_incentive_claim";
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";
@@ -910,6 +912,14 @@ mod tests {
.to_string(),
created_at: "2026-04-22T10:05:00Z".to_string(),
},
ProfileWalletLedgerEntryResponse {
id: "ledger-7".to_string(),
amount_delta: 2,
balance_after: 202,
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM
.to_string(),
created_at: "2026-04-22T10:06:00Z".to_string(),
},
],
})
.expect("payload should serialize");
@@ -940,6 +950,10 @@ mod tests {
payload["entries"][5]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND)
);
assert_eq!(
payload["entries"][6]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM)
);
assert_eq!(
payload["entries"][0]["createdAt"],
json!("2026-04-22T10:00:00Z")
+3 -3
View File
@@ -39,9 +39,9 @@ pub use mapper::{
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput, PuzzleWorkProfileRecord,
PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord,
ResolveNpcBattleInteractionInput,
PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput,
PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, ResolveNpcBattleInteractionInput,
};
pub mod ai;
@@ -2436,6 +2436,8 @@ pub(crate) fn map_puzzle_work_profile(
remix_count: snapshot.remix_count,
like_count: snapshot.like_count,
recent_play_count_7d: snapshot.recent_play_count_7d,
point_incentive_total_half_points: snapshot.point_incentive_total_half_points,
point_incentive_claimed_points: snapshot.point_incentive_claimed_points,
publish_ready: snapshot.publish_ready,
anchor_pack: map_puzzle_anchor_pack(snapshot.anchor_pack),
levels: snapshot
@@ -2491,6 +2493,13 @@ fn map_puzzle_recommended_next_work(
pub(crate) fn map_puzzle_runtime_level_snapshot(
snapshot: DomainPuzzleRuntimeLevelSnapshot,
) -> PuzzleRuntimeLevelRecord {
// 中文注释:历史 run_json 可能缺 started_at_ms,领域 serde 会回填为 0;API 层继续补成 1,避免前端计时器拿到无效开局时间。
let started_at_ms = if snapshot.started_at_ms == 0 {
1
} else {
snapshot.started_at_ms
};
PuzzleRuntimeLevelRecord {
run_id: snapshot.run_id,
level_index: snapshot.level_index,
@@ -2503,7 +2512,7 @@ pub(crate) fn map_puzzle_runtime_level_snapshot(
cover_image_src: snapshot.cover_image_src,
board: map_puzzle_board_snapshot(snapshot.board),
status: snapshot.status.as_str().to_string(),
started_at_ms: snapshot.started_at_ms,
started_at_ms,
cleared_at_ms: snapshot.cleared_at_ms,
elapsed_ms: snapshot.elapsed_ms,
time_limit_ms: snapshot.time_limit_ms,
@@ -3485,6 +3494,9 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back(
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => {
module_runtime::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward
}
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim => {
module_runtime::RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim
}
}
}
@@ -4535,6 +4547,7 @@ pub struct PuzzleRunPropRecordInput {
pub owner_user_id: String,
pub prop_kind: String,
pub used_at_micros: i64,
pub spent_points: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -4716,11 +4729,20 @@ pub struct PuzzleWorkProfileRecord {
pub remix_count: u32,
pub like_count: u32,
pub recent_play_count_7d: u32,
pub point_incentive_total_half_points: u64,
pub point_incentive_claimed_points: u64,
pub publish_ready: bool,
pub anchor_pack: PuzzleAnchorPackRecord,
pub levels: Vec<PuzzleDraftLevelRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleWorkPointIncentiveClaimRecordInput {
pub profile_id: String,
pub owner_user_id: String,
pub claimed_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleCellPositionRecord {
pub row: u32,
@@ -0,0 +1,58 @@
// 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::puzzle_work_point_incentive_claim_input_type::PuzzleWorkPointIncentiveClaimInput;
use super::puzzle_work_procedure_result_type::PuzzleWorkProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct ClaimPuzzleWorkPointIncentiveArgs {
pub input: PuzzleWorkPointIncentiveClaimInput,
}
impl __sdk::InModule for ClaimPuzzleWorkPointIncentiveArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `claim_puzzle_work_point_incentive`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait claim_puzzle_work_point_incentive {
fn claim_puzzle_work_point_incentive(&self, input: PuzzleWorkPointIncentiveClaimInput,
) {
self.claim_puzzle_work_point_incentive_then(input, |_, _| {});
}
fn claim_puzzle_work_point_incentive_then(
&self,
input: PuzzleWorkPointIncentiveClaimInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<PuzzleWorkProcedureResult, __sdk::InternalError>) + Send + 'static,
);
}
impl claim_puzzle_work_point_incentive for super::RemoteProcedures {
fn claim_puzzle_work_point_incentive_then(
&self,
input: PuzzleWorkPointIncentiveClaimInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<PuzzleWorkProcedureResult, __sdk::InternalError>) + Send + 'static,
) {
self.imp.invoke_procedure_with_callback::<_, PuzzleWorkProcedureResult>(
"claim_puzzle_work_point_incentive",
ClaimPuzzleWorkPointIncentiveArgs { input, },
__callback,
);
}
}
@@ -0,0 +1,58 @@
// 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::match_3_d_run_click_input_type::Match3DRunClickInput;
use super::match_3_d_click_item_procedure_result_type::Match3DClickItemProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct ClickMatch3DItemArgs {
pub input: Match3DRunClickInput,
}
impl __sdk::InModule for ClickMatch3DItemArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `click_match_3_d_item`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait click_match_3_d_item {
fn click_match_3_d_item(&self, input: Match3DRunClickInput,
) {
self.click_match_3_d_item_then(input, |_, _| {});
}
fn click_match_3_d_item_then(
&self,
input: Match3DRunClickInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<Match3DClickItemProcedureResult, __sdk::InternalError>) + Send + 'static,
);
}
impl click_match_3_d_item for super::RemoteProcedures {
fn click_match_3_d_item_then(
&self,
input: Match3DRunClickInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<Match3DClickItemProcedureResult, __sdk::InternalError>) + Send + 'static,
) {
self.imp.invoke_procedure_with_callback::<_, Match3DClickItemProcedureResult>(
"click_match_3_d_item",
ClickMatch3DItemArgs { input, },
__callback,
);
}
}
@@ -0,0 +1,58 @@
// 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::match_3_d_draft_compile_input_type::Match3DDraftCompileInput;
use super::match_3_d_agent_session_procedure_result_type::Match3DAgentSessionProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct CompileMatch3DDraftArgs {
pub input: Match3DDraftCompileInput,
}
impl __sdk::InModule for CompileMatch3DDraftArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `compile_match_3_d_draft`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait compile_match_3_d_draft {
fn compile_match_3_d_draft(&self, input: Match3DDraftCompileInput,
) {
self.compile_match_3_d_draft_then(input, |_, _| {});
}
fn compile_match_3_d_draft_then(
&self,
input: Match3DDraftCompileInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<Match3DAgentSessionProcedureResult, __sdk::InternalError>) + Send + 'static,
);
}
impl compile_match_3_d_draft for super::RemoteProcedures {
fn compile_match_3_d_draft_then(
&self,
input: Match3DDraftCompileInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<Match3DAgentSessionProcedureResult, __sdk::InternalError>) + Send + 'static,
) {
self.imp.invoke_procedure_with_callback::<_, Match3DAgentSessionProcedureResult>(
"compile_match_3_d_draft",
CompileMatch3DDraftArgs { input, },
__callback,
);
}
}
@@ -0,0 +1,58 @@
// 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::match_3_d_agent_session_procedure_result_type::Match3DAgentSessionProcedureResult;
use super::match_3_d_agent_session_create_input_type::Match3DAgentSessionCreateInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct CreateMatch3DAgentSessionArgs {
pub input: Match3DAgentSessionCreateInput,
}
impl __sdk::InModule for CreateMatch3DAgentSessionArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `create_match_3_d_agent_session`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait create_match_3_d_agent_session {
fn create_match_3_d_agent_session(&self, input: Match3DAgentSessionCreateInput,
) {
self.create_match_3_d_agent_session_then(input, |_, _| {});
}
fn create_match_3_d_agent_session_then(
&self,
input: Match3DAgentSessionCreateInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<Match3DAgentSessionProcedureResult, __sdk::InternalError>) + Send + 'static,
);
}
impl create_match_3_d_agent_session for super::RemoteProcedures {
fn create_match_3_d_agent_session_then(
&self,
input: Match3DAgentSessionCreateInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<Match3DAgentSessionProcedureResult, __sdk::InternalError>) + Send + 'static,
) {
self.imp.invoke_procedure_with_callback::<_, Match3DAgentSessionProcedureResult>(
"create_match_3_d_agent_session",
CreateMatch3DAgentSessionArgs { input, },
__callback,
);
}
}

Some files were not shown because too many files have changed in this diff Show More