Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-05 14:58:07 +08:00
238 changed files with 29234 additions and 144 deletions

View File

@@ -1,4 +0,0 @@
interface:
display_name: "新增玩法入口"
short_description: "把新增玩法入口的文档、配置、路由和验证流程一次收口"
default_prompt: "Use $genarrative-gameplay-entry-type to add a new gameplay entry type end to end in Genarrative."

View File

@@ -0,0 +1,389 @@
---
name: genarrative-play-type-integration
description: 在 Genarrative 中新增一个创作入口/玩法类型时,按入口配置、前端分流、契约、后端接口、工作台、结果页、可选 runtime 与作品架的顺序接入。
license: MIT
metadata:
author: Hermes Agent
version: "1.0"
---
# Genarrative 新增玩法类型接入流程
用于在 Genarrative 中新增一个创作入口/玩法类型,而不是单纯说明用户如何从入口创建作品。
## 适用场景
- 新增一个游戏玩法入口
- 让某个玩法从“敬请期待”变为可创建
- 为新玩法补齐创作工作台、结果页、发布与试玩链路
- 将新玩法接入创作中心作品架与广场
## 先判断接入级别
### 1. 只做入口占位
只需要新增入口配置,不接 session/workspace/result/runtime。
适合:
- 敬请期待
- 灰度占位
### 2. 可进入创作工作台
需要补齐前端分流、session、工作台、结果页至少能生成草稿。
### 3. 完整玩法闭环
需要补齐:
- 创作入口
- 工作台
- 草稿生成
- 结果页
- 发布
- 试玩 runtime
- 作品架 / 广场 / 分享
## 推荐接入顺序
### Step 1: 先定玩法 ID 和能力边界
先明确:
- `id` 是什么
- 入口是否可见
- 是否可点击创建
- 是否需要对话式创作
- 是否需要生成中页面
- 是否需要 result/runtime/gallery/share
不要先随便起临时 ID 再改名。
### Step 2: 新增入口配置
文件:
- `src/config/newWorkEntryConfig.ts`
`creationTypes` 中新增:
- `id`
- `title`
- `subtitle`
- `badge`
- `visible`
- `open`
如果只是占位:
- `visible: true`
- `open: false`
### Step 3: 确认类型过滤逻辑
文件:
- `src/components/platform-entry/platformEntryCreationTypes.ts`
检查:
- `getVisiblePlatformCreationTypes()` 是否能展示新类型
- `isPlatformCreationTypeVisible()` 是否能识别新类型
- `locked` / `hidden` 是否正确映射
### Step 4: 扩展页面阶段
文件:
- `src/components/platform-entry/platformEntryTypes.ts`
为新玩法补充 `SelectionStage`
- `*-agent-workspace`
- `*-generating`(可选)
- `*-result`
- `*-runtime`(可选)
- `*-gallery-detail`(可选)
### Step 5: 在总流程中加类型分流
文件:
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
`handleCreationHubCreateType(type)` 中新增分支,确保:
- 能进入对应工作台
- 能设置对应 `selectionStage`
- 能关闭类型弹层
同时按玩法补齐:
- `open<Play>AgentWorkspace()`
- `leave<Play>Flow()`
- `submit<Play>Message()`(对话式玩法)
- `execute<Play>Action()`
### Step 6: 接入通用 Agent flow controller
文件:
- `src/components/platform-entry/usePlatformCreationAgentFlowController.ts`
如果是 Agent 型玩法,复用通用控制器:
- `createSession`
- `getSession`
- `streamMessage`
- `executeAction`
- `isBusy`
- `error`
- `streamingReplyText`
- `selectionStage` 切换
### Step 7: 定义 shared contracts
前端:
- `packages/shared/src/contracts/`
后端:
- `server-rs/crates/shared-contracts/src/`
至少补齐:
- session snapshot
- create session request/response
- message request/response
- action request/response
- draft/result 结构
- work summary / gallery 结构(如果需要)
- runtime 结构(如果需要)
### Step 8: 实现前端 service client
目录参考:
- `src/services/`
按玩法补:
- creation client
- runtime client可选
- works client可选
- gallery client可选
建议保持和现有玩法一致的 API base 与命名风格。
### Step 9: 接后端 API
文件参考:
- `server-rs/crates/api-server/src/puzzle.rs`
- `server-rs/crates/api-server/src/puzzle_agent_turn.rs`
- `server-rs/crates/api-server/src/match3d.rs`
通常需要:
- create session
- get session
- send message
- stream message
- execute action
- publish / save / delete
- runtime start / action可选
- gallery / detail可选
后端设计优先按 Genarrative 的 DDD 分层拆开不要把玩法规则、数据库事务、LLM 调用和 HTTP handler 混在一个文件里:
- `module-<play>`纯领域规则、状态机、draft/runtime 校验,不依赖 Axum、SpacetimeDB 或外部平台。
- `shared-contracts`:前后端 DTO、请求/响应、session snapshot、draft/result/runtime 结构。
- `spacetime-module`表定义、reducer/procedure、事务编排、migration表结构变化要同步生成绑定。
- `spacetime-client`api-server 到 SpacetimeDB 的 facade隐藏 reducer 调用细节。
- `api-server`Axum 路由、鉴权、SSE/stream、应用层编排。
- `platform-*`LLM、资产上传、鉴权、第三方服务等副作用。
建议按四条线设计后端能力:
- Agent 创作线session、turn、stream、compile action。
- Works 作品线:保存、发布、删除、草稿恢复。
- Gallery 广场线公开列表、详情、like/remix/share。
- Runtime 运行态线:开始试玩、提交动作、读取状态。
### Step 10: 新增工作台组件
目录建议:
- `src/components/<play>-creation/<Play>AgentWorkspace.tsx`
两种形态:
#### 对话式
适合设定逐轮补齐。
参考:
- `BigFishAgentWorkspace.tsx`
- `Match3DAgentWorkspace.tsx`
#### 表单式
适合输入结构明确的玩法。
参考:
- `PuzzleAgentWorkspace.tsx`
### Step 11: 在渲染树中挂载新页面
文件:
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
补齐:
- workspace 分支
- generating 分支(如需要)
- result 分支
- runtime 分支(如需要)
### Step 12: 新增结果页
目录建议:
- `src/components/<play>-result/<Play>ResultView.tsx`
结果页至少支持:
- 展示 draft
- 返回编辑
- 发布
- 试玩
- 错误展示
### Step 13: 需要试玩就补 runtime
目录建议:
- `src/components/<play>-runtime/<Play>RuntimeShell.tsx`
如果玩法是游戏类,建议补完整 runtime 闭环。
### Step 14: 接入作品架 / 广场 / 分享
需要改:
- `src/components/custom-world-home/creationWorkShelf.ts`
- `src/components/custom-world-home/CustomWorldCreationHub.tsx`
- `src/services/publicWorkCode.ts`
如果玩法支持发布,还要补:
- public work code
- public detail
- publish share modal
- like/remix可选
### Step 15: 处理登录态与草稿恢复
要考虑:
- 刷新恢复草稿
- 退出登录清空私有状态
- result/draft 缺失时回退
- busy / generating / runtime 中断恢复
### Step 16: 补测试
至少覆盖:
- 入口展示
- 类型分流
- 工作台打开
- session 创建
- compile action
- result 页切换
- 发布后刷新作品架
- runtime 进入与退出
## 最小改动清单
### 只做占位
只改:
- `src/config/newWorkEntryConfig.ts`
### 做到可进入工作台
至少改:
- `newWorkEntryConfig.ts`
- `platformEntryTypes.ts`
- `PlatformEntryFlowShellImpl.tsx`
- 新玩法 service client
- 新玩法工作台组件
- shared contracts
- 后端 API
### 做到完整闭环
还要补:
- result 页
- runtime
- works / gallery
- public code
- share
- 作品架聚合
- 测试
## 常见坑
1. 只加入口配置不够,类型分流和页面阶段也要补。
2. `SelectionStage` 不扩展,前端无法安全切页。
3. 新玩法如果要出现在作品架,必须改聚合逻辑,不只是加入口。
4. 发布后不刷新 works/gallery用户会看不到新作品。
5. 如果走 SpacetimeDB表结构变化要同步 migration 和绑定;`spacetime-client/src/module_bindings/` 通常是生成物,不要为了修编译或格式化而手改,优先改 module 源 schema/reducer/procedure 后重新生成。
6. 做 analytics/tracking 这类 runtime 能力时,不要只补 API DTO先在 `module-runtime` 写纯函数测试(例如 day/week/month/quarter/year bucket 聚合、scope/event 过滤RED 后再补领域类型与聚合函数。
7. 时间粒度聚合建议复用已有 date dimension 逻辑,把 daily stat 映射到 day/week/month/quarter/year bucketbucket 输出要有稳定排序,并显式携带 `bucketKey``bucketStartDateKey``bucketEndDateKey``value`
8. 后端 shared-contracts 与前端 `packages/shared/src/contracts/runtime.ts` 要同步补 request/response/type unionadmin-web 若有独立 `api/adminApiTypes.ts`,也要同步,避免共享包已更新但管理端本地类型缺失。
9. 退出登录时要清空新玩法私有状态,避免串用户。
10. 移动端入口卡片增多后要检查布局和滚动体验。
## 验证标准
一个玩法算真正接入成功,至少要满足:
- 入口能展示
- 能进入对应工作台
- 能创建 session
- 能生成草稿
- 能进入结果页
- 能返回编辑
- 如果需要,可试玩
- 如果需要,可发布
- 发布后能回到作品架 / 广场 / 分享链路
## 建议验证命令
按改动范围选择:
```bash
# 后端 contracts / module-runtime / api-server
cd server-rs
cargo test -p shared-contracts
cargo test -p module-runtime
cargo check -p api-server
# SpacetimeDB schema/reducer/procedure 改动后,优先在有 CLI 的机器重新生成 bindings
npm run spacetime:generate -- --rust-only
# 前端类型
npm run admin-web:typecheck
```
如果新增完整前端玩法闭环,还要按项目实际脚本补充 web typecheck、lint 或 Playwright/单元测试。

53
.hermes/README.md Normal file
View File

@@ -0,0 +1,53 @@
# Genarrative 团队 Hermes 共享记忆
本目录用于在仓库内共享团队级 Hermes 上下文,供 3 名开发人员在各自本地 Hermes 中读取、更新和同步。
## 使用原则
- `.hermes/` 中只保存可以进入 Git 的团队共享内容。
- 不提交个人配置、API Key、会话转录、模型密钥、本地路径密钥等敏感内容。
- 个人 Hermes 的 `~/.hermes/config.yaml``~/.hermes/.env``~/.hermes/sessions/` 不应复制到本仓库。
- 开发前先阅读本目录下与任务相关的记忆文件;开发后如产生稳定知识,更新对应文档。
- 若本目录内容与 `docs/` 或代码事实冲突,以当前代码和最新 `docs/` 为准,并同步修正过期记忆。
## 目录结构
```text
.hermes/
├─ README.md # 本说明
├─ shared-memory/
│ ├─ project-overview.md # 项目概览与当前技术路线
│ ├─ team-conventions.md # 团队协作约定
│ ├─ development-workflow.md # 开发、测试、提交流程
│ ├─ document-map.md # README / AGENTS / docs 阅读索引
│ ├─ decision-log.md # 长期决策记录
│ ├─ pitfalls.md # 踩坑与排障记录
│ └─ handoff-template.md # 任务交接模板
├─ plans/ # 阶段性计划与实施方案
└─ skills/ # 未来可沉淀的仓库级 Hermes skills
```
## 推荐给 Hermes 的启动提示
在本仓库中开始复杂任务时,可以先对 Hermes 说:
```text
请先读取 AGENTS.md 以及 .hermes/shared-memory/ 下与本任务相关的团队共享记忆,再开始分析。若任务完成后产生稳定项目知识,请更新 .hermes/shared-memory/ 对应文件。
```
## 需要沉淀到这里的内容
- 长期有效的架构约定
- 反复会用到的本地开发/测试流程
- 已确认的接口契约或模块边界
- 重要技术决策及原因
- 踩坑、排障方式、验证命令
- 团队协作规则和任务交接规范
## 不应沉淀到这里的内容
- API Key、Token、Cookie、私有密钥
- 个人账号、个人本地绝对路径、个人隐私信息
- 大段临时聊天记录
- 尚未确认的一次性猜测
- 构建产物、日志、缓存、数据库 dump

View File

@@ -0,0 +1,724 @@
# 埋点系统新增周、月、季、年维度映射表计划
## 目标
在 Genarrative 的埋点/统计系统中新增“周、月、季、年”维度映射表,让后续统计查询可以按不同时间粒度稳定聚合,而不是只依赖运行时临时计算日期范围。
本计划只做设计与落地步骤,不直接修改业务代码。
## 当前上下文与初步发现
1. 当前仓库根目录为 `/home/dsk/workspace/Genarrative`
2. 远端更新后已定位到当前真实埋点/任务系统:
- 原始埋点表:`tracking_event`
- 日聚合投影表:`tracking_daily_stat`
- 任务配置表:`profile_task_config`
- 任务进度表:`profile_task_progress`
- 领奖记录表:`profile_task_reward_claim`
3. 相关文件:
- `server-rs/crates/spacetime-module/src/runtime/profile.rs`
- `server-rs/crates/module-runtime/src/domain.rs`
- `server-rs/crates/module-runtime/src/application.rs`
- `server-rs/crates/api-server/src/runtime_profile.rs`
- `apps/admin-web/src/pages/AdminTaskConfigPage.tsx`
- `apps/admin-web/src/api/adminApiTypes.ts`
- `apps/admin-web/src/config/trackingEventDefinitions.ts`
- `docs/technical/PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md`
- `docs/tracking/TRACKING_QUERY_PLAYBOOK_2026-05-03.md`
4. 当前 `tracking_event` 是明细表,`tracking_daily_stat` 是统一日汇总表,不按范围拆表;它通过 `event_key + scope_kind + scope_id + day_key` 区分不同聚合桶。
5. 当前任务系统是“个人任务系统”,首版任务配置均面向用户维度;后台暴露“埋点范围”选择会导致运营误配。
6. 已决定采用任务配置方案 B
- 后台任务配置不再让运营手动选择埋点范围。
- 后端个人任务配置统一限制为 `RuntimeTrackingScopeKind::User`
- 若 API 收到非 `user``scopeKind`,应拒绝或兼容忽略但最终落库必须为 `User`;推荐直接拒绝并返回清晰错误。
7. 已发现一个需要纳入本计划修复的语义问题:`profile_task_tracking_scope_id``RuntimeTrackingScopeKind::Work` 当前返回 `user_id`,这不符合 work 维度语义。即使个人任务暂不支持 work也应避免错误映射继续存在。
8. 当前日期桶使用北京时间自然日:`day_key = floor((occurred_at_micros + 8h) / 1d)`;周/月/季/年映射表应优先沿用这一业务日口径,除非产品另行确认。
9. 如果新增正式后端表,需要同步:
- 表定义
- reducer/procedure
- migration.rs
- 生成绑定
- spacetime-client facade
- shared-contracts / API DTO如有接口暴露
## 关键假设
在未定位现有埋点模块前,先按以下假设规划:
1. 当前已有某种事件明细表或统计事实表,例如:
- `telemetry_event`
- `analytics_event`
- `metric_event`
- `narrative_telemetry`
- 或类似命名
2. 新增的“映射表”用于把具体日期或事件时间映射到时间维度 bucket。
3. 映射维度包括:
-week
-month
-quarter
-year
4. 已明确选择“一张通用日期维度映射表”方案:`analytics_date_dimension`
5. 统计口径需要明确:
- 周从周一还是周日开始
- 是否使用 ISO week
- 季度是自然季度还是财务季度
- 时区使用 UTC 还是业务本地时区
## 推荐设计方向
### 方案 A单一时间维度映射表推荐
新增一张日历维度表,每一行对应一个自然日,并包含它归属的周、月、季、年。
表概念:
```text
analytics_date_dimension
```
建议字段:
```text
date_key string 例如 2026-05-04
calendar_date string 真实日期,按 YYYY-MM-DD 存储
weekday u8 1-7 或 0-6需要统一约定
iso_week_key string 例如 2026-W19
week_start_date_key string 例如 2026-05-04
week_end_date_key string 例如 2026-05-10
month_key string 例如 2026-05
month_start_date_key string
month_end_date_key string
quarter_key string 例如 2026-Q2
year_key string 例如 2026
created_at timestamp/string
updated_at timestamp/string
```
优点:
- 一张表即可支持日、周、月、季、年映射。
- 便于后续新增半月、财年、节假日、自然周等维度。
- 查询逻辑简单:事件日期 join/date_key 映射到目标粒度。
- 数据量很小,按 20 年也只有约 7300 行。
缺点:
- 需要在事件时间写入或统计查询时把 timestamp 归一为 date_key。
- 如果要支持多时区,可能需要增加 timezone 字段或多套 calendar。
### 方案 B四张独立映射表
分别新增:
```text
analytics_week_dimension
analytics_month_dimension
analytics_quarter_dimension
analytics_year_dimension
```
优点:
- 每个粒度表结构更纯粹。
- 查询时可以直接针对目标粒度表。
缺点:
- 表更多,维护复杂。
- 日期归属关系仍然需要额外处理。
- 容易出现周/月/季/年口径漂移。
### 最终选择
本计划采用方案 A单一 `analytics_date_dimension` 日期维表,而不是四张独立映射表。
如业务未来明确要求“周、月、季、年各自有独立映射表”,也应优先在日期维表基础上派生视图或物化派生表,而不是一开始拆成四张重复表。
## 后端设计建议
### 1. 明确埋点领域归属
先定位现有埋点模块。如果没有独立模块,建议新增或归入:
```text
server-rs/crates/module-analytics/
```
或如果当前项目已有 telemetry 命名,则保持已有命名,例如:
```text
server-rs/crates/module-telemetry/
```
领域层职责:
- 时间粒度定义
- date_key/week_key/month_key/quarter_key/year_key 生成规则
- 时间维度校验
- 事件聚合查询输入的纯规则
不应包含:
- SpacetimeDB 表读写
- Axum handler
- HTTP response
### 2. SpacetimeDB 表设计
`spacetime-module` 中新增时间维度表。
建议表名:
```text
analytics_date_dimension
```
建议主键:
```text
date_key
```
建议索引:
```text
iso_week_key
month_key
quarter_key
year_key
```
如果 SpacetimeDB 表定义已有统一命名规范,应按现有规范命名。
### 3. 初始化/补全 reducer
新增 reducer 或内部 procedure用于生成指定日期范围内的维度数据。
建议能力:
```text
seed_analytics_date_dimensions(start_date, end_date)
ensure_analytics_date_dimension_for_date(date_key)
ensure_analytics_date_dimensions_for_range(start_date, end_date)
```
设计原则:
- 可幂等执行。
- 已存在 date_key 时不重复插入。
- 支持一次补一段日期。
- 避免一次补太大范围导致事务过重。
- 生产环境建议按年份或月份分批。
### 4. 事件表和映射表关系
如果事件表目前只有 timestamp建议新增或计算出
```text
event_date_key
```
可选策略:
1. 写入事件时同步写 `event_date_key`
2. 查询统计时从 timestamp 临时计算 date_key。
3. 后台迁移为历史事件补 `event_date_key`
推荐:
- 新事件写入时保存 `event_date_key`
- 历史事件通过批量迁移 reducer 分批补齐。
### 5. 聚合查询设计
支持按粒度查询时API 或 facade 可以接收:
```text
granularity = day | week | month | quarter | year
start_date
end_date
metric/event_name
filters
```
内部根据粒度选择 bucket key
```text
day -> date_key
week -> iso_week_key 或 week_key
month -> month_key
quarter -> quarter_key
year -> year_key
```
返回结构建议统一:
```text
bucket_key
bucket_label
bucket_start_date
bucket_end_date
value
```
## 可能涉及的文件
由于当前尚未定位明确埋点模块,以下是预计文件范围。
### 必查文件/目录
```text
./Genarrative/server-rs/crates/
./Genarrative/server-rs/crates/spacetime-module/src/
./Genarrative/server-rs/crates/spacetime-client/src/
./Genarrative/server-rs/crates/shared-contracts/src/
./Genarrative/server-rs/crates/api-server/src/
./Genarrative/packages/shared/src/contracts/
./Genarrative/src/services/
```
### 可能新增文件
如果采用 analytics 命名:
```text
server-rs/crates/module-analytics/src/domain.rs
server-rs/crates/module-analytics/src/commands.rs
server-rs/crates/module-analytics/src/application.rs
server-rs/crates/module-analytics/src/errors.rs
server-rs/crates/module-analytics/src/events.rs
server-rs/crates/shared-contracts/src/analytics.rs
server-rs/crates/spacetime-client/src/analytics.rs
server-rs/crates/api-server/src/analytics.rs
packages/shared/src/contracts/analytics.ts
```
如果只是新增 SpacetimeDB 映射表且暂不暴露 API则可能只需
```text
server-rs/crates/spacetime-module/src/**
server-rs/crates/spacetime-module/src/migration.rs
server-rs/crates/spacetime-client/src/** # 如果查询会被 api-server 使用
```
## 详细实施步骤
### Step 1复核现有埋点系统与任务配置链路
当前已定位真实链路,实施前再做一次只读复核,确认远端最新代码没有继续变化。
已知核心表:
```text
tracking_event # 原始埋点明细
tracking_daily_stat # 日聚合投影
profile_task_config # 个人任务配置
profile_task_progress # 个人任务进度
profile_task_reward_claim # 领奖记录
```
已知核心文件:
```text
server-rs/crates/spacetime-module/src/runtime/profile.rs
server-rs/crates/module-runtime/src/domain.rs
server-rs/crates/module-runtime/src/application.rs
server-rs/crates/api-server/src/runtime_profile.rs
apps/admin-web/src/pages/AdminTaskConfigPage.tsx
apps/admin-web/src/api/adminApiTypes.ts
apps/admin-web/src/config/trackingEventDefinitions.ts
docs/technical/PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md
docs/tracking/TRACKING_QUERY_PLAYBOOK_2026-05-03.md
```
重点确认:
1. `tracking_event` 是否仍包含 `event_key/scope_kind/scope_id/day_key/user_id/occurred_at`
2. `tracking_daily_stat` 是否仍按 `event_key + scope_kind + scope_id + day_key` 生成 `stat_id`
3. `profile_task_config` 是否仍包含 `scope_kind``sort_order`
4. 后台 `AdminTaskConfigPage` 是否仍暴露“埋点范围”下拉。
5. `profile_task_tracking_scope_id``Work => user_id` 的错误映射是否仍存在。
### Step 1.5:先收紧个人任务配置的埋点范围,采用方案 B
在做周/月/季/年维度映射前,先修正个人任务配置边界,避免后续在错误配置模型上继续扩展。
目标行为:
```text
个人任务配置只支持用户维度埋点。
后台页面不再展示“埋点范围”。
后端不允许 profile_task_config 被写入 site/work/module 维度。
```
建议实现:
1. 前端隐藏 `AdminTaskConfigPage` 的“埋点范围”选择。
- 文件:`apps/admin-web/src/pages/AdminTaskConfigPage.tsx`
- 移除或隐藏:`scopeKinds` 下拉 UI。
- 保存请求仍可兼容传 `scopeKind: 'user'`,避免一次性改动 API contract。
2. 后端 upsert 校验 `scopeKind` 必须为 `RuntimeTrackingScopeKind::User`
- 文件:`server-rs/crates/api-server/src/runtime_profile.rs`
- 或更底层:`server-rs/crates/module-runtime/src/domain.rs` / `server-rs/crates/spacetime-module/src/runtime/profile.rs` 的输入构造函数。
- 推荐在领域输入构造处兜底校验API 层返回清晰错误。
3. 若暂不改 API DTO则保持字段存在但限定值只能是 `user`
- 文件:`apps/admin-web/src/api/adminApiTypes.ts`
- `AdminUpsertProfileTaskConfigRequest.scopeKind` 可保留,前端固定传 `user`
4. 更新后台埋点定义注册表的语义:
- 文件:`apps/admin-web/src/config/trackingEventDefinitions.ts`
- 当前每个 event definition 包含 `scopeKind`,如果个人任务统一 `user`,可以保留为只读内部默认值;但不要让运营在页面改。
5. 更新技术文档:
- `docs/technical/PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md`
- 明确“个人任务首版只支持用户维度埋点,后台不开放 scope_kind 配置”。
验收:
```text
后台任务配置页不再出现“埋点范围”选择。
保存 daily_login 后落库 scope_kind 仍为 User。
直接调用后台 upsert 接口传 site/work/module 时被拒绝,或最终不会落库为非 User推荐拒绝。
```
### Step 1.6:修复 Work 范围错误返回 user_id 的语义问题
当前函数:
```text
server-rs/crates/spacetime-module/src/runtime/profile.rs
profile_task_tracking_scope_id(user_id, config)
```
当前问题:
```rust
RuntimeTrackingScopeKind::Work => user_id.to_string()
```
这会把 work 维度错误映射为用户 ID。虽然个人任务将限制为 User但保留这个分支会误导后续扩展。
推荐修复策略:
1. 对个人任务进度计算来说,`Work` 不应进入该函数。
2.`profile_task_tracking_scope_id` 改为返回 `Result<String, String>``Option<String>`
3. 对不支持的范围返回错误,而不是伪造 scope_id
```text
Site -> "site",如果个人任务仍不允许 site则上游先拒绝
Module -> "profile",如果个人任务仍不允许 module则上游先拒绝
User -> user_id
Work -> error: personal task progress does not support work scope without work_id
```
更严格的推荐:
```text
个人任务链路只接受 User。
Work/Site/Module 在 profile_task_progress_count 前就被拒绝。
profile_task_tracking_scope_id 只保留 User 分支,或者非 User 返回错误。
```
需要同步调整调用点:
```text
profile_task_progress_count
refresh_profile_task_progress
build_profile_task_center_snapshot
claim_profile_task_reward
```
避免因为函数返回 `Result` 后调用链未处理错误。
验收:
```text
不存在 Work => user_id 的映射。
个人任务配置非 User 时不会静默算出错误进度。
相关测试覆盖User 正常Work/Site/Module 被拒绝。
```
### Step 2确定时间口径
必须先确认:
1. 周维度是否使用 ISO week。
2. 周开始日是周一还是周日。
3. 月/季/年是否按自然日历。
4. 统计时区是 UTC、服务器时区还是用户本地时区。
5. 跨年周如何命名,例如 `2025-W01` 可能开始于 2024 年末。
推荐默认:
```text
时区UTC除非产品明确要求中国时区
ISO week周一开始
月:自然月
季:自然季度
年:自然年
```
如果业务面向国内用户,建议考虑:
```text
时区Asia/Shanghai
周:周一开始
```
### Step 3设计 date dimension 表
设计字段和 key 格式,写入技术文档。
建议 key 格式:
```text
date_key: 2026-05-04
week_key: 2026-W19
month_key: 2026-05
quarter_key: 2026-Q2
year_key: 2026
```
注意:
- `week_key` 建议使用 ISO week-year不一定等于 calendar year。
- `quarter_key` 使用 calendar year。
### Step 4新增领域纯函数
在领域层或 shared-kernel 中实现纯函数:
```text
resolve_date_dimension(date, timezone) -> AnalyticsDateDimension
resolve_bucket_key(date_dimension, granularity) -> String
resolve_bucket_range(bucket_key, granularity) -> start/end date
```
要求:
- 有单元测试。
- 覆盖跨年周。
- 覆盖闰年 2 月。
- 覆盖季度边界。
### Step 5新增 SpacetimeDB 表
`spacetime-module` 中新增表。
遵守约束:
- 新增表通常安全。
- 不修改已有表字段。
- 如果必须给已有事件表加 `event_date_key`,必须加在表定义末尾并提供 default。
- 若要补历史数据,使用 reducer 分批迁移。
### Step 6新增 seed/ensure reducer
新增幂等 reducer
```text
seed_analytics_date_dimensions(start_date, end_date)
ensure_analytics_date_dimension_for_date(date_key)
```
验证点:
- 重复执行不会重复插入。
- 日期范围非法时返回稳定错误。
- 单次范围过大时拒绝或分页。
### Step 7接入事件写入链路
如果现有事件写入链路存在,新增:
```text
event_date_key
```
策略:
- 新事件写入时同步计算并保存。
- 写入前确保对应 date dimension 存在。
- 历史事件通过迁移 reducer 补齐。
如果暂不改事件表,也可以在查询阶段临时映射,但性能和一致性较差。
### Step 8接入聚合查询
如已有统计接口,扩展请求参数:
```text
granularity: day | week | month | quarter | year
```
查询逻辑改为:
```text
事件/事实表
→ event_date_key
→ analytics_date_dimension
→ 取对应 bucket key
→ group by bucket key
```
返回 bucket 时包含:
```text
bucket_key
bucket_start_date
bucket_end_date
value
```
### Step 9补 shared contracts 和前端 contracts
如果有 API 暴露,需要补:
```text
server-rs/crates/shared-contracts/src/analytics.rs
packages/shared/src/contracts/analytics.ts
```
建议 DTO
```text
AnalyticsGranularity = day | week | month | quarter | year
AnalyticsBucketMetric
AnalyticsMetricQueryRequest
AnalyticsMetricQueryResponse
```
### Step 10补测试
测试范围:
1. 领域日期映射测试
2. SpacetimeDB reducer 幂等测试
3. API 查询维度测试
4. 历史事件迁移测试,如涉及
5. 跨边界日期测试
6. 个人任务配置 scope 限制测试
7. `Work => user_id` 错误映射回归测试
重点用例:
```text
2024-02-29 闰年
2025-12-29 ISO week 可能属于 2026-W01
2026-01-01 跨年周
2026-03-31 Q1 结束
2026-04-01 Q2 开始
2026-12-31 年末
```
任务配置重点用例:
```text
admin upsert daily_login + scopeKind=user -> 成功
admin upsert daily_login + scopeKind=site -> 失败,错误信息说明个人任务仅支持 user
admin upsert daily_login + scopeKind=module -> 失败
admin upsert daily_login + scopeKind=work -> 失败
任务中心读取 daily_login -> 按 User + 当前 user_id 查询进度
代码中不存在 Work => user_id 的静默映射
```
## 测试与验证命令
具体命令需在定位模块后确认。初步建议:
```text
npm run typecheck
npm test
```
后端如涉及 Rust
```text
cargo test -p module-analytics
cargo test -p spacetime-module
cargo test -p api-server
```
涉及 API smoke
```text
npm run api-server
```
然后验证:
```text
GET /healthz
```
涉及 SpacetimeDB schema
- 需要生成绑定。
- 需要确认 migration.rs 对齐。
- 需要确认 publish 不触发不安全 schema 变更。
## 风险与权衡
### 风险 1个人任务 scope_kind 被误配置导致进度异常
当前个人任务系统本质上按用户维度计算进度。如果允许运营配置 `site/work/module`,可能导致任务进度查错 `tracking_daily_stat` 聚合桶,出现任务永远不可领取或错误可领取。
缓解:
```text
采用方案 B后台隐藏埋点范围后端限制个人任务配置只能写入 User。
```
### 风险 2Work 维度缺少 work_id上游却静默用 user_id 代替
当前 `profile_task_tracking_scope_id``Work => user_id` 是错误语义。若后续扩展作品任务,会把作品维度统计错误映射到用户维度。
缓解:
```text
移除 Work => user_id 映射;非 User 的个人任务配置应被拒绝。未来做作品任务时新增明确 work_id 来源和任务类型。
```
### 风险 3时区口径影响统计结果
周/月/季/年映射对时区敏感。当前日桶使用北京时间自然日:`floor((occurred_at_micros + 8h) / 1d)`。新增映射表应明确沿用北京时间业务日,还是切换为 UTC/用户本地时区。
### 风险 4ISO week 跨年
ISO week-year 与自然年不同。若前端展示按自然年理解,可能产生认知差异。
### 风险 5修改已有事件表可能触发 SpacetimeDB 迁移限制
如果已有事件表需要新增字段:
- 字段必须加末尾。
- 必须提供 default。
- 历史数据要分批迁移。
### 风险 5表设计过早绑定单一业务
建议用通用 date dimension而不是为某个单一埋点写死周/月/季/年表,避免后续复用困难。
## 待确认问题
1. 周维度使用 ISO week 还是自然周?周一开始还是周日开始?
2. 周/月/季/年映射是否沿用当前北京时间业务日口径?
3. 这个映射表服务的是所有埋点,还是只服务个人任务/运营后台统计?
4. 是否需要 API 暴露这些映射关系,还是只用于后端聚合?
5. 是否需要回填历史事件?历史数据规模多大?
6. 未来是否会存在非个人任务,例如整站任务、模块任务、作品任务?如果会,应另行设计任务类型和 `scope_id` 来源,不应复用当前个人任务配置页直接开放 scope。
## 建议结论
优先采用“一张通用日期维度映射表”的设计:
```text
analytics_date_dimension
```
通过字段同时提供:
```text
day / week / month / quarter / year
```
后续统计按 `granularity` 选择 bucket key 聚合。这样比直接新增四张独立映射表更稳定、更容易复用,也更容易处理跨年周、季度边界和历史回填。

View File

@@ -0,0 +1,271 @@
# 邀请码有效期与后台二次确认实施计划
> **For Hermes:** 按 plan 模式,仅输出并保存实施计划,不直接改业务代码。
**Goal:** 为邀请码新增开始日期与截止日期,并让后台所有会修改数据的操作在提交前增加二次确认,降低误操作风险。
**Architecture:**
邀请码仍作为“用户稳定邀请身份码”保留,不做停用删除;在数据层增加 `starts_at` / `expires_at`,前台填写邀请码时按时间窗校验,后台列表与编辑页展示状态。后台所有写操作统一先弹二次确认,再真正调用 API避免对兑换码、邀请码、任务配置等管理动作误触发。
**Tech Stack:**
Rust / SpacetimeDB / Axum / shared-contracts / TS + React 的 admin-web。
---
## 当前上下文
- 邀请码当前只有 `user_id``invite_code``metadata_json``created_at``updated_at`,没有状态字段。
- 目前后台存在邀请码管理入口,但没有停用能力,也没有有效期概念。
- 邀请码用于 `redeem_profile_referral_invite_code` 时的实时校验,适合增加“时间窗”而不是“禁用删除”。
- 后台已存在兑换码、任务配置等可写操作;本次要求把所有后台操作统一加二次确认,包括新增、编辑、禁用、删除等写入口。
---
## 设计原则
1. **邀请码不做软删除**:保留历史记录和邀请链路。
2. **有效期由时间窗推导**
- `starts_at` 为空表示立即生效。
- `expires_at` 为空表示长期有效。
3. **前台只拒绝新绑定**:已绑定关系不回溯修改。
4. **后台写操作统一确认**:所有会触发 POST / PATCH / DELETE 的管理动作,在真正提交前必须弹出二次确认。
5. **尽量少改接口语义**:优先在现有 admin upsert/list 体系内扩展字段,而不是新增一套并行 API。
---
## 方案概要
### 邀请码时间窗
新增字段:
- `starts_at: Option<Timestamp>`
- `expires_at: Option<Timestamp>`
校验规则:
- 当前时间 `< starts_at`:返回“邀请码未生效”
- 当前时间 `>= expires_at`:返回“邀请码已过期”
- 其他情况允许填写
建议把状态展示为:
- 未生效
- 有效
- 已过期
- 长期有效(两个字段都为空或仅无截止)
### 后台二次确认
对 admin-web 所有管理动作统一加确认弹窗/对话框,至少覆盖:
- 兑换码新增/更新
- 兑换码停用
- 邀请码新增/更新
- 任务配置新增/更新
- 任务配置停用
- 其他后续新增的后台写操作
确认文案要求:
- 显示对象标识(如 code / inviteCode / taskId
- 显示操作类型(新增 / 更新 / 停用)
- 明确提醒“该操作会立即影响线上数据”
- 允许取消返回,不调用 API
---
## 预期修改文件
### 1. 服务端领域与契约
- `server-rs/crates/spacetime-module/src/runtime/profile.rs`
- `ProfileInviteCode` 表结构新增开始/截止字段
- 邀请码 upsert 逻辑写入时间窗
- 邀请码 redeem 逻辑增加时间窗校验
- 邀请中心快照补充时间窗/状态
- `server-rs/crates/spacetime-module/src/migration.rs`
- 兼容旧表数据,给旧邀请码补默认空值
- `server-rs/crates/shared-contracts/src/runtime*.rs` 或对应生成/手写契约文件
- `AdminUpsertProfileInviteCodeRequest` 扩展字段
- `ProfileInviteCodeAdminResponse` 扩展字段
- 如需要,增加时间窗相关状态枚举或派生字段
- `server-rs/crates/spacetime-client/src/module_bindings/*`
- 重新生成 bindings
- mapper 补齐新字段
### 2. API Server
- `server-rs/crates/api-server/src/runtime_profile.rs`
- 接收/转发邀请码时间窗参数
- 返回新增字段给后台
- 如需要,调整校验错误文案
- `server-rs/crates/api-server/src/app.rs`
- 若有新路由或错误码需挂接,在此统一登记
### 3. Admin Web
- `apps/admin-web/src/api/adminApiTypes.ts`
- 增加邀请码时间窗字段
- 如有需要,增加后台操作请求结构字段
- `apps/admin-web/src/api/adminApiClient.ts`
- 透传新的请求/响应字段
- `apps/admin-web/src/app/adminRoutes.ts`
- 不一定需要改,但如果新增独立页面/子面板,需要在此登记
- `apps/admin-web/src/styles/admin.css`
- 确认弹窗与时间窗展示样式
- `apps/admin-web/src/**` 实际管理页面组件
- 邀请码编辑表单
- 邀请码列表状态展示
- 所有写操作前的二次确认弹窗
### 4. 文档
- `docs/technical/``docs/design/`
- 补一份邀请码时间窗与后台确认交互说明
- 如现有文档已经覆盖后台管理规范,则优先补充现有文档,不重复造新说明页
---
## 分步实施计划
### Task 1: 明确数据模型与契约扩展
**Objective:** 定义邀请码开始/截止日期字段及其在响应中的展示方式。
**要点:**
- 确认字段名采用 `starts_at` / `expires_at`,避免与现有字段语义冲突。
- 确认时间类型统一用 `Timestamp` / 毫秒微秒整数转换策略。
- 明确返回给后台的字段是否需要附带派生状态(如 `status`)。
**产出:**
- 契约字段定义
- 状态枚举/派生规则
---
### Task 2: 更新 SpacetimeDB 表与迁移
**Objective:** 让邀请码表可保存有效期,并兼容旧数据。
**要点:**
- 修改 `ProfileInviteCode` 表结构。
- 更新迁移逻辑,旧记录默认无开始/截止。
- 检查是否需要补充索引或查询辅助字段。
**验证:**
- 旧数据能正常读取。
- 新数据能写入开始/截止。
---
### Task 3: 实现邀请填写时的时间窗校验
**Objective:** 在邀请码被填写时正确拒绝未生效或已过期的邀请码。
**要点:**
-`redeem_profile_referral_invite_code_record` 内增加开始/截止校验。
- 保持“自己的邀请码不能填”“邀请码不存在”等原有错误优先级清晰。
- 保留历史绑定关系不受影响。
**验证:**
- 未到开始时间时返回明确错误。
- 超过截止时间时返回明确错误。
- 正常区间可绑定成功。
---
### Task 4: 扩展后台邀请码管理接口
**Objective:** 让后台可以创建/修改邀请码时间窗,并在列表中查看状态。
**要点:**
- 扩展 `AdminUpsertProfileInviteCodeRequest`
- 扩展 `ProfileInviteCodeAdminResponse`
- `api-server` 接口负责接收新字段并转发。
- 列表接口返回可读时间字段与状态。
**验证:**
- 后台表单提交后,返回结果包含时间窗信息。
- 列表页能看到状态与时间。
---
### Task 5: 给后台所有写操作加二次确认
**Objective:** 统一拦截所有后台写动作,避免误点直接生效。
**覆盖范围建议:**
- 邀请码新增/更新
- 兑换码新增/更新/停用
- 任务配置新增/更新/停用
- 后续新增的管理写操作
**实现要求:**
- 在真正调用 API 之前弹出确认框。
- 确认框需要展示对象名、操作类型、影响范围。
- 取消后不发送请求。
- 尽量抽象出通用确认组件/通用 action 包装函数,避免每个页面重复写。
**验证:**
- 点击“保存”不会直接提交,需先确认。
- 点击“取消”不会发请求。
- 所有后台写入口行为一致。
---
### Task 6: 补充文档与交互说明
**Objective:** 把新规则写进项目文档,避免后续实现偏差。
**要点:**
- 记录邀请码时间窗语义。
- 记录后台二次确认规范。
- 说明哪些动作属于“必须确认”的写操作。
---
## 测试与验证
### 服务端
- 邀请码时间窗单测 / 集成测试
- 邀请码 redeem 流程回归测试
- 旧数据兼容测试
### API / 前端
- 管理后台列表展示正确
- 表单提交能回传新字段
- 二次确认取消后不请求接口
- 二次确认确认后正常提交
### 推荐验证命令
- 视项目现有脚本执行对应后端测试
- 前端按 admin-web 构建/测试脚本验证
- 如涉及生成绑定,先确认生成产物无漏字段
---
## 风险与权衡
1. **时间字段格式不统一**
- 风险:前后端对时间单位理解不一致。
- 处理:在契约层明确是 ISO 字符串还是微秒整数,并全链路统一。
2. **后台“所有操作”范围过大**
- 风险:遗漏某些写入口。
- 处理:先枚举现有写 API再做统一确认封装。
3. **邀请码过期后历史链接解释成本**
- 风险:用户误以为历史邀请码失效影响已绑定关系。
- 处理:文案明确“仅影响新填写,不影响已绑定记录”。
4. **契约与生成绑定联动较多**
- 风险:字段变更后生成文件数量较多。
- 处理:先改源契约与服务端,再统一重生成 bindings。
---
## 待确认问题
1. `starts_at` / `expires_at` 在接口里要返回 **ISO 字符串** 还是 **微秒整数**
2. 后台二次确认是否统一用一个全局弹窗组件,还是页面级本地实现?
3. 邀请码列表是否需要直接展示“状态标签”还是只展示时间字段由前端推导?
4. 现有后台所有写操作里,是否还要覆盖调试类接口,还是仅覆盖业务管理接口?
---
## 建议执行顺序
1. 先确认时间字段格式与确认弹窗范围。
2. 再改服务端契约与迁移。
3. 再改 redeem 校验与后台接口。
4. 最后统一改 admin-web 的二次确认与表单展示。
---
**结论:** 这是一个适合分阶段落地的改动,建议先做“邀请码时间窗 + 后台统一二次确认”的基础能力,再补交互细节。

View File

@@ -0,0 +1,65 @@
# 决策记录
> 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。
## 记录格式
```md
## YYYY-MM-DD 决策标题
- 背景:为什么需要这个决策
- 决策:最终决定是什么
- 影响范围:涉及哪些模块/文档/流程
- 验证方式:如何确认决策仍有效
- 关联文档:相关 PRD、技术文档、提交或 Issue
```
---
## 2026-05-04 在仓库 `.hermes/` 中建立团队共享记忆
- 背景:团队有 3 名开发人员,均在各自本地安装 Hermes并需要独立拉取仓库、修改代码、本地测试团队希望形成共享的长期项目记忆。
- 决策:不共享个人 `~/.hermes`,先在 Genarrative 仓库内使用 `.hermes/` 保存可 Git 同步的团队共享记忆、计划和未来 skills。
- 影响范围:`AGENTS.md``.hermes/README.md``.hermes/shared-memory/`
- 验证方式:任一开发者拉取仓库后,在项目根目录启动 Hermes均可读取同一套 `.hermes/shared-memory/` 文件。
- 关联文档:`.hermes/README.md``.hermes/shared-memory/team-conventions.md`
## 2026-04-25 后端唯一落地口径固定为 Rust / SpacetimeDB
- 背景:项目经历过 Node/Express/PostgreSQL、Go 试验、Rust/SpacetimeDB 等多条后端路线,旧路线文档容易造成开发歧义。
- 决策新功能以后端当前基线为准HTTP 门面使用 Rust `api-server` / Axum业务真相使用 SpacetimeDB领域和契约在 `server-rs` 多 crate 分层维护。
- 影响范围:所有后端、数据真相、运行时状态、创作结果、用户系统、资产、任务、埋点、后台 API 等相关开发。
- 验证方式:开发前优先阅读 `CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md`;旧 `server-node`、Express、PostgreSQL、Go 方向只允许作为迁移参考。
- 关联文档:`docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md``AGENTS.md`
## 2026-04-28/29 server-rs DDD 分层与契约矩阵冻结
- 背景server-rs 模块多、上下文多需防止领域规则、SpacetimeDB 表、HTTP BFF、前端临时逻辑互相污染。
- 决策:按 DDD 总纲和 G1 契约/路由矩阵开发:`module-*` 承载领域,`spacetime-module` 承载表和事务,`spacetime-client` 承载 facade`api-server` 承载 HTTP/SSE/BFF`platform-*` 承载外部副作用,`shared-contracts` 承载 DTO。
- 影响范围server-rs 全部 crate、前端 API client、SpacetimeDB schema、旧接口清理。
- 验证方式:执行任务前对照 DDD 总纲、并行任务清单、G1 矩阵;提交前运行相关 DDD 边界检查和定向测试。
- 关联文档:`SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md``SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md``SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md`
## SpacetimeDB 表结构变更必须显式维护迁移与表目录
- 背景SpacetimeDB 的 schema 迁移模型不同于 PostgreSQL部分变更会触发冲突或拒绝自动迁移。
- 决策:凡涉及 table、reducer、procedure、row shape 或 binding 变化,必须同步 `migration.rs`、表目录和生成绑定;涉及 private 表迁移时按 JSON 导入导出和分片导入流程处理。
- 影响范围:`server-rs/crates/spacetime-module``spacetime-client` bindings、`SPACETIMEDB_TABLE_CATALOG.md`、部署/发布脚本。
- 验证方式:发布前检查 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md` 清单,更新 `SPACETIMEDB_TABLE_CATALOG.md`,执行生成绑定和相关测试。
- 关联文档:`SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md``SPACETIMEDB_TABLE_CATALOG.md``SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md`
## 生产部署切换到 systemd + Nginx + 自托管 SpacetimeDB
- 背景:旧一体化启动脚本和历史 Jenkinsfile 已不再是生产发布唯一入口。
- 决策:生产部署以 systemd 托管 SpacetimeDB 与 Rust `api-server`Nginx 负责站点和代理,生产 Jenkinsfile 按 web/api/stdB module/build/deploy/publish 拆分。
- 影响范围部署脚本、服务器目录、维护模式、Jenkins、Nginx、systemd 服务。
- 验证方式生产发布、服务器配置、Jenkins Job 重建或回滚时,先看 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
- 关联文档:`PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
## 个人任务与埋点首版边界冻结
- 背景“我的”Tab、任务、奖励、钱包和埋点涉及用户、运营、分析多条链路需要避免范围泛化。
- 决策:埋点原始事实进入 `tracking_event`,聚合投影进入 `tracking_daily_stat`;个人任务配置/进度/领奖/钱包分别进入 `profile_task_config``profile_task_progress``profile_task_reward_claim``profile_wallet_ledger`;首版个人任务 scope 仅支持 `user`
- 影响范围:用户侧任务中心、后台任务配置、运营查询、埋点查询、钱包流水。
- 验证方式:非 `user` scope 的个人任务配置应被 API 和领域构造层拒绝;任务查询与埋点查询分别放在 `docs/operations/``docs/tracking/`
- 关联文档:`PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md``RUNTIME_PROFILE_TASK_SCOPE_2026-05-04.md``ANALYTICS_DATE_DIMENSION_IMPLEMENTATION_2026-05-04.md`

View File

@@ -0,0 +1,203 @@
# 开发工作流
> 用途:给本地 Hermes 和开发人员提供统一的开发、测试、提交流程。具体命令以 `package.json`、`server-rs/Cargo.toml`、`AGENTS.md` 和相关 `docs/` 最新文档为准。
## 标准任务流程
```text
同步代码 → 读取 AGENTS.md → 读取 .hermes/shared-memory → 查找/完善 docs → 制定计划 → 小步实现 → 本地验证 → 更新文档/记忆 → 提交
```
## 建议启动方式
在项目根目录启动 Hermes
```bash
cd /path/to/Genarrative
hermes
```
在本机当前常见路径为:
```bash
/home/dsk/workspace/Genarrative
```
其他开发者以自己本地实际路径为准,不要把个人绝对路径写入共享文档作为通用规则。
## 开发前检查清单
- [ ] 当前分支是否正确
- [ ] 是否已拉取最新代码
- [ ] 是否阅读 `AGENTS.md`
- [ ] 是否阅读 `.hermes/shared-memory/` 相关文件
- [ ] 是否阅读 `README.md` 中的运行和检查命令
- [ ] 是否阅读 `docs/README.md` 及任务相关分类 README
- [ ] 是否存在足够具体的 PRD / 设计 / 技术文档
- [ ] 是否明确测试、验收和文档更新方式
## 本地运行命令
安装依赖:
```bash
npm install
```
完整联调开发环境:
```bash
npm run dev
```
该命令会启动:
- SpacetimeDB standalone
- Rust `api-server`
- 主站 Vite
- 后台 Vite
单独启动前端:
```bash
npm run dev:web
```
单独启动 Rust API server
```bash
npm run api-server
```
查看本地 Rust/SpacetimeDB 日志:
```bash
npm run dev:rust:logs
```
后台管理前端:
```bash
npm run admin-web:dev
npm run admin-web:build
npm run admin-web:typecheck
```
SpacetimeDB bindings 生成:
```bash
npm run spacetime:generate
```
## 常用检查命令
编码检查:
```bash
npm run check:encoding
```
ESLint
```bash
npm run lint:eslint
```
类型检查:
```bash
npm run typecheck
```
综合 lint
```bash
npm run lint
```
测试:
```bash
npm run test
```
生产构建:
```bash
npm run build
```
内容检查:
```bash
npm run check:data
npm run check:overrides
npm run check:smoke
npm run check:content
```
全量检查:
```bash
npm run check
```
DDD 边界检查:
```bash
npm run check:server-rs-ddd
```
## 后端相关默认验证
后端修改后,按 DDD 文档中的验收命令执行。涉及 API smoke 时:
- 使用 `npm run api-server` 重新拉起后端。
- 检查 `/healthz`
- 执行对应自动测试。
- 涉及 SpacetimeDB 表、reducer、procedure、row shape 或绑定变化时,同步更新 `migration.rs`、表目录和生成绑定。
关键文档:
- `docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md`
- `docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md`
- `docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md`
- `docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`
- `docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`
- `docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
## 前端相关默认验证
前端修改后,应根据修改范围选择:
- `npm run check:encoding`
- `npm run lint:eslint`
- `npm run typecheck`
- `npm run test`
- 页面交互 smoke
- 移动端视口检查
前端原则:
- 移动端优先,再兼容网页端。
- 页面只展示后端返回的状态,不自行计算结论型业务状态。
- 优先复用现有面板、抽屉、弹窗,不新建独立大系统。
- 不在 UI 中默认写功能说明类文本。
- 弹出独立面板的交互不要实现成在当前面板下方追加内容。
## 文档更新规则
- 工程修改要同步更新对应文档。
- 如果没有现成文档,新文档统一放入 `docs/` 下合适分类。
- `.hermes/shared-memory/` 只记录高频、长期、团队共享的摘要和索引,不替代完整 PRD/技术文档。
- 如果 `.hermes/shared-memory/` 与代码或 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正共享记忆。
## 提交前建议让 Hermes 执行
```text
请检查当前 git diff指出
1. 是否违反 AGENTS.md 或 .hermes/shared-memory 约定;
2. 是否需要补充 docs
3. 是否有长期知识需要写入 .hermes/shared-memory
4. 建议的测试命令和提交信息。
```

View File

@@ -0,0 +1,100 @@
# 文档地图与阅读索引
> 用途:根据 `README.md`、`AGENTS.md` 和 `docs/` 下文档索引整理团队记忆入口,帮助本地 Hermes 快速选择应该读哪些资料。
## 全局入口
| 场景 | 优先阅读 |
| --- | --- |
| 建立项目背景 | `README.md``AGENTS.md``.hermes/shared-memory/project-overview.md` |
| 找文档分类 | `docs/README.md` |
| 开发方法论 | `docs/experience/README.md` |
| 查风险与历史问题 | `docs/audits/README.md` |
| 做玩法/交互/系统设计 | `docs/design/README.md` |
| 做技术实现/后端/部署 | `docs/technical/README.md` |
| 排期与拆阶段 | `docs/planning/README.md` |
| 查脚本/Function/prompt/职责地图 | `docs/reference/README.md` |
| 查埋点 SQL | `docs/tracking/README.md` |
| 查运营/任务/钱包对账 SQL | `docs/operations/README.md` |
| 查 PRD | `docs/prd/` |
## docs 分类规则
- `experience/`:方法论、交接经验、长期有效的开发结论。
- `audits/`:现状扫描、问题定位、是否达标的审查类文档。
- `design/`:玩法机制、叙事关系、系统结构设计。
- `technical/`:技术选型、实现路线、竞品/产品形态拆解。
- `planning/`:阶段优先级与推进顺序。
- `reference/`:目录、速查、检索辅助。
- `tracking/`:埋点原始事实和聚合投影查询。
- `operations/`:后台运营核查、对账和排障查询。
- `prd/`:产品需求与阶段计划。
## 推荐阅读顺序
通用复杂任务:
1. `AGENTS.md`
2. `.hermes/shared-memory/`
3. `docs/README.md`
4. `docs/experience/README.md`
5. `docs/audits/README.md`
6. 任务对应分类下的 README 和具体文档
后端 / 数据真相 / SpacetimeDB
1. `docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md`
2. `docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md`
3. `docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`
4. `docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md`
5. `docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`
6. `docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
7. 具体模块方案文档
生产部署 / 服务器 / Jenkins
1. `docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
2. 需要迁移时再看 `SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md`
3. 历史 Jenkins / CORS / 本地远端脚本文档只作追溯,不作为当前入口
RPG 创作与运行时链路:
1. `docs/reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md`
2. `docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md`
3. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md`
4. 相关工作包 progress / closure 文档
移动端 UI / 游戏 UI
1. `docs/experience/MOBILE_UI_DEV_EXPERIENCE.md`
2. `UI_CODING_STANDARD.md`
3. 相关 `docs/design/` 文档
创作 Agent / 自定义世界:
1. `docs/design/CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md`
2. `docs/design/CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md`
3. `docs/design/CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md`
4. `docs/technical/UNIFIED_CREATION_AGENT_CHAT_FRAMEWORK_2026-04-22.md`
5. 相关 `SPACETIMEDB_CUSTOM_WORLD_*` 技术方案
拼图 / 大鱼 / Match3D
- 拼图:优先看 `PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md``PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md` 和相关 Puzzle 技术文档。
- 大鱼吃小鱼:优先看 `BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md` 和相关 Big Fish 技术/经验文档。
- 抓大鹅 Match3D优先看 `docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md``MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md` 和相关 Match3D 技术文档。
个人任务 / 埋点 / 运营查询:
1. `docs/technical/PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md`
2. `docs/technical/RUNTIME_PROFILE_TASK_SCOPE_2026-05-04.md`
3. `docs/technical/ANALYTICS_DATE_DIMENSION_IMPLEMENTATION_2026-05-04.md`
4. `docs/tracking/TRACKING_QUERY_PLAYBOOK_2026-05-03.md`
5. `docs/operations/PROFILE_TASK_QUERY_PLAYBOOK_2026-05-03.md`
## 文档维护规则
- 新增工程实现时,如果已有对应文档,必须同步更新。
- 如果没有对应文档,新文档放入 `docs/` 下合适分类。
- `.hermes/shared-memory/` 只保留跨任务、跨成员、高频使用的摘要和索引。
- 如果文档与代码冲突,先确认代码事实,再更新过期文档和共享记忆。

View File

@@ -0,0 +1,53 @@
# 任务交接模板
> 用途:当一名开发者把任务交给另一名开发者,或让 Hermes 接续上下文时,复制本模板并填写。
## 基本信息
- 任务名称:
- 负责人:
- 当前分支:
- 相关需求/Issue
- 相关文档:
## 背景
简要说明为什么做这个任务,以及业务/技术目标。
## 已完成
- [ ]
- [ ]
- [ ]
## 未完成
- [ ]
- [ ]
- [ ]
## 关键文件
- `path/to/file`:说明
- `path/to/file`:说明
## 当前问题/风险
-
## 已执行验证
```bash
# 粘贴已执行命令和结果摘要
```
## 建议下一步
1.
2.
3.
## 是否需要更新团队记忆
- [ ] 不需要
- [ ] 需要,建议更新:`.hermes/shared-memory/...`

View File

@@ -0,0 +1,102 @@
# 踩坑与排障记录
> 用途:记录已验证、未来很可能再次遇到的问题。每条都应包含现象、原因、处理方式和验证方式。
## 记录格式
```md
## 问题标题
- 现象:看到什么错误或异常行为
- 原因:确认后的根因
- 处理:具体修复步骤
- 验证:如何确认修复有效
- 关联:相关文件、文档、提交或 Issue
```
---
## 中文乱码与编码风险
- 现象:中文文案、注释、剧情或文档显示为乱码,或被改写成英文。
- 原因Windows/PowerShell/终端编码不一致,或整文件重写导致编码变化。
- 处理:
- 不要直接沿用乱码文本。
- 不要用英文替换中文,除非用户明确要求翻译。
- 在 PowerShell 5.1 中显式使用 UTF-8。
- 优先用 Python/Node 或 `Get-Content -Encoding UTF8` 核对原文。
- 修改中文文件时优先局部补丁,避免无关内容重写。
- 验证:运行仓库已有编码检查;人工抽查修改文件中的中文内容。
- 关联:`AGENTS.md``npm run check:encoding`
## `.hermes` 只放共享内容,不放个人 Hermes 配置
- 现象:团队成员误把个人 Hermes 配置、会话或密钥复制进仓库。
- 原因:仓库 `.hermes/` 与个人 `~/.hermes/` 名称相似。
- 处理:仓库 `.hermes/` 只放 Markdown 共享记忆、计划和可公开 skills不提交 `.env``config.yaml``sessions/``auth.json`
- 验证:提交前检查 `git diff -- .hermes`,确认没有密钥、会话记录或个人路径敏感信息。
- 关联:`.hermes/README.md`
## 旧后端路线文档造成判断漂移
- 现象:开发时参考到 Express、Node、PostgreSQL 或 Go 方向旧文档,导致接口、数据真相或部署路径与当前主线不一致。
- 原因:项目历史文档较多,部分旧方案仍保留作迁移参考。
- 处理涉及服务端、数据真相、SpacetimeDB、运行时状态时先看 `CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md`,再看 DDD 总纲和具体技术方案。
- 验证:代码改动应落在 `server-rs + Axum + SpacetimeDB` 主线;旧路线只作为迁移参考,不作为兼容目标。
- 关联:`docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md``AGENTS.md`
## SpacetimeDB 表结构变更不能按 PostgreSQL 迁移直觉处理
- 现象:发布时 schema 冲突、自动迁移拒绝、旧客户端调用 reducer 失败、private 表数据迁移遗漏。
- 原因SpacetimeDB 对字段删除、类型变化、索引/主键/RLS/reducer 变化有不同自动迁移边界。
- 处理:变更前阅读 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`;涉及表变化时同步 `migration.rs``SPACETIMEDB_TABLE_CATALOG.md` 和 bindings必要时走 JSON 导入导出与分片导入迁移流程。
- 验证:发布前完成 schema 检查、bindings 生成、表目录更新和相关 smoke。
- 关联:`docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md``docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
## 本地 SpacetimeDB replica identity 不匹配
- 现象:本地 standalone 启动时报 `mismatched database identity`
- 原因root-dir / replica 数据残留与当前数据库身份不一致。
- 处理:按本地 replica identity mismatch 文档进行备份、重建和脚本诊断。
- 验证:本地 SpacetimeDB 可正常启动并 publish / 访问。
- 关联:`docs/technical/SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md`
## Vite SPA fallback 吞掉 API 请求
- 现象:本地请求 `/api/profile/*` 等接口时返回 HTML被前端当 JSON 解析报错。
- 原因Vite 代理缺少对应 `/api/*` 前缀API 请求落到 SPA fallback。
- 处理:补齐 Vite 代理,让 API 请求转发到 Rust `api-server`
- 验证:请求返回 JSON相关页面不再出现 HTML parse 错误。
- 关联:`docs/technical/PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md`
## Rust 冷编译导致 api-server 健康检查误超时
- 现象:`npm run dev:rust` 在 Windows 冷编译/链接阶段误判 `/healthz` 等待超时并杀掉 `cargo run`
- 原因:脚本把 SpacetimeDB 与 api-server 等待窗口混在一起,未考虑 Rust 冷编译耗时。
- 处理:按冷编译超时修复文档拆分等待窗口。
- 验证:冷启动时不再误杀仍在编译的 api-server。
- 关联:`docs/technical/API_SERVER_DEV_STACK_COLD_BUILD_TIMEOUT_FIX_2026-04-25.md`
## server-rs 默认 cargo build 不能等同于构建 SpacetimeDB 模块
- 现象:在 `server-rs` 下无参数 `cargo build` 期望同时构建 `spacetime-module`,导致链接或构建范围误判。
- 原因workspace default-members 当前只包含 `crates/api-server`SpacetimeDB module 有独立构建/发布方式。
- 处理:默认 Rust 构建只覆盖原生 `api-server`;模块产物继续走 `spacetime build` / publish / bindings 生成流程。
- 验证:查看 `server-rs/Cargo.toml` default-members并按相关 SpacetimeDB 文档执行模块构建。
- 关联:`server-rs/Cargo.toml``docs/technical/RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md`
## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本
- 现象:部署、回滚或 Jenkins Job 重建时参考旧发布文档,导致 systemd、Nginx、SpacetimeDB 自托管和生产包拆分不一致。
- 原因:旧 Jenkins / 旧本地远端部署脚本文档仍作为历史经验保留。
- 处理:生产相关操作先看 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`,再按需追溯旧文档。
- 验证:发布链路使用当前 `deploy/systemd``deploy/nginx``scripts/deploy``jenkins/Jenkinsfile.production-*`
- 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
## 个人任务 scope 不得扩成 work/site/module
- 现象:个人任务配置为 `work` / `site` / `module` 后进度串桶或静默按 0 处理。
- 原因:首版个人任务只支持用户维度,非 user scope 会造成任务进度读取语义错误。
- 处理Admin 任务配置页不展示范围选择,保存时固定 `scopeKind: 'user'`API 和领域构造层拒绝非 `User`
- 验证:非 `user` scope 返回错误;相关测试覆盖 `Site` / `Module` / `Work` 被拒绝。
- 关联:`docs/technical/RUNTIME_PROFILE_TASK_SCOPE_2026-05-04.md``docs/technical/ANALYTICS_DATE_DIMENSION_IMPLEMENTATION_2026-05-04.md`

View File

@@ -0,0 +1,138 @@
# Genarrative 项目共享概览
> 用途:给团队成员本地 Hermes 快速建立项目背景。内容应保持高层、稳定、可验证;细节以代码、`README.md`、`AGENTS.md` 和 `docs/` 最新文档为准。
## 一句话定位
Genarrative / AI Native Visual RPG 是一个以 **AI 叙事 + 本地规则 + 像素演出** 为核心的视觉 RPG 与 AI 原生游戏创作平台原型。
项目当前不只是单一 RPG demo而是在同一平台内同时承载
- RPG / 自定义世界创作与运行时
- 拼图玩法创作与运行时
- 大鱼吃小鱼玩法链路
- 抓大鹅 Match3D 玩法链路
- 用户账号、存档、钱包、任务、埋点、后台管理与生产部署链路
## 已具备的主要能力
来自根目录 `README.md` 的当前主能力:
- 世界与角色选择
- AI 剧情推进与流式对话
- 战斗演出、NPC 战斗、切磋
- NPC 交易、送礼、求助、招募
- 宝藏交互
- 同伴跟随与战斗
- 游戏主流程内嵌的角色资产工坊、自定义世界实体编辑与角色形象编辑
- 自动存档与继续游戏
## 当前前端与平台入口
- 主站默认地址:`http://127.0.0.1:3000`
- 后台可从 `http://127.0.0.1:3000/admin/` 进入,也可直连 `http://127.0.0.1:3102`
- 主站、后台和 Rust 后端联调默认走 `npm run dev`
- 只启动前端页面可用 `npm run dev:web`,默认代理到本地 Rust `api-server`
- 后台管理独立前端工程为 `apps/admin-web`,管理端只做表现,数据和写操作走 `server-rs``/admin/api/*`
## 当前后端唯一落地口径
后端主线已经切到:
```text
server-rs + Axum + SpacetimeDB
```
当前唯一有效后端方向:
- HTTP 门面Rust `api-server` / Axum
- 实时状态与业务真相:`server-rs/crates/spacetime-module` / SpacetimeDB
- 共享领域与契约:`server-rs` 多 crate 分层维护
- 前端职责:表现、输入采集、临时 UI 状态、服务端结果渲染
明确不再作为正式兼容目标:
- `server-node` / Express / PostgreSQL 正式后端路线
- Go 服务端试验路线
- 浏览器侧承担正式运行时逻辑、正式生成编排或正式数据真相的路线
## server-rs DDD 分层边界
DDD 分层边界以 `docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md``SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md``AGENTS.md` 为准:
- `module-*`:领域模型、命令、应用编排结果、领域事件、领域错误
- `spacetime-module`SpacetimeDB 表、reducer、procedure、事务 adapter、mapper
- `spacetime-client`:后端访问 SpacetimeDB 的 typed facade
- `api-server`HTTP / SSE / BFF adapter 与外部平台服务编排
- `platform-*`LLM、OSS、SMS、微信等外部副作用
- `shared-contracts`:前后端 DTO 与公开协议
- `shared-kernel`:跨纯领域 crate 复用的基础字符串、ID、时间和归一化能力
- `tests-support``server-rs` workspace 共享测试支撑
## 当前 Rust workspace 主要 crate
`server-rs/Cargo.toml` 为准,当前主要成员包括:
- 业务领域:`module-ai``module-assets``module-auth``module-big-fish``module-combat``module-inventory``module-custom-world``module-match3d``module-npc``module-puzzle``module-progression``module-quest``module-runtime``module-runtime-story``module-runtime-item``module-story`
- 平台副作用:`platform-oss``platform-auth``platform-llm`
- 共享层:`shared-contracts``shared-kernel``shared-logging`
- SpacetimeDB 接入:`spacetime-client``spacetime-module`
- HTTP 服务与测试:`api-server``tests-support`
注意:`server-rs` 的默认 `cargo build` 只构建 `crates/api-server`SpacetimeDB 模块产物继续走 `spacetime build` / 发布链路。
## SpacetimeDB 表域总览
`docs/technical/SPACETIMEDB_TABLE_CATALOG.md` 为持续维护入口。当前表域包括:
- 运维迁移:`database_migration_operator``database_migration_import_chunk`
- 认证:`auth_store_snapshot``user_account``auth_identity``refresh_session`
- 运行时档案:`runtime_setting``runtime_snapshot``user_browse_history``profile_dashboard_state``profile_wallet_ledger``analytics_date_dimension``tracking_event``tracking_daily_stat``profile_task_config``profile_task_progress``profile_task_reward_claim`
- 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_event``puzzle_runtime_run``puzzle_leaderboard_entry`
- 抓大鹅 Match3D`match3d_agent_session``match3d_agent_message``match3d_work_profile``match3d_runtime_run`
- 大鱼吃小鱼:`big_fish_creation_session``big_fish_agent_message``big_fish_asset_slot``big_fish_event``big_fish_runtime_run`
- 资产:`asset_object``asset_entity_binding``asset_event`
- AI 任务:`ai_task``ai_task_stage``ai_text_chunk``ai_result_reference``ai_task_event`
## 产品命名与运营口径
`docs/technical/PRODUCT_NAMING_BAIMENG_RENAME_2026-05-01.md` 为准:
- 产品展示名:百梦
- 消费单位:光点
- 公开账号标识:百梦号
- 创作侧称谓:百梦主
个人任务与埋点系统首版边界:
- 埋点原始事实写入 `tracking_event`
- 聚合投影写入 `tracking_daily_stat`
- 任务配置写入 `profile_task_config`
- 任务进度写入 `profile_task_progress`
- 领奖记录写入 `profile_task_reward_claim`
- 钱包流水写入 `profile_wallet_ledger`
- “星光”奖励复用现有“光点”钱包,不新增第二种货币
- 个人任务 scope 首版仅支持 `user`
## 关键文档入口
- 根项目说明:`README.md`
- 项目总约束:`AGENTS.md`
- 文档总入口:`docs/README.md`
- 经验沉淀:`docs/experience/README.md`
- 审计与复盘:`docs/audits/README.md`
- 系统设计:`docs/design/README.md`
- 技术方案:`docs/technical/README.md`
- 规划与优先级:`docs/planning/README.md`
- 参考目录:`docs/reference/README.md`
- 埋点查询:`docs/tracking/README.md`
- 运营查询:`docs/operations/README.md`
- 后端当前基线:`docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md`
- 后端 DDD 总纲:`docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md`
- 后端并行任务清单:`docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md`
- 契约与路由矩阵:`docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`
- SpacetimeDB 表结构变更约束:`docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`
- SpacetimeDB 表目录:`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
- 生产部署计划:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`

View File

@@ -0,0 +1,95 @@
# 团队协作约定
> 用途:约定 3 名开发人员在各自本地 Hermes 中协作开发、共享项目记忆的方式。
## 基本模式
- 每位开发人员在自己的电脑上使用本地 Hermes。
- 每位开发人员本地拉取同一个项目仓库,独立修改代码、运行测试、提交分支。
- 团队共享内容优先放在本仓库 `.hermes/``docs/` 中,通过 Git 同步。
- 不共享个人 `~/.hermes` 目录。
## 共享与禁止共享
推荐共享:
- `.hermes/shared-memory/` 团队级长期记忆
- `.hermes/plans/` 阶段性实施计划
- `.hermes/skills/` 未来可复用仓库级 skills
- `docs/` 中 PRD、设计、技术、经验、审计、查询手册
- `AGENTS.md` 项目级 Agent 约束
禁止提交:
- 个人 `~/.hermes/config.yaml`
- 个人 `~/.hermes/.env`
- 个人 `~/.hermes/sessions/`
- API Key、Token、Cookie、认证文件
- 个人本地私密路径和个人隐私信息
- 构建产物、日志、缓存、数据库 dump
## 开发前
1. 拉取最新代码。
2. 阅读 `AGENTS.md`
3. 阅读 `.hermes/shared-memory/` 中与任务相关的文件。
4. 阅读 `docs/README.md` 和任务相关分类 README。
5. 阅读对应 PRD、设计、技术、经验或审计文档。
6. 如果文档不足以指导编码,先补充或修正文档。
## 开发中
- 保持修改范围聚焦,不做无关重构。
- 复用、修改、扩展现有系统优先,避免新建重复系统或页面。
- 涉及中文文本时注意 UTF-8 编码和乱码排查。
- 涉及后端时遵循 DDD 分层,不把业务真相下沉到前端或临时兼容层。
- 涉及 SpacetimeDB 表结构、发布或迁移时,先看 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md``SPACETIMEDB_TABLE_CATALOG.md`
- 涉及生产发布、服务器配置、Jenkins Job 重建或回滚时,先看 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
## 开发后
1. 运行与修改范围匹配的测试或验证命令。
2. 更新相关 `docs/` 文档。
3. 若产生长期有效知识,更新 `.hermes/shared-memory/`
4. 若形成可复用流程,考虑沉淀到 `.hermes/skills/`
5. 在提交信息中区分代码变更与文档/记忆变更。
## 文档阅读顺序
通用任务建议:
1. `README.md`
2. `AGENTS.md`
3. `.hermes/shared-memory/`
4. `docs/README.md`
5. `docs/experience/README.md`
6. `docs/audits/README.md`
7. 任务所属分类:`docs/design/``docs/technical/``docs/planning/``docs/prd/``docs/reference/``docs/tracking/``docs/operations/`
后端任务建议:
1. `docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md`
2. `docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md`
3. `docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`
4. `docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md`
5. `docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`
6. `docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
## 共享记忆更新准则
适合更新:
- 新增稳定架构约定
- 新增长期开发流程
- 已验证的踩坑和排障步骤
- 重要接口契约变化
- 团队协作规范变化
- 文档索引或阅读顺序变化
不适合更新:
- 一次性临时计划
- 未验证猜测
- 个人偏好和个人路径
- 敏感信息
- 大段聊天记录

27
.hermes/skills/README.md Normal file
View File

@@ -0,0 +1,27 @@
# 仓库级 Hermes Skills
本目录预留给未来可共享的仓库级 Hermes skills。
## 什么时候沉淀为 Skill
当某个流程满足以下条件之一时,可以考虑从普通 Markdown 升级为 skill
- 需要反复执行,且步骤稳定。
- 涉及多个目录、命令或验证步骤。
- 曾经踩过坑,需要明确规避步骤。
- 新成员容易做错。
- Hermes 在执行时需要强制加载专门知识。
## 建议结构
```text
.hermes/skills/
└─ skill-name/
└─ SKILL.md
```
## 注意
- 不要把 API Key、Token、账号密码写入 skill。
- 如果 skill 与 `AGENTS.md``docs/` 冲突,先更新冲突来源再使用。
- Skill 应包含触发条件、步骤、坑点和验证方式。

View File

@@ -1,5 +1,17 @@
# AGENTS.md
## 团队 Hermes 共享记忆
- 本仓库的团队级 Hermes 共享内容位于 [`.hermes/`](.hermes/),用于在 3 名开发人员各自本地 Hermes 之间同步长期项目记忆。
- 开始复杂开发任务前,除阅读本文件外,还应优先读取:
- [`.hermes/README.md`](.hermes/README.md)
- [`.hermes/shared-memory/project-overview.md`](.hermes/shared-memory/project-overview.md)
- [`.hermes/shared-memory/team-conventions.md`](.hermes/shared-memory/team-conventions.md)
- [`.hermes/shared-memory/development-workflow.md`](.hermes/shared-memory/development-workflow.md)
- 与任务相关的 [`.hermes/shared-memory/decision-log.md`](.hermes/shared-memory/decision-log.md) 和 [`.hermes/shared-memory/pitfalls.md`](.hermes/shared-memory/pitfalls.md)
- 如果本次任务产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则,应同步更新 `.hermes/shared-memory/` 中对应文件。
- 仓库 `.hermes/` 只保存可进入 Git 的团队共享内容;禁止提交个人 `~/.hermes` 配置、`.env`、API Key、Token、会话记录、认证文件和本地私密路径。
-`.hermes/shared-memory/` 与当前代码或 `docs/` 最新文档冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。
## 项目约束
- 代码需要有完善的中文注释
- 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。

View File

@@ -122,6 +122,8 @@ export interface AdminUpsertProfileRedeemCodeRequest {
export interface AdminUpsertProfileInviteCodeRequest {
inviteCode: string;
metadata?: Record<string, unknown>;
startsAt?: string | null;
expiresAt?: string | null;
}
export interface AdminDisableProfileRedeemCodeRequest {
@@ -166,6 +168,9 @@ export interface ProfileInviteCodeAdminResponse {
userId: string;
inviteCode: string;
metadata: Record<string, unknown>;
startsAt?: string | null;
expiresAt?: string | null;
status: 'pending' | 'active' | 'expired';
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,105 @@
import {useCallback, useEffect, useRef, useState} from 'react';
interface AdminWriteConfirmOptions {
action: string;
target: string;
}
interface PendingConfirm extends AdminWriteConfirmOptions {
resolve: (confirmed: boolean) => void;
}
export function useAdminWriteConfirm() {
const [pendingConfirm, setPendingConfirm] = useState<PendingConfirm | null>(null);
const cancelButtonRef = useRef<HTMLButtonElement | null>(null);
const confirmWrite = useCallback((options: AdminWriteConfirmOptions) => {
return new Promise<boolean>((resolve) => {
setPendingConfirm((current) => {
if (current) {
current.resolve(false);
}
return {...options, resolve};
});
});
}, []);
const closeConfirm = useCallback(
(confirmed: boolean) => {
const current = pendingConfirm;
if (!current) {
return;
}
setPendingConfirm(null);
current.resolve(confirmed);
},
[pendingConfirm],
);
useEffect(() => {
if (!pendingConfirm) {
return;
}
cancelButtonRef.current?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
closeConfirm(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [closeConfirm, pendingConfirm]);
const confirmDialog = pendingConfirm ? (
<div
aria-modal="true"
aria-labelledby="admin-write-confirm-title"
className="admin-confirm-backdrop"
role="dialog"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
closeConfirm(false);
}
}}
>
<section className="admin-confirm-panel">
<div className="admin-panel-heading">
<h3 id="admin-write-confirm-title"></h3>
<span>{pendingConfirm.action}</span>
</div>
<dl className="admin-info-list">
<div>
<dt></dt>
<dd>{pendingConfirm.action}</dd>
</div>
<div>
<dt></dt>
<dd>{pendingConfirm.target}</dd>
</div>
</dl>
<div className="admin-confirm-warning">线</div>
<div className="admin-confirm-actions">
<button
className="admin-secondary-button"
ref={cancelButtonRef}
type="button"
onClick={() => closeConfirm(false)}
>
</button>
<button
className="admin-danger-button"
type="button"
onClick={() => closeConfirm(true)}
>
</button>
</div>
</section>
</div>
) : null;
return {confirmWrite, confirmDialog};
}

View File

@@ -7,6 +7,7 @@ import type {
AdminDebugHttpMethod,
AdminDebugHttpResponse,
} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {formatUnknownJson, handlePageError} from './pageUtils';
interface AdminDebugHttpPageProps {
@@ -33,6 +34,7 @@ export function AdminDebugHttpPage({
const [result, setResult] = useState<AdminDebugHttpResponse | null>(null);
const [errorMessage, setErrorMessage] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
const jsonPreview = useMemo(
() => formatUnknownJson(result?.bodyJson),
@@ -46,6 +48,16 @@ export function AdminDebugHttpPage({
}
setErrorMessage('');
if (method !== 'GET') {
const confirmed = await confirmWrite({
action: `${method} 调试请求`,
target: path.trim(),
});
if (!confirmed) {
return;
}
}
setIsSubmitting(true);
try {
const response = await debugAdminHttp(token, {
@@ -209,6 +221,7 @@ export function AdminDebugHttpPage({
)}
</section>
</div>
{confirmDialog}
</section>
);
}

View File

@@ -5,7 +5,11 @@ import {
listProfileInviteCodes,
upsertProfileInviteCode,
} from '../api/adminApiClient';
import type {ProfileInviteCodeAdminResponse} from '../api/adminApiTypes';
import type {
AdminUpsertProfileInviteCodeRequest,
ProfileInviteCodeAdminResponse,
} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {handlePageError} from './pageUtils';
interface AdminInviteCodePageProps {
@@ -22,12 +26,15 @@ export function AdminInviteCodePage({
onResultChange,
}: AdminInviteCodePageProps) {
const [inviteCode, setInviteCode] = useState('');
const [startsAt, setStartsAt] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const [metadataText, setMetadataText] = useState('{}');
const [errorMessage, setErrorMessage] = useState('');
const [listErrorMessage, setListErrorMessage] = useState('');
const [entries, setEntries] = useState<ProfileInviteCodeAdminResponse[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
useEffect(() => {
void refreshInviteCodes();
@@ -54,12 +61,29 @@ export function AdminInviteCodePage({
}
setErrorMessage('');
const validityError = validateValidityWindow(startsAt, expiresAt);
if (validityError) {
setErrorMessage(validityError);
return;
}
const confirmed = await confirmWrite({
action: '保存邀请码',
target: inviteCode.trim(),
});
if (!confirmed) {
return;
}
setIsSaving(true);
try {
const response = await upsertProfileInviteCode(token, {
const payload: AdminUpsertProfileInviteCodeRequest = {
inviteCode: inviteCode.trim(),
metadata: parseMetadata(metadataText),
});
startsAt: startsAt ? toIsoDateTime(startsAt) : null,
expiresAt: expiresAt ? toIsoDateTime(expiresAt) : null,
};
const response = await upsertProfileInviteCode(token, payload);
onResultChange(response);
upsertEntry(response);
fillForm(response);
@@ -89,9 +113,13 @@ export function AdminInviteCodePage({
function fillForm(entry: ProfileInviteCodeAdminResponse) {
setInviteCode(entry.inviteCode);
setStartsAt(toDateTimeLocalValue(entry.startsAt));
setExpiresAt(toDateTimeLocalValue(entry.expiresAt));
setMetadataText(JSON.stringify(entry.metadata, null, 2));
}
const validityError = validateValidityWindow(startsAt, expiresAt);
return (
<section className="admin-page">
<div className="admin-page-heading">
@@ -127,6 +155,25 @@ export function AdminInviteCodePage({
/>
</label>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
type="datetime-local"
value={startsAt}
onChange={(event) => setStartsAt(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
type="datetime-local"
value={expiresAt}
onChange={(event) => setExpiresAt(event.target.value)}
/>
</label>
</div>
<label className="admin-field">
<span>Metadata JSON</span>
<textarea
@@ -142,10 +189,20 @@ export function AdminInviteCodePage({
{errorMessage}
</div>
) : null}
{validityError ? (
<div className="admin-alert" role="status">
{validityError}
</div>
) : null}
<button
className="admin-primary-button"
disabled={isSaving || !inviteCode.trim() || !isMetadataReady(metadataText)}
disabled={
isSaving ||
!inviteCode.trim() ||
!isMetadataReady(metadataText) ||
Boolean(validityError)
}
type="submit"
>
<Save size={17} aria-hidden="true" />
@@ -165,8 +222,8 @@ export function AdminInviteCodePage({
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
@@ -181,8 +238,13 @@ export function AdminInviteCodePage({
{entry.inviteCode}
</button>
</td>
<td>
<span className={`admin-status ${inviteValidityClass(entry)}`}>
{inviteValidityLabel(entry)}
</span>
<small>{formatValidityWindow(entry)}</small>
</td>
<td>{entry.createdAt}</td>
<td>{entry.updatedAt}</td>
</tr>
))}
</tbody>
@@ -206,6 +268,10 @@ export function AdminInviteCodePage({
<dt></dt>
<dd>{result.inviteCode}</dd>
</div>
<div>
<dt></dt>
<dd>{formatValidityWindow(result)}</dd>
</div>
<div>
<dt></dt>
<dd>{result.createdAt}</dd>
@@ -229,6 +295,7 @@ export function AdminInviteCodePage({
</section>
</div>
</div>
{confirmDialog}
</section>
);
}
@@ -255,6 +322,84 @@ function isMetadataReady(value: string) {
}
}
function validateValidityWindow(startsAt: string, expiresAt: string) {
if (!startsAt || !expiresAt) {
return '';
}
const startsAtTime = Date.parse(toIsoDateTime(startsAt));
const expiresAtTime = Date.parse(toIsoDateTime(expiresAt));
if (!Number.isFinite(startsAtTime) || !Number.isFinite(expiresAtTime)) {
return '有效期时间无效';
}
return startsAtTime < expiresAtTime ? '' : '截止时间必须晚于开始时间';
}
function toIsoDateTime(value: string) {
const time = Date.parse(value);
if (!Number.isFinite(time)) {
throw new Error('有效期时间无效');
}
return new Date(time).toISOString();
}
function toDateTimeLocalValue(value?: string | null) {
if (!value) {
return '';
}
const date = new Date(value);
if (!Number.isFinite(date.getTime())) {
return '';
}
const offsetMs = date.getTimezoneOffset() * 60 * 1000;
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
}
function inviteValidityLabel(entry: ProfileInviteCodeAdminResponse) {
const now = Date.now();
const startsAtTime = entry.startsAt ? Date.parse(entry.startsAt) : null;
const expiresAtTime = entry.expiresAt ? Date.parse(entry.expiresAt) : null;
if (startsAtTime && Number.isFinite(startsAtTime) && now < startsAtTime) {
return '未生效';
}
if (expiresAtTime && Number.isFinite(expiresAtTime) && now >= expiresAtTime) {
return '已过期';
}
if (entry.startsAt || entry.expiresAt) {
return '有效';
}
return '长期有效';
}
function inviteValidityClass(entry: ProfileInviteCodeAdminResponse) {
const label = inviteValidityLabel(entry);
if (label === '已过期') {
return 'admin-status-error';
}
if (label === '未生效') {
return 'admin-status-pending';
}
return 'admin-status-ok';
}
function formatValidityWindow(entry: ProfileInviteCodeAdminResponse) {
const startsAt = entry.startsAt ? formatDateTime(entry.startsAt) : '立即';
const expiresAt = entry.expiresAt ? formatDateTime(entry.expiresAt) : '长期';
return `${startsAt} / ${expiresAt}`;
}
function formatDateTime(value: string) {
const date = new Date(value);
if (!Number.isFinite(date.getTime())) {
return value;
}
return date.toLocaleString('zh-CN', {hour12: false});
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View File

@@ -10,6 +10,7 @@ import type {
ProfileRedeemCodeAdminResponse,
ProfileRedeemCodeMode,
} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {handlePageError, splitLines} from './pageUtils';
interface AdminRedeemCodePageProps {
@@ -46,6 +47,7 @@ export function AdminRedeemCodePage({
const [isSaving, setIsSaving] = useState(false);
const [isDisabling, setIsDisabling] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
useEffect(() => {
void refreshRedeemCodes();
@@ -72,6 +74,14 @@ export function AdminRedeemCodePage({
}
setErrorMessage('');
const confirmed = await confirmWrite({
action: '保存兑换码',
target: code.trim(),
});
if (!confirmed) {
return;
}
setIsSaving(true);
try {
const response = await upsertProfileRedeemCode(token, {
@@ -101,6 +111,14 @@ export function AdminRedeemCodePage({
}
setDisableErrorMessage('');
const confirmed = await confirmWrite({
action: '停用兑换码',
target: disableCode.trim(),
});
if (!confirmed) {
return;
}
setIsDisabling(true);
try {
const response = await disableProfileRedeemCode(token, {
@@ -376,6 +394,7 @@ export function AdminRedeemCodePage({
</section>
</div>
</div>
{confirmDialog}
</section>
);
}

View File

@@ -11,6 +11,7 @@ import type {
ProfileTaskCycle,
TrackingScopeKind,
} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {
filterAdminTrackingEventDefinitions,
findAdminTrackingEventDefinition,
@@ -28,12 +29,7 @@ const taskCycles: Array<{value: ProfileTaskCycle; label: string}> = [
{value: 'daily', label: '每日'},
];
const scopeKinds: Array<{value: TrackingScopeKind; label: string}> = [
{value: 'user', label: '用户'},
{value: 'site', label: '整站'},
{value: 'work', label: '作品'},
{value: 'module', label: '模块'},
];
const profileTaskScopeKind = 'user' satisfies TrackingScopeKind;
export function AdminTaskConfigPage({
token,
@@ -49,7 +45,6 @@ export function AdminTaskConfigPage({
const [eventKeySearch, setEventKeySearch] = useState('每日登录');
const [isEventKeyPickerOpen, setIsEventKeyPickerOpen] = useState(false);
const [cycle, setCycle] = useState<ProfileTaskCycle>('daily');
const [scopeKind, setScopeKind] = useState<TrackingScopeKind>('user');
const [threshold, setThreshold] = useState('1');
const [rewardPoints, setRewardPoints] = useState('10');
const [sortOrder, setSortOrder] = useState('10');
@@ -61,6 +56,7 @@ export function AdminTaskConfigPage({
const [isSaving, setIsSaving] = useState(false);
const [isDisabling, setIsDisabling] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
useEffect(() => {
void refreshTaskConfigs();
@@ -102,6 +98,14 @@ export function AdminTaskConfigPage({
}
setErrorMessage('');
const confirmed = await confirmWrite({
action: '保存任务配置',
target: taskId.trim(),
});
if (!confirmed) {
return;
}
setIsSaving(true);
try {
const response = await upsertProfileTaskConfig(token, {
@@ -110,7 +114,7 @@ export function AdminTaskConfigPage({
description,
eventKey: eventKey.trim(),
cycle,
scopeKind,
scopeKind: profileTaskScopeKind,
threshold: parsePositiveInteger(threshold),
rewardPoints: parsePositiveInteger(rewardPoints),
enabled,
@@ -132,6 +136,14 @@ export function AdminTaskConfigPage({
}
setDisableErrorMessage('');
const confirmed = await confirmWrite({
action: '停用任务配置',
target: disableTaskId.trim(),
});
if (!confirmed) {
return;
}
setIsDisabling(true);
try {
const response = await disableProfileTaskConfig(token, {
@@ -165,7 +177,6 @@ export function AdminTaskConfigPage({
setDescription(entry.description);
setEventKey(entry.eventKey);
setCycle(entry.cycle);
setScopeKind(entry.scopeKind);
setThreshold(String(entry.threshold));
setRewardPoints(String(entry.rewardPoints));
setSortOrder(String(entry.sortOrder));
@@ -181,7 +192,6 @@ export function AdminTaskConfigPage({
setEventKey(nextEventKey);
if (nextDefinition) {
setEventKeySearch(nextDefinition.title);
setScopeKind(nextDefinition.scopeKind);
} else {
setEventKeySearch(nextEventKey);
}
@@ -349,21 +359,6 @@ export function AdminTaskConfigPage({
))}
</select>
</label>
<label className="admin-field">
<span></span>
<select
value={scopeKind}
onChange={(event) =>
setScopeKind(event.target.value as TrackingScopeKind)
}
>
{scopeKinds.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
</div>
<div className="admin-form-row">
@@ -530,6 +525,7 @@ export function AdminTaskConfigPage({
</section>
</div>
</div>
{confirmDialog}
</section>
);
}

View File

@@ -457,6 +457,47 @@ button:disabled {
font-weight: 700;
}
.admin-confirm-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
background: rgba(23, 33, 43, 0.42);
padding: 16px;
}
.admin-confirm-panel {
display: grid;
width: min(100%, 420px);
gap: 16px;
border: 1px solid #d8e2e8;
border-radius: 10px;
background: #ffffff;
box-shadow: 0 22px 60px rgba(23, 33, 43, 0.24);
padding: 18px;
}
.admin-confirm-warning {
border: 1px solid #efc894;
border-radius: 8px;
color: #8a5a1b;
background: #fffaf3;
padding: 10px 12px;
font-size: 13px;
font-weight: 650;
}
.admin-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.admin-confirm-actions button {
min-width: 92px;
}
.admin-primary-button {
color: #ffffff;
background: #126e82;
@@ -575,6 +616,11 @@ button:disabled {
background: #e6f5ed;
}
.admin-status-pending {
color: #8a5a1b;
background: #fff4df;
}
.admin-status-error {
color: #8a2f2f;
background: #fff1ef;
@@ -755,6 +801,14 @@ button:disabled {
padding: 16px;
}
.admin-confirm-panel {
padding: 16px;
}
.admin-confirm-actions {
display: grid;
}
.admin-login-brand h1,
.admin-page-heading h2 {
font-size: 22px;

View File

@@ -13,12 +13,14 @@
重点补充RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。
- [埋点查询](./tracking/README.md):埋点原始事件与聚合投影的本地 SQL 查询。
- [运营查询](./operations/README.md):任务、领奖、钱包对账等后台核查查询。
- [PRD](./prd):产品需求与阶段计划;后台管理独立前端工程见 [ADMIN_WEB_CONSOLE_PRD_2026-04-30.md](./prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md),新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md),新增抓大鹅 Match3D 玩法方案见 [AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](./prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md)。
- [PRD](./prd):产品需求与阶段计划;后台管理独立前端工程见 [ADMIN_WEB_CONSOLE_PRD_2026-04-30.md](./prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md),新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md),新增抓大鹅 Match3D 玩法方案见 [AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](./prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md),方洞挑战创作、发布与试玩闭环见 [AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md](./prd/AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md)
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
创作 Agent 问答流式失败时保留已显示回复、并透出更具体上游错误的契约见 [CREATION_AGENT_STREAM_FAILURE_RETENTION_FIX_2026-05-05.md](./technical/CREATION_AGENT_STREAM_FAILURE_RETENTION_FIX_2026-05-05.md)。
## 推荐阅读顺序
1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。

View File

@@ -0,0 +1,20 @@
# AdminTaskConfigPage 埋点范围收口记录2026-05-04
## 变更目标
- 前端隐藏「埋点范围」选择项。
- 保存任务配置时固定传递 `scopeKind=user`,避免运营在后台选择其他范围。
## 落地结果
- `apps/admin-web/src/pages/AdminTaskConfigPage.tsx`
- 移除了「埋点范围」下拉选择 UI。
- 保存时改为固定提交 `scopeKind: 'user'`
- 回填表单时不再读取或维护 `scopeKind` 的可编辑状态。
## 验证结果
- 已检查页面源码,确认不再存在「埋点范围」字段与可编辑逻辑。
- 已执行 `npm run admin-web:typecheck`
- 当前验证失败原因为环境缺少 `node_modules/typescript/bin/tsc`,属于依赖安装状态问题,不是本次前端改动引入的代码错误。
## 遗留说明
- `apps/admin-web/src/api/adminApiTypes.ts` 中仍保留 `TrackingScopeKind` 类型,供接口契约与其他前端模块兼容使用。
- `apps/admin-web/src/config/trackingEventDefinitions.ts` 仍保留事件定义中的 `scopeKind` 元数据,但页面不再允许运营修改。

View File

@@ -0,0 +1,278 @@
# AI 原生方洞挑战玩法创作工具与玩法系统 PRD
更新时间:`2026-05-04`
## 0. 文档目的
这份 PRD 用于在当前平台内新增一条“方洞挑战”玩法类型,并先冻结它从入口占位到完整可创建闭环的产品边界。
本玩法来自参考视频中的核心反差:玩家看到不同形状的积木与多个洞口,会本能判断“形状应放入对应洞口”,但演示不断把不同形状都放进同一个方洞,形成“规则预期被打破”的喜剧张力。
本次不是简单复制视频内容,也不是只新增一个前端小游戏。正式版本需要把这种反直觉机制抽象成平台内可创作、可试玩、可发布的玩法类型。
---
## 1. 一句话定义
“方洞挑战”是一个反直觉形状分拣玩法:百梦主通过 Agent 设定形状主题、洞口规则、误导节奏和反差演出,系统生成一个移动端优先的单局挑战;玩家在限时内把连续出现的形状投入正确洞口,后端根据当前关卡的真实兼容规则裁决成功、失败和连击。
---
## 2. 当前接入级别
根据 `genarrative-play-type-integration` 的接入分级,本次落地到 **完整玩法闭环**
1. 新增玩法 ID`square-hole`
2. 新增展示名称:`方洞挑战`
3. 新增子标题:`反直觉形状分拣`
4. 在新建作品入口、创作类型弹层、结果页、运行态和作品架展示。
5. 支持 Agent 创作、草稿生成、结果页编辑、试玩、发布和公开运行。
6. 后端以 `server-rs + Axum + SpacetimeDB` 为真相源,前端只渲染快照与交互。
不允许把运行规则临时写成前端本地真相,也不复用 `server-node`、Express 或 PostgreSQL。
---
## 3. 产品定位
## 3.1 模板名称
1. 对外模板名称:`方洞挑战`
2. 对外子标题:`反直觉形状分拣`
3. 开发代号:`SquareHole`
4. 工程玩法域:`square-hole`
5. 后端模块命名预期:`square_hole`
## 3.2 核心乐趣
1. 玩家先根据形状轮廓做直觉判断。
2. 关卡通过洞口、提示、视觉遮挡和演出制造误导。
3. 真实规则可以是“方洞万能”“指定洞口万能”“颜色优先于形状”“本轮只看尺寸”等反直觉规则。
4. 玩家通过连续试探和反馈理解规则,形成短局重复挑战。
## 3.3 与现有玩法的区别
1. 不等同于拼图:不切图、不交换、不合并拼块。
2. 不等同于抓大鹅:不做三消备选栏,不做堆叠遮挡点击。
3. 不等同于大鱼吃小鱼:不做摇杆移动、吞噬、成长等级。
4. 不复用 RPG 的世界、角色、章节或剧情推进结构。
5. 运行态只保留“当前形状 + 洞口选择 + 后端裁决”,不在前端写正式规则真相。
---
## 4. 完整闭环目标
本次完整闭环必须补齐:
1. 平台创作入口选择“方洞挑战”。
2. Agent 对话收集玩法锚点。
3. 生成方洞挑战草稿。
4. 进入结果页编辑作品名、标签、封面、形状数量、反差规则和视觉主题。
5. 支持发布前试玩。
6. 发布作品。
7. 玩家从作品详情或广场进入运行态。
8. 后端初始化单局形状队列、洞口兼容规则和计分状态。
9. 玩家拖拽或点击形状投入洞口。
10. 后端裁决投入结果、连击、扣时、失败、胜利和成绩。
11. 前端只渲染后端快照与即时反馈,不承接正式规则真相。
---
## 5. 创作锚点设计
Agent 型创作版本至少收集下面 5 个锚点:
| 锚点 | 字段建议 | 用途 |
| --- | --- | --- |
| 主题外观 | `themePrompt` | 决定玩具、洞板、背景、形状材质和色彩风格。 |
| 反差规则 | `twistRule` | 决定“为什么不是按形状匹配”的真实判定规则。 |
| 洞口组 | `holeSet` | 决定本局出现的洞口种类、数量、位置和视觉误导强度。 |
| 形状队列 | `shapeSequence` | 决定连续出现的形状、颜色、大小和难度递增。 |
| 反馈节奏 | `feedbackRhythm` | 决定成功、错误、连击、惊讶和结算演出风格。 |
Agent 需要把玩家一句灵感收束为上述锚点,不允许逐项盘问低价值字段。
## 5.1 Agent AI 生成契约
方洞挑战的创作对话必须接入 `api-server` 的现有 LLM 能力,不能把用户输入解析成固定模板后直接写回会话。工程实现以 `state.llm_client()` 为唯一模型入口,通过方洞专属 Agent turn 生成回复与下一轮配置。
单轮模型输出必须是严格 JSON
```json
{
"replyText": "",
"progressPercent": 0,
"nextConfig": {
"themeText": "",
"twistRule": "",
"shapeCount": 12,
"difficulty": 4
}
}
```
落地约束:
1. `replyText` 是直接展示给百梦主的中文回复,不得出现 JSON、字段名、内部结构等说明。
2. `nextConfig` 必须是完整配置,不允许只输出 patch缺失字段只能由后端用当前会话配置兜底。
3. `shapeCount` 由后端限制在 `6``24``difficulty` 限制在 `1``10`
4. `quickFillRequested=true` 时,模型应直接补齐剩余配置,后端把 `progressPercent` 固定为 `100`
5. 模型不可用或结果无法解析时,接口返回明确错误,不允许用确定性模板伪装成 AI 回复。
6. 非流式消息接口和 SSE 流式消息接口都必须走同一套方洞 Agent turnSSE 只额外负责把 `replyText` 增量回传。
---
## 6. 运行规则设计
## 6.1 单局结构
1. 单局默认 `60` 秒。
2. 每局默认 `12` 个形状。
3. 洞口数量默认 `4``6` 个。
4. 玩家每次只能操作当前形状。
5. 正确投入后进入下一个形状。
6. 错误投入扣除时间或清空连击。
7. 全部形状完成即胜利。
8. 时间归零即失败。
## 6.2 真实兼容规则
首版可支持下面几类规则:
1. `shape_match`:形状轮廓匹配。
2. `square_hole_priority`:方洞兼容所有形状,其他洞口只作为误导。
3. `color_match`:颜色优先于形状。
4. `size_match`:尺寸优先于形状。
5. `round_prompt`:本轮按后端给出的短提示规则判定。
其中 `square_hole_priority` 是参考视频核心反差的首选默认规则。
## 6.3 前端表现
1. 竖屏优先,桌面端居中显示游戏台。
2. 当前形状位于屏幕下半区域,洞板位于上半区域。
3. 只显示必要状态:剩余时间、连击、当前进度。
4. 不默认展示长篇规则说明。
5. 错误反馈用短促动画、颜色闪烁和轻量文字状态,不堆解释。
6. 点击按钮弹出的配置或结算必须使用独立面板,不在当前面板下方展开。
---
## 7. 后端分层边界
完整实现时必须遵循当前 `server-rs + Axum + SpacetimeDB` 路线:
1. `server-rs/crates/module-square-hole`
- 纯领域规则、形状队列生成、兼容性裁决、分数计算。
- 不依赖 Axum、SpacetimeDB、OSS 或 LLM。
2. `server-rs/crates/shared-contracts`
- 暴露 Agent、作品、运行态 DTO。
3. `server-rs/crates/spacetime-module`
- 存储 session、message、work profile、runtime run。
- 表结构变化必须同步 `migration.rs` 与表目录。
4. `server-rs/crates/spacetime-client`
- 提供 api-server 调用 SpacetimeDB 的 typed facade。
5. `server-rs/crates/api-server`
- 暴露 `/api/creation/square-hole/*``/api/runtime/square-hole/*`
- 处理鉴权、错误 envelope、LLM turn 和 HTTP facade。
---
## 8. 数据模型
## 8.1 创作 session
1. `sessionId`
2. `ownerUserId`
3. `currentTurn`
4. `progressPercent`
5. `stage`
6. `config`
7. `draft`
8. `messages`
9. `lastAssistantReply`
10. `publishedProfileId`
## 8.2 结果页 work profile
1. `workId`
2. `profileId`
3. `ownerUserId`
4. `sourceSessionId`
5. `gameName`
6. `themeText`
7. `twistRule`
8. `summary`
9. `tags`
10. `coverImageSrc`
11. `shapeCount`
12. `difficulty`
13. `publicationStatus`
14. `playCount`
15. `updatedAt`
16. `publishedAt`
## 8.3 运行态 run snapshot
1. `runId`
2. `profileId`
3. `ownerUserId`
4. `status`
5. `snapshotVersion`
6. `startedAtMs`
7. `durationLimitMs`
8. `remainingMs`
9. `totalShapeCount`
10. `completedShapeCount`
11. `combo`
12. `bestCombo`
13. `score`
14. `ruleLabel`
15. `currentShape`
16. `holes`
17. `lastFeedback`
---
## 9. API 设计
## 9.1 创作接口
1. `POST /api/creation/square-hole/sessions`
2. `GET /api/creation/square-hole/sessions/{sessionId}`
3. `POST /api/creation/square-hole/sessions/{sessionId}/messages`
4. `POST /api/creation/square-hole/sessions/{sessionId}/messages/stream`
5. `POST /api/creation/square-hole/sessions/{sessionId}/actions`
6. `POST /api/creation/square-hole/sessions/{sessionId}/compile`
7. `GET /api/creation/square-hole/works`
8. `GET /api/creation/square-hole/works/{profileId}`
9. `PUT /api/creation/square-hole/works/{profileId}`
10. `POST /api/creation/square-hole/works/{profileId}/publish`
11. `DELETE /api/creation/square-hole/works/{profileId}`
## 9.2 运行接口
1. `GET /api/runtime/square-hole/gallery`
2. `GET /api/runtime/square-hole/gallery/{profileId}`
3. `POST /api/runtime/square-hole/works/{profileId}/runs`
4. `GET /api/runtime/square-hole/runs/{runId}`
5. `POST /api/runtime/square-hole/runs/{runId}/drop`
6. `POST /api/runtime/square-hole/runs/{runId}/stop`
7. `POST /api/runtime/square-hole/runs/{runId}/restart`
8. `POST /api/runtime/square-hole/runs/{runId}/time-up`
---
## 10. 验收标准
1. `src/config/newWorkEntryConfig.ts` 中存在 `square-hole` 类型且开放创建。
2. 新建作品入口和创作类型弹层能展示“方洞挑战”。
3. 能进入 `square-hole` Agent 工作台。
4. 能生成草稿并进入结果页。
5. 能编辑结果页并保存、发布。
6. 能从作品详情或广场进入运行态。
7. 能点击或拖拽当前形状投入洞口。
8. 后端裁决命中规则、连击、失败和胜利。
9. 刷新后可恢复作品与运行态快照。
10. `docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md` 记录该入口开放状态。
11. 后端改动完成后必须执行 `npm run api-server:maincloud`,以 `GET /healthz` 返回 `200` 作为主云配置启动 smoke 通过标准,并在 smoke 后清理本次启动进程。

View File

@@ -192,6 +192,8 @@ export interface AdminDisableProfileRedeemCodeRequest {
export interface AdminUpsertProfileInviteCodeRequest {
inviteCode: string;
startsAt?: string | null;
expiresAt?: string | null;
metadata?: Record<string, unknown>;
}
@@ -215,6 +217,9 @@ export interface ProfileRedeemCodeAdminListResponse {
export interface ProfileInviteCodeAdminResponse {
userId: string;
inviteCode: string;
startsAt: string | null;
expiresAt: string | null;
status: 'pending' | 'active' | 'expired';
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
@@ -376,6 +381,9 @@ export interface ProfileInviteCodeAdminListResponse {
{
"userId": "admin:root:SPRING2026",
"inviteCode": "SPRING2026",
"startsAt": "2026-05-01T00:00:00Z",
"expiresAt": "2026-06-01T00:00:00Z",
"status": "active",
"metadata": {
"batch": "spring"
},
@@ -393,6 +401,8 @@ export interface ProfileInviteCodeAdminListResponse {
```json
{
"inviteCode": "SPRING2026",
"startsAt": "2026-05-01T00:00:00Z",
"expiresAt": "2026-06-01T00:00:00Z",
"metadata": {
"batch": "spring"
}
@@ -405,6 +415,9 @@ export interface ProfileInviteCodeAdminListResponse {
{
"userId": "admin",
"inviteCode": "SPRING2026",
"startsAt": "2026-05-01T00:00:00Z",
"expiresAt": "2026-06-01T00:00:00Z",
"status": "active",
"metadata": {
"batch": "spring"
},
@@ -415,6 +428,118 @@ export interface ProfileInviteCodeAdminListResponse {
邀请码页的 metadata 输入必须先在前端解析为 JSON 对象;空字符串按 `{}` 处理,数组、字符串、数字等非对象值直接提示错误。最终标准化、长度限制和邀请码合法性以 `server-rs` 为准。
#### 4.8.1 邀请码有效期语义
邀请码仍然是“用户稳定邀请身份码”,不做删除或软删除。本轮只增加时间窗字段,用于控制**新填写邀请码**是否允许绑定:
1. `startsAt` / 后端 `starts_at`:邀请码开始生效时间;为空表示立即生效。
2. `expiresAt` / 后端 `expires_at`:邀请码截止时间;为空表示长期有效。
3. 两个字段都为空时,邀请码视为长期有效。
4. `expiresAt` 采用左闭右开语义:当前时间 `>= expiresAt` 时视为已过期。
5. 时间字段在管理 API JSON 中统一使用 ISO 8601 UTC 字符串或 `null`SpacetimeDB 内部仍按 `Timestamp` 存储,契约层负责转换,前端不得自行假设微秒/毫秒整数。
6. 有效期只影响用户之后调用填写邀请码接口建立新邀请关系;已绑定的邀请关系、历史奖励、统计和审计记录不回溯修改。
字段合法性要求:
1. `startsAt``expiresAt` 均允许为空。
2. 若两者都存在,必须满足 `startsAt < expiresAt`;相等或开始晚于截止应由后端拒绝,前端可提前提示但不能替代后端校验。
3. 后台编辑已有邀请码时,空值代表清空该边界;不要用空字符串写入契约。
#### 4.8.2 用户填写邀请码的错误优先级与校验逻辑
填写邀请码时,后端是唯一业务真相。前端只展示后端错误,不复制完整业务规则。推荐校验优先级如下:
1. **请求身份与输入基础校验**:未登录、空邀请码、格式不合法等请求级错误优先返回。
2. **用户自身状态校验**:用户不存在、用户资料不可用、已绑定过邀请关系等与当前用户直接相关的错误优先于邀请码时间窗。
3. **邀请码查找**:按标准化后的邀请码查找记录;不存在时返回“邀请码不存在或不可用”。
4. **自邀请校验**:邀请码归属用户等于当前用户时,返回“不能填写自己的邀请码”。
5. **时间窗校验**
- `starts_at` 存在且当前时间 `< starts_at`,返回“邀请码未生效”。
- `expires_at` 存在且当前时间 `>= expires_at`,返回“邀请码已过期”。
6. **绑定写入与奖励发放**:只有以上校验全部通过,才写入邀请绑定、奖励或相关流水。
该顺序的目标是避免用“未生效/已过期”泄露不该暴露的用户状态,同时保证用户看到的错误与实际阻断原因一致。若后续新增风控、封禁、黑名单等规则,应在写入前补入,并在本节同步明确优先级。
#### 4.8.3 后台邀请码列表状态展示规则
后台列表状态可由后端返回 `status`,也可在前端用同一规则从 `startsAt` / `expiresAt` 派生;如果两者同时存在,列表展示以后端 `status` 为准,并仅把前端派生结果用于兜底。
| 条件 | 状态值 | 中文标签 | 展示建议 |
| --- | --- | --- | --- |
| `startsAt` 存在且当前时间 `< startsAt` | `pending` | 未生效 | 展示开始时间,提示尚不能被新用户填写 |
| `expiresAt` 存在且当前时间 `>= expiresAt` | `expired` | 已过期 | 展示截止时间,提示不再允许新绑定 |
| 其他情况 | `active` | 有效 | 正常高亮展示 |
补充展示规则:
1. 两个字段都为空时状态为 `active`,中文可展示为“长期有效”。
2. `startsAt` 为空、`expiresAt` 未来存在时状态为 `active`,中文可展示为“有效至 YYYY-MM-DD HH:mm”。
3. `startsAt` 未来、`expiresAt` 为空时状态为 `pending`中文可展示为“YYYY-MM-DD HH:mm 生效”。
4. 列表至少展示邀请码、状态、开始时间、截止时间、更新时间metadata 可保留折叠/摘要展示,避免挤占移动端宽度。
5. 列表状态只用于运营理解,不作为安全边界;真正是否可填写仍以后端 redeem 校验为准。
### 4.9 后台写操作二次确认规范
后台所有会修改线上数据的操作,在真正调用 API 前必须二次确认;取消确认时不得发送任何请求。该规范覆盖当前和未来新增的管理写入口,不限于 profile 模块。
必须二次确认的操作包括但不限于:
1. 创建/更新兑换码:`POST /admin/api/profile/redeem-codes`
2. 停用兑换码:`POST /admin/api/profile/redeem-codes/disable`
3. 创建/更新邀请码:`POST /admin/api/profile/invite-codes`
4. 创建/更新个人任务配置:`POST /admin/api/profile/tasks`
5. 停用个人任务配置:`POST /admin/api/profile/tasks/disable`
6. 后续任何 `POST` / `PATCH` / `PUT` / `DELETE` 管理接口,只要会修改数据、触发任务、写审计或影响线上配置,均默认纳入确认。
交互要求:
1. 确认弹窗必须在 API 调用前出现,确认后才进入 loading 和提交状态。
2. 弹窗必须展示操作类型(新增、更新、停用、删除、发布等)、对象标识(如 `code``inviteCode``taskId`)和影响说明。
3. 默认按钮顺序为“取消 / 确认”,取消不应有危险色;危险操作(停用、删除、覆盖线上配置)确认按钮使用警示样式。
4. 弹窗文案统一提示“该操作会立即影响线上数据”,但不要在页面常驻展示大段规则说明。
5. 支持键盘和移动端Esc 或取消按钮关闭;移动端弹窗宽度自适应,不遮挡关键对象信息。
6. loading 期间锁定确认按钮和原页面提交按钮,避免重复写入。
7. 成功后按现有页面规则刷新列表或合并返回记录;失败时展示后端错误,不能静默关闭为成功。
建议抽象通用确认能力,例如 `confirmAdminWriteAction({ actionLabel, targetLabel, riskLevel, onConfirm })` 或通用 `AdminConfirmDialog`,页面只传入对象与回调,避免每个页面重复实现不同交互。
#### 4.9.1 二次确认文案模板
```text
标题:确认{操作类型}{对象类型}
正文:即将{操作类型}「{对象标识}」。该操作会立即影响线上数据。
取消按钮:取消
确认按钮:确认{操作类型}
```
示例:
1. `确认更新邀请码`即将更新「SPRING2026」的有效期与 metadata。该操作会立即影响线上数据。
2. `确认停用兑换码`即将停用「WELCOME2026」。该操作会立即影响线上数据。
3. `确认更新任务配置`即将更新「daily_login」。该操作会立即影响线上数据。
### 4.10 邀请码有效期与二次确认改动范围
实现本设计时预期改动范围如下,未列出的层级不要擅自承接业务规则:
1. `server-rs/crates/spacetime-module/src/runtime/profile.rs`邀请码表结构、upsert、redeem 时间窗校验与后台列表投影。
2. `server-rs/crates/spacetime-module/src/migration.rs`:旧邀请码记录迁移,默认 `starts_at = None``expires_at = None`
3. `server-rs/crates/shared-contracts/src/**`:管理请求/响应 DTO 增加 `startsAt``expiresAt``status` 等字段。
4. `server-rs/crates/spacetime-client/src/module_bindings/**` 与 mapper按表结构变更重新生成/补齐绑定字段。
5. `server-rs/crates/api-server/src/runtime_profile.rs`:接收、校验、转发并返回邀请码时间窗字段;保持错误 envelope 兼容后台读取逻辑。
6. `apps/admin-web/src/api/adminApiTypes.ts``adminApiClient.ts`:同步契约字段,不在 client 层写业务判断。
7. `apps/admin-web/src/pages/AdminInviteCodePage.tsx`:有效期表单、列表状态展示、保存前确认。
8. `apps/admin-web/src/pages/AdminRedeemCodePage.tsx``AdminTaskConfigPage.tsx` 及后续写页面:统一接入写操作二次确认。
9. `apps/admin-web/src/styles/admin.css`:状态标签、确认弹窗与移动端样式。
验证建议:
1. 服务端单测覆盖:未生效邀请码拒绝、已过期邀请码拒绝、有效时间窗可绑定、空时间窗长期有效、已绑定关系不受后续过期影响。
2. 管理 API 覆盖upsert 能写入/清空 `startsAt``expiresAt`;列表返回状态正确;`startsAt >= expiresAt` 被拒绝。
3. 前端交互覆盖:点击保存/停用不会直接请求,取消确认不请求,确认后只请求一次,失败展示后端错误。
4. 回归兑换码与任务配置页面,确认所有写操作均有统一二次确认。
5. 修改后端时按项目约束运行对应 Rust 测试、`npm run api-server` 联调和 `/healthz`;修改前端时运行 `npm run admin-web:typecheck``npm run admin-web:build`;文档或中文改动后运行 `npm run check:encoding`
## 5. 鉴权与会话
1. token key 固定为 `genarrative_admin_token`

View File

@@ -0,0 +1,367 @@
# Analytics Date Dimension 与个人任务埋点范围收口记录2026-05-04
## 背景
本记录用于收口 `.hermes/plans/2026-05-04_022223-analytics-time-dimension-mapping.md` 中当前阶段已经落地的内容,并明确尚未执行的后续范围。
本阶段目标不是完整上线运营统计查询,而是先完成两件基础工作:
1. 收紧个人任务系统的埋点范围,避免运营或接口把个人任务错误配置为 `site``module``work` 等非用户维度。
2. 新增统一日期维表 `analytics_date_dimension`,为后续按周、月、季、年聚合埋点数据提供稳定的日期 bucket 映射。
## 当前已完成范围
### 1. 个人任务埋点范围锁定为 User
当前个人任务系统首版只支持用户维度埋点。
已完成:
- Admin 任务配置页不再展示“埋点范围”选择。
- Admin 保存任务配置时固定传 `scopeKind: 'user'`
- API 层拒绝非 `user` 的个人任务配置。
- 领域输入构造层拒绝非 `User` 的个人任务配置。
- `Work => user_id` 的错误映射已移除。
- 任务进度刷新、任务中心快照、领奖链路遇到非 `User` 的异常个人任务配置时显式报错,不再静默按 0 进度处理。
相关文件:
```text
apps/admin-web/src/pages/AdminTaskConfigPage.tsx
apps/admin-web/src/api/adminApiTypes.ts
server-rs/crates/api-server/src/runtime_profile.rs
server-rs/crates/module-runtime/src/commands.rs
server-rs/crates/module-runtime/src/errors.rs
server-rs/crates/spacetime-module/src/runtime/profile.rs
```
### 2. 日期维表领域模型与纯函数
已在 `module-runtime` 中补充日期维表快照和纯函数。
日期维表使用现有北京时间业务日 `day_key` 语义:
```text
date_key = floor((occurred_at_micros + 8h) / 1d)
```
已完成能力:
-`YYYY-MM-DD` 解析业务日 `date_key`
-`date_key` 构造日期维表快照。
- 生成 ISO weekday周一=1周日=7。
- 生成 ISO week key`YYYYWW`,跨年周按 ISO week-year。
- 生成 week/month/quarter/year 的 key 和起止 `date_key`
- 限制日期维表支持范围为:
- `2000-01-01`
-`2100-12-31`
相关文件:
```text
server-rs/crates/module-runtime/src/domain.rs
server-rs/crates/module-runtime/src/application.rs
server-rs/crates/module-runtime/src/lib.rs
```
### 3. SpacetimeDB 日期维表与 reducer
已新增 SpacetimeDB 表:
```text
analytics_date_dimension
```
表字段包括:
```text
date_key
calendar_date
weekday
iso_week_key
week_start_date_key
week_end_date_key
month_key
month_start_date_key
month_end_date_key
quarter_key
quarter_start_date_key
quarter_end_date_key
year_key
year_start_date_key
year_end_date_key
created_at
updated_at
```
已新增索引:
```text
iso_week_key
month_key
quarter_key
year_key
```
已新增 reducer
```text
ensure_analytics_date_dimension_for_date
seed_analytics_date_dimensions
```
当前 reducer 行为:
- `ensure` 单日幂等补齐。
- `seed` 按日期范围幂等补齐。
- `seed` 拒绝 `start_date > end_date`
- `seed` 单次最多允许 `ANALYTICS_DATE_DIMENSION_MAX_SEED_DAYS = 3660` 天。
-`date_key` 进入 ensure 前先做支持范围校验,避免极端整数进入日历算法。
相关文件:
```text
server-rs/crates/spacetime-module/src/runtime/analytics_date_dimension.rs
server-rs/crates/spacetime-module/src/runtime/mod.rs
server-rs/crates/spacetime-module/src/migration.rs
docs/technical/SPACETIMEDB_TABLE_CATALOG.md
```
### 4. SpacetimeDB Rust client bindings
已按项目脚本生成 Rust bindings并在生成参数中显式包含 private tables/functions
```bash
PATH="/tmp/spacetime-bin:$PATH" npm run spacetime:generate -- --rust-only
```
本次已修改生成脚本:
```text
scripts/generate-spacetime-bindings.mjs
```
`spacetime generate` 参数中加入:
```text
--include-private
```
说明SpacetimeDB CLI 2.1.0 的参数名是 `--include-private`,不是 `--non-private`。该参数含义是将 private tables/functions 也包含进生成代码,满足 api-server 通过 Rust bindings 访问 module private table/reducer 的需求。
```text
spacetimedb tool version 2.1.0; spacetimedb-lib version 2.1.0
```
生成脚本:
```text
scripts/generate-spacetime-bindings.mjs
```
已新增 analytics date dimension 相关 bindings
```text
server-rs/crates/spacetime-client/src/module_bindings/analytics_date_dimension_ensure_input_type.rs
server-rs/crates/spacetime-client/src/module_bindings/analytics_date_dimension_seed_input_type.rs
server-rs/crates/spacetime-client/src/module_bindings/analytics_date_dimension_type.rs
server-rs/crates/spacetime-client/src/module_bindings/analytics_date_dimension_table.rs
server-rs/crates/spacetime-client/src/module_bindings/ensure_analytics_date_dimension_for_date_reducer.rs
server-rs/crates/spacetime-client/src/module_bindings/seed_analytics_date_dimensions_reducer.rs
```
并更新了:
```text
server-rs/crates/spacetime-client/src/module_bindings/mod.rs
```
注意:
- `analytics_date_dimension` 表当前是 private table由于生成脚本已加 `--include-private`,本次 codegen 已生成 `analytics_date_dimension_table.rs`,可通过 `ctx.db.analytics_date_dimension()` 访问 client cache / query builder。
- bindings 目录是自动生成产物,本次以项目脚本整体刷新,除新增 analytics 文件外,也带来了大量已存在 table/reducer/procedure 文件的格式化/生成器输出差异。
### 5. 测试覆盖
已新增测试:
```text
server-rs/crates/module-runtime/tests/analytics_date_dimension.rs
server-rs/crates/module-runtime/tests/profile_task_scope.rs
server-rs/crates/shared-contracts/tests/profile_task_contract.rs
```
覆盖重点:
- `2024-02-29` 闰年。
- `2025-12-29` ISO week 跨年。
- `2026-01-01` 跨年周。
- `2026-03-31` Q1 结束。
- `2026-04-01` Q2 开始。
- `2026-12-31` 年末。
- 非法日期解析失败。
- 超出日期维表支持范围失败。
- 个人任务 `scopeKind=user` 成功。
- 个人任务 `scopeKind=site/module/work` 失败。
- `work` scope 不会静默映射到 `user_id`
- Admin 个人任务配置 contract 保持 `scopeKind: user`
## 已验证命令
`server-rs/` 执行:
```bash
cargo fmt -p module-runtime -p spacetime-module -p spacetime-client
cargo test -p spacetime-client --no-run
cargo test -p spacetime-module --no-run
cargo test -p module-runtime --test analytics_date_dimension
cargo test -p module-runtime --test profile_task_scope
cargo test -p shared-contracts --test profile_task_contract
```
从项目根目录执行:
```bash
npm run admin-web:typecheck
```
当前结果:
- `spacetime-client --no-run` 编译通过。
- `spacetime-module --no-run` 编译通过。
- `analytics_date_dimension` 测试通过8 passed。
- `profile_task_scope` 测试通过3 passed。
- `profile_task_contract` 测试通过2 passed。
- `admin-web:typecheck` 通过。
已知非本阶段阻塞:
- 完整运行 `cargo test -p spacetime-module` 时,曾出现既有 puzzle 测试失败:
```text
puzzle::tests::puzzle_preview_is_publishable_with_complete_draft FAILED
assertion failed: preview.publish_ready
```
该失败与当前埋点范围和日期维表改动无直接关系,本阶段以 `cargo test -p spacetime-module --no-run` 作为编译门禁。
## 当前未完成 / 暂缓项
### 1. 暂未新增 spacetime-client facade
当前没有新增:
```text
SpacetimeClient::ensure_analytics_date_dimension_for_date
SpacetimeClient::seed_analytics_date_dimensions
```
原因:
- 生成脚本已加入 `--include-private`private reducer/type/table bindings 已可用于后续 facade 实现。
- 但 Step 7/8/9 暂缓,尚未由 `api-server` 或统计查询链路调用该能力。
- 如后续只是 SpacetimeDB module 内部写入统计时 ensure可以直接复用 module 内部 helper不一定需要远程 client facade。
- 若后续需要由 API 或运维接口触发 seed/ensure可基于本次已生成的 reducer bindings 再补 facade。
### 2. Step 7/8/9 暂缓
本阶段未接入:
- 事件写入链路自动 ensure 日期维表。
- 聚合查询 API 的 `granularity = day | week | month | quarter | year`
- shared contracts / 前端 analytics contracts。
- 历史事件回填。
这些应作为后续阶段单独设计和落地。
## 后续建议顺序
1. 如需提交本阶段改动,确认是否接受 `module_bindings` 整体刷新带来的大量生成文件 diff。
2. 如希望 diff 更小,可评估仅提交 analytics date dimension 相关生成文件与 `mod.rs`;但需要非常谨慎,因为 `module_bindings` 是自动生成产物。
3. 如需要由 `api-server` 触发 seed/ensure再补 `spacetime-client` facade。
4. 进入 Step 7/8/9事件写入链路、聚合查询 API、前端 contracts。
## Step 7/8/9 后续接入记录2026-05-04
本次继续推进此前暂缓的 Step 7/8/9 中“按日期维度聚合查询 API / contracts / client facade”部分。
### 已新增能力
1. `module-runtime` 新增 analytics metric 聚合领域类型与纯函数:
- `AnalyticsGranularity = day | week | month | quarter | year`
- `AnalyticsMetricQueryInput`
- `AnalyticsBucketMetric`
- `AnalyticsMetricQueryResponse`
- `aggregate_runtime_tracking_daily_stats(...)`
2. `spacetime-module` 新增 `query_analytics_metric` procedure直接聚合 tracking daily stat输出按 bucket 排序的统计结果。
3. `spacetime-client` 新增 facade
```rust
SpacetimeClient::query_analytics_metric(event_key, scope_kind, scope_id, granularity)
```
4. `api-server` 新增登录态接口:
```http
GET /api/profile/analytics/metric?eventKey=...&scopeKind=user&scopeId=...&granularity=day
```
请求参数:
| 参数 | 说明 |
| --- | --- |
| `eventKey` | 埋点事件 key必填 |
| `scopeKind` | `site | work | module | user` |
| `scopeId` | 对应范围 ID必填 |
| `granularity` | `day | week | month | quarter | year` |
响应 data
```ts
type AnalyticsMetricQueryResponse = {
buckets: Array<{
bucketKey: string;
bucketStartDateKey: number;
bucketEndDateKey: number;
value: number;
}>;
};
```
5. shared contracts / 前端 shared contracts 已新增 analytics query 类型:
- `AnalyticsMetricQueryRequest`
- `AnalyticsMetricQueryResponse`
- `AnalyticsBucketMetricResponse` / `AnalyticsBucketMetric`
- `AnalyticsGranularity`
### 本次验证
`server-rs/` 执行通过:
```bash
cargo test -p module-runtime --test analytics_granularity
cargo check -p spacetime-module
cargo check -p spacetime-client
cargo check -p api-server
```
验证结果:
- `analytics_granularity` 测试通过3 passed。
- `spacetime-module` 编译通过,仅存在既有 dead_code warnings。
- `spacetime-client` 编译通过。
- `api-server` 编译通过,仅存在既有 prompt dead_code warnings。
### 注意事项
当前环境未检测到 `spacetime` / `spacetimedb` CLI因此 analytics metric 相关 `module_bindings` 是按现有生成物结构手动补齐的临时生成物。后续有 CLI 的开发机应优先通过项目脚本重新生成 bindings并复核手写生成物是否可被正式生成输出覆盖。
---
## 阶段结论
当前阶段已经完成“个人任务埋点范围收紧”和“日期维表 module 侧能力”的核心落地,并已生成 SpacetimeDB Rust client bindings。
剩余工作不再是 bindings 环境阻塞,而是后续业务接入范围:是否增加 `spacetime-client` facade以及是否继续推进事件写入链路、聚合查询 API 和前端 analytics contracts。

View File

@@ -0,0 +1,53 @@
# 创作 Agent 流式失败保留可见回复修复 2026-05-05
## 1. 问题
方洞挑战等轻量玩法复用 `usePlatformCreationAgentFlowController``creationAgentSse.ts` 消费 `reply_delta / session / error`。当上游 LLM 已经返回部分 `replyText`但后续因为超时、上游断流、SSE 解析或最终 JSON 解析失败而发送 `error` 事件时,前端会在 `finally` 里退出流式态。
旧 UI 只在 `isStreamingReply=true` 时展示临时 assistant 气泡,因此用户会先看到一段回答,然后回答突然消失,只剩错误提示。
## 2. 目标
1. 已经展示给用户的流式回复不能因为最终失败从聊天区消失。
2. SSE `error` 仍然终止本轮提交,并保留错误提示。
3. 后端错误不能只压成 `上游服务请求失败`,应优先把 LLM 流错误原因放到业务 `message`
4. 不修改 SpacetimeDB schema、消息表结构或玩法运行规则。
## 3. 前端契约
`readCreationAgentSessionFromSse()` 在收到 `reply_delta` 后再收到 `error` 时,必须先触发 `onUpdate(text)`,再抛出错误。调用方可以从最近一次可见文本中恢复 UI。
`usePlatformCreationAgentFlowController.submitMessage()` 的失败收尾规则:
1. 提交时仍先追加 optimistic user message。
2. 每次 `onUpdate` 同步更新 `streamingReplyText` 与最近可见回复引用。
3. 如果 `streamMessage()` 抛错且最近可见回复非空,把该文本追加为本地 assistant `warning` 消息。
4. 再设置 `error`,最后关闭 `isStreamingReply`
5. 成功拿到最终 session 时,以后端 session snapshot 为准,并清空最近可见回复。
这条本地 `warning` 消息只用于失败态 UI 保留,不代表该 assistant 消息已经写入 SpacetimeDB。
## 4. 后端契约
`creation_agent_llm_turn``LlmClient::stream_text()` 失败时,返回:
```text
<玩法 generation_failed 文案><LlmError Display>
```
同时写 `warn` 日志,便于结合 `logs/llm-raw` 定位上游原始输入输出。
方洞挑战 SSE 错误提取优先级:
1. `error.details.message`
2. `error.message`
3. 其它嵌套 JSON message
4. 原始 body 文本
5. 状态码兜底
## 5. 验收
1. `reply_delta` 后收到 `error` 时,测试应断言 `onUpdate` 已经收到可见文本。
2. 控制器测试应断言失败后本地消息列表包含 user 消息和 assistant warning 消息。
3. `cargo check -p api-server` 通过。
4. `npm run typecheck` 与编码检查通过。

View File

@@ -22,6 +22,7 @@
| 大鱼吃小鱼 | 否 | 是 | 功能仍保留,不在新建作品入口展示 |
| 拼图 | 是 | 是 | 点击后进入拼图 Agent 共创工作台 |
| 抓大鹅 | 否 | 是 | 暂时从创作端入口下线,既有链路与作品能力保留 |
| 方洞挑战 | 是 | 是 | 点击后进入方洞挑战 Agent 共创工作台,支持草稿、结果页、发布、试玩、作品架与广场 |
| AIRP | 是 | 否 | 保留入口,显示敬请期待 |
| 视觉小说 | 是 | 否 | 保留入口,显示敬请期待 |
@@ -31,3 +32,4 @@
2. 隐藏玩法不触发入口预加载,也不出现在新建作品入口中。
3. 未开放玩法点击态保持禁用,不应进入鉴权或创建会话链路。
4. 已开放玩法点击后必须进入对应创建链路;若用户未登录,先走登录保护。
5. 方洞挑战作品发布后应生成 `SH-` 作品号,并能从作品架、广场详情和试玩 runtime 回到同一作品详情。

View File

@@ -195,7 +195,8 @@ Jenkins 可运行在 Windows 或其他机器上,本机 Windows 只作为人工
- Jenkins Job 参数不暴露真实节点名、IP 或带 IP 的标签。
- 构建 Job 固定使用 label expression`linux && genarrative-build`
- 当前开发/构建/开发部署 agent 必须同时配置 `linux``genarrative-build` 两个标签;非 Linux 节点不能承担构建或部署。
- 当前开发/构建/开发部署 agent 使用脱敏节点名 `genarrative-build-01`必须同时配置 `linux``genarrative-build` 两个标签;非 Linux 节点不能承担构建或部署。
- 构建机 agent 启动方式统一改为 inbound agent + systemd 自守护,不再依赖 Jenkins controller 通过 SSH launcher 长期拉起。SSH 只作为首次登录和安装 systemd 服务的运维通道。
- 用途:拉代码、安装依赖、构建主站、构建后台、构建 `api-server`、构建 SpacetimeDB wasm、归档产物并执行 `DEPLOY_TARGET=development` 的开发环境部署。
### 生产/发布实例
@@ -209,25 +210,34 @@ Jenkins 可运行在 Windows 或其他机器上,本机 Windows 只作为人工
### Jenkins inbound agent 自恢复
发布 agent 必须由目标 Linux 机器主动连接 Jenkins controller并由 systemd 托管:
构建 agent 与发布 agent 必须由目标 Linux 机器主动连接 Jenkins controller并由 systemd 托管:
- Jenkins 节点 Launch method 使用 inbound agent优先启用 WebSocket。这样目标机只需要能访问 Jenkins Web 地址,不依赖 controller 每次 SSH 拉起 agent。
- 目标机安装 `deploy/systemd/jenkins-agent@.service``scripts/deploy/jenkins-inbound-agent-start.sh``scripts/deploy/install-jenkins-inbound-agent.sh`
- systemd 服务名采用 `jenkins-agent@<node-name>.service`,例如 `jenkins-agent@genarrative-release-deploy-01.service`
- systemd 自身 `WorkingDirectory` 保持 `/var/lib/jenkins/agent/<node-name>`Jenkins remoting `-workDir` 可继续使用旧 SSH agent 的 `/root/jenkins-agent`,避免迁移时 workspace 和缓存路径漂移。
- systemd 服务名采用 `jenkins-agent@<node-name>.service`,例如 `jenkins-agent@genarrative-build-01.service``jenkins-agent@genarrative-release-deploy-01.service`
- systemd 自身 `WorkingDirectory` 保持 `/var/lib/jenkins/agent/<node-name>`Jenkins remoting `-workDir`按节点拆分,例如构建机使用 `/root/jenkins-agent-build`、发布机继续使用旧 SSH agent 的 `/root/jenkins-agent`,避免多 agent 共用 remoting 根目录,同时减少发布机迁移时 workspace 和缓存路径漂移。
- inbound secret 只能放在目标机 `/etc/jenkins-agent/<node-name>.secret` 或等价 Secret Text 注入位置,不能提交到 Git也不能写入 Jenkinsfile 默认参数。
- systemd unit 使用 `Restart=always``RestartSec=10`agent Java 进程退出、网络短断或机器重启后由 systemd 自动恢复,不需要人工盯着 Jenkins 页面手动重启。
- 当前 `Genarrative-Server-Provision` 仍负责 systemd、Nginx、`/opt/genarrative``/etc/genarrative` 等特权写入,因此 inbound agent 默认仍按现有 root 执行口径迁移。若后续改为 `jenkins` 用户运行 agent必须先把生产流水线需要的特权命令收敛为精确 `NOPASSWD` sudoers 白名单。
如果 Jenkins controller 只运行在本地 Windows不直接对目标机暴露公网地址需要在本地控制机启动 `scripts/deploy/jenkins-agent-reverse-tunnel.ps1`。该脚本通过同一条 SSH 会话把远端 `127.0.0.1:18080` 转到本地 Jenkins Web `127.0.0.1:8080`,把远端 `127.0.0.1:50000` 转到本地 Jenkins inbound TCP agent port `127.0.0.1:50000`,并在隧道断开后自动重试。此时远端 agent 的 `JENKINS_URL` 固定写 `http://127.0.0.1:18080/`,不写本地 Windows 的 `127.0.0.1:8080`
本地反向隧道脚本不内置目标机地址;注册 Windows 计划任务时必须显式传入 `-RemoteHost <release-agent-host>`,真实 IP 或主机名只保存在本地计划任务配置中,不提交到 Git。
本地反向隧道脚本不内置目标机地址;注册 Windows 计划任务时必须显式传入 `-RemoteHost <agent-host>`,真实 IP 或主机名只保存在本地计划任务配置中,不提交到 Git。同一台 Linux 机器上同时运行构建与发布 agent 时,两者共用这一条反向隧道,不为每个 Jenkins 节点重复注册本地隧道任务。
当 Jenkins controller 以本地 Windows `java -jar jenkins.war` 方式运行时,使用 `scripts/deploy/jenkins-local-controller-watchdog.ps1` 作为本地守护脚本。该脚本只保存本机 Java、`jenkins.war``JENKINS_HOME` 和端口路径,不保存 Jenkins 账号、密码、token 或 agent secret注册 Windows 计划任务后,脚本会在登录后检查 `8080` 是否已有 Jenkins 监听,若已有则监控现有 PID若进程退出或端口空闲则重新启动 Jenkins并固定 `--agentPort=50000` 供远端 inbound agent 连接。
首次迁移示例:
```bash
sudo install -m 0600 /tmp/genarrative-build-01.secret /etc/jenkins-agent/genarrative-build-01.secret
sudo scripts/deploy/install-jenkins-inbound-agent.sh \
--agent-name genarrative-build-01 \
--jenkins-url http://127.0.0.1:18080/ \
--secret-file /etc/jenkins-agent/genarrative-build-01.secret \
--workdir /root/jenkins-agent-build \
--java-bin /usr/bin/java
sudo systemctl status jenkins-agent@genarrative-build-01.service --no-pager -l
sudo install -m 0600 /tmp/genarrative-release-deploy-01.secret /etc/jenkins-agent/genarrative-release-deploy-01.secret
sudo scripts/deploy/install-jenkins-inbound-agent.sh \
--agent-name genarrative-release-deploy-01 \
@@ -236,7 +246,7 @@ sudo scripts/deploy/install-jenkins-inbound-agent.sh \
--workdir /root/jenkins-agent \
--java-bin /usr/bin/java
sudo systemctl status jenkins-agent@genarrative-release-deploy-01.service --no-pager -l
journalctl -u jenkins-agent@genarrative-release-deploy-01.service -f
journalctl -u 'jenkins-agent@*.service' -f
```
如果 Jenkins controller 暂时仍配置为 SSH launcher只能作为过渡方案使用需要把 SSH launch timeout 拉长、增加 retry 和 retry wait、固定 Java 路径,并确认 `ssh user@host 'java -version'` 稳定返回。最终仍要切到 inbound + systemd避免 SSH 连接卡住时阻塞发布队列。

View File

@@ -0,0 +1,18 @@
# 个人任务 scope 限制说明2026-05-04
## 背景
个人任务配置首版只支持按用户维度统计进度,即 `RuntimeTrackingScopeKind::User` / API `scopeKind: "user"``site``module``work` 未来可作为全站、模块或作品维度任务扩展,但当前不应被个人任务配置接受。
## 后端约束
- HTTP 管理接口 `admin_upsert_profile_task_config` 在解析 `scopeKind` 后立即校验:非 `user` 返回 400并提示“个人任务 scopeKind 首版仅支持 user”。
- 领域构造函数 `build_runtime_profile_task_config_admin_upsert_input` 兜底校验:非 `RuntimeTrackingScopeKind::User` 返回 `RuntimeProfileFieldError::UnsupportedProfileTaskScopeKind`
- SpacetimeDB 模块内 `profile_task_tracking_scope_id` 不再把 `Work` 静默映射到 `user_id`;非 User scope 返回 `None`,个人任务进度读取按 0 处理,避免错误串桶。
## 测试覆盖
`module-runtime` 单元测试覆盖:
- `User` scope 可成功构造个人任务配置输入。
- `Site` / `Module` / `Work` scope 均被拒绝,错误为 `UnsupportedProfileTaskScopeKind`

View File

@@ -24,11 +24,12 @@ spacetime sql <db> "SELECT * FROM custom_world_gallery_entry"
| --- | --- |
| 运维迁移 | `database_migration_operator`, `database_migration_import_chunk` |
| 认证 | `auth_store_snapshot`, `user_account`, `auth_identity`, `refresh_session` |
| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `tracking_event`, `tracking_daily_stat`, `profile_task_config`, `profile_task_progress`, `profile_task_reward_claim`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_invite_code`, `profile_referral_relation`, `profile_played_world`, `profile_membership`, `profile_recharge_order`, `profile_save_archive` |
| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `analytics_date_dimension`, `tracking_event`, `tracking_daily_stat`, `profile_task_config`, `profile_task_progress`, `profile_task_reward_claim`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_invite_code`, `profile_referral_relation`, `profile_played_world`, `profile_membership`, `profile_recharge_order`, `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_event`, `puzzle_runtime_run`, `puzzle_leaderboard_entry` |
| 抓大鹅 Match3D | `match3d_agent_session`, `match3d_agent_message`, `match3d_work_profile`, `match3d_runtime_run` |
| 方洞挑战 | `square_hole_agent_session`, `square_hole_agent_message`, `square_hole_work_profile`, `square_hole_runtime_run` |
| 大鱼吃小鱼 | `big_fish_creation_session`, `big_fish_agent_message`, `big_fish_asset_slot`, `big_fish_event`, `big_fish_runtime_run` |
| 资产 | `asset_object`, `asset_entity_binding`, `asset_event` |
| AI 任务 | `ai_task`, `ai_task_stage`, `ai_text_chunk`, `ai_result_reference`, `ai_task_event` |
@@ -158,6 +159,20 @@ 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;
```
### `analytics_date_dimension`
- 作用:分析日期维表,每个北京时间业务自然日一行,用于把日桶映射到周、月、季度和年。
- 结构:`date_key PK: i64`, `calendar_date: String`, `weekday: u8`, `iso_week_key: i32`, `week_start_date_key: i64`, `week_end_date_key: i64`, `month_key: i32`, `month_start_date_key: i64`, `month_end_date_key: i64`, `quarter_key: i32`, `quarter_start_date_key: i64`, `quarter_end_date_key: i64`, `year_key: i32`, `year_start_date_key: i64`, `year_end_date_key: i64`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:主键 `date_key``iso_week_key``month_key``quarter_key``year_key`
- 写入口:`ensure_analytics_date_dimension_for_date({ date_key })` 幂等补单日;`seed_analytics_date_dimensions({ start_date, end_date })``YYYY-MM-DD` 闭区间幂等批量补种,单次最多 `3660` 天。
- 口径:`date_key` 沿用当前埋点日桶 `floor((occurred_at_micros + 8h) / 1d)``calendar_date` 是该北京时间业务日的公历日期。
```sql
SELECT * FROM analytics_date_dimension WHERE date_key = <date_key>;
SELECT * FROM analytics_date_dimension WHERE iso_week_key = 202501 ORDER BY date_key;
SELECT * FROM analytics_date_dimension WHERE month_key = 202402 ORDER BY date_key;
```
### `tracking_event`
- 作用:埋点原始事件表,保存整站、作品、模块和用户层的原始事实。
@@ -655,6 +670,53 @@ SELECT * FROM match3d_runtime_run WHERE owner_user_id = '<user_id>' ORDER BY upd
SELECT * FROM match3d_runtime_run WHERE profile_id = '<profile_id>';
```
## 方洞挑战表
### `square_hole_agent_session`
- 作用:方洞挑战创作 Agent 会话表,保存种子、配置 JSON、草稿 JSON 和发布 profile 指针。
- 结构:`session_id PK: String`, `owner_user_id: String`, `seed_text: String`, `current_turn: u32`, `progress_percent: u32`, `stage: String`, `config_json: String`, `draft_json: String`, `last_assistant_reply: String`, `published_profile_id: String`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`owner_user_id`
```sql
SELECT * FROM square_hole_agent_session WHERE session_id = '<session_id>';
SELECT * FROM square_hole_agent_session WHERE owner_user_id = '<user_id>' ORDER BY updated_at DESC;
```
### `square_hole_agent_message`
- 作用:方洞挑战创作 Agent 消息流水。
- 结构:`message_id PK: String`, `session_id: String`, `role: String`, `kind: String`, `text: String`, `created_at: Timestamp`
- 索引:`session_id`
```sql
SELECT * FROM square_hole_agent_message WHERE session_id = '<session_id>' ORDER BY created_at ASC;
```
### `square_hole_work_profile`
- 作用:方洞挑战作品主表,保存作品基础信息、反直觉规则、配置、发布状态和游玩次数。
- 结构:`profile_id PK: String`, `work_id: String`, `owner_user_id: String`, `source_session_id: String`, `author_display_name: String`, `game_name: String`, `theme_text: String`, `twist_rule: String`, `summary_text: String`, `tags_json: String`, `cover_image_src: String`, `shape_count: u32`, `difficulty: u32`, `config_json: String`, `publication_status: String`, `play_count: u32`, `updated_at: Timestamp`, `published_at: Option<Timestamp>`
- 索引:`owner_user_id`, `publication_status`
```sql
SELECT * FROM square_hole_work_profile WHERE profile_id = '<profile_id>';
SELECT * FROM square_hole_work_profile WHERE owner_user_id = '<user_id>' ORDER BY updated_at DESC;
SELECT * FROM square_hole_work_profile WHERE publication_status = 'Published';
```
### `square_hole_runtime_run`
- 作用:方洞挑战单局运行态表,保存后端权威快照、快照版本、胜负状态和成绩基础字段。
- 结构:`run_id PK: String`, `owner_user_id: String`, `profile_id: String`, `status: String`, `snapshot_version: u64`, `started_at_ms: i64`, `duration_limit_ms: i64`, `finished_at_ms: i64`, `elapsed_ms: i64`, `total_shape_count: u32`, `completed_shape_count: u32`, `score: u32`, `snapshot_json: String`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`owner_user_id`, `profile_id`
```sql
SELECT * FROM square_hole_runtime_run WHERE run_id = '<run_id>';
SELECT * FROM square_hole_runtime_run WHERE owner_user_id = '<user_id>' ORDER BY updated_at DESC;
SELECT * FROM square_hole_runtime_run WHERE profile_id = '<profile_id>';
```
## 大鱼吃小鱼表
### `big_fish_creation_session`

View File

@@ -14,6 +14,7 @@
"admin-web:preview": "npm --prefix apps/admin-web run preview --",
"spacetime:generate": "node scripts/generate-spacetime-bindings.mjs",
"api-server": "node scripts/api-server-dev.mjs",
"api-server:maincloud": "node scripts/api-server-maincloud.mjs",
"deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh",
"build:production-release": "node scripts/run-bash-script.mjs scripts/build-production-release.sh",
"build:rust:ubuntu": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh",

View File

@@ -189,6 +189,7 @@ export type RedeemProfileRewardCodeResponse = {
export type ProfileTaskCycle = 'daily';
export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user';
export type AnalyticsGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
export type ProfileTaskStatus =
| 'incomplete'
| 'claimable'
@@ -247,6 +248,24 @@ export type ProfileTaskConfigAdminListResponse = {
entries: ProfileTaskConfigAdminResponse[];
};
export type AnalyticsMetricQueryRequest = {
eventKey: string;
scopeKind: TrackingScopeKind;
scopeId: string;
granularity: AnalyticsGranularity;
};
export type AnalyticsBucketMetric = {
bucketKey: string;
bucketStartDateKey: number;
bucketEndDateKey: number;
value: number;
};
export type AnalyticsMetricQueryResponse = {
buckets: AnalyticsBucketMetric[];
};
export type AdminUpsertProfileTaskConfigRequest = {
taskId: string;
title: string;

View File

@@ -0,0 +1,100 @@
/**
* 方洞挑战创作 Agent 共享契约。
* 字段按 HTTP facade 的 camelCase DTO 命名,后端领域层 snake_case 字段由 facade 映射。
*/
export type SquareHoleCreationStage =
| 'collecting_config'
| 'draft_ready'
| string;
export type SquareHoleAnchorStatus =
| 'confirmed'
| 'missing'
| 'inferred'
| string;
export interface CreateSquareHoleSessionRequest {
seedText?: string;
themeText?: string;
twistRule?: string;
shapeCount?: number;
difficulty?: number;
}
export interface SendSquareHoleMessageRequest {
clientMessageId: string;
text: string;
quickFillRequested?: boolean;
}
export interface ExecuteSquareHoleActionRequest {
action: string;
gameName?: string;
summary?: string;
tags?: string[];
coverImageSrc?: string | null;
}
export interface SquareHoleAnchorItemResponse {
key: string;
label: string;
value: string;
status: SquareHoleAnchorStatus;
}
export interface SquareHoleAnchorPackResponse {
theme: SquareHoleAnchorItemResponse;
twistRule: SquareHoleAnchorItemResponse;
shapeCount: SquareHoleAnchorItemResponse;
difficulty: SquareHoleAnchorItemResponse;
}
export interface SquareHoleCreatorConfig {
themeText: string;
twistRule: string;
shapeCount: number;
difficulty: number;
}
export interface SquareHoleResultDraft {
profileId: string;
gameName: string;
themeText: string;
twistRule: string;
summary: string;
tags: string[];
shapeCount: number;
difficulty: number;
publishReady: boolean;
blockers: string[];
}
export interface SquareHoleAgentMessage {
id: string;
role: 'user' | 'assistant' | 'system' | string;
kind: 'chat' | 'summary' | 'action_result' | 'warning' | string;
text: string;
createdAt: string;
}
export interface SquareHoleSessionSnapshot {
sessionId: string;
currentTurn: number;
progressPercent: number;
stage: SquareHoleCreationStage;
anchorPack: SquareHoleAnchorPackResponse;
config: SquareHoleCreatorConfig;
draft?: SquareHoleResultDraft | null;
messages: SquareHoleAgentMessage[];
lastAssistantReply?: string | null;
publishedProfileId?: string | null;
updatedAt: string;
}
export interface SquareHoleSessionResponse {
session: SquareHoleSessionSnapshot;
}
export interface SquareHoleActionResponse {
session: SquareHoleSessionSnapshot;
}

View File

@@ -0,0 +1,99 @@
/**
* 方洞挑战运行态共享契约。
* 后端负责当前形状、洞口兼容、胜负和连击真相;前端只提交洞口选择并渲染快照。
*/
export type SquareHoleRunStatus =
| 'running'
| 'won'
| 'failed'
| 'stopped'
| string;
export type SquareHoleShapeKind =
| 'square'
| 'circle'
| 'triangle'
| 'star'
| 'arch'
| 'diamond'
| string;
export type SquareHoleHoleKind =
| 'square'
| 'circle'
| 'triangle'
| 'star'
| 'arch'
| 'diamond'
| string;
export type SquareHoleDropRejectReason =
| 'run_not_active'
| 'snapshot_version_mismatch'
| 'hole_not_found'
| 'incompatible'
| 'time_up'
| string;
export interface SquareHoleShapeSnapshot {
shapeId: string;
shapeKind: SquareHoleShapeKind;
label: string;
color: string;
}
export interface SquareHoleHoleSnapshot {
holeId: string;
holeKind: SquareHoleHoleKind;
label: string;
x: number;
y: number;
}
export interface SquareHoleRunSnapshot {
runId: string;
profileId: string;
ownerUserId: string;
status: SquareHoleRunStatus;
snapshotVersion: number;
startedAtMs: number;
durationLimitMs: number;
remainingMs: number;
totalShapeCount: number;
completedShapeCount: number;
combo: number;
bestCombo: number;
score: number;
ruleLabel: string;
currentShape?: SquareHoleShapeSnapshot | null;
holes: SquareHoleHoleSnapshot[];
lastFeedback?: SquareHoleDropFeedback | null;
}
export interface StartSquareHoleRunRequest {
profileId: string;
}
export interface DropSquareHoleShapeRequest {
runId?: string;
holeId: string;
clientSnapshotVersion: number;
clientEventId: string;
droppedAtMs: number;
}
export interface StopSquareHoleRunRequest {
clientActionId: string;
}
export interface SquareHoleDropFeedback {
accepted: boolean;
rejectReason?: SquareHoleDropRejectReason | null;
message: string;
}
export interface SquareHoleDropResponse {
feedback: SquareHoleDropFeedback;
run: SquareHoleRunSnapshot;
}
export interface SquareHoleRunResponse {
run: SquareHoleRunSnapshot;
}

View File

@@ -0,0 +1,50 @@
/**
* 方洞挑战作品读写共享契约。
* 作品字段只表达结果页可编辑信息;运行规则真相由后端 runtime 快照负责。
*/
export type SquareHoleWorkPublicationStatus = 'draft' | 'published' | string;
export interface PutSquareHoleWorkRequest {
gameName: string;
themeText?: string;
twistRule: string;
summary: string;
tags: string[];
coverImageSrc?: string | null;
shapeCount: number;
difficulty: number;
}
export interface SquareHoleWorkSummary {
workId: string;
profileId: string;
ownerUserId: string;
sourceSessionId?: string | null;
gameName: string;
themeText: string;
twistRule: string;
summary: string;
tags: string[];
coverImageSrc?: string | null;
shapeCount: number;
difficulty: number;
publicationStatus: SquareHoleWorkPublicationStatus;
playCount: number;
updatedAt: string;
publishedAt?: string | null;
publishReady: boolean;
}
export interface SquareHoleWorkProfile extends SquareHoleWorkSummary {}
export interface SquareHoleWorksResponse {
items: SquareHoleWorkSummary[];
}
export interface SquareHoleWorkDetailResponse {
item: SquareHoleWorkProfile;
}
export interface SquareHoleWorkMutationResponse {
item: SquareHoleWorkProfile;
}

View File

@@ -13,6 +13,9 @@ export * from './contracts/puzzleAgentSession';
export * from './contracts/puzzleResultPreview';
export * from './contracts/puzzleRuntimeSession';
export * from './contracts/puzzleWorkSummary';
export * from './contracts/squareHoleAgent';
export * from './contracts/squareHoleRuntime';
export * from './contracts/squareHoleWorks';
export * from './contracts/rpgAgentActions';
export * from './contracts/rpgAgentAnchors';
export * from './contracts/rpgAgentDraft';

View File

@@ -0,0 +1,220 @@
import { execFileSync, spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
const repoRoot = process.cwd();
const apiServerExePath = resolve(
repoRoot,
'server-rs/target/debug/api-server.exe',
);
const defaultHealthHost = '127.0.0.1';
const defaultHealthPort = '3100';
const healthTimeoutMs =
Number(process.env.GENARRATIVE_API_SERVER_MAINCLOUD_SMOKE_TIMEOUT_SECONDS) *
1000 || 180_000;
function loadEnvFile(path, target) {
if (!existsSync(path)) {
return;
}
const rawText = readFileSync(path, 'utf8');
for (const rawLine of rawText.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
if (!match) {
continue;
}
const [, key, rawValue] = match;
if (target[key] !== undefined) {
continue;
}
target[key] = rawValue.replace(/^['"]|['"]$/gu, '');
}
}
function stopExistingWindowsApiServer() {
if (process.platform !== 'win32') {
return;
}
// Windows 下 cargo 重编译不能覆盖正在运行的 exe只清理本仓库 target 内的 api-server。
const command = [
'$ErrorActionPreference = "Continue"',
'$target = [System.IO.Path]::GetFullPath($env:GENARRATIVE_API_SERVER_EXE_TARGET)',
'$processes = Get-Process -Name api-server -ErrorAction SilentlyContinue | Where-Object {',
' $_.Path -and ([System.IO.Path]::GetFullPath($_.Path) -ieq $target)',
'}',
'foreach ($process in $processes) {',
' try {',
' Stop-Process -Id $process.Id -Force -ErrorAction Stop',
' Wait-Process -Id $process.Id -Timeout 5 -ErrorAction SilentlyContinue',
' Write-Output $process.Id',
' } catch {',
' Write-Error "[api-server:maincloud] 忽略旧进程清理瞬时失败 pid=$($process.Id): $($_.Exception.Message)"',
' }',
'}',
'exit 0',
].join('\n');
const output = execFileSync(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command],
{
encoding: 'utf8',
env: {
...process.env,
GENARRATIVE_API_SERVER_EXE_TARGET: apiServerExePath,
},
},
).trim();
if (output) {
console.log(`[api-server:maincloud] 已停止旧 api-server 进程: ${output}`);
}
}
function stopProcessTree(child) {
if (!child || child.exitCode !== null || child.signalCode) {
return;
}
if (process.platform === 'win32') {
try {
execFileSync('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
stdio: 'ignore',
});
return;
} catch {
// taskkill 可能已经被进程自然退出抢先;继续走兜底清理。
}
}
child.kill('SIGTERM');
}
async function waitForHealthz({ child, healthUrl }) {
const deadline = Date.now() + healthTimeoutMs;
let childExit = null;
child.once('exit', (code, signal) => {
childExit = { code, signal };
});
while (Date.now() < deadline) {
if (childExit) {
throw new Error(
`api-server 在 healthz 就绪前退出code=${childExit.code ?? ''} signal=${
childExit.signal ?? ''
}`,
);
}
try {
const response = await fetch(healthUrl, {
signal: AbortSignal.timeout(1_000),
});
const body = await response.text();
if (response.status === 200) {
return body;
}
} catch {
// 服务启动期间连接失败是预期状态,继续轮询。
}
await delay(500);
}
throw new Error(`等待 /healthz 超时:${healthUrl}`);
}
const mergedEnv = { ...process.env };
loadEnvFile(resolve(repoRoot, '.env'), mergedEnv);
loadEnvFile(resolve(repoRoot, '.env.local'), mergedEnv);
loadEnvFile(resolve(repoRoot, '.env.secrets.local'), mergedEnv);
mergedEnv.GENARRATIVE_API_HOST =
mergedEnv.GENARRATIVE_API_HOST || defaultHealthHost;
mergedEnv.GENARRATIVE_API_PORT =
mergedEnv.GENARRATIVE_API_PORT || defaultHealthPort;
mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL =
mergedEnv.GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL ||
mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL ||
'https://maincloud.spacetimedb.com';
mergedEnv.GENARRATIVE_SPACETIME_DATABASE =
mergedEnv.GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE ||
mergedEnv.GENARRATIVE_SPACETIME_DATABASE ||
'';
mergedEnv.GENARRATIVE_SPACETIME_TOKEN =
mergedEnv.GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN ||
mergedEnv.GENARRATIVE_SPACETIME_TOKEN ||
'';
if (!mergedEnv.GENARRATIVE_SPACETIME_DATABASE) {
console.error(
'[api-server:maincloud] 缺少 GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE 或 GENARRATIVE_SPACETIME_DATABASE。',
);
process.exit(1);
}
try {
stopExistingWindowsApiServer();
} catch (error) {
console.error(
`[api-server:maincloud] 清理旧 api-server 进程失败: ${error.message}`,
);
process.exit(1);
}
console.log(
`[api-server:maincloud] SpacetimeDB ${mergedEnv.GENARRATIVE_SPACETIME_DATABASE} @ ${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL}`,
);
const child = spawn(
'cargo',
['run', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml'],
{
cwd: repoRoot,
env: mergedEnv,
stdio: 'inherit',
},
);
const cleanup = () => {
stopProcessTree(child);
try {
stopExistingWindowsApiServer();
} catch {
// 退出阶段只做 best-effort 清理,不能覆盖真实 smoke 结果。
}
};
process.once('SIGINT', () => {
cleanup();
process.exit(130);
});
process.once('SIGTERM', () => {
cleanup();
process.exit(143);
});
try {
const healthHost =
mergedEnv.GENARRATIVE_API_HOST === '0.0.0.0'
? defaultHealthHost
: mergedEnv.GENARRATIVE_API_HOST;
const healthUrl = `http://${healthHost}:${mergedEnv.GENARRATIVE_API_PORT}/healthz`;
const body = await waitForHealthz({ child, healthUrl });
console.log(`[api-server:maincloud] /healthz 通过:${body}`);
cleanup();
} catch (error) {
console.error(`[api-server:maincloud] smoke 失败:${error.message}`);
cleanup();
process.exit(1);
}

View File

@@ -142,6 +142,7 @@ function buildGenerateArgs(target, outDir) {
outDir,
'--module-path',
MODULE_PATH,
'--include-private',
'--yes',
];

12
server-rs/Cargo.lock generated
View File

@@ -90,6 +90,7 @@ dependencies = [
"module-runtime",
"module-runtime-item",
"module-runtime-story",
"module-square-hole",
"module-story",
"platform-auth",
"platform-llm",
@@ -1639,6 +1640,15 @@ dependencies = [
"time",
]
[[package]]
name = "module-square-hole"
version = "0.1.0"
dependencies = [
"serde",
"shared-kernel",
"spacetimedb",
]
[[package]]
name = "module-story"
version = "0.1.0"
@@ -2681,6 +2691,7 @@ dependencies = [
"module-runtime",
"module-runtime-item",
"module-runtime-story",
"module-square-hole",
"module-story",
"serde",
"serde_json",
@@ -2708,6 +2719,7 @@ dependencies = [
"module-quest",
"module-runtime",
"module-runtime-item",
"module-square-hole",
"module-story",
"serde",
"serde_json",

View File

@@ -23,6 +23,7 @@ members = [
"crates/module-runtime",
"crates/module-runtime-story",
"crates/module-runtime-item",
"crates/module-square-hole",
"crates/module-story",
"crates/platform-oss",
"crates/platform-auth",

View File

@@ -25,6 +25,7 @@ module-puzzle = { path = "../module-puzzle" }
module-runtime = { path = "../module-runtime" }
module-runtime-story = { path = "../module-runtime-story" }
module-runtime-item = { path = "../module-runtime-item" }
module-square-hole = { path = "../module-square-hole" }
module-story = { path = "../module-story" }
platform-auth = { path = "../platform-auth" }
platform-llm = { path = "../platform-llm" }

View File

@@ -109,16 +109,24 @@ use crate::{
admin_list_profile_invite_codes, admin_list_profile_redeem_codes,
admin_list_profile_task_configs, admin_upsert_profile_invite_code,
admin_upsert_profile_redeem_code, admin_upsert_profile_task_config,
claim_profile_task_reward, create_profile_recharge_order, get_profile_dashboard,
get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center,
get_profile_task_center, get_profile_wallet_ledger, redeem_profile_referral_invite_code,
redeem_profile_reward_code,
claim_profile_task_reward, create_profile_recharge_order, get_profile_analytics_metric,
get_profile_dashboard, get_profile_play_stats, get_profile_recharge_center,
get_profile_referral_invite_center, get_profile_task_center, get_profile_wallet_ledger,
redeem_profile_referral_invite_code, redeem_profile_reward_code,
},
runtime_save::{
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
put_runtime_snapshot, resume_profile_save_archive,
},
runtime_settings::{get_runtime_settings, put_runtime_settings},
square_hole::{
compile_square_hole_agent_draft, create_square_hole_agent_session, delete_square_hole_work,
drop_square_hole_shape, execute_square_hole_agent_action, finish_square_hole_time_up,
get_square_hole_agent_session, get_square_hole_run, get_square_hole_work_detail,
get_square_hole_works, list_square_hole_gallery, publish_square_hole_work,
put_square_hole_work, restart_square_hole_run, start_square_hole_run, stop_square_hole_run,
stream_square_hole_agent_message, submit_square_hole_agent_message,
},
state::AppState,
story_battles::{
create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle,
@@ -829,6 +837,119 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/sessions",
post(create_square_hole_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/sessions/{session_id}",
get(get_square_hole_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/sessions/{session_id}/messages",
post(submit_square_hole_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/sessions/{session_id}/messages/stream",
post(stream_square_hole_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/sessions/{session_id}/actions",
post(execute_square_hole_agent_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/sessions/{session_id}/compile",
post(compile_square_hole_agent_draft).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/works",
get(get_square_hole_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/works/{profile_id}",
get(get_square_hole_work_detail)
.patch(put_square_hole_work)
.put(put_square_hole_work)
.delete(delete_square_hole_work)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/works/{profile_id}/publish",
post(publish_square_hole_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/square-hole/gallery",
get(list_square_hole_gallery),
)
.route(
"/api/runtime/square-hole/works/{profile_id}/runs",
post(start_square_hole_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/square-hole/runs/{run_id}",
get(get_square_hole_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/square-hole/runs/{run_id}/drop",
post(drop_square_hole_shape).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/square-hole/runs/{run_id}/stop",
post(stop_square_hole_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/square-hole/runs/{run_id}/restart",
post(restart_square_hole_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/square-hole/runs/{run_id}/time-up",
post(finish_square_hole_time_up).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/agent/sessions",
post(create_puzzle_agent_session)
@@ -1081,6 +1202,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/profile/analytics/metric",
get(get_profile_analytics_metric).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/tasks",
get(get_profile_task_center).route_layer(middleware::from_fn_with_state(

View File

@@ -64,8 +64,12 @@ where
};
turn_output.map_err(|error| match error {
CreationAgentJsonTurnFailure::Stream(_) => {
build_error(messages.generation_failed.to_string())
CreationAgentJsonTurnFailure::Stream(error) => {
tracing::warn!(
error = %error,
"创作 Agent 流式 LLM 请求失败"
);
build_error(format!("{}{error}", messages.generation_failed))
}
CreationAgentJsonTurnFailure::Parse => build_error(messages.parse_failed.to_string()),
})

View File

@@ -61,6 +61,8 @@ mod runtime_profile;
mod runtime_save;
mod runtime_settings;
mod session_client;
mod square_hole;
mod square_hole_agent_turn;
mod state;
mod story_battles;
mod story_sessions;

View File

@@ -4,6 +4,7 @@ pub(crate) mod character_visual;
pub(crate) mod puzzle;
pub(crate) mod rpg;
pub(crate) mod scene_background;
pub(crate) mod square_hole;
pub(crate) use rpg::agent_chat;
pub(crate) use rpg::foundation_draft;

View File

@@ -0,0 +1,164 @@
use serde_json::{Value as JsonValue, json};
use spacetime_client::{SquareHoleAgentMessageRecord, SquareHoleAgentSessionRecord};
use crate::creation_agent_chat::render_quick_fill_extra_rules;
/// 方洞挑战共创 Agent 的系统提示词。
///
/// 这里只定义模型职责与输出约束,具体的模型调用、解析和写库由方洞 Agent turn 负责。
pub(crate) const SQUARE_HOLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和百梦主共创“方洞挑战”竖屏玩法的中文创意策划。
你要把用户灵感收束成一个反直觉形状分拣小游戏:玩家会本能把形状投入对应洞口,但真实规则可能让所有形状都优先进入方洞,形成类似参考视频“所有东西都进方洞”的喜剧反差。
你必须同时输出:
1. 一段直接发给用户的中文回复 replyText
2. 当前进度 progressPercent
3. 下一轮完整可用的 nextConfig
硬约束:
1. 只能输出 JSON不能输出代码块或解释
2. nextConfig 必须是完整对象,不能只输出 patch
3. replyText 必须是自然中文不能提“字段”“结构”“JSON”“后端”等内部词
4. replyText 一次最多推进一个最关键问题
5. 如果用户要求自动配置,就直接补齐可发布草稿需要的题材、反差规则、形状数量和难度,不要继续提问
6. 默认核心反差优先使用“方洞万能”或“方洞优先”,但可以根据用户题材包装成更有记忆点的规则
7. progressPercent 范围只能是 0 到 100
8. shapeCount 只能是 6 到 24 的整数difficulty 只能是 1 到 10 的整数
"#;
const SQUARE_HOLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextConfig": {
"themeText": "",
"twistRule": "",
"shapeCount": 12,
"difficulty": 4
}
}"#;
pub(crate) const SQUARE_HOLE_AGENT_JSON_TURN_USER_PROMPT: &str = "请按约定输出这一轮的 JSON。";
/// 方洞挑战草稿生成对话提示词脚本。
///
/// 方洞首版只需要四个可写回 SpacetimeDB 的配置项,因此提示词直接围绕配置收束,
/// 不在模型输出层引入额外锚点,避免和当前持久化 schema 产生漂移。
pub(crate) fn build_square_hole_agent_prompt(
session: &SquareHoleAgentSessionRecord,
quick_fill_requested: bool,
) -> String {
let quick_fill_rules = if quick_fill_requested {
format!(
"\n\n{}",
render_quick_fill_extra_rules(
"当前方洞挑战方向里的题材、反差规则、形状数量和难度",
"不要要求用户再提供洞口、形状、演出或难度信息",
"输出完整 nextConfig直接补齐空缺或仍过于泛化的项",
"生成结果页",
)
)
} else {
String::new()
};
format!(
"模板目标:收束成可试玩、可发布的方洞挑战玩法草稿。{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动配置:{quick_fill_requested_text}\n\n当前配置:\n{current_config}\n\n最近聊天记录:\n{chat_history}\n\n收束要求:\n1. themeText 描述本局的玩具、道具或场景题材,保持短句。\n2. twistRule 描述真实判定规则,优先体现方洞优先或类似反直觉逻辑。\n3. shapeCount 决定单局形状数量,移动端短局建议 8 到 16。\n4. difficulty 决定误导强度和节奏,建议 3 到 7。\n5. 用户给出明确方向时优先吸收并推进,不要机械问完四个问题。\n\n{contract}",
quick_fill_rules = quick_fill_rules,
turn = session.current_turn.saturating_add(1),
progress = session.progress_percent,
quick_fill_requested_text = if quick_fill_requested { "" } else { "" },
current_config = serialize_square_hole_session_config(session),
chat_history =
serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice()))
.unwrap_or_else(|_| "[]".to_string()),
contract = SQUARE_HOLE_AGENT_OUTPUT_CONTRACT,
)
}
fn serialize_square_hole_session_config(session: &SquareHoleAgentSessionRecord) -> String {
serde_json::to_string_pretty(&json!({
"themeText": session.config.theme_text,
"twistRule": session.config.twist_rule,
"shapeCount": session.config.shape_count,
"difficulty": session.config.difficulty,
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn build_chat_history(messages: &[SquareHoleAgentMessageRecord]) -> Vec<JsonValue> {
messages
.iter()
.map(|message| {
json!({
"role": message.role,
"kind": message.kind,
"content": message.text,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::build_square_hole_agent_prompt;
fn message(role: &str, text: &str) -> spacetime_client::SquareHoleAgentMessageRecord {
spacetime_client::SquareHoleAgentMessageRecord {
id: format!("message-{role}"),
role: role.to_string(),
kind: "chat".to_string(),
text: text.to_string(),
created_at: "2026-05-04T10:00:00.000Z".to_string(),
}
}
fn session_record() -> spacetime_client::SquareHoleAgentSessionRecord {
spacetime_client::SquareHoleAgentSessionRecord {
session_id: "square-hole-session-test".to_string(),
current_turn: 1,
progress_percent: 25,
stage: "collecting_config".to_string(),
anchor_pack: spacetime_client::SquareHoleAnchorPackRecord {
theme: anchor("theme", "题材主题", "积木纸箱"),
twist_rule: anchor("twistRule", "反差规则", ""),
shape_count: anchor("shapeCount", "形状数量", "12"),
difficulty: anchor("difficulty", "难度", "4"),
},
config: spacetime_client::SquareHoleCreatorConfigRecord {
theme_text: "积木纸箱".to_string(),
twist_rule: "方洞万能".to_string(),
shape_count: 12,
difficulty: 4,
},
draft: None,
messages: vec![message("user", "做成办公室文具版")],
last_assistant_reply: Some("这次可以从办公室文具题材开始。".to_string()),
published_profile_id: None,
updated_at: "2026-05-04T10:00:00.000Z".to_string(),
}
}
fn anchor(key: &str, label: &str, value: &str) -> spacetime_client::SquareHoleAnchorItemRecord {
spacetime_client::SquareHoleAnchorItemRecord {
key: key.to_string(),
label: label.to_string(),
value: value.to_string(),
status: if value.is_empty() {
"missing"
} else {
"confirmed"
}
.to_string(),
}
}
#[test]
fn quick_fill_prompt_requires_complete_config() {
let prompt = build_square_hole_agent_prompt(&session_record(), true);
assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字"));
assert!(prompt.contains("不要再继续提问"));
assert!(prompt.contains("nextConfig"));
assert!(prompt.contains("progressPercent 直接输出为 100"));
}
}

View File

@@ -1,11 +1,11 @@
use axum::{
Json,
extract::{Extension, Path, State},
extract::{Extension, Path, Query, State},
http::StatusCode,
response::Response,
};
use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileInviteCodeRecord,
AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileInviteCodeRecord,
RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord,
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductRecord,
RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord,
@@ -15,10 +15,12 @@ use module_runtime::{
RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind,
};
use serde_json::{Value, json};
use serde::Deserialize;
use shared_contracts::runtime::{
AdminDisableProfileRedeemCodeRequest, AdminDisableProfileTaskConfigRequest,
AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRedeemCodeRequest,
AdminUpsertProfileTaskConfigRequest, ClaimProfileTaskRewardResponse,
AdminUpsertProfileTaskConfigRequest, AnalyticsBucketMetricResponse,
AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse,
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED,
PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE,
@@ -31,6 +33,8 @@ use shared_contracts::runtime::{
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER,
ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse,
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
@@ -44,6 +48,7 @@ use shared_contracts::runtime::{
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, TRACKING_SCOPE_KIND_MODULE,
TRACKING_SCOPE_KIND_SITE, TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK,
};
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
@@ -277,6 +282,51 @@ pub async fn redeem_profile_reward_code(
))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalyticsMetricQueryParams {
pub event_key: String,
pub scope_kind: String,
pub scope_id: String,
pub granularity: String,
}
pub async fn get_profile_analytics_metric(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Query(query): Query<AnalyticsMetricQueryParams>,
) -> Result<Json<Value>, Response> {
let scope_kind = parse_tracking_scope_kind(&query.scope_kind).map_err(|error| {
runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
let granularity = parse_analytics_granularity(&query.granularity).map_err(|error| {
runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
let record = state
.spacetime_client()
.query_analytics_metric(query.event_key, scope_kind, query.scope_id, granularity)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_analytics_metric_query_response(record),
))
}
pub async fn get_profile_task_center(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -369,6 +419,14 @@ pub async fn admin_upsert_profile_task_config(
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
// 中文注释:个人任务配置首版只开放 User scopeHTTP 层先返回清晰错误,领域层再兜底。
if scope_kind != RuntimeTrackingScopeKind::User {
return Err(runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("个人任务 scopeKind 首版仅支持 user"),
));
}
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
@@ -558,6 +616,10 @@ pub async fn admin_upsert_profile_invite_code(
) -> Result<Json<Value>, Response> {
let metadata_json = normalize_admin_invite_code_metadata(payload.metadata)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
let starts_at_micros = parse_admin_invite_code_time_field("startsAt", payload.starts_at)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
let expires_at_micros = parse_admin_invite_code_time_field("expiresAt", payload.expires_at)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
@@ -565,6 +627,8 @@ pub async fn admin_upsert_profile_invite_code(
admin.session().username.clone(),
payload.invite_code,
metadata_json,
starts_at_micros,
expires_at_micros,
updated_at_micros as i64,
)
.await
@@ -796,6 +860,23 @@ fn build_profile_task_center_response(
}
}
fn build_analytics_metric_query_response(
record: module_runtime::AnalyticsMetricQueryResponse,
) -> AnalyticsMetricQueryResponse {
AnalyticsMetricQueryResponse {
buckets: record
.buckets
.into_iter()
.map(|bucket| AnalyticsBucketMetricResponse {
bucket_key: bucket.bucket_key,
bucket_start_date_key: bucket.bucket_start_date_key,
bucket_end_date_key: bucket.bucket_end_date_key,
value: bucket.value,
})
.collect(),
}
}
fn build_profile_task_item_response(
record: RuntimeProfileTaskItemRecord,
) -> ProfileTaskItemResponse {
@@ -873,6 +954,27 @@ fn normalize_admin_invite_code_metadata(metadata: Option<Value>) -> Result<Strin
Ok(metadata_json)
}
fn parse_admin_invite_code_time_field(
field: &'static str,
value: Option<String>,
) -> Result<Option<i64>, AppError> {
let Some(value) = value else {
return Ok(None);
};
let value = value.trim();
if value.is_empty() {
return Ok(None);
}
let parsed = parse_rfc3339(value).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message(format!("邀请码 {field} 必须是 RFC3339 时间字符串"))
.with_details(json!({ "field": field, "message": error }))
})?;
Ok(Some(offset_datetime_to_unix_micros(parsed)))
}
fn parse_profile_redeem_code_mode(raw: &str) -> Result<RuntimeProfileRedeemCodeMode, String> {
match raw.trim().to_ascii_lowercase().as_str() {
"public" => Ok(RuntimeProfileRedeemCodeMode::Public),
@@ -899,6 +1001,17 @@ fn parse_tracking_scope_kind(raw: &str) -> Result<RuntimeTrackingScopeKind, Stri
}
}
fn parse_analytics_granularity(raw: &str) -> Result<AnalyticsGranularity, String> {
match raw.trim().to_ascii_lowercase().as_str() {
ANALYTICS_GRANULARITY_DAY => Ok(AnalyticsGranularity::Day),
ANALYTICS_GRANULARITY_WEEK => Ok(AnalyticsGranularity::Week),
ANALYTICS_GRANULARITY_MONTH => Ok(AnalyticsGranularity::Month),
ANALYTICS_GRANULARITY_QUARTER => Ok(AnalyticsGranularity::Quarter),
ANALYTICS_GRANULARITY_YEAR => Ok(AnalyticsGranularity::Year),
_ => Err("统计粒度无效".to_string()),
}
}
fn format_profile_task_cycle(cycle: RuntimeProfileTaskCycle) -> &'static str {
match cycle {
RuntimeProfileTaskCycle::Daily => PROFILE_TASK_CYCLE_DAILY,
@@ -932,6 +1045,9 @@ fn build_profile_invite_code_admin_response(
user_id: record.user_id,
invite_code: record.invite_code,
metadata,
starts_at: record.starts_at,
expires_at: record.expires_at,
status: record.status.as_str().to_string(),
created_at: record.created_at,
updated_at: record.updated_at,
}
@@ -1256,9 +1372,8 @@ mod tests {
#[tokio::test]
async fn admin_profile_task_routes_require_admin_authentication() {
let app = build_router(
AppState::new(admin_enabled_test_config()).expect("state should build"),
);
let app =
build_router(AppState::new(admin_enabled_test_config()).expect("state should build"));
let list_response = app
.clone()
@@ -1302,9 +1417,8 @@ mod tests {
#[tokio::test]
async fn admin_profile_code_list_routes_require_admin_authentication() {
let app = build_router(
AppState::new(admin_enabled_test_config()).expect("state should build"),
);
let app =
build_router(AppState::new(admin_enabled_test_config()).expect("state should build"));
for uri in [
"/admin/api/profile/redeem-codes",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,307 @@
use module_square_hole::{
SQUARE_HOLE_MAX_DIFFICULTY, SQUARE_HOLE_MAX_SHAPE_COUNT, SQUARE_HOLE_MIN_DIFFICULTY,
SQUARE_HOLE_MIN_SHAPE_COUNT,
};
use platform_llm::LlmClient;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use spacetime_client::{SquareHoleAgentMessageFinalizeRecordInput, SquareHoleAgentSessionRecord};
use crate::creation_agent_llm_turn::{
CreationAgentLlmTurnErrorMessages, stream_creation_agent_json_turn,
};
use crate::prompt::square_hole::{
SQUARE_HOLE_AGENT_JSON_TURN_USER_PROMPT, SQUARE_HOLE_AGENT_SYSTEM_PROMPT,
build_square_hole_agent_prompt,
};
#[derive(Clone, Debug)]
pub(crate) struct SquareHoleAgentTurnRequest<'a> {
pub llm_client: Option<&'a LlmClient>,
pub session: &'a SquareHoleAgentSessionRecord,
pub quick_fill_requested: bool,
pub enable_web_search: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct SquareHoleAgentTurnResult {
pub assistant_reply_text: String,
pub stage: String,
pub progress_percent: u32,
pub config_json: String,
pub error_message: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct SquareHoleAgentTurnError {
message: String,
}
impl SquareHoleAgentTurnError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl std::fmt::Display for SquareHoleAgentTurnError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for SquareHoleAgentTurnError {}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SquareHoleAgentModelOutput {
reply_text: String,
progress_percent: u32,
next_config: SquareHoleAgentConfigOutput,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SquareHoleAgentConfigOutput {
theme_text: String,
twist_rule: String,
shape_count: u32,
difficulty: u32,
}
pub(crate) async fn run_square_hole_agent_turn<F>(
request: SquareHoleAgentTurnRequest<'_>,
on_reply_update: F,
) -> Result<SquareHoleAgentTurnResult, SquareHoleAgentTurnError>
where
F: FnMut(&str),
{
let prompt = build_square_hole_agent_prompt(request.session, request.quick_fill_requested);
let turn_output = stream_creation_agent_json_turn(
request.llm_client,
format!("{SQUARE_HOLE_AGENT_SYSTEM_PROMPT}\n\n{prompt}"),
SQUARE_HOLE_AGENT_JSON_TURN_USER_PROMPT,
request.enable_web_search,
CreationAgentLlmTurnErrorMessages {
model_unavailable: "当前模型不可用,请稍后重试。",
generation_failed: "方洞挑战聊天生成失败,请稍后重试。",
parse_failed: "方洞挑战聊天结果解析失败,请稍后重试。",
},
on_reply_update,
SquareHoleAgentTurnError::new,
)
.await?;
let output = parse_model_output(&turn_output.parsed, request.session)?;
let progress_percent = if request.quick_fill_requested {
100
} else {
output.progress_percent.min(100)
};
Ok(SquareHoleAgentTurnResult {
assistant_reply_text: output.reply_text,
stage: resolve_stage(progress_percent),
progress_percent,
config_json: serde_json::to_string(&output.next_config)
.map_err(|_| SquareHoleAgentTurnError::new("方洞挑战配置序列化失败。"))?,
error_message: None,
})
}
pub(crate) fn build_finalize_record_input(
session_id: String,
owner_user_id: String,
assistant_message_id: String,
result: SquareHoleAgentTurnResult,
updated_at_micros: i64,
) -> SquareHoleAgentMessageFinalizeRecordInput {
SquareHoleAgentMessageFinalizeRecordInput {
session_id,
owner_user_id,
assistant_message_id: Some(assistant_message_id),
assistant_reply_text: Some(result.assistant_reply_text),
config_json: Some(result.config_json),
progress_percent: result.progress_percent,
stage: result.stage,
updated_at_micros,
error_message: result.error_message,
}
}
fn parse_model_output(
parsed: &JsonValue,
session: &SquareHoleAgentSessionRecord,
) -> Result<SquareHoleAgentModelOutput, SquareHoleAgentTurnError> {
let reply_text = parsed
.get("replyText")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| SquareHoleAgentTurnError::new("方洞挑战聊天结果缺少有效回复,请稍后重试。"))?
.to_string();
let progress_percent = parsed
.get("progressPercent")
.and_then(JsonValue::as_u64)
.map(|value| value.min(100) as u32)
.unwrap_or(session.progress_percent);
let next_config_value = parsed
.get("nextConfig")
.ok_or_else(|| SquareHoleAgentTurnError::new("方洞挑战聊天结果缺少 nextConfig。"))?;
let next_config = parse_model_config(next_config_value, session)?;
Ok(SquareHoleAgentModelOutput {
reply_text,
progress_percent,
next_config,
})
}
fn parse_model_config(
value: &JsonValue,
session: &SquareHoleAgentSessionRecord,
) -> Result<SquareHoleAgentConfigOutput, SquareHoleAgentTurnError> {
if !value.is_object() {
return Err(SquareHoleAgentTurnError::new(
"方洞挑战聊天结果中的 nextConfig 必须是对象。",
));
}
Ok(SquareHoleAgentConfigOutput {
theme_text: read_text_field(value, "themeText")
.unwrap_or_else(|| session.config.theme_text.clone()),
twist_rule: read_text_field(value, "twistRule")
.unwrap_or_else(|| session.config.twist_rule.clone()),
shape_count: read_u32_field(value, "shapeCount")
.unwrap_or(session.config.shape_count)
.clamp(SQUARE_HOLE_MIN_SHAPE_COUNT, SQUARE_HOLE_MAX_SHAPE_COUNT),
difficulty: read_u32_field(value, "difficulty")
.unwrap_or(session.config.difficulty)
.clamp(SQUARE_HOLE_MIN_DIFFICULTY, SQUARE_HOLE_MAX_DIFFICULTY),
})
}
fn read_text_field(value: &JsonValue, field_name: &str) -> Option<String> {
value
.get(field_name)
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_string)
}
fn read_u32_field(value: &JsonValue, field_name: &str) -> Option<u32> {
value
.get(field_name)
.and_then(JsonValue::as_u64)
.and_then(|number| u32::try_from(number).ok())
}
fn resolve_stage(progress_percent: u32) -> String {
if progress_percent >= 100 {
"ReadyToCompile"
} else {
"Collecting"
}
.to_string()
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{parse_model_output, resolve_stage};
fn session_record() -> spacetime_client::SquareHoleAgentSessionRecord {
spacetime_client::SquareHoleAgentSessionRecord {
session_id: "square-hole-session-test".to_string(),
current_turn: 1,
progress_percent: 25,
stage: "collecting_config".to_string(),
anchor_pack: spacetime_client::SquareHoleAnchorPackRecord {
theme: anchor("theme", "题材主题", "纸箱"),
twist_rule: anchor("twistRule", "反差规则", "方洞万能"),
shape_count: anchor("shapeCount", "形状数量", "12"),
difficulty: anchor("difficulty", "难度", "4"),
},
config: spacetime_client::SquareHoleCreatorConfigRecord {
theme_text: "纸箱".to_string(),
twist_rule: "方洞万能".to_string(),
shape_count: 12,
difficulty: 4,
},
draft: None,
messages: Vec::new(),
last_assistant_reply: None,
published_profile_id: None,
updated_at: "2026-05-04T10:00:00.000Z".to_string(),
}
}
fn anchor(key: &str, label: &str, value: &str) -> spacetime_client::SquareHoleAnchorItemRecord {
spacetime_client::SquareHoleAnchorItemRecord {
key: key.to_string(),
label: label.to_string(),
value: value.to_string(),
status: if value.is_empty() {
"missing"
} else {
"confirmed"
}
.to_string(),
}
}
#[test]
fn parse_model_output_accepts_camel_case_config_contract() {
let model_output = json!({
"replyText": "可以,把办公室文具都做成会被方洞吞进去的挑战。",
"progressPercent": 86,
"nextConfig": {
"themeText": "办公室文具",
"twistRule": "所有文具最终都优先进入方洞",
"shapeCount": 14,
"difficulty": 6
}
});
let output =
parse_model_output(&model_output, &session_record()).expect("模型输出应能解析");
assert_eq!(
output.reply_text,
"可以,把办公室文具都做成会被方洞吞进去的挑战。"
);
assert_eq!(output.progress_percent, 86);
assert_eq!(output.next_config.theme_text, "办公室文具");
assert_eq!(output.next_config.twist_rule, "所有文具最终都优先进入方洞");
assert_eq!(output.next_config.shape_count, 14);
assert_eq!(output.next_config.difficulty, 6);
}
#[test]
fn parse_model_output_clamps_numeric_config() {
let model_output = json!({
"replyText": "我先把数字压到可试玩范围里。",
"progressPercent": 120,
"nextConfig": {
"themeText": "霓虹积木",
"twistRule": "方洞优先",
"shapeCount": 99,
"difficulty": 0
}
});
let output =
parse_model_output(&model_output, &session_record()).expect("模型输出应能解析");
assert_eq!(output.progress_percent, 100);
assert_eq!(output.next_config.shape_count, 24);
assert_eq!(output.next_config.difficulty, 1);
}
#[test]
fn resolve_stage_switches_to_compile_only_at_complete_progress() {
assert_eq!(resolve_stage(99), "Collecting");
assert_eq!(resolve_stage(100), "ReadyToCompile");
}
}

View File

@@ -4,6 +4,7 @@
use serde_json::Value;
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use std::collections::BTreeMap;
use crate::domain::*;
use crate::errors::RuntimeProfileFieldError;
@@ -223,6 +224,189 @@ pub fn runtime_profile_beijing_day_key(now_micros: i64) -> i64 {
.div_euclid(PROFILE_RUNTIME_DAY_MICROS)
}
/// 从 YYYY-MM-DD 解析分析业务日 date_key。
///
/// 这里故意不引入时区库date_key 本身就是“北京时间日历日期自 Unix 纪元起的天数”。
pub fn parse_analytics_calendar_date_key(
calendar_date: &str,
) -> Result<i64, RuntimeProfileFieldError> {
let (year, month, day) = parse_calendar_date_parts(calendar_date)?;
validate_calendar_date(year, month, day)?;
let date_key = days_from_civil(year, month, day);
validate_analytics_date_dimension_date_key(date_key)?;
Ok(date_key)
}
/// 校验分析日期维表 date_key 是否位于业务允许范围内。
///
/// 裸 i64 date_key 可由 reducer 直接传入,因此在进入日历算法前先限制范围,避免极端输入
/// 生成无意义日期或触发整数边界风险。
pub fn validate_analytics_date_dimension_date_key(
date_key: i64,
) -> Result<(), RuntimeProfileFieldError> {
let min_date_key = days_from_civil(2000, 1, 1);
let max_date_key = days_from_civil(2100, 12, 31);
if date_key < min_date_key || date_key > max_date_key {
return Err(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate);
}
Ok(())
}
pub fn build_analytics_date_dimension_from_date_key(
date_key: i64,
) -> AnalyticsDateDimensionSnapshot {
let (year, month, day) = civil_from_days(date_key);
let weekday = weekday_from_date_key(date_key);
let iso_week_key = iso_week_key(year, month, day, weekday);
let week_start_date_key = date_key - i64::from(weekday - 1);
let week_end_date_key = week_start_date_key + 6;
let month_start_date_key = days_from_civil(year, month, 1);
let month_end_date_key = days_from_civil(year, month, days_in_month(year, month));
let quarter = (month - 1) / 3 + 1;
let quarter_start_month = (quarter - 1) * 3 + 1;
let quarter_end_month = quarter_start_month + 2;
let quarter_start_date_key = days_from_civil(year, quarter_start_month, 1);
let quarter_end_date_key = days_from_civil(
year,
quarter_end_month,
days_in_month(year, quarter_end_month),
);
let year_start_date_key = days_from_civil(year, 1, 1);
let year_end_date_key = days_from_civil(year, 12, 31);
AnalyticsDateDimensionSnapshot {
date_key,
calendar_date: format!("{year:04}-{month:02}-{day:02}"),
weekday,
iso_week_key,
week_start_date_key,
week_end_date_key,
month_key: year * 100 + i32::from(month),
month_start_date_key,
month_end_date_key,
quarter_key: year * 10 + i32::from(quarter),
quarter_start_date_key,
quarter_end_date_key,
year_key: year,
year_start_date_key,
year_end_date_key,
}
}
fn parse_calendar_date_parts(
calendar_date: &str,
) -> Result<(i32, u8, u8), RuntimeProfileFieldError> {
let mut parts = calendar_date.trim().split('-');
let year = parts
.next()
.and_then(|value| value.parse::<i32>().ok())
.ok_or(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate)?;
let month = parts
.next()
.and_then(|value| value.parse::<u8>().ok())
.ok_or(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate)?;
let day = parts
.next()
.and_then(|value| value.parse::<u8>().ok())
.ok_or(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate)?;
if parts.next().is_some() {
return Err(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate);
}
Ok((year, month, day))
}
fn validate_calendar_date(year: i32, month: u8, day: u8) -> Result<(), RuntimeProfileFieldError> {
if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) {
return Err(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate);
}
Ok(())
}
fn days_in_month(year: i32, month: u8) -> u8 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(year) => 29,
2 => 28,
_ => 0,
}
}
fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
fn weekday_from_date_key(date_key: i64) -> u8 {
// 中文注释1970-01-01 是周四;这里返回 ISO weekday周一=1周日=7。
(date_key + 3).rem_euclid(7) as u8 + 1
}
fn iso_week_key(year: i32, month: u8, day: u8, weekday: u8) -> i32 {
let ordinal = ordinal_day(year, month, day);
let week = (i32::from(ordinal) - i32::from(weekday) + 10).div_euclid(7);
let iso_year = if week < 1 {
year - 1
} else if week > iso_weeks_in_year(year) {
year + 1
} else {
year
};
let iso_week = if week < 1 {
iso_weeks_in_year(year - 1)
} else if week > iso_weeks_in_year(year) {
1
} else {
week
};
iso_year * 100 + iso_week
}
fn ordinal_day(year: i32, month: u8, day: u8) -> u16 {
(1..month)
.map(|current_month| u16::from(days_in_month(year, current_month)))
.sum::<u16>()
+ u16::from(day)
}
fn iso_weeks_in_year(year: i32) -> i32 {
let jan_first_weekday = weekday_from_date_key(days_from_civil(year, 1, 1));
if jan_first_weekday == 4 || (jan_first_weekday == 3 && is_leap_year(year)) {
53
} else {
52
}
}
fn days_from_civil(year: i32, month: u8, day: u8) -> i64 {
// 中文注释Howard Hinnant civil calendar 算法,返回 1970-01-01 起的日序号。
let adjusted_year = year - if month <= 2 { 1 } else { 0 };
let era = adjusted_year.div_euclid(400);
let year_of_era = adjusted_year - era * 400;
let month = i32::from(month);
let day = i32::from(day);
let month_prime = month + if month > 2 { -3 } else { 9 };
let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
i64::from(era * 146_097 + day_of_era - 719_468)
}
fn civil_from_days(date_key: i64) -> (i32, u8, u8) {
// 中文注释days_from_civil 的反向算法,避免依赖运行环境时区。
let z = date_key + 719_468;
let era = z.div_euclid(146_097);
let day_of_era = z - era * 146_097;
let year_of_era = (day_of_era - day_of_era / 1_460 + day_of_era / 36_524
- day_of_era / 146_096)
.div_euclid(365);
let mut year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2).div_euclid(153);
let day = day_of_year - (153 * month_prime + 2).div_euclid(5) + 1;
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
year += if month <= 2 { 1 } else { 0 };
(year as i32, month as u8, day as u8)
}
pub fn build_default_runtime_profile_task_config(
updated_at_micros: i64,
updated_by: String,
@@ -319,6 +503,81 @@ pub fn build_runtime_tracking_daily_stat_id(
)
}
pub fn aggregate_runtime_tracking_daily_stats(
stats: Vec<RuntimeAnalyticsDailyStatSnapshot>,
event_key: &str,
scope_kind: RuntimeTrackingScopeKind,
scope_id: &str,
granularity: AnalyticsGranularity,
) -> Vec<AnalyticsBucketMetric> {
let mut buckets: BTreeMap<(String, i64, i64), u64> = BTreeMap::new();
let event_key = event_key.trim();
let scope_id = scope_id.trim();
for stat in stats {
if stat.event_key.trim() != event_key
|| stat.scope_kind != scope_kind
|| stat.scope_id.trim() != scope_id
{
continue;
}
let dimension = build_analytics_date_dimension_from_date_key(stat.day_key);
let (bucket_key, bucket_start_date_key, bucket_end_date_key) =
analytics_bucket_for_dimension(&dimension, granularity);
*buckets
.entry((bucket_key, bucket_start_date_key, bucket_end_date_key))
.or_insert(0) += u64::from(stat.count);
}
buckets
.into_iter()
.map(
|((bucket_key, bucket_start_date_key, bucket_end_date_key), value)| {
AnalyticsBucketMetric {
bucket_key,
bucket_start_date_key,
bucket_end_date_key,
value,
}
},
)
.collect()
}
fn analytics_bucket_for_dimension(
dimension: &AnalyticsDateDimensionSnapshot,
granularity: AnalyticsGranularity,
) -> (String, i64, i64) {
match granularity {
AnalyticsGranularity::Day => (
dimension.calendar_date.clone(),
dimension.date_key,
dimension.date_key,
),
AnalyticsGranularity::Week => (
dimension.iso_week_key.to_string(),
dimension.week_start_date_key,
dimension.week_end_date_key,
),
AnalyticsGranularity::Month => (
dimension.month_key.to_string(),
dimension.month_start_date_key,
dimension.month_end_date_key,
),
AnalyticsGranularity::Quarter => (
dimension.quarter_key.to_string(),
dimension.quarter_start_date_key,
dimension.quarter_end_date_key,
),
AnalyticsGranularity::Year => (
dimension.year_key.to_string(),
dimension.year_start_date_key,
dimension.year_end_date_key,
),
}
}
pub fn build_runtime_profile_task_config_record(
snapshot: RuntimeProfileTaskConfigSnapshot,
) -> RuntimeProfileTaskConfigRecord {
@@ -416,10 +675,21 @@ pub fn build_runtime_profile_redeem_code_record(
pub fn build_runtime_profile_invite_code_record(
snapshot: RuntimeProfileInviteCodeSnapshot,
) -> RuntimeProfileInviteCodeRecord {
let status = crate::commands::resolve_runtime_profile_invite_code_status(
snapshot.starts_at_micros,
snapshot.expires_at_micros,
snapshot.updated_at_micros,
);
RuntimeProfileInviteCodeRecord {
user_id: snapshot.user_id,
invite_code: snapshot.invite_code,
metadata_json: snapshot.metadata_json,
starts_at: snapshot.starts_at_micros.map(format_utc_micros),
starts_at_micros: snapshot.starts_at_micros,
expires_at: snapshot.expires_at_micros.map(format_utc_micros),
expires_at_micros: snapshot.expires_at_micros,
status,
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),

View File

@@ -89,8 +89,8 @@ pub fn build_runtime_tracking_event_input(
) -> Result<RuntimeTrackingEventInput, RuntimeProfileFieldError> {
let event_id = normalize_required_string(event_id)
.ok_or(RuntimeProfileFieldError::MissingTrackingEventId)?;
let event_key =
normalize_required_string(event_key).ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
let event_key = normalize_required_string(event_key)
.ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
let scope_id = normalize_required_string(scope_id)
.ok_or(RuntimeProfileFieldError::MissingTrackingScopeId)?;
let metadata_json = normalize_tracking_metadata_json(metadata_json)?;
@@ -116,6 +116,24 @@ pub fn build_runtime_profile_task_center_get_input(
Ok(RuntimeProfileTaskCenterGetInput { user_id })
}
pub fn build_analytics_metric_query_input(
event_key: String,
scope_kind: RuntimeTrackingScopeKind,
scope_id: String,
granularity: AnalyticsGranularity,
) -> Result<AnalyticsMetricQueryInput, RuntimeProfileFieldError> {
let event_key = normalize_required_string(event_key)
.ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
let scope_id = normalize_required_string(scope_id)
.ok_or(RuntimeProfileFieldError::MissingTrackingScopeId)?;
Ok(AnalyticsMetricQueryInput {
event_key,
scope_kind,
scope_id,
granularity,
})
}
pub fn build_runtime_profile_task_claim_input(
user_id: String,
task_id: String,
@@ -151,8 +169,12 @@ pub fn build_runtime_profile_task_config_admin_upsert_input(
let task_id = normalize_profile_task_id(task_id)?;
let title =
normalize_required_string(title).ok_or(RuntimeProfileFieldError::MissingTaskTitle)?;
let event_key =
normalize_required_string(event_key).ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
let event_key = normalize_required_string(event_key)
.ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
// 中文注释:个人任务首版只按用户维度累计,避免 site/work/module 误复用用户桶。
if scope_kind != RuntimeTrackingScopeKind::User {
return Err(RuntimeProfileFieldError::UnsupportedProfileTaskScopeKind);
}
if threshold == 0 {
return Err(RuntimeProfileFieldError::InvalidTaskThreshold);
}
@@ -326,17 +348,25 @@ pub fn build_runtime_profile_invite_code_admin_upsert_input(
admin_user_id: String,
invite_code: String,
metadata_json: String,
starts_at_micros: Option<i64>,
expires_at_micros: Option<i64>,
updated_at_micros: i64,
) -> Result<RuntimeProfileInviteCodeAdminUpsertInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
let invite_code =
normalize_invite_code(invite_code).ok_or(RuntimeProfileFieldError::MissingInviteCode)?;
let metadata_json = normalize_invite_code_metadata_json(metadata_json)?;
crate::commands::validate_runtime_profile_invite_code_validity_window(
starts_at_micros,
expires_at_micros,
)?;
Ok(RuntimeProfileInviteCodeAdminUpsertInput {
admin_user_id,
invite_code,
metadata_json,
starts_at_micros,
expires_at_micros,
updated_at_micros,
})
}
@@ -639,6 +669,40 @@ pub fn normalize_invite_code_metadata_json(
serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)
}
pub fn validate_runtime_profile_invite_code_validity_window(
starts_at_micros: Option<i64>,
expires_at_micros: Option<i64>,
) -> Result<(), RuntimeProfileFieldError> {
if matches!((starts_at_micros, expires_at_micros), (Some(starts_at), Some(expires_at)) if starts_at > expires_at)
{
return Err(RuntimeProfileFieldError::InvalidInviteCodeValidityWindow);
}
Ok(())
}
pub fn resolve_runtime_profile_invite_code_status(
starts_at_micros: Option<i64>,
expires_at_micros: Option<i64>,
now_micros: i64,
) -> RuntimeProfileInviteCodeStatus {
if starts_at_micros
.map(|starts_at| now_micros < starts_at)
.unwrap_or(false)
{
return RuntimeProfileInviteCodeStatus::Pending;
}
if expires_at_micros
.map(|expires_at| now_micros >= expires_at)
.unwrap_or(false)
{
return RuntimeProfileInviteCodeStatus::Expired;
}
RuntimeProfileInviteCodeStatus::Active
}
fn normalize_tracking_metadata_json(value: String) -> Result<String, RuntimeProfileFieldError> {
let trimmed = value.trim();
if trimmed.is_empty() {

View File

@@ -21,6 +21,10 @@ pub const PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON: &str = "{}";
pub const PROFILE_INVITE_CODE_METADATA_MAX_BYTES: usize = 4096;
pub const PROFILE_RUNTIME_DAY_MICROS: i64 = 86_400_000_000;
pub const PROFILE_TASK_BEIJING_OFFSET_MICROS: i64 = 28_800_000_000;
pub const ANALYTICS_DATE_DIMENSION_MAX_SEED_DAYS: i64 = 3_660;
// 中文注释:日期维表当前只预置运营统计可接受的业务日期范围,避免裸 date_key 极值进入日历算法。
pub const ANALYTICS_DATE_DIMENSION_MIN_DATE: &str = "2000-01-01";
pub const ANALYTICS_DATE_DIMENSION_MAX_DATE: &str = "2100-12-31";
pub const PROFILE_TASK_ID_DAILY_LOGIN: &str = "daily_login";
pub const PROFILE_TASK_EVENT_KEY_DAILY_LOGIN: &str = "daily_login";
pub const PROFILE_TASK_DEFAULT_TITLE_DAILY_LOGIN: &str = "每日登录";
@@ -30,6 +34,102 @@ pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。";
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock";
/// 分析日期维表的纯领域快照。
///
/// date_key 沿用现有北京时间自然日桶floor((occurred_at_micros + 8h) / 1d)。
/// calendar_date 使用该业务日对应的公历日期,格式固定为 YYYY-MM-DD。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnalyticsDateDimensionSnapshot {
pub date_key: i64,
pub calendar_date: String,
pub weekday: u8,
pub iso_week_key: i32,
pub week_start_date_key: i64,
pub week_end_date_key: i64,
pub month_key: i32,
pub month_start_date_key: i64,
pub month_end_date_key: i64,
pub quarter_key: i32,
pub quarter_start_date_key: i64,
pub quarter_end_date_key: i64,
pub year_key: i32,
pub year_start_date_key: i64,
pub year_end_date_key: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AnalyticsGranularity {
Day,
Week,
Month,
Quarter,
Year,
}
impl AnalyticsGranularity {
pub fn as_str(&self) -> &'static str {
match self {
Self::Day => "day",
Self::Week => "week",
Self::Month => "month",
Self::Quarter => "quarter",
Self::Year => "year",
}
}
pub fn from_client_str(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"day" => Some(Self::Day),
"week" => Some(Self::Week),
"month" => Some(Self::Month),
"quarter" => Some(Self::Quarter),
"year" => Some(Self::Year),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeAnalyticsDailyStatSnapshot {
pub event_key: String,
pub scope_kind: RuntimeTrackingScopeKind,
pub scope_id: String,
pub day_key: i64,
pub count: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnalyticsBucketMetric {
pub bucket_key: String,
pub bucket_start_date_key: i64,
pub bucket_end_date_key: i64,
pub value: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AnalyticsMetricQueryRequest {
pub event_key: String,
pub scope_kind: RuntimeTrackingScopeKind,
pub scope_id: String,
pub granularity: AnalyticsGranularity,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AnalyticsMetricQueryResponse {
pub buckets: Vec<AnalyticsBucketMetric>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnalyticsMetricQueryProcedureResult {
pub ok: bool,
pub buckets: Vec<AnalyticsBucketMetric>,
pub error_message: Option<String>,
}
/// 运行时平台主题。
///
/// 当前只冻结 light/dark 两种主题,避免各层散落字符串字面量。
@@ -411,6 +511,24 @@ impl RuntimeProfileTaskStatus {
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeProfileInviteCodeStatus {
Pending,
Active,
Expired,
}
impl RuntimeProfileInviteCodeStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Active => "active",
Self::Expired => "expired",
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeTrackingEventInput {
@@ -506,6 +624,15 @@ pub struct RuntimeProfileTaskCenterGetInput {
pub user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AnalyticsMetricQueryInput {
pub event_key: String,
pub scope_kind: RuntimeTrackingScopeKind,
pub scope_id: String,
pub granularity: AnalyticsGranularity,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskClaimInput {
@@ -904,6 +1031,8 @@ pub struct RuntimeProfileInviteCodeAdminUpsertInput {
pub admin_user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub starts_at_micros: Option<i64>,
pub expires_at_micros: Option<i64>,
pub updated_at_micros: i64,
}
@@ -919,6 +1048,8 @@ pub struct RuntimeProfileInviteCodeSnapshot {
pub user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub starts_at_micros: Option<i64>,
pub expires_at_micros: Option<i64>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
@@ -1289,6 +1420,11 @@ pub struct RuntimeProfileInviteCodeRecord {
pub user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub starts_at: Option<String>,
pub starts_at_micros: Option<i64>,
pub expires_at: Option<String>,
pub expires_at_micros: Option<i64>,
pub status: RuntimeProfileInviteCodeStatus,
pub created_at: String,
pub created_at_micros: i64,
pub updated_at: String,

View File

@@ -52,6 +52,7 @@ pub enum RuntimeProfileFieldError {
InvalidRedeemCodeReward,
InvalidRedeemCodeMaxUses,
InvalidInviteCodeMetadata,
InvalidInviteCodeValidityWindow,
MissingTaskId,
MissingTaskTitle,
MissingTaskEventKey,
@@ -59,6 +60,7 @@ pub enum RuntimeProfileFieldError {
MissingTrackingScopeId,
InvalidTaskCycle,
InvalidTaskScopeKind,
UnsupportedProfileTaskScopeKind,
InvalidTaskThreshold,
InvalidTaskReward,
TaskDisabled,
@@ -77,6 +79,7 @@ pub enum RuntimeProfileFieldError {
actual_session_id: String,
},
NonPersistentRuntimeSnapshot,
InvalidAnalyticsCalendarDate,
}
impl std::fmt::Display for RuntimeProfileFieldError {
@@ -98,6 +101,7 @@ impl std::fmt::Display for RuntimeProfileFieldError {
Self::InvalidInviteCodeMetadata => {
f.write_str("邀请码 metadata 必须是合法 JSON object")
}
Self::InvalidInviteCodeValidityWindow => f.write_str("邀请码开始时间不能晚于截止时间"),
Self::MissingTaskId => f.write_str("profile_task.task_id 不能为空"),
Self::MissingTaskTitle => f.write_str("profile_task.title 不能为空"),
Self::MissingTaskEventKey => f.write_str("profile_task.event_key 不能为空"),
@@ -105,6 +109,9 @@ impl std::fmt::Display for RuntimeProfileFieldError {
Self::MissingTrackingScopeId => f.write_str("tracking_event.scope_id 不能为空"),
Self::InvalidTaskCycle => f.write_str("profile_task.cycle 无效"),
Self::InvalidTaskScopeKind => f.write_str("profile_task.scope_kind 无效"),
Self::UnsupportedProfileTaskScopeKind => {
f.write_str("个人任务 scope_kind 首版仅支持 user")
}
Self::InvalidTaskThreshold => f.write_str("profile_task.threshold 必须大于 0"),
Self::InvalidTaskReward => f.write_str("profile_task.reward_points 必须大于 0"),
Self::TaskDisabled => f.write_str("任务已停用"),
@@ -130,6 +137,9 @@ impl std::fmt::Display for RuntimeProfileFieldError {
Self::NonPersistentRuntimeSnapshot => {
f.write_str("预览或测试运行态不能创建正式 checkpoint")
}
Self::InvalidAnalyticsCalendarDate => {
f.write_str("analytics_date_dimension.calendar_date 必须是合法 YYYY-MM-DD 日期")
}
}
}
}

View File

@@ -1,4 +1,4 @@
mod application;
mod application;
mod commands;
mod domain;
mod errors;
@@ -465,6 +465,57 @@ mod tests {
);
}
#[test]
fn analytics_date_dimension_handles_iso_week_across_year() {
let date_key = parse_analytics_calendar_date_key("2024-12-31").unwrap();
let dimension = build_analytics_date_dimension_from_date_key(date_key);
assert_eq!(dimension.calendar_date, "2024-12-31");
assert_eq!(dimension.weekday, 2);
assert_eq!(dimension.iso_week_key, 202501);
assert_eq!(
dimension.week_start_date_key,
parse_analytics_calendar_date_key("2024-12-30").unwrap()
);
assert_eq!(
dimension.week_end_date_key,
parse_analytics_calendar_date_key("2025-01-05").unwrap()
);
}
#[test]
fn analytics_date_dimension_handles_leap_day() {
let date_key = parse_analytics_calendar_date_key("2024-02-29").unwrap();
let dimension = build_analytics_date_dimension_from_date_key(date_key);
assert_eq!(dimension.calendar_date, "2024-02-29");
assert_eq!(dimension.weekday, 4);
assert_eq!(dimension.month_key, 202402);
assert_eq!(dimension.month_end_date_key, date_key);
assert_eq!(dimension.quarter_key, 20241);
}
#[test]
fn analytics_date_dimension_handles_quarter_boundary() {
let date_key = parse_analytics_calendar_date_key("2024-04-01").unwrap();
let dimension = build_analytics_date_dimension_from_date_key(date_key);
assert_eq!(dimension.quarter_key, 20242);
assert_eq!(dimension.quarter_start_date_key, date_key);
assert_eq!(
dimension.quarter_end_date_key,
parse_analytics_calendar_date_key("2024-06-30").unwrap()
);
assert_eq!(
dimension.year_start_date_key,
parse_analytics_calendar_date_key("2024-01-01").unwrap()
);
assert_eq!(
dimension.year_end_date_key,
parse_analytics_calendar_date_key("2024-12-31").unwrap()
);
}
#[test]
fn runtime_profile_task_status_matches_progress_and_claim() {
assert_eq!(
@@ -525,6 +576,51 @@ mod tests {
);
}
#[test]
fn build_task_config_input_accepts_only_user_scope() {
let input = build_runtime_profile_task_config_admin_upsert_input(
"admin".to_string(),
PROFILE_TASK_ID_DAILY_LOGIN.to_string(),
"每日登录".to_string(),
"".to_string(),
PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(),
RuntimeProfileTaskCycle::Daily,
RuntimeTrackingScopeKind::User,
1,
10,
true,
10,
1,
)
.expect("user scope should be accepted");
assert_eq!(input.scope_kind, RuntimeTrackingScopeKind::User);
for scope_kind in [
RuntimeTrackingScopeKind::Site,
RuntimeTrackingScopeKind::Module,
RuntimeTrackingScopeKind::Work,
] {
assert_eq!(
build_runtime_profile_task_config_admin_upsert_input(
"admin".to_string(),
PROFILE_TASK_ID_DAILY_LOGIN.to_string(),
"每日登录".to_string(),
"".to_string(),
PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(),
RuntimeProfileTaskCycle::Daily,
scope_kind,
1,
10,
true,
10,
1,
)
.expect_err("non-user scope should fail"),
RuntimeProfileFieldError::UnsupportedProfileTaskScopeKind
);
}
}
#[test]
fn recharge_product_catalog_matches_reference_prices() {
let point_products = runtime_profile_recharge_point_products();

View File

@@ -0,0 +1,148 @@
use module_runtime::{
AnalyticsDateDimensionSnapshot, RuntimeProfileFieldError,
build_analytics_date_dimension_from_date_key, parse_analytics_calendar_date_key,
validate_analytics_date_dimension_date_key,
};
fn dimension(calendar_date: &str) -> AnalyticsDateDimensionSnapshot {
let date_key = parse_analytics_calendar_date_key(calendar_date).expect("日期应可解析");
build_analytics_date_dimension_from_date_key(date_key)
}
fn date_key(calendar_date: &str) -> i64 {
parse_analytics_calendar_date_key(calendar_date).expect("日期应可解析")
}
#[test]
fn analytics_date_dimension_handles_leap_day() {
// 中文注释2024 是闰年2 月应包含 29 日且属于 ISO 第 9 周。
let snapshot = dimension("2024-02-29");
assert_eq!(snapshot.calendar_date, "2024-02-29");
assert_eq!(snapshot.weekday, 4);
assert_eq!(snapshot.iso_week_key, 202409);
assert_eq!(snapshot.week_start_date_key, date_key("2024-02-26"));
assert_eq!(snapshot.week_end_date_key, date_key("2024-03-03"));
assert_eq!(snapshot.month_key, 202402);
assert_eq!(snapshot.month_start_date_key, date_key("2024-02-01"));
assert_eq!(snapshot.month_end_date_key, date_key("2024-02-29"));
assert_eq!(snapshot.quarter_key, 20241);
assert_eq!(snapshot.year_key, 2024);
}
#[test]
fn analytics_date_dimension_uses_iso_week_year_across_year_boundary() {
// 中文注释2025-12-29 是周一,但 ISO week-year 已经进入 2026-W01。
let snapshot = dimension("2025-12-29");
assert_eq!(snapshot.calendar_date, "2025-12-29");
assert_eq!(snapshot.weekday, 1);
assert_eq!(snapshot.iso_week_key, 202601);
assert_eq!(snapshot.week_start_date_key, date_key("2025-12-29"));
assert_eq!(snapshot.week_end_date_key, date_key("2026-01-04"));
assert_eq!(snapshot.month_key, 202512);
assert_eq!(snapshot.quarter_key, 20254);
assert_eq!(snapshot.year_key, 2025);
}
#[test]
fn analytics_date_dimension_handles_new_year_inside_iso_week() {
// 中文注释2026-01-01 仍落在 2026-W01周范围跨自然年。
let snapshot = dimension("2026-01-01");
assert_eq!(snapshot.calendar_date, "2026-01-01");
assert_eq!(snapshot.weekday, 4);
assert_eq!(snapshot.iso_week_key, 202601);
assert_eq!(snapshot.week_start_date_key, date_key("2025-12-29"));
assert_eq!(snapshot.week_end_date_key, date_key("2026-01-04"));
assert_eq!(snapshot.month_key, 202601);
assert_eq!(snapshot.month_start_date_key, date_key("2026-01-01"));
assert_eq!(snapshot.month_end_date_key, date_key("2026-01-31"));
assert_eq!(snapshot.quarter_key, 20261);
assert_eq!(snapshot.year_key, 2026);
}
#[test]
fn analytics_date_dimension_handles_q1_end() {
// 中文注释Q1 结束日应映射到 2026Q1季度边界为 1 月 1 日到 3 月 31 日。
let snapshot = dimension("2026-03-31");
assert_eq!(snapshot.calendar_date, "2026-03-31");
assert_eq!(snapshot.weekday, 2);
assert_eq!(snapshot.iso_week_key, 202614);
assert_eq!(snapshot.month_key, 202603);
assert_eq!(snapshot.month_start_date_key, date_key("2026-03-01"));
assert_eq!(snapshot.month_end_date_key, date_key("2026-03-31"));
assert_eq!(snapshot.quarter_key, 20261);
assert_eq!(snapshot.quarter_start_date_key, date_key("2026-01-01"));
assert_eq!(snapshot.quarter_end_date_key, date_key("2026-03-31"));
assert_eq!(snapshot.year_key, 2026);
}
#[test]
fn analytics_date_dimension_handles_q2_start() {
// 中文注释Q2 开始日应映射到 2026Q2季度边界为 4 月 1 日到 6 月 30 日。
let snapshot = dimension("2026-04-01");
assert_eq!(snapshot.calendar_date, "2026-04-01");
assert_eq!(snapshot.weekday, 3);
assert_eq!(snapshot.iso_week_key, 202614);
assert_eq!(snapshot.month_key, 202604);
assert_eq!(snapshot.month_start_date_key, date_key("2026-04-01"));
assert_eq!(snapshot.month_end_date_key, date_key("2026-04-30"));
assert_eq!(snapshot.quarter_key, 20262);
assert_eq!(snapshot.quarter_start_date_key, date_key("2026-04-01"));
assert_eq!(snapshot.quarter_end_date_key, date_key("2026-06-30"));
assert_eq!(snapshot.year_key, 2026);
}
#[test]
fn analytics_date_dimension_handles_year_end() {
// 中文注释2026 年末属于 2026-W53且所有自然年边界应保持在 2026 年内。
let snapshot = dimension("2026-12-31");
assert_eq!(snapshot.calendar_date, "2026-12-31");
assert_eq!(snapshot.weekday, 4);
assert_eq!(snapshot.iso_week_key, 202653);
assert_eq!(snapshot.week_start_date_key, date_key("2026-12-28"));
assert_eq!(snapshot.week_end_date_key, date_key("2027-01-03"));
assert_eq!(snapshot.month_key, 202612);
assert_eq!(snapshot.month_start_date_key, date_key("2026-12-01"));
assert_eq!(snapshot.month_end_date_key, date_key("2026-12-31"));
assert_eq!(snapshot.quarter_key, 20264);
assert_eq!(snapshot.quarter_start_date_key, date_key("2026-10-01"));
assert_eq!(snapshot.quarter_end_date_key, date_key("2026-12-31"));
assert_eq!(snapshot.year_key, 2026);
assert_eq!(snapshot.year_start_date_key, date_key("2026-01-01"));
assert_eq!(snapshot.year_end_date_key, date_key("2026-12-31"));
}
#[test]
fn analytics_date_key_parser_rejects_invalid_calendar_dates() {
// 中文注释:非法日期和非 YYYY-MM-DD 字符串都必须解析失败,避免写入脏维表。
assert_eq!(
parse_analytics_calendar_date_key("2026-02-30"),
Err(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate)
);
assert_eq!(
parse_analytics_calendar_date_key("bad"),
Err(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate)
);
}
#[test]
fn analytics_date_dimension_rejects_out_of_supported_range() {
// 中文注释:维表只允许受控业务日期范围,避免裸 date_key 极值进入 reducer 日历算法。
assert_eq!(
parse_analytics_calendar_date_key("1999-12-31"),
Err(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate)
);
assert_eq!(
parse_analytics_calendar_date_key("2101-01-01"),
Err(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate)
);
assert_eq!(
validate_analytics_date_dimension_date_key(i64::MAX),
Err(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate)
);
}

View File

@@ -0,0 +1,105 @@
use module_runtime::{
AnalyticsGranularity, RuntimeAnalyticsDailyStatSnapshot, RuntimeTrackingScopeKind,
aggregate_runtime_tracking_daily_stats,
};
fn stat(event_key: &str, scope_id: &str, day_key: i64, count: u32) -> RuntimeAnalyticsDailyStatSnapshot {
RuntimeAnalyticsDailyStatSnapshot {
event_key: event_key.to_string(),
scope_kind: RuntimeTrackingScopeKind::User,
scope_id: scope_id.to_string(),
day_key,
count,
}
}
#[test]
fn aggregates_daily_stats_by_iso_week_bucket() {
let buckets = aggregate_runtime_tracking_daily_stats(
vec![
stat("daily_login", "user-1", 20_517, 1), // 2026-03-05
stat("daily_login", "user-1", 20_518, 2), // 2026-03-06
stat("daily_login", "user-1", 20_524, 3), // 2026-03-12
stat("daily_login", "user-2", 20_517, 9),
],
"daily_login",
RuntimeTrackingScopeKind::User,
"user-1",
AnalyticsGranularity::Week,
);
assert_eq!(buckets.len(), 2);
assert_eq!(buckets[0].bucket_key, "202610");
assert_eq!(buckets[0].bucket_start_date_key, 20_514); // 2026-03-02
assert_eq!(buckets[0].bucket_end_date_key, 20_520); // 2026-03-08
assert_eq!(buckets[0].value, 3);
assert_eq!(buckets[1].bucket_key, "202611");
assert_eq!(buckets[1].bucket_start_date_key, 20_521); // 2026-03-09
assert_eq!(buckets[1].bucket_end_date_key, 20_527); // 2026-03-15
assert_eq!(buckets[1].value, 3);
}
#[test]
fn aggregates_daily_stats_by_month_quarter_and_year_bucket() {
let stats = vec![
stat("play", "user-1", 20_545, 2), // 2026-04-02
stat("play", "user-1", 20_573, 3), // 2026-04-30
stat("play", "user-1", 20_574, 5), // 2026-05-01
stat("play", "user-1", 20_818, 7), // 2026-12-31
];
let month = aggregate_runtime_tracking_daily_stats(
stats.clone(),
"play",
RuntimeTrackingScopeKind::User,
"user-1",
AnalyticsGranularity::Month,
);
assert_eq!(month.iter().map(|bucket| (&bucket.bucket_key, bucket.value)).collect::<Vec<_>>(), vec![(&"202604".to_string(), 5), (&"202605".to_string(), 5), (&"202612".to_string(), 7)]);
assert_eq!(month[0].bucket_start_date_key, 20_544);
assert_eq!(month[0].bucket_end_date_key, 20_573);
let quarter = aggregate_runtime_tracking_daily_stats(
stats.clone(),
"play",
RuntimeTrackingScopeKind::User,
"user-1",
AnalyticsGranularity::Quarter,
);
assert_eq!(quarter.iter().map(|bucket| (&bucket.bucket_key, bucket.value)).collect::<Vec<_>>(), vec![(&"20262".to_string(), 10), (&"20264".to_string(), 7)]);
let year = aggregate_runtime_tracking_daily_stats(
stats,
"play",
RuntimeTrackingScopeKind::User,
"user-1",
AnalyticsGranularity::Year,
);
assert_eq!(year.len(), 1);
assert_eq!(year[0].bucket_key, "2026");
assert_eq!(year[0].value, 17);
}
#[test]
fn day_granularity_keeps_each_day_bucket_and_filters_scope() {
let buckets = aggregate_runtime_tracking_daily_stats(
vec![
stat("daily_login", "user-1", 20_517, 1),
stat("daily_login", "user-1", 20_518, 2),
stat("daily_login", "user-2", 20_517, 9),
stat("other", "user-1", 20_517, 9),
],
"daily_login",
RuntimeTrackingScopeKind::User,
"user-1",
AnalyticsGranularity::Day,
);
assert_eq!(buckets.len(), 2);
assert_eq!(buckets[0].bucket_key, "2026-03-05");
assert_eq!(buckets[0].bucket_start_date_key, 20_517);
assert_eq!(buckets[0].bucket_end_date_key, 20_517);
assert_eq!(buckets[0].value, 1);
assert_eq!(buckets[1].bucket_key, "2026-03-06");
assert_eq!(buckets[1].value, 2);
}

View File

@@ -0,0 +1,60 @@
use module_runtime::{
RuntimeProfileFieldError, RuntimeProfileInviteCodeSnapshot, RuntimeProfileInviteCodeStatus,
build_runtime_profile_invite_code_record, resolve_runtime_profile_invite_code_status,
validate_runtime_profile_invite_code_validity_window,
};
#[test]
fn invite_code_validity_window_rejects_start_after_expire() {
let result = validate_runtime_profile_invite_code_validity_window(Some(20), Some(10));
assert_eq!(
result,
Err(RuntimeProfileFieldError::InvalidInviteCodeValidityWindow)
);
}
#[test]
fn invite_code_validity_window_allows_open_ended_or_equal_boundary() {
assert!(validate_runtime_profile_invite_code_validity_window(None, None).is_ok());
assert!(validate_runtime_profile_invite_code_validity_window(Some(10), None).is_ok());
assert!(validate_runtime_profile_invite_code_validity_window(None, Some(10)).is_ok());
assert!(validate_runtime_profile_invite_code_validity_window(Some(10), Some(10)).is_ok());
}
#[test]
fn invite_code_status_uses_inclusive_start_and_exclusive_expire_boundary() {
assert_eq!(
resolve_runtime_profile_invite_code_status(Some(20), None, 19),
RuntimeProfileInviteCodeStatus::Pending
);
assert_eq!(
resolve_runtime_profile_invite_code_status(Some(20), Some(30), 20),
RuntimeProfileInviteCodeStatus::Active
);
assert_eq!(
resolve_runtime_profile_invite_code_status(Some(20), Some(30), 29),
RuntimeProfileInviteCodeStatus::Active
);
assert_eq!(
resolve_runtime_profile_invite_code_status(Some(20), Some(30), 30),
RuntimeProfileInviteCodeStatus::Expired
);
}
#[test]
fn invite_code_record_formats_window_and_status() {
let record = build_runtime_profile_invite_code_record(RuntimeProfileInviteCodeSnapshot {
user_id: "user-1".to_string(),
invite_code: "SY00000001".to_string(),
metadata_json: "{}".to_string(),
starts_at_micros: Some(0),
expires_at_micros: Some(1_000_000),
created_at_micros: 0,
updated_at_micros: 1_000_000,
});
assert_eq!(record.starts_at.as_deref(), Some("1970-01-01T00:00:00Z"));
assert_eq!(record.expires_at.as_deref(), Some("1970-01-01T00:00:01Z"));
assert_eq!(record.status, RuntimeProfileInviteCodeStatus::Expired);
}

View File

@@ -0,0 +1,83 @@
use module_runtime::{
RuntimeProfileFieldError, RuntimeProfileTaskCycle, RuntimeTrackingScopeKind,
build_runtime_profile_task_config_admin_upsert_input, build_runtime_tracking_daily_stat_id,
};
fn build_task_scope_input(
scope_kind: RuntimeTrackingScopeKind,
) -> Result<module_runtime::RuntimeProfileTaskConfigAdminUpsertInput, RuntimeProfileFieldError> {
build_runtime_profile_task_config_admin_upsert_input(
"admin-1".to_string(),
"daily-login".to_string(),
"每日登录".to_string(),
"".to_string(),
"daily_login".to_string(),
RuntimeProfileTaskCycle::Daily,
scope_kind,
1,
10,
true,
10,
1_000,
)
}
#[test]
fn admin_upsert_build_input_accepts_user_scope() {
let input = build_task_scope_input(RuntimeTrackingScopeKind::User)
.expect("个人任务 user scope 应允许保存");
assert_eq!(input.scope_kind, RuntimeTrackingScopeKind::User);
}
#[test]
fn admin_upsert_build_input_rejects_non_user_scope_with_clear_message() {
for scope_kind in [
RuntimeTrackingScopeKind::Site,
RuntimeTrackingScopeKind::Module,
RuntimeTrackingScopeKind::Work,
] {
let error = build_task_scope_input(scope_kind).expect_err("非 user scope 应拒绝保存");
assert_eq!(
error,
RuntimeProfileFieldError::UnsupportedProfileTaskScopeKind
);
assert!(
error.to_string().contains("仅支持 user"),
"错误信息应明确说明个人任务仅支持 user实际为{}",
error
);
}
}
#[test]
fn tracking_daily_stat_id_keeps_work_scope_separate_from_user_scope() {
let user_id = "user-1";
let work_id = "work-1";
let day_key = 20_000;
let user_stat_id = build_runtime_tracking_daily_stat_id(
"daily_login",
RuntimeTrackingScopeKind::User,
user_id,
day_key,
);
let work_stat_id = build_runtime_tracking_daily_stat_id(
"daily_login",
RuntimeTrackingScopeKind::Work,
work_id,
day_key,
);
let invalid_work_with_user_id_stat_id = build_runtime_tracking_daily_stat_id(
"daily_login",
RuntimeTrackingScopeKind::Work,
user_id,
day_key,
);
// 中文注释Work 维度必须保留独立 scope_kind不允许被静默当作 user_id 查询用户桶。
assert!(user_stat_id.contains(":user:user-1:"));
assert!(work_stat_id.contains(":work:work-1:"));
assert_ne!(user_stat_id, invalid_work_with_user_id_stat_id);
}

View File

@@ -0,0 +1,14 @@
[package]
name = "module-square-hole"
edition.workspace = true
version.workspace = true
license.workspace = true
[features]
default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
spacetimedb = { workspace = true, optional = true }

View File

@@ -0,0 +1,456 @@
use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list};
use crate::commands::{default_tags_for_theme, validate_publish_requirements};
use crate::{
SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS, SQUARE_HOLE_MAX_DIFFICULTY, SQUARE_HOLE_MIN_DIFFICULTY,
SquareHoleCreatorConfig, SquareHoleDropConfirmation, SquareHoleDropFeedback,
SquareHoleDropInput, SquareHoleDropRejectReason, SquareHoleError, SquareHoleHoleSnapshot,
SquareHolePublicationStatus, SquareHoleResultDraft, SquareHoleRunSnapshot, SquareHoleRunStatus,
SquareHoleShapeSnapshot, SquareHoleWorkProfile,
};
pub fn compile_result_draft(
profile_id: String,
config: &SquareHoleCreatorConfig,
) -> SquareHoleResultDraft {
let game_name = format!("{}方洞挑战", config.theme_text);
let summary = format!(
"{}主题,{} 个形状,难度 {},真实规则:{}",
config.theme_text, config.shape_count, config.difficulty, config.twist_rule
);
let mut draft = SquareHoleResultDraft {
profile_id,
game_name,
theme_text: config.theme_text.clone(),
twist_rule: config.twist_rule.clone(),
summary,
tags: default_tags_for_theme(&config.theme_text),
shape_count: config.shape_count,
difficulty: config.difficulty,
publish_ready: false,
blockers: Vec::new(),
};
draft.blockers = validate_publish_requirements(&draft);
draft.publish_ready = draft.blockers.is_empty();
draft
}
pub fn create_work_profile(
work_id: String,
profile_id: String,
owner_user_id: String,
source_session_id: Option<String>,
draft: &SquareHoleResultDraft,
updated_at_micros: i64,
) -> Result<SquareHoleWorkProfile, SquareHoleError> {
let work_id = normalize_required_string(work_id).ok_or(SquareHoleError::MissingText)?;
let profile_id =
normalize_required_string(profile_id).ok_or(SquareHoleError::MissingProfileId)?;
let owner_user_id =
normalize_required_string(owner_user_id).ok_or(SquareHoleError::MissingOwnerUserId)?;
Ok(SquareHoleWorkProfile {
work_id,
profile_id,
owner_user_id,
source_session_id: normalize_optional_string(source_session_id),
game_name: draft.game_name.clone(),
theme_text: draft.theme_text.clone(),
twist_rule: draft.twist_rule.clone(),
summary: draft.summary.clone(),
tags: normalize_string_list(draft.tags.clone()),
cover_image_src: None,
shape_count: draft.shape_count,
difficulty: draft.difficulty,
publication_status: SquareHolePublicationStatus::Draft,
play_count: 0,
updated_at_micros,
published_at_micros: None,
})
}
pub fn publish_work_profile(
profile: &SquareHoleWorkProfile,
published_at_micros: i64,
) -> Result<SquareHoleWorkProfile, SquareHoleError> {
if profile.shape_count == 0 {
return Err(SquareHoleError::InvalidShapeCount);
}
if !(SQUARE_HOLE_MIN_DIFFICULTY..=SQUARE_HOLE_MAX_DIFFICULTY).contains(&profile.difficulty) {
return Err(SquareHoleError::InvalidDifficulty);
}
let mut next = profile.clone();
next.publication_status = SquareHolePublicationStatus::Published;
next.updated_at_micros = published_at_micros;
next.published_at_micros = Some(published_at_micros);
Ok(next)
}
pub fn start_run_at(
run_id: String,
owner_user_id: String,
profile_id: String,
config: &SquareHoleCreatorConfig,
started_at_ms: u64,
) -> Result<SquareHoleRunSnapshot, SquareHoleError> {
let run_id = normalize_required_string(run_id).ok_or(SquareHoleError::MissingRunId)?;
let owner_user_id =
normalize_required_string(owner_user_id).ok_or(SquareHoleError::MissingOwnerUserId)?;
let profile_id =
normalize_required_string(profile_id).ok_or(SquareHoleError::MissingProfileId)?;
Ok(SquareHoleRunSnapshot {
run_id,
profile_id,
owner_user_id,
status: SquareHoleRunStatus::Running,
snapshot_version: 1,
started_at_ms,
duration_limit_ms: SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS,
remaining_ms: SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS,
total_shape_count: config.shape_count,
completed_shape_count: 0,
combo: 0,
best_combo: 0,
score: 0,
rule_label: config.twist_rule.clone(),
current_shape: Some(build_shape_at(0, config.shape_count)),
holes: default_holes(),
last_feedback: None,
})
}
pub fn confirm_drop_at(
run: &SquareHoleRunSnapshot,
input: &SquareHoleDropInput,
) -> Result<SquareHoleDropConfirmation, SquareHoleError> {
let hole_id =
normalize_required_string(&input.hole_id).ok_or(SquareHoleError::MissingHoleId)?;
let mut next = resolve_run_timer_at(run, input.dropped_at_ms);
if next.status != SquareHoleRunStatus::Running {
return Ok(rejected(next, SquareHoleDropRejectReason::RunNotActive));
}
if input.client_snapshot_version != next.snapshot_version {
return Ok(rejected(
next,
SquareHoleDropRejectReason::SnapshotVersionMismatch,
));
}
let Some(hole) = next.holes.iter().find(|item| item.hole_id == hole_id) else {
return Ok(rejected(next, SquareHoleDropRejectReason::HoleNotFound));
};
let Some(shape) = next.current_shape.clone() else {
return Ok(rejected(next, SquareHoleDropRejectReason::Incompatible));
};
if !is_shape_accepted_by_hole(&shape, hole) {
next.combo = 0;
next.snapshot_version = next.snapshot_version.saturating_add(1);
return Ok(rejected(next, SquareHoleDropRejectReason::Incompatible));
}
let message = format!("{}进入{}", shape.label, hole.label);
let feedback = SquareHoleDropFeedback {
accepted: true,
reject_reason: None,
message,
};
next.completed_shape_count = next.completed_shape_count.saturating_add(1);
next.combo = next.combo.saturating_add(1);
next.best_combo = next.best_combo.max(next.combo);
next.score = next.score.saturating_add(100 + next.combo * 10);
next.current_shape = if next.completed_shape_count >= next.total_shape_count {
next.status = SquareHoleRunStatus::Won;
None
} else {
Some(build_shape_at(
next.completed_shape_count,
next.total_shape_count,
))
};
next.snapshot_version = next.snapshot_version.saturating_add(1);
next.last_feedback = Some(feedback.clone());
Ok(SquareHoleDropConfirmation {
feedback,
run: next,
})
}
pub fn resolve_run_timer_at(run: &SquareHoleRunSnapshot, now_ms: u64) -> SquareHoleRunSnapshot {
let mut next = run.clone();
if next.status != SquareHoleRunStatus::Running {
return next;
}
let elapsed_ms = now_ms.saturating_sub(next.started_at_ms);
next.remaining_ms = next.duration_limit_ms.saturating_sub(elapsed_ms);
if next.remaining_ms == 0 {
let feedback = SquareHoleDropFeedback {
accepted: false,
reject_reason: Some(SquareHoleDropRejectReason::TimeUp),
message: "时间到".to_string(),
};
next.status = SquareHoleRunStatus::Failed;
next.combo = 0;
next.current_shape = None;
next.last_feedback = Some(feedback);
next.snapshot_version = next.snapshot_version.saturating_add(1);
}
next
}
pub fn stop_run_at(run: &SquareHoleRunSnapshot) -> SquareHoleRunSnapshot {
let mut next = run.clone();
if next.status == SquareHoleRunStatus::Running {
next.status = SquareHoleRunStatus::Stopped;
next.combo = 0;
next.snapshot_version = next.snapshot_version.saturating_add(1);
next.last_feedback = Some(SquareHoleDropFeedback {
accepted: false,
reject_reason: Some(SquareHoleDropRejectReason::RunNotActive),
message: "已退出本局".to_string(),
});
}
next
}
pub fn build_shape_at(index: u32, total: u32) -> SquareHoleShapeSnapshot {
let kind = if index + 1 == total {
"square"
} else if index % 4 == 0 {
"circle"
} else if index % 4 == 1 {
"triangle"
} else if index % 4 == 2 {
"diamond"
} else {
"star"
};
SquareHoleShapeSnapshot {
shape_id: format!("square-hole-shape-{index:03}"),
shape_kind: kind.to_string(),
label: match kind {
"square" => "方块",
"circle" => "圆块",
"triangle" => "三角块",
"diamond" => "菱形块",
_ => "星形块",
}
.to_string(),
color: match kind {
"square" => "#facc15",
"circle" => "#22c55e",
"triangle" => "#38bdf8",
"diamond" => "#fb7185",
_ => "#c084fc",
}
.to_string(),
}
}
pub fn default_holes() -> Vec<SquareHoleHoleSnapshot> {
vec![
SquareHoleHoleSnapshot {
hole_id: "square-hole".to_string(),
hole_kind: "square".to_string(),
label: "方洞".to_string(),
x: 0.5,
y: 0.28,
},
SquareHoleHoleSnapshot {
hole_id: "circle-hole".to_string(),
hole_kind: "circle".to_string(),
label: "圆洞".to_string(),
x: 0.24,
y: 0.54,
},
SquareHoleHoleSnapshot {
hole_id: "triangle-hole".to_string(),
hole_kind: "triangle".to_string(),
label: "三角洞".to_string(),
x: 0.76,
y: 0.54,
},
]
}
fn is_shape_accepted_by_hole(
shape: &SquareHoleShapeSnapshot,
hole: &SquareHoleHoleSnapshot,
) -> bool {
// 中文注释:首版核心反差固定为“方洞万能”,保留同形状洞口兼容便于后续扩展规则。
hole.hole_kind == "square" || hole.hole_kind == shape.shape_kind
}
fn rejected(
mut run: SquareHoleRunSnapshot,
reject_reason: SquareHoleDropRejectReason,
) -> SquareHoleDropConfirmation {
let message = match reject_reason {
SquareHoleDropRejectReason::RunNotActive => "当前局已结束",
SquareHoleDropRejectReason::SnapshotVersionMismatch => "操作慢了一步",
SquareHoleDropRejectReason::HoleNotFound => "洞口不存在",
SquareHoleDropRejectReason::Incompatible => "这个洞不对",
SquareHoleDropRejectReason::TimeUp => "时间到",
}
.to_string();
let feedback = SquareHoleDropFeedback {
accepted: false,
reject_reason: Some(reject_reason),
message,
};
run.last_feedback = Some(feedback.clone());
SquareHoleDropConfirmation { feedback, run }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::build_creator_config;
fn test_config(shape_count: u32) -> SquareHoleCreatorConfig {
build_creator_config("玩具", "方洞万能", shape_count, 4).expect("config should be valid")
}
#[test]
fn draft_is_publishable_with_required_fields() {
let draft = compile_result_draft("profile-1".to_string(), &test_config(8));
assert!(draft.publish_ready);
assert!(draft.blockers.is_empty());
assert!(draft.tags.contains(&"方洞挑战".to_string()));
}
#[test]
fn run_starts_with_current_shape_and_default_holes() {
let run = start_run_at(
"run-1".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config(8),
1_000,
)
.expect("run should start");
assert_eq!(run.status, SquareHoleRunStatus::Running);
assert!(run.current_shape.is_some());
assert_eq!(run.holes.len(), 3);
}
#[test]
fn square_hole_accepts_non_square_shape() {
let run = start_run_at(
"run-1".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config(8),
1_000,
)
.expect("run should start");
let result = confirm_drop_at(
&run,
&SquareHoleDropInput {
run_id: run.run_id.clone(),
owner_user_id: run.owner_user_id.clone(),
hole_id: "square-hole".to_string(),
client_snapshot_version: run.snapshot_version,
client_event_id: "event-1".to_string(),
dropped_at_ms: 1_100,
},
)
.expect("drop should resolve");
assert!(result.feedback.accepted);
assert_eq!(result.run.completed_shape_count, 1);
assert_eq!(result.run.combo, 1);
}
#[test]
fn wrong_non_square_hole_rejects_and_resets_combo() {
let mut run = start_run_at(
"run-1".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config(8),
1_000,
)
.expect("run should start");
run.current_shape = Some(build_shape_at(1, 8));
run.combo = 2;
let result = confirm_drop_at(
&run,
&SquareHoleDropInput {
run_id: run.run_id.clone(),
owner_user_id: run.owner_user_id.clone(),
hole_id: "circle-hole".to_string(),
client_snapshot_version: run.snapshot_version,
client_event_id: "event-1".to_string(),
dropped_at_ms: 1_100,
},
)
.expect("drop should resolve");
assert!(!result.feedback.accepted);
assert_eq!(
result.feedback.reject_reason,
Some(SquareHoleDropRejectReason::Incompatible)
);
assert_eq!(result.run.combo, 0);
}
#[test]
fn last_shape_win_finishes_run() {
let mut run = start_run_at(
"run-1".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config(6),
1_000,
)
.expect("run should start");
run.completed_shape_count = 5;
run.current_shape = Some(build_shape_at(5, 6));
let result = confirm_drop_at(
&run,
&SquareHoleDropInput {
run_id: run.run_id.clone(),
owner_user_id: run.owner_user_id.clone(),
hole_id: "square-hole".to_string(),
client_snapshot_version: run.snapshot_version,
client_event_id: "event-1".to_string(),
dropped_at_ms: 1_100,
},
)
.expect("drop should resolve");
assert_eq!(result.run.status, SquareHoleRunStatus::Won);
assert!(result.run.current_shape.is_none());
}
#[test]
fn timer_expiration_fails_running_run() {
let run = start_run_at(
"run-1".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config(8),
1_000,
)
.expect("run should start");
let expired = resolve_run_timer_at(&run, 1_000 + SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS);
assert_eq!(expired.status, SquareHoleRunStatus::Failed);
assert_eq!(
expired
.last_feedback
.and_then(|feedback| feedback.reject_reason),
Some(SquareHoleDropRejectReason::TimeUp)
);
}
}

View File

@@ -0,0 +1,116 @@
use shared_kernel::{normalize_required_string, normalize_string_list};
use crate::{
SQUARE_HOLE_MAX_DIFFICULTY, SQUARE_HOLE_MAX_SHAPE_COUNT, SQUARE_HOLE_MIN_DIFFICULTY,
SQUARE_HOLE_MIN_SHAPE_COUNT, SquareHoleCreatorConfig, SquareHoleError, SquareHoleResultDraft,
};
pub fn validate_shape_count(value: u32) -> Result<u32, SquareHoleError> {
if (SQUARE_HOLE_MIN_SHAPE_COUNT..=SQUARE_HOLE_MAX_SHAPE_COUNT).contains(&value) {
Ok(value)
} else {
Err(SquareHoleError::InvalidShapeCount)
}
}
pub fn validate_difficulty(value: u32) -> Result<u32, SquareHoleError> {
if (SQUARE_HOLE_MIN_DIFFICULTY..=SQUARE_HOLE_MAX_DIFFICULTY).contains(&value) {
Ok(value)
} else {
Err(SquareHoleError::InvalidDifficulty)
}
}
pub fn normalize_theme_text(value: impl AsRef<str>) -> Result<String, SquareHoleError> {
normalize_required_string(value).ok_or(SquareHoleError::MissingText)
}
pub fn build_creator_config(
theme_text: &str,
twist_rule: &str,
shape_count: u32,
difficulty: u32,
) -> Result<SquareHoleCreatorConfig, SquareHoleError> {
Ok(SquareHoleCreatorConfig {
theme_text: normalize_theme_text(theme_text)?,
twist_rule: normalize_required_string(twist_rule).ok_or(SquareHoleError::MissingText)?,
shape_count: validate_shape_count(shape_count)?,
difficulty: validate_difficulty(difficulty)?,
})
}
pub fn build_default_tags(theme_text: &str) -> Vec<String> {
normalize_string_list(vec![
"方洞挑战".to_string(),
theme_text.to_string(),
"反直觉".to_string(),
])
}
pub fn default_tags_for_theme(theme_text: &str) -> Vec<String> {
let mut tags = vec![
"方洞挑战".to_string(),
"反直觉".to_string(),
theme_text.to_string(),
];
tags.sort();
tags.dedup();
tags
}
pub fn validate_publish_requirements(draft: &SquareHoleResultDraft) -> Vec<String> {
let mut blockers = Vec::new();
if normalize_required_string(&draft.game_name).is_none() {
blockers.push("游戏名称不能为空".to_string());
}
if normalize_required_string(&draft.summary).is_none() {
blockers.push("简介不能为空".to_string());
}
if normalize_required_string(&draft.theme_text).is_none() {
blockers.push("题材不能为空".to_string());
}
if normalize_required_string(&draft.twist_rule).is_none() {
blockers.push("反直觉规则不能为空".to_string());
}
if normalize_string_list(draft.tags.clone()).is_empty() {
blockers.push("至少需要 1 个标签".to_string());
}
if validate_shape_count(draft.shape_count).is_err() {
blockers.push(format!(
"形状数量必须在 {}{} 之间",
SQUARE_HOLE_MIN_SHAPE_COUNT, SQUARE_HOLE_MAX_SHAPE_COUNT
));
}
if validate_difficulty(draft.difficulty).is_err() {
blockers.push("难度必须在 1 到 10 之间".to_string());
}
blockers
}
#[deprecated(note = "请使用 compile_result_draft(profile_id, &config)")]
pub fn build_result_draft(
profile_id: String,
theme_text: String,
twist_rule: String,
shape_count: u32,
difficulty: u32,
) -> SquareHoleResultDraft {
let game_name = format!("{theme_text}方洞挑战");
let summary = format!(
"{theme_text}主题,{} 个形状,难度 {},规则:{twist_rule}",
shape_count, difficulty
);
let blockers = Vec::new();
SquareHoleResultDraft {
profile_id,
game_name,
theme_text,
twist_rule,
summary,
tags: build_default_tags("方洞挑战"),
shape_count,
difficulty,
publish_ready: true,
blockers,
}
}

View File

@@ -0,0 +1,204 @@
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const SQUARE_HOLE_SESSION_ID_PREFIX: &str = "square-hole-session-";
pub const SQUARE_HOLE_MESSAGE_ID_PREFIX: &str = "square-hole-message-";
pub const SQUARE_HOLE_PROFILE_ID_PREFIX: &str = "square-hole-profile-";
pub const SQUARE_HOLE_WORK_ID_PREFIX: &str = "square-hole-work-";
pub const SQUARE_HOLE_RUN_ID_PREFIX: &str = "square-hole-run-";
pub const SQUARE_HOLE_MIN_SHAPE_COUNT: u32 = 6;
pub const SQUARE_HOLE_MAX_SHAPE_COUNT: u32 = 24;
pub const SQUARE_HOLE_MIN_DIFFICULTY: u32 = 1;
pub const SQUARE_HOLE_MAX_DIFFICULTY: u32 = 10;
pub const SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS: u64 = 60_000;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SquareHoleCreationStage {
CollectingConfig,
DraftReady,
ReadyToPublish,
Published,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SquareHolePublicationStatus {
Draft,
Published,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SquareHoleRunStatus {
Running,
Won,
Failed,
Stopped,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SquareHoleDropRejectReason {
RunNotActive,
SnapshotVersionMismatch,
HoleNotFound,
Incompatible,
TimeUp,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SquareHoleCreatorConfig {
pub theme_text: String,
pub twist_rule: String,
pub shape_count: u32,
pub difficulty: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SquareHoleResultDraft {
pub profile_id: String,
pub game_name: String,
pub theme_text: String,
pub twist_rule: String,
pub summary: String,
pub tags: Vec<String>,
pub shape_count: u32,
pub difficulty: u32,
pub publish_ready: bool,
pub blockers: Vec<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SquareHoleWorkProfile {
pub work_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub source_session_id: Option<String>,
pub game_name: String,
pub theme_text: String,
pub twist_rule: String,
pub summary: String,
pub tags: Vec<String>,
pub cover_image_src: Option<String>,
pub shape_count: u32,
pub difficulty: u32,
pub publication_status: SquareHolePublicationStatus,
pub play_count: u32,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SquareHoleShapeSnapshot {
pub shape_id: String,
pub shape_kind: String,
pub label: String,
pub color: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SquareHoleHoleSnapshot {
pub hole_id: String,
pub hole_kind: String,
pub label: String,
pub x: f32,
pub y: f32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SquareHoleDropFeedback {
pub accepted: bool,
pub reject_reason: Option<SquareHoleDropRejectReason>,
pub message: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SquareHoleRunSnapshot {
pub run_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub status: SquareHoleRunStatus,
pub snapshot_version: u64,
pub started_at_ms: u64,
pub duration_limit_ms: u64,
pub remaining_ms: u64,
pub total_shape_count: u32,
pub completed_shape_count: u32,
pub combo: u32,
pub best_combo: u32,
pub score: u32,
pub rule_label: String,
pub current_shape: Option<SquareHoleShapeSnapshot>,
pub holes: Vec<SquareHoleHoleSnapshot>,
pub last_feedback: Option<SquareHoleDropFeedback>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SquareHoleDropInput {
pub run_id: String,
pub owner_user_id: String,
pub hole_id: String,
pub client_snapshot_version: u64,
pub client_event_id: String,
pub dropped_at_ms: u64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SquareHoleDropConfirmation {
pub feedback: SquareHoleDropFeedback,
pub run: SquareHoleRunSnapshot,
}
impl SquareHoleCreationStage {
pub fn as_str(self) -> &'static str {
match self {
Self::CollectingConfig => "collecting_config",
Self::DraftReady => "draft_ready",
Self::ReadyToPublish => "ready_to_publish",
Self::Published => "published",
}
}
}
impl SquareHolePublicationStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Draft => "draft",
Self::Published => "published",
}
}
}
impl SquareHoleRunStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Running => "running",
Self::Won => "won",
Self::Failed => "failed",
Self::Stopped => "stopped",
}
}
}
impl SquareHoleDropRejectReason {
pub fn as_str(self) -> &'static str {
match self {
Self::RunNotActive => "run_not_active",
Self::SnapshotVersionMismatch => "snapshot_version_mismatch",
Self::HoleNotFound => "hole_not_found",
Self::Incompatible => "incompatible",
Self::TimeUp => "time_up",
}
}
}

View File

@@ -0,0 +1,37 @@
use std::fmt::{self, Display};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SquareHoleError {
MissingText,
MissingOwnerUserId,
InvalidShapeCount,
InvalidDifficulty,
MissingProfileId,
MissingRunId,
MissingHoleId,
RunNotActive,
SnapshotVersionMismatch,
HoleNotFound,
Incompatible,
}
impl Display for SquareHoleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::MissingText => "文本不能为空",
Self::MissingOwnerUserId => "owner_user_id 缺失",
Self::InvalidShapeCount => "形状数量必须在 6 到 24 之间",
Self::InvalidDifficulty => "难度必须在 1 到 10 之间",
Self::MissingProfileId => "缺少 profileId",
Self::MissingRunId => "缺少 runId",
Self::MissingHoleId => "缺少 holeId",
Self::RunNotActive => "当前运行态未激活",
Self::SnapshotVersionMismatch => "快照版本不一致",
Self::HoleNotFound => "洞口不存在",
Self::Incompatible => "当前形状不能投入这个洞口",
};
write!(f, "{message}")
}
}
impl std::error::Error for SquareHoleError {}

View File

@@ -0,0 +1,25 @@
//! 方洞挑战领域事件。
//!
//! 事件只表达已经发生的领域事实,是否持久化、投影或通知前端由
//! SpacetimeDB adapter 与 BFF 编排层决定。
/// 方洞挑战领域事件。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SquareHoleDomainEvent {
DraftCompiled {
profile_id: String,
owner_user_id: String,
occurred_at_micros: i64,
},
WorkPublished {
profile_id: String,
owner_user_id: String,
occurred_at_micros: i64,
},
RunSettled {
run_id: String,
owner_user_id: String,
status: String,
occurred_at_micros: i64,
},
}

View File

@@ -0,0 +1,11 @@
mod application;
mod commands;
mod domain;
mod errors;
mod events;
pub use application::*;
pub use commands::*;
pub use domain::*;
pub use errors::*;
pub use events::*;

View File

@@ -16,4 +16,7 @@ pub mod puzzle_runtime;
pub mod puzzle_works;
pub mod runtime;
pub mod runtime_story;
pub mod square_hole_agent;
pub mod square_hole_runtime;
pub mod square_hole_works;
pub mod story;

View File

@@ -25,6 +25,11 @@ pub const TRACKING_SCOPE_KIND_SITE: &str = "site";
pub const TRACKING_SCOPE_KIND_WORK: &str = "work";
pub const TRACKING_SCOPE_KIND_MODULE: &str = "module";
pub const TRACKING_SCOPE_KIND_USER: &str = "user";
pub const ANALYTICS_GRANULARITY_DAY: &str = "day";
pub const ANALYTICS_GRANULARITY_WEEK: &str = "week";
pub const ANALYTICS_GRANULARITY_MONTH: &str = "month";
pub const ANALYTICS_GRANULARITY_QUARTER: &str = "quarter";
pub const ANALYTICS_GRANULARITY_YEAR: &str = "year";
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";
@@ -367,6 +372,30 @@ pub struct ProfileTaskConfigAdminListResponse {
pub entries: Vec<ProfileTaskConfigAdminResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AnalyticsMetricQueryRequest {
pub event_key: String,
pub scope_kind: String,
pub scope_id: String,
pub granularity: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AnalyticsBucketMetricResponse {
pub bucket_key: String,
pub bucket_start_date_key: i64,
pub bucket_end_date_key: i64,
pub value: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AnalyticsMetricQueryResponse {
pub buckets: Vec<AnalyticsBucketMetricResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminUpsertProfileTaskConfigRequest {
@@ -412,6 +441,10 @@ pub struct AdminUpsertProfileInviteCodeRequest {
pub invite_code: String,
#[serde(default)]
pub metadata: Option<serde_json::Value>,
#[serde(default)]
pub starts_at: Option<String>,
#[serde(default)]
pub expires_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -447,6 +480,9 @@ pub struct ProfileInviteCodeAdminResponse {
pub user_id: String,
pub invite_code: String,
pub metadata: serde_json::Value,
pub starts_at: Option<String>,
pub expires_at: Option<String>,
pub status: String,
pub created_at: String,
pub updated_at: String,
}

View File

@@ -0,0 +1,122 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CreateSquareHoleSessionRequest {
#[serde(default)]
pub seed_text: Option<String>,
#[serde(default)]
pub theme_text: Option<String>,
#[serde(default)]
pub twist_rule: Option<String>,
#[serde(default)]
pub shape_count: Option<u32>,
#[serde(default)]
pub difficulty: Option<u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SendSquareHoleMessageRequest {
pub client_message_id: String,
pub text: String,
#[serde(default)]
pub quick_fill_requested: Option<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ExecuteSquareHoleActionRequest {
pub action: String,
#[serde(default)]
pub game_name: Option<String>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub cover_image_src: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleAnchorItemResponse {
pub key: String,
pub label: String,
pub value: String,
pub status: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleAnchorPackResponse {
pub theme: SquareHoleAnchorItemResponse,
pub twist_rule: SquareHoleAnchorItemResponse,
pub shape_count: SquareHoleAnchorItemResponse,
pub difficulty: SquareHoleAnchorItemResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleCreatorConfigResponse {
pub theme_text: String,
pub twist_rule: String,
pub shape_count: u32,
pub difficulty: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleResultDraftResponse {
pub profile_id: String,
pub game_name: String,
pub theme_text: String,
pub twist_rule: String,
pub summary: String,
pub tags: Vec<String>,
pub shape_count: u32,
pub difficulty: u32,
pub publish_ready: bool,
pub blockers: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleAgentMessageResponse {
pub id: String,
pub role: String,
pub kind: String,
pub text: String,
pub created_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleSessionSnapshotResponse {
pub session_id: String,
pub current_turn: u32,
pub progress_percent: u32,
pub stage: String,
pub anchor_pack: SquareHoleAnchorPackResponse,
pub config: SquareHoleCreatorConfigResponse,
#[serde(default)]
pub draft: Option<SquareHoleResultDraftResponse>,
pub messages: Vec<SquareHoleAgentMessageResponse>,
#[serde(default)]
pub last_assistant_reply: Option<String>,
#[serde(default)]
pub published_profile_id: Option<String>,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleSessionResponse {
pub session: SquareHoleSessionSnapshotResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleActionResponse {
pub session: SquareHoleSessionSnapshotResponse,
}

View File

@@ -0,0 +1,89 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct StartSquareHoleRunRequest {
pub profile_id: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DropSquareHoleShapeRequest {
#[serde(default)]
pub run_id: Option<String>,
pub hole_id: String,
pub client_snapshot_version: u64,
pub client_event_id: String,
pub dropped_at_ms: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct StopSquareHoleRunRequest {
pub client_action_id: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleShapeSnapshotResponse {
pub shape_id: String,
pub shape_kind: String,
pub label: String,
pub color: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleHoleSnapshotResponse {
pub hole_id: String,
pub hole_kind: String,
pub label: String,
pub x: f32,
pub y: f32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleDropFeedbackResponse {
pub accepted: bool,
#[serde(default)]
pub reject_reason: Option<String>,
pub message: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleRunSnapshotResponse {
pub run_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub status: String,
pub snapshot_version: u64,
pub started_at_ms: u64,
pub duration_limit_ms: u64,
pub remaining_ms: u64,
pub total_shape_count: u32,
pub completed_shape_count: u32,
pub combo: u32,
pub best_combo: u32,
pub score: u32,
pub rule_label: String,
#[serde(default)]
pub current_shape: Option<SquareHoleShapeSnapshotResponse>,
pub holes: Vec<SquareHoleHoleSnapshotResponse>,
#[serde(default)]
pub last_feedback: Option<SquareHoleDropFeedbackResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleRunResponse {
pub run: SquareHoleRunSnapshotResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleDropResponse {
pub feedback: SquareHoleDropFeedbackResponse,
pub run: SquareHoleRunSnapshotResponse,
}

View File

@@ -0,0 +1,66 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PutSquareHoleWorkRequest {
pub game_name: String,
#[serde(default)]
pub theme_text: Option<String>,
pub twist_rule: String,
pub summary: String,
pub tags: Vec<String>,
#[serde(default)]
pub cover_image_src: Option<String>,
pub shape_count: u32,
pub difficulty: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleWorkSummaryResponse {
pub work_id: String,
pub profile_id: String,
pub owner_user_id: String,
#[serde(default)]
pub source_session_id: Option<String>,
pub game_name: String,
pub theme_text: String,
pub twist_rule: String,
pub summary: String,
pub tags: Vec<String>,
#[serde(default)]
pub cover_image_src: Option<String>,
pub shape_count: u32,
pub difficulty: u32,
pub publication_status: String,
pub play_count: u32,
pub updated_at: String,
#[serde(default)]
pub published_at: Option<String>,
pub publish_ready: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleWorkProfileResponse {
#[serde(flatten)]
pub summary: SquareHoleWorkSummaryResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleWorksResponse {
pub items: Vec<SquareHoleWorkSummaryResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleWorkDetailResponse {
pub item: SquareHoleWorkProfileResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleWorkMutationResponse {
pub item: SquareHoleWorkProfileResponse,
}

View File

@@ -0,0 +1,37 @@
use serde_json::json;
use shared_contracts::runtime::{
AdminUpsertProfileInviteCodeRequest, ProfileInviteCodeAdminResponse,
};
#[test]
fn admin_upsert_invite_code_request_accepts_optional_validity_window() {
let request: AdminUpsertProfileInviteCodeRequest = serde_json::from_value(json!({
"inviteCode": "SY00000001",
"metadata": { "note": "测试" },
"startsAt": "2026-05-04T00:00:00Z",
"expiresAt": null
}))
.expect("邀请码管理请求应接受 startsAt/expiresAt");
assert_eq!(request.starts_at.as_deref(), Some("2026-05-04T00:00:00Z"));
assert_eq!(request.expires_at, None);
}
#[test]
fn admin_invite_code_response_serializes_window_and_status_as_camel_case() {
let response = ProfileInviteCodeAdminResponse {
user_id: "user-1".to_string(),
invite_code: "SY00000001".to_string(),
metadata: json!({}),
starts_at: Some("2026-05-04T00:00:00Z".to_string()),
expires_at: None,
status: "active".to_string(),
created_at: "2026-05-04T00:00:00Z".to_string(),
updated_at: "2026-05-04T00:00:00Z".to_string(),
};
let value = serde_json::to_value(response).expect("邀请码管理响应应可序列化");
assert_eq!(value["startsAt"], json!("2026-05-04T00:00:00Z"));
assert_eq!(value["expiresAt"], json!(null));
assert_eq!(value["status"], json!("active"));
}

View File

@@ -0,0 +1,51 @@
use serde_json::json;
use shared_contracts::runtime::{
AdminUpsertProfileTaskConfigRequest, ProfileTaskConfigAdminResponse, TRACKING_SCOPE_KIND_USER,
};
#[test]
fn admin_upsert_profile_task_config_keeps_personal_task_scope_user() {
let request: AdminUpsertProfileTaskConfigRequest = serde_json::from_value(json!({
"taskId": "daily_login",
"title": "每日登录",
"description": "",
"eventKey": "daily_login",
"cycle": "daily",
"scopeKind": "user",
"threshold": 1,
"rewardPoints": 10,
"enabled": true,
"sortOrder": 10
}))
.expect("个人任务配置请求应接受 user scope");
assert_eq!(request.scope_kind, TRACKING_SCOPE_KIND_USER);
let value = serde_json::to_value(request).expect("个人任务配置请求应可序列化");
assert_eq!(value["scopeKind"], json!(TRACKING_SCOPE_KIND_USER));
}
#[test]
fn profile_task_config_admin_response_serializes_scope_kind_as_user() {
let response = ProfileTaskConfigAdminResponse {
task_id: "daily_login".to_string(),
title: "每日登录".to_string(),
description: "".to_string(),
event_key: "daily_login".to_string(),
cycle: "daily".to_string(),
scope_kind: TRACKING_SCOPE_KIND_USER.to_string(),
threshold: 1,
reward_points: 10,
enabled: true,
sort_order: 10,
created_by: "admin".to_string(),
created_at: "2026-05-04T00:00:00Z".to_string(),
updated_by: "admin".to_string(),
updated_at: "2026-05-04T00:00:00Z".to_string(),
};
let value = serde_json::to_value(response).expect("个人任务配置响应应可序列化");
assert_eq!(value["scopeKind"], json!(TRACKING_SCOPE_KIND_USER));
assert_eq!(value["taskId"], json!("daily_login"));
assert_eq!(value["rewardPoints"], json!(10));
}

View File

@@ -17,6 +17,7 @@ module-puzzle = { path = "../module-puzzle" }
module-runtime = { path = "../module-runtime" }
module-runtime-story = { path = "../module-runtime-story" }
module-runtime-item = { path = "../module-runtime-item" }
module-square-hole = { path = "../module-square-hole" }
module-story = { path = "../module-story" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -51,6 +51,15 @@ pub use mapper::{
PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput,
PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, ResolveNpcBattleInteractionInput,
SquareHoleAgentMessageFinalizeRecordInput, SquareHoleAgentMessageRecord,
SquareHoleAgentMessageSubmitRecordInput, SquareHoleAgentSessionCreateRecordInput,
SquareHoleAgentSessionRecord, SquareHoleAnchorItemRecord, SquareHoleAnchorPackRecord,
SquareHoleCompileDraftRecordInput, SquareHoleCreatorConfigRecord,
SquareHoleDropConfirmationRecord, SquareHoleDropFeedbackRecord, SquareHoleHoleSnapshotRecord,
SquareHoleResultDraftRecord, SquareHoleRunDropRecordInput, SquareHoleRunRecord,
SquareHoleRunRestartRecordInput, SquareHoleRunStartRecordInput, SquareHoleRunStopRecordInput,
SquareHoleRunTimeUpRecordInput, SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord,
SquareHoleWorkUpdateRecordInput,
};
pub mod ai;
@@ -64,6 +73,7 @@ pub mod match3d;
pub mod npc;
pub mod puzzle;
pub mod runtime;
pub mod square_hole;
pub mod story;
pub mod story_runtime;
@@ -136,6 +146,7 @@ use module_puzzle::{
use module_runtime::{
RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme,
RuntimeProfileDashboardRecord, RuntimeProfileInviteCodeRecord, RuntimeProfilePlayStatsRecord,
AnalyticsMetricQueryResponse as DomainAnalyticsMetricQueryResponse,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
@@ -167,6 +178,7 @@ use module_runtime::{
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_analytics_metric_query_input,
build_runtime_referral_invite_center_record, build_runtime_referral_redeem_input,
build_runtime_referral_redeem_record, build_runtime_setting_get_input,
build_runtime_setting_record, build_runtime_setting_upsert_input,

View File

@@ -181,6 +181,17 @@ impl From<module_runtime::RuntimeProfileTaskCenterGetInput> for RuntimeProfileTa
}
}
impl From<module_runtime::AnalyticsMetricQueryInput> for AnalyticsMetricQueryInput {
fn from(input: module_runtime::AnalyticsMetricQueryInput) -> Self {
Self {
event_key: input.event_key,
scope_kind: map_runtime_tracking_scope_kind(input.scope_kind),
scope_id: input.scope_id,
granularity: map_analytics_granularity(input.granularity),
}
}
}
impl From<module_runtime::RuntimeProfileTaskClaimInput> for RuntimeProfileTaskClaimInput {
fn from(input: module_runtime::RuntimeProfileTaskClaimInput) -> Self {
Self {
@@ -281,6 +292,8 @@ impl From<module_runtime::RuntimeProfileInviteCodeAdminUpsertInput>
admin_user_id: input.admin_user_id,
invite_code: input.invite_code,
metadata_json: input.metadata_json,
starts_at_micros: input.starts_at_micros,
expires_at_micros: input.expires_at_micros,
updated_at_micros: input.updated_at_micros,
}
}
@@ -897,6 +910,22 @@ pub(crate) fn map_runtime_profile_task_center_procedure_result(
))
}
pub(crate) fn map_analytics_metric_query_procedure_result(
result: AnalyticsMetricQueryProcedureResult,
) -> Result<DomainAnalyticsMetricQueryResponse, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
Ok(DomainAnalyticsMetricQueryResponse {
buckets: result
.buckets
.into_iter()
.map(map_analytics_bucket_metric)
.collect(),
})
}
pub(crate) fn map_runtime_profile_task_claim_procedure_result(
result: RuntimeProfileTaskClaimProcedureResult,
) -> Result<RuntimeProfileTaskClaimRecord, SpacetimeClientError> {
@@ -1546,6 +1575,110 @@ pub(crate) fn map_match3d_click_item_procedure_result(
})
}
pub(crate) fn map_square_hole_agent_session_procedure_result(
result: SquareHoleAgentSessionProcedureResult,
) -> Result<SquareHoleAgentSessionRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
let session_json = result
.session_json
.ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole agent session 快照"))?;
let session =
serde_json::from_str::<SquareHoleAgentSessionJsonRecord>(&session_json).map_err(
|error| {
SpacetimeClientError::Runtime(format!(
"square hole session_json 非法: {error}"
))
},
)?;
Ok(map_square_hole_agent_session_snapshot(session))
}
pub(crate) fn map_square_hole_work_procedure_result(
result: SquareHoleWorkProcedureResult,
) -> Result<SquareHoleWorkProfileRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
let work_json = result
.work_json
.ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole work 快照"))?;
let work = serde_json::from_str::<SquareHoleWorkJsonRecord>(&work_json).map_err(|error| {
SpacetimeClientError::Runtime(format!("square hole work_json 非法: {error}"))
})?;
Ok(map_square_hole_work_snapshot(work))
}
pub(crate) fn map_square_hole_works_procedure_result(
result: SquareHoleWorksProcedureResult,
) -> Result<Vec<SquareHoleWorkProfileRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
let items_json = result
.items_json
.ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole works 快照"))?;
let items = serde_json::from_str::<Vec<SquareHoleWorkJsonRecord>>(&items_json).map_err(
|error| {
SpacetimeClientError::Runtime(format!("square hole works items_json 非法: {error}"))
},
)?;
Ok(items.into_iter().map(map_square_hole_work_snapshot).collect())
}
pub(crate) fn map_square_hole_run_procedure_result(
result: SquareHoleRunProcedureResult,
) -> Result<SquareHoleRunRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
let run_json = result
.run_json
.ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole run 快照"))?;
map_square_hole_run_json(run_json)
}
pub(crate) fn map_square_hole_drop_shape_procedure_result(
result: SquareHoleDropShapeProcedureResult,
) -> Result<SquareHoleDropConfirmationRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
let run_json = result
.run_json
.ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole drop run 快照"))?;
let feedback_json = result.feedback_json.ok_or_else(|| {
SpacetimeClientError::missing_snapshot("square hole drop feedback 快照")
})?;
let run = map_square_hole_run_json(run_json)?;
let feedback =
serde_json::from_str::<SquareHoleDropFeedbackJsonRecord>(&feedback_json).map_err(
|error| {
SpacetimeClientError::Runtime(format!(
"square hole feedback_json 非法: {error}"
))
},
)?;
Ok(SquareHoleDropConfirmationRecord {
status: result.status,
accepted: feedback.accepted,
reject_reason: feedback.reject_reason.clone(),
failure_reason: result.failure_reason,
feedback: map_square_hole_feedback_snapshot(feedback),
run,
})
}
pub(crate) fn map_story_session_procedure_result(
result: StorySessionProcedureResult,
) -> Result<StorySessionResultRecord, SpacetimeClientError> {
@@ -1749,6 +1882,17 @@ pub(crate) fn map_runtime_profile_dashboard_snapshot(
}
}
pub(crate) fn map_analytics_bucket_metric(
bucket: AnalyticsBucketMetric,
) -> module_runtime::AnalyticsBucketMetric {
module_runtime::AnalyticsBucketMetric {
bucket_key: bucket.bucket_key,
bucket_start_date_key: bucket.bucket_start_date_key,
bucket_end_date_key: bucket.bucket_end_date_key,
value: bucket.value,
}
}
pub(crate) fn map_runtime_profile_wallet_ledger_entry_snapshot(
snapshot: RuntimeProfileWalletLedgerEntrySnapshot,
) -> module_runtime::RuntimeProfileWalletLedgerEntrySnapshot {
@@ -1997,6 +2141,8 @@ pub(crate) fn map_runtime_profile_invite_code_snapshot(
user_id: snapshot.user_id,
invite_code: snapshot.invite_code,
metadata_json: snapshot.metadata_json,
starts_at_micros: snapshot.starts_at_micros,
expires_at_micros: snapshot.expires_at_micros,
created_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
@@ -2773,6 +2919,198 @@ fn build_match3d_anchor_item(key: &str, label: &str, value: &str) -> Match3DAnch
}
}
fn map_square_hole_agent_session_snapshot(
snapshot: SquareHoleAgentSessionJsonRecord,
) -> SquareHoleAgentSessionRecord {
let config = map_square_hole_creator_config(snapshot.config);
SquareHoleAgentSessionRecord {
session_id: snapshot.session_id,
current_turn: snapshot.current_turn,
progress_percent: snapshot.progress_percent,
stage: normalize_square_hole_stage(&snapshot.stage).to_string(),
anchor_pack: build_square_hole_anchor_pack(&config),
config,
draft: snapshot.draft.map(map_square_hole_result_draft),
messages: snapshot
.messages
.into_iter()
.map(map_square_hole_agent_message_snapshot)
.collect(),
last_assistant_reply: empty_string_to_none(snapshot.last_assistant_reply),
published_profile_id: snapshot.published_profile_id,
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
}
}
fn map_square_hole_creator_config(
snapshot: SquareHoleCreatorConfigJsonRecord,
) -> SquareHoleCreatorConfigRecord {
SquareHoleCreatorConfigRecord {
theme_text: snapshot.theme_text,
twist_rule: snapshot.twist_rule,
shape_count: snapshot.shape_count,
difficulty: snapshot.difficulty,
}
}
fn map_square_hole_result_draft(
snapshot: SquareHoleDraftJsonRecord,
) -> SquareHoleResultDraftRecord {
SquareHoleResultDraftRecord {
profile_id: snapshot.profile_id,
game_name: snapshot.game_name,
theme_text: snapshot.theme_text,
twist_rule: snapshot.twist_rule,
summary: snapshot.summary_text,
tags: snapshot.tags,
shape_count: snapshot.shape_count,
difficulty: snapshot.difficulty,
publish_ready: false,
blockers: Vec::new(),
}
}
fn map_square_hole_agent_message_snapshot(
snapshot: SquareHoleAgentMessageJsonRecord,
) -> SquareHoleAgentMessageRecord {
SquareHoleAgentMessageRecord {
id: snapshot.message_id,
role: snapshot.role,
kind: normalize_square_hole_message_kind(&snapshot.kind).to_string(),
text: snapshot.text,
created_at: format_timestamp_micros(snapshot.created_at_micros),
}
}
fn map_square_hole_work_snapshot(
snapshot: SquareHoleWorkJsonRecord,
) -> SquareHoleWorkProfileRecord {
SquareHoleWorkProfileRecord {
work_id: snapshot.work_id,
profile_id: snapshot.profile_id,
owner_user_id: snapshot.owner_user_id,
source_session_id: empty_string_to_none(snapshot.source_session_id),
author_display_name: snapshot.author_display_name,
game_name: snapshot.game_name,
theme_text: snapshot.theme_text,
twist_rule: snapshot.twist_rule,
summary: snapshot.summary_text,
tags: snapshot.tags,
cover_image_src: empty_string_to_none(snapshot.cover_image_src),
shape_count: snapshot.shape_count,
difficulty: snapshot.difficulty,
publication_status: normalize_square_hole_publication_status(&snapshot.publication_status)
.to_string(),
play_count: snapshot.play_count,
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
published_at: snapshot.published_at_micros.map(format_timestamp_micros),
publish_ready: snapshot.publish_ready,
}
}
fn map_square_hole_run_json(run_json: String) -> Result<SquareHoleRunRecord, SpacetimeClientError> {
let run = serde_json::from_str::<SquareHoleRunJsonRecord>(&run_json).map_err(|error| {
SpacetimeClientError::Runtime(format!("square hole run_json 非法: {error}"))
})?;
Ok(map_square_hole_run_snapshot(run))
}
fn map_square_hole_run_snapshot(snapshot: SquareHoleRunJsonRecord) -> SquareHoleRunRecord {
SquareHoleRunRecord {
run_id: snapshot.run_id,
profile_id: snapshot.profile_id,
owner_user_id: snapshot.owner_user_id,
status: normalize_square_hole_run_status(&snapshot.status).to_string(),
snapshot_version: snapshot.snapshot_version,
started_at_ms: i64_to_u64_ms(snapshot.started_at_ms),
duration_limit_ms: i64_to_u64_ms(snapshot.duration_limit_ms),
server_now_ms: Some(i64_to_u64_ms(snapshot.server_now_ms)),
remaining_ms: i64_to_u64_ms(snapshot.remaining_ms),
total_shape_count: snapshot.total_shape_count,
completed_shape_count: snapshot.completed_shape_count,
combo: snapshot.combo,
best_combo: snapshot.best_combo,
score: snapshot.score,
rule_label: snapshot.rule_label,
current_shape: snapshot.current_shape.map(map_square_hole_shape_snapshot),
holes: snapshot
.holes
.into_iter()
.map(map_square_hole_hole_snapshot)
.collect(),
last_feedback: snapshot
.last_feedback
.map(map_square_hole_feedback_snapshot),
last_confirmed_action_id: None,
}
}
fn map_square_hole_shape_snapshot(
snapshot: SquareHoleShapeJsonRecord,
) -> SquareHoleShapeSnapshotRecord {
SquareHoleShapeSnapshotRecord {
shape_id: snapshot.shape_id,
shape_kind: snapshot.shape_kind,
label: snapshot.label,
color: snapshot.color,
}
}
fn map_square_hole_hole_snapshot(
snapshot: SquareHoleHoleJsonRecord,
) -> SquareHoleHoleSnapshotRecord {
SquareHoleHoleSnapshotRecord {
hole_id: snapshot.hole_id,
hole_kind: snapshot.hole_kind,
label: snapshot.label,
x: snapshot.x,
y: snapshot.y,
}
}
fn map_square_hole_feedback_snapshot(
snapshot: SquareHoleDropFeedbackJsonRecord,
) -> SquareHoleDropFeedbackRecord {
SquareHoleDropFeedbackRecord {
accepted: snapshot.accepted,
reject_reason: snapshot
.reject_reason
.map(|value| normalize_square_hole_reject_reason(&value).to_string()),
message: snapshot.message,
}
}
fn build_square_hole_anchor_pack(
config: &SquareHoleCreatorConfigRecord,
) -> SquareHoleAnchorPackRecord {
let shape_count = config.shape_count.to_string();
let difficulty = config.difficulty.to_string();
SquareHoleAnchorPackRecord {
theme: build_square_hole_anchor_item("theme", "题材主题", config.theme_text.as_str()),
twist_rule: build_square_hole_anchor_item("twistRule", "反差规则", config.twist_rule.as_str()),
shape_count: build_square_hole_anchor_item("shapeCount", "形状数量", shape_count.as_str()),
difficulty: build_square_hole_anchor_item("difficulty", "难度", difficulty.as_str()),
}
}
fn build_square_hole_anchor_item(
key: &str,
label: &str,
value: &str,
) -> SquareHoleAnchorItemRecord {
SquareHoleAnchorItemRecord {
key: key.to_string(),
label: label.to_string(),
value: value.to_string(),
status: if value.trim().is_empty() {
"missing"
} else {
"confirmed"
}
.to_string(),
}
}
fn normalize_match3d_stage(value: &str) -> &str {
match value {
"Collecting" | "collecting" | "collecting_config" => "collecting_config",
@@ -2798,6 +3136,54 @@ fn normalize_match3d_message_kind(value: &str) -> &str {
}
}
fn normalize_square_hole_stage(value: &str) -> &str {
match value {
"Collecting" | "CollectingConfig" | "collecting" | "collecting_config" => {
"collecting_config"
}
"ReadyToCompile" | "ready_to_compile" => "ready_to_compile",
"DraftCompiled" | "DraftReady" | "draft_compiled" | "draft_ready" => "draft_ready",
"Published" | "published" => "published",
_ => value,
}
}
fn normalize_square_hole_publication_status(value: &str) -> &str {
match value {
"Draft" | "draft" => "draft",
"Published" | "published" => "published",
_ => value,
}
}
fn normalize_square_hole_run_status(value: &str) -> &str {
match value {
"Running" | "running" => "running",
"Won" | "won" => "won",
"Failed" | "failed" => "failed",
"Stopped" | "stopped" => "stopped",
_ => value,
}
}
fn normalize_square_hole_message_kind(value: &str) -> &str {
match value {
"text" => "chat",
_ => value,
}
}
fn normalize_square_hole_reject_reason(value: &str) -> &str {
match value {
"RunNotActive" | "run_not_active" => "run_not_active",
"SnapshotVersionMismatch" | "snapshot_version_mismatch" => "snapshot_version_mismatch",
"HoleNotFound" | "hole_not_found" => "hole_not_found",
"Incompatible" | "incompatible" => "incompatible",
"TimeUp" | "time_up" => "time_up",
_ => value,
}
}
fn empty_string_to_none(value: String) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
@@ -4008,6 +4394,18 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back(
}
}
pub(crate) fn map_analytics_granularity(
granularity: module_runtime::AnalyticsGranularity,
) -> AnalyticsGranularity {
match granularity {
module_runtime::AnalyticsGranularity::Day => AnalyticsGranularity::Day,
module_runtime::AnalyticsGranularity::Week => AnalyticsGranularity::Week,
module_runtime::AnalyticsGranularity::Month => AnalyticsGranularity::Month,
module_runtime::AnalyticsGranularity::Quarter => AnalyticsGranularity::Quarter,
module_runtime::AnalyticsGranularity::Year => AnalyticsGranularity::Year,
}
}
pub(crate) fn map_runtime_tracking_scope_kind(
value: DomainRuntimeTrackingScopeKind,
) -> crate::module_bindings::RuntimeTrackingScopeKind {
@@ -5538,6 +5936,378 @@ struct Match3DRunJsonRecord {
failure_reason: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SquareHoleAgentSessionCreateRecordInput {
pub session_id: String,
pub owner_user_id: String,
pub seed_text: String,
pub welcome_message_id: String,
pub welcome_message_text: String,
pub config_json: Option<String>,
pub created_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SquareHoleAgentMessageSubmitRecordInput {
pub session_id: String,
pub owner_user_id: String,
pub user_message_id: String,
pub user_message_text: String,
pub submitted_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SquareHoleAgentMessageFinalizeRecordInput {
pub session_id: String,
pub owner_user_id: String,
pub assistant_message_id: Option<String>,
pub assistant_reply_text: Option<String>,
pub config_json: Option<String>,
pub progress_percent: u32,
pub stage: String,
pub updated_at_micros: i64,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SquareHoleCompileDraftRecordInput {
pub session_id: String,
pub owner_user_id: String,
pub profile_id: String,
pub author_display_name: String,
pub game_name: Option<String>,
pub summary_text: Option<String>,
pub tags_json: Option<String>,
pub cover_image_src: Option<String>,
pub compiled_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SquareHoleWorkUpdateRecordInput {
pub profile_id: String,
pub owner_user_id: String,
pub game_name: String,
pub theme_text: String,
pub twist_rule: String,
pub summary_text: String,
pub tags_json: String,
pub cover_image_src: String,
pub shape_count: u32,
pub difficulty: u32,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SquareHoleRunStartRecordInput {
pub run_id: String,
pub owner_user_id: String,
pub profile_id: String,
pub started_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SquareHoleRunDropRecordInput {
pub run_id: String,
pub owner_user_id: String,
pub hole_id: String,
pub client_snapshot_version: u64,
pub client_event_id: String,
pub dropped_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SquareHoleRunStopRecordInput {
pub run_id: String,
pub owner_user_id: String,
pub stopped_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SquareHoleRunRestartRecordInput {
pub source_run_id: String,
pub next_run_id: String,
pub owner_user_id: String,
pub restarted_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SquareHoleRunTimeUpRecordInput {
pub run_id: String,
pub owner_user_id: String,
pub finished_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SquareHoleAnchorItemRecord {
pub key: String,
pub label: String,
pub value: String,
pub status: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SquareHoleAnchorPackRecord {
pub theme: SquareHoleAnchorItemRecord,
pub twist_rule: SquareHoleAnchorItemRecord,
pub shape_count: SquareHoleAnchorItemRecord,
pub difficulty: SquareHoleAnchorItemRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SquareHoleCreatorConfigRecord {
pub theme_text: String,
pub twist_rule: String,
pub shape_count: u32,
pub difficulty: u32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SquareHoleResultDraftRecord {
pub profile_id: String,
pub game_name: String,
pub theme_text: String,
pub twist_rule: String,
pub summary: String,
pub tags: Vec<String>,
pub shape_count: u32,
pub difficulty: u32,
pub publish_ready: bool,
pub blockers: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SquareHoleAgentMessageRecord {
pub id: String,
pub role: String,
pub kind: String,
pub text: String,
pub created_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SquareHoleAgentSessionRecord {
pub session_id: String,
pub current_turn: u32,
pub progress_percent: u32,
pub stage: String,
pub anchor_pack: SquareHoleAnchorPackRecord,
pub config: SquareHoleCreatorConfigRecord,
pub draft: Option<SquareHoleResultDraftRecord>,
pub messages: Vec<SquareHoleAgentMessageRecord>,
pub last_assistant_reply: Option<String>,
pub published_profile_id: Option<String>,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SquareHoleWorkProfileRecord {
pub work_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub source_session_id: Option<String>,
pub author_display_name: String,
pub game_name: String,
pub theme_text: String,
pub twist_rule: String,
pub summary: String,
pub tags: Vec<String>,
pub cover_image_src: Option<String>,
pub shape_count: u32,
pub difficulty: u32,
pub publication_status: String,
pub play_count: u32,
pub updated_at: String,
pub published_at: Option<String>,
pub publish_ready: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SquareHoleShapeSnapshotRecord {
pub shape_id: String,
pub shape_kind: String,
pub label: String,
pub color: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct SquareHoleHoleSnapshotRecord {
pub hole_id: String,
pub hole_kind: String,
pub label: String,
pub x: f32,
pub y: f32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SquareHoleDropFeedbackRecord {
pub accepted: bool,
pub reject_reason: Option<String>,
pub message: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct SquareHoleRunRecord {
pub run_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub status: String,
pub snapshot_version: u64,
pub started_at_ms: u64,
pub duration_limit_ms: u64,
pub server_now_ms: Option<u64>,
pub remaining_ms: u64,
pub total_shape_count: u32,
pub completed_shape_count: u32,
pub combo: u32,
pub best_combo: u32,
pub score: u32,
pub rule_label: String,
pub current_shape: Option<SquareHoleShapeSnapshotRecord>,
pub holes: Vec<SquareHoleHoleSnapshotRecord>,
pub last_feedback: Option<SquareHoleDropFeedbackRecord>,
pub last_confirmed_action_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct SquareHoleDropConfirmationRecord {
pub status: String,
pub accepted: bool,
pub reject_reason: Option<String>,
pub failure_reason: Option<String>,
pub feedback: SquareHoleDropFeedbackRecord,
pub run: SquareHoleRunRecord,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct SquareHoleCreatorConfigJsonRecord {
theme_text: String,
twist_rule: String,
shape_count: u32,
difficulty: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct SquareHoleAgentMessageJsonRecord {
message_id: String,
#[allow(dead_code)]
session_id: String,
role: String,
kind: String,
text: String,
created_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct SquareHoleDraftJsonRecord {
profile_id: String,
game_name: String,
theme_text: String,
twist_rule: String,
summary_text: String,
tags: Vec<String>,
shape_count: u32,
difficulty: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct SquareHoleAgentSessionJsonRecord {
session_id: String,
#[allow(dead_code)]
owner_user_id: String,
#[allow(dead_code)]
seed_text: String,
current_turn: u32,
progress_percent: u32,
stage: String,
config: SquareHoleCreatorConfigJsonRecord,
draft: Option<SquareHoleDraftJsonRecord>,
messages: Vec<SquareHoleAgentMessageJsonRecord>,
last_assistant_reply: String,
published_profile_id: Option<String>,
#[allow(dead_code)]
created_at_micros: i64,
updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct SquareHoleWorkJsonRecord {
work_id: String,
profile_id: String,
owner_user_id: String,
source_session_id: String,
author_display_name: String,
game_name: String,
theme_text: String,
twist_rule: String,
summary_text: String,
tags: Vec<String>,
cover_image_src: String,
shape_count: u32,
difficulty: u32,
#[allow(dead_code)]
config: SquareHoleCreatorConfigJsonRecord,
publication_status: String,
publish_ready: bool,
play_count: u32,
updated_at_micros: i64,
published_at_micros: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct SquareHoleShapeJsonRecord {
shape_id: String,
shape_kind: String,
label: String,
color: String,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct SquareHoleHoleJsonRecord {
hole_id: String,
hole_kind: String,
label: String,
x: f32,
y: f32,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct SquareHoleDropFeedbackJsonRecord {
accepted: bool,
reject_reason: Option<String>,
message: String,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct SquareHoleRunJsonRecord {
run_id: String,
profile_id: String,
owner_user_id: String,
status: String,
snapshot_version: u64,
started_at_ms: i64,
duration_limit_ms: i64,
server_now_ms: i64,
remaining_ms: i64,
total_shape_count: u32,
completed_shape_count: u32,
combo: u32,
best_combo: u32,
score: u32,
rule_label: String,
current_shape: Option<SquareHoleShapeJsonRecord>,
holes: Vec<SquareHoleHoleJsonRecord>,
last_feedback: Option<SquareHoleDropFeedbackJsonRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleAnchorItemRecord {
pub key: String,

View File

@@ -0,0 +1,166 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::ai_result_reference_kind_type::AiResultReferenceKind;
use super::ai_result_reference_type::AiResultReference;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `ai_result_reference`.
///
/// Obtain a handle from the [`AiResultReferenceTableAccess::ai_result_reference`] method on [`super::RemoteTables`],
/// like `ctx.db.ai_result_reference()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.ai_result_reference().on_insert(...)`.
pub struct AiResultReferenceTableHandle<'ctx> {
imp: __sdk::TableHandle<AiResultReference>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `ai_result_reference`.
///
/// Implemented for [`super::RemoteTables`].
pub trait AiResultReferenceTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`AiResultReferenceTableHandle`], which mediates access to the table `ai_result_reference`.
fn ai_result_reference(&self) -> AiResultReferenceTableHandle<'_>;
}
impl AiResultReferenceTableAccess for super::RemoteTables {
fn ai_result_reference(&self) -> AiResultReferenceTableHandle<'_> {
AiResultReferenceTableHandle {
imp: self
.imp
.get_table::<AiResultReference>("ai_result_reference"),
ctx: std::marker::PhantomData,
}
}
}
pub struct AiResultReferenceInsertCallbackId(__sdk::CallbackId);
pub struct AiResultReferenceDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for AiResultReferenceTableHandle<'ctx> {
type Row = AiResultReference;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = AiResultReference> + '_ {
self.imp.iter()
}
type InsertCallbackId = AiResultReferenceInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> AiResultReferenceInsertCallbackId {
AiResultReferenceInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: AiResultReferenceInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = AiResultReferenceDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> AiResultReferenceDeleteCallbackId {
AiResultReferenceDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: AiResultReferenceDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct AiResultReferenceUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for AiResultReferenceTableHandle<'ctx> {
type UpdateCallbackId = AiResultReferenceUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> AiResultReferenceUpdateCallbackId {
AiResultReferenceUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: AiResultReferenceUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `result_reference_row_id` unique index on the table `ai_result_reference`,
/// which allows point queries on the field of the same name
/// via the [`AiResultReferenceResultReferenceRowIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.ai_result_reference().result_reference_row_id().find(...)`.
pub struct AiResultReferenceResultReferenceRowIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<AiResultReference, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> AiResultReferenceTableHandle<'ctx> {
/// Get a handle on the `result_reference_row_id` unique index on the table `ai_result_reference`.
pub fn result_reference_row_id(&self) -> AiResultReferenceResultReferenceRowIdUnique<'ctx> {
AiResultReferenceResultReferenceRowIdUnique {
imp: self
.imp
.get_unique_constraint::<String>("result_reference_row_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> AiResultReferenceResultReferenceRowIdUnique<'ctx> {
/// Find the subscribed row whose `result_reference_row_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<AiResultReference> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<AiResultReference>("ai_result_reference");
_table.add_unique_constraint::<String>("result_reference_row_id", |row| {
&row.result_reference_row_id
});
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<AiResultReference>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<AiResultReference>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `AiResultReference`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait ai_result_referenceQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `AiResultReference`.
fn ai_result_reference(&self) -> __sdk::__query_builder::Table<AiResultReference>;
}
impl ai_result_referenceQueryTableAccess for __sdk::QueryTableAccessor {
fn ai_result_reference(&self) -> __sdk::__query_builder::Table<AiResultReference> {
__sdk::__query_builder::Table::new("ai_result_reference")
}
}

View File

@@ -0,0 +1,161 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::ai_task_stage_kind_type::AiTaskStageKind;
use super::ai_task_stage_status_type::AiTaskStageStatus;
use super::ai_task_stage_type::AiTaskStage;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `ai_task_stage`.
///
/// Obtain a handle from the [`AiTaskStageTableAccess::ai_task_stage`] method on [`super::RemoteTables`],
/// like `ctx.db.ai_task_stage()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.ai_task_stage().on_insert(...)`.
pub struct AiTaskStageTableHandle<'ctx> {
imp: __sdk::TableHandle<AiTaskStage>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `ai_task_stage`.
///
/// Implemented for [`super::RemoteTables`].
pub trait AiTaskStageTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`AiTaskStageTableHandle`], which mediates access to the table `ai_task_stage`.
fn ai_task_stage(&self) -> AiTaskStageTableHandle<'_>;
}
impl AiTaskStageTableAccess for super::RemoteTables {
fn ai_task_stage(&self) -> AiTaskStageTableHandle<'_> {
AiTaskStageTableHandle {
imp: self.imp.get_table::<AiTaskStage>("ai_task_stage"),
ctx: std::marker::PhantomData,
}
}
}
pub struct AiTaskStageInsertCallbackId(__sdk::CallbackId);
pub struct AiTaskStageDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for AiTaskStageTableHandle<'ctx> {
type Row = AiTaskStage;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = AiTaskStage> + '_ {
self.imp.iter()
}
type InsertCallbackId = AiTaskStageInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> AiTaskStageInsertCallbackId {
AiTaskStageInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: AiTaskStageInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = AiTaskStageDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> AiTaskStageDeleteCallbackId {
AiTaskStageDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: AiTaskStageDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct AiTaskStageUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for AiTaskStageTableHandle<'ctx> {
type UpdateCallbackId = AiTaskStageUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> AiTaskStageUpdateCallbackId {
AiTaskStageUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: AiTaskStageUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `task_stage_id` unique index on the table `ai_task_stage`,
/// which allows point queries on the field of the same name
/// via the [`AiTaskStageTaskStageIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.ai_task_stage().task_stage_id().find(...)`.
pub struct AiTaskStageTaskStageIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<AiTaskStage, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> AiTaskStageTableHandle<'ctx> {
/// Get a handle on the `task_stage_id` unique index on the table `ai_task_stage`.
pub fn task_stage_id(&self) -> AiTaskStageTaskStageIdUnique<'ctx> {
AiTaskStageTaskStageIdUnique {
imp: self.imp.get_unique_constraint::<String>("task_stage_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> AiTaskStageTaskStageIdUnique<'ctx> {
/// Find the subscribed row whose `task_stage_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<AiTaskStage> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<AiTaskStage>("ai_task_stage");
_table.add_unique_constraint::<String>("task_stage_id", |row| &row.task_stage_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<AiTaskStage>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<AiTaskStage>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `AiTaskStage`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait ai_task_stageQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `AiTaskStage`.
fn ai_task_stage(&self) -> __sdk::__query_builder::Table<AiTaskStage>;
}
impl ai_task_stageQueryTableAccess for __sdk::QueryTableAccessor {
fn ai_task_stage(&self) -> __sdk::__query_builder::Table<AiTaskStage> {
__sdk::__query_builder::Table::new("ai_task_stage")
}
}

View File

@@ -0,0 +1,161 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::ai_task_kind_type::AiTaskKind;
use super::ai_task_status_type::AiTaskStatus;
use super::ai_task_type::AiTask;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `ai_task`.
///
/// Obtain a handle from the [`AiTaskTableAccess::ai_task`] method on [`super::RemoteTables`],
/// like `ctx.db.ai_task()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.ai_task().on_insert(...)`.
pub struct AiTaskTableHandle<'ctx> {
imp: __sdk::TableHandle<AiTask>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `ai_task`.
///
/// Implemented for [`super::RemoteTables`].
pub trait AiTaskTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`AiTaskTableHandle`], which mediates access to the table `ai_task`.
fn ai_task(&self) -> AiTaskTableHandle<'_>;
}
impl AiTaskTableAccess for super::RemoteTables {
fn ai_task(&self) -> AiTaskTableHandle<'_> {
AiTaskTableHandle {
imp: self.imp.get_table::<AiTask>("ai_task"),
ctx: std::marker::PhantomData,
}
}
}
pub struct AiTaskInsertCallbackId(__sdk::CallbackId);
pub struct AiTaskDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for AiTaskTableHandle<'ctx> {
type Row = AiTask;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = AiTask> + '_ {
self.imp.iter()
}
type InsertCallbackId = AiTaskInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> AiTaskInsertCallbackId {
AiTaskInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: AiTaskInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = AiTaskDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> AiTaskDeleteCallbackId {
AiTaskDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: AiTaskDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct AiTaskUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for AiTaskTableHandle<'ctx> {
type UpdateCallbackId = AiTaskUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> AiTaskUpdateCallbackId {
AiTaskUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: AiTaskUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `task_id` unique index on the table `ai_task`,
/// which allows point queries on the field of the same name
/// via the [`AiTaskTaskIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.ai_task().task_id().find(...)`.
pub struct AiTaskTaskIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<AiTask, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> AiTaskTableHandle<'ctx> {
/// Get a handle on the `task_id` unique index on the table `ai_task`.
pub fn task_id(&self) -> AiTaskTaskIdUnique<'ctx> {
AiTaskTaskIdUnique {
imp: self.imp.get_unique_constraint::<String>("task_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> AiTaskTaskIdUnique<'ctx> {
/// Find the subscribed row whose `task_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<AiTask> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<AiTask>("ai_task");
_table.add_unique_constraint::<String>("task_id", |row| &row.task_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<AiTask>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<AiTask>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `AiTask`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait ai_taskQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `AiTask`.
fn ai_task(&self) -> __sdk::__query_builder::Table<AiTask>;
}
impl ai_taskQueryTableAccess for __sdk::QueryTableAccessor {
fn ai_task(&self) -> __sdk::__query_builder::Table<AiTask> {
__sdk::__query_builder::Table::new("ai_task")
}
}

View File

@@ -0,0 +1,162 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::ai_task_stage_kind_type::AiTaskStageKind;
use super::ai_text_chunk_type::AiTextChunk;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `ai_text_chunk`.
///
/// Obtain a handle from the [`AiTextChunkTableAccess::ai_text_chunk`] method on [`super::RemoteTables`],
/// like `ctx.db.ai_text_chunk()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.ai_text_chunk().on_insert(...)`.
pub struct AiTextChunkTableHandle<'ctx> {
imp: __sdk::TableHandle<AiTextChunk>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `ai_text_chunk`.
///
/// Implemented for [`super::RemoteTables`].
pub trait AiTextChunkTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`AiTextChunkTableHandle`], which mediates access to the table `ai_text_chunk`.
fn ai_text_chunk(&self) -> AiTextChunkTableHandle<'_>;
}
impl AiTextChunkTableAccess for super::RemoteTables {
fn ai_text_chunk(&self) -> AiTextChunkTableHandle<'_> {
AiTextChunkTableHandle {
imp: self.imp.get_table::<AiTextChunk>("ai_text_chunk"),
ctx: std::marker::PhantomData,
}
}
}
pub struct AiTextChunkInsertCallbackId(__sdk::CallbackId);
pub struct AiTextChunkDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for AiTextChunkTableHandle<'ctx> {
type Row = AiTextChunk;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = AiTextChunk> + '_ {
self.imp.iter()
}
type InsertCallbackId = AiTextChunkInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> AiTextChunkInsertCallbackId {
AiTextChunkInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: AiTextChunkInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = AiTextChunkDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> AiTextChunkDeleteCallbackId {
AiTextChunkDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: AiTextChunkDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct AiTextChunkUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for AiTextChunkTableHandle<'ctx> {
type UpdateCallbackId = AiTextChunkUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> AiTextChunkUpdateCallbackId {
AiTextChunkUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: AiTextChunkUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `text_chunk_row_id` unique index on the table `ai_text_chunk`,
/// which allows point queries on the field of the same name
/// via the [`AiTextChunkTextChunkRowIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.ai_text_chunk().text_chunk_row_id().find(...)`.
pub struct AiTextChunkTextChunkRowIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<AiTextChunk, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> AiTextChunkTableHandle<'ctx> {
/// Get a handle on the `text_chunk_row_id` unique index on the table `ai_text_chunk`.
pub fn text_chunk_row_id(&self) -> AiTextChunkTextChunkRowIdUnique<'ctx> {
AiTextChunkTextChunkRowIdUnique {
imp: self
.imp
.get_unique_constraint::<String>("text_chunk_row_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> AiTextChunkTextChunkRowIdUnique<'ctx> {
/// Find the subscribed row whose `text_chunk_row_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<AiTextChunk> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<AiTextChunk>("ai_text_chunk");
_table.add_unique_constraint::<String>("text_chunk_row_id", |row| &row.text_chunk_row_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<AiTextChunk>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<AiTextChunk>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `AiTextChunk`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait ai_text_chunkQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `AiTextChunk`.
fn ai_text_chunk(&self) -> __sdk::__query_builder::Table<AiTextChunk>;
}
impl ai_text_chunkQueryTableAccess for __sdk::QueryTableAccessor {
fn ai_text_chunk(&self) -> __sdk::__query_builder::Table<AiTextChunk> {
__sdk::__query_builder::Table::new("ai_text_chunk")
}
}

View File

@@ -0,0 +1,18 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct AnalyticsBucketMetric {
pub bucket_key: String,
pub bucket_start_date_key: i64,
pub bucket_end_date_key: i64,
pub value: u64,
}
impl __sdk::InModule for AnalyticsBucketMetric {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,15 @@
// 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 AnalyticsDateDimensionEnsureInput {
pub date_key: i64,
}
impl __sdk::InModule for AnalyticsDateDimensionEnsureInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,16 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct AnalyticsDateDimensionSeedInput {
pub start_date: String,
pub end_date: String,
}
impl __sdk::InModule for AnalyticsDateDimensionSeedInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,162 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::analytics_date_dimension_type::AnalyticsDateDimension;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `analytics_date_dimension`.
///
/// Obtain a handle from the [`AnalyticsDateDimensionTableAccess::analytics_date_dimension`] method on [`super::RemoteTables`],
/// like `ctx.db.analytics_date_dimension()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.analytics_date_dimension().on_insert(...)`.
pub struct AnalyticsDateDimensionTableHandle<'ctx> {
imp: __sdk::TableHandle<AnalyticsDateDimension>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `analytics_date_dimension`.
///
/// Implemented for [`super::RemoteTables`].
pub trait AnalyticsDateDimensionTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`AnalyticsDateDimensionTableHandle`], which mediates access to the table `analytics_date_dimension`.
fn analytics_date_dimension(&self) -> AnalyticsDateDimensionTableHandle<'_>;
}
impl AnalyticsDateDimensionTableAccess for super::RemoteTables {
fn analytics_date_dimension(&self) -> AnalyticsDateDimensionTableHandle<'_> {
AnalyticsDateDimensionTableHandle {
imp: self
.imp
.get_table::<AnalyticsDateDimension>("analytics_date_dimension"),
ctx: std::marker::PhantomData,
}
}
}
pub struct AnalyticsDateDimensionInsertCallbackId(__sdk::CallbackId);
pub struct AnalyticsDateDimensionDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for AnalyticsDateDimensionTableHandle<'ctx> {
type Row = AnalyticsDateDimension;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = AnalyticsDateDimension> + '_ {
self.imp.iter()
}
type InsertCallbackId = AnalyticsDateDimensionInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> AnalyticsDateDimensionInsertCallbackId {
AnalyticsDateDimensionInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: AnalyticsDateDimensionInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = AnalyticsDateDimensionDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> AnalyticsDateDimensionDeleteCallbackId {
AnalyticsDateDimensionDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: AnalyticsDateDimensionDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct AnalyticsDateDimensionUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for AnalyticsDateDimensionTableHandle<'ctx> {
type UpdateCallbackId = AnalyticsDateDimensionUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> AnalyticsDateDimensionUpdateCallbackId {
AnalyticsDateDimensionUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: AnalyticsDateDimensionUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `date_key` unique index on the table `analytics_date_dimension`,
/// which allows point queries on the field of the same name
/// via the [`AnalyticsDateDimensionDateKeyUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.analytics_date_dimension().date_key().find(...)`.
pub struct AnalyticsDateDimensionDateKeyUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<AnalyticsDateDimension, i64>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> AnalyticsDateDimensionTableHandle<'ctx> {
/// Get a handle on the `date_key` unique index on the table `analytics_date_dimension`.
pub fn date_key(&self) -> AnalyticsDateDimensionDateKeyUnique<'ctx> {
AnalyticsDateDimensionDateKeyUnique {
imp: self.imp.get_unique_constraint::<i64>("date_key"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> AnalyticsDateDimensionDateKeyUnique<'ctx> {
/// Find the subscribed row whose `date_key` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &i64) -> Option<AnalyticsDateDimension> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table =
client_cache.get_or_make_table::<AnalyticsDateDimension>("analytics_date_dimension");
_table.add_unique_constraint::<i64>("date_key", |row| &row.date_key);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<AnalyticsDateDimension>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<AnalyticsDateDimension>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `AnalyticsDateDimension`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait analytics_date_dimensionQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `AnalyticsDateDimension`.
fn analytics_date_dimension(&self) -> __sdk::__query_builder::Table<AnalyticsDateDimension>;
}
impl analytics_date_dimensionQueryTableAccess for __sdk::QueryTableAccessor {
fn analytics_date_dimension(&self) -> __sdk::__query_builder::Table<AnalyticsDateDimension> {
__sdk::__query_builder::Table::new("analytics_date_dimension")
}
}

View File

@@ -0,0 +1,120 @@
// 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 AnalyticsDateDimension {
pub date_key: i64,
pub calendar_date: String,
pub weekday: u8,
pub iso_week_key: i32,
pub week_start_date_key: i64,
pub week_end_date_key: i64,
pub month_key: i32,
pub month_start_date_key: i64,
pub month_end_date_key: i64,
pub quarter_key: i32,
pub quarter_start_date_key: i64,
pub quarter_end_date_key: i64,
pub year_key: i32,
pub year_start_date_key: i64,
pub year_end_date_key: i64,
pub created_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for AnalyticsDateDimension {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `AnalyticsDateDimension`.
///
/// Provides typed access to columns for query building.
pub struct AnalyticsDateDimensionCols {
pub date_key: __sdk::__query_builder::Col<AnalyticsDateDimension, i64>,
pub calendar_date: __sdk::__query_builder::Col<AnalyticsDateDimension, String>,
pub weekday: __sdk::__query_builder::Col<AnalyticsDateDimension, u8>,
pub iso_week_key: __sdk::__query_builder::Col<AnalyticsDateDimension, i32>,
pub week_start_date_key: __sdk::__query_builder::Col<AnalyticsDateDimension, i64>,
pub week_end_date_key: __sdk::__query_builder::Col<AnalyticsDateDimension, i64>,
pub month_key: __sdk::__query_builder::Col<AnalyticsDateDimension, i32>,
pub month_start_date_key: __sdk::__query_builder::Col<AnalyticsDateDimension, i64>,
pub month_end_date_key: __sdk::__query_builder::Col<AnalyticsDateDimension, i64>,
pub quarter_key: __sdk::__query_builder::Col<AnalyticsDateDimension, i32>,
pub quarter_start_date_key: __sdk::__query_builder::Col<AnalyticsDateDimension, i64>,
pub quarter_end_date_key: __sdk::__query_builder::Col<AnalyticsDateDimension, i64>,
pub year_key: __sdk::__query_builder::Col<AnalyticsDateDimension, i32>,
pub year_start_date_key: __sdk::__query_builder::Col<AnalyticsDateDimension, i64>,
pub year_end_date_key: __sdk::__query_builder::Col<AnalyticsDateDimension, i64>,
pub created_at: __sdk::__query_builder::Col<AnalyticsDateDimension, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<AnalyticsDateDimension, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for AnalyticsDateDimension {
type Cols = AnalyticsDateDimensionCols;
fn cols(table_name: &'static str) -> Self::Cols {
AnalyticsDateDimensionCols {
date_key: __sdk::__query_builder::Col::new(table_name, "date_key"),
calendar_date: __sdk::__query_builder::Col::new(table_name, "calendar_date"),
weekday: __sdk::__query_builder::Col::new(table_name, "weekday"),
iso_week_key: __sdk::__query_builder::Col::new(table_name, "iso_week_key"),
week_start_date_key: __sdk::__query_builder::Col::new(
table_name,
"week_start_date_key",
),
week_end_date_key: __sdk::__query_builder::Col::new(table_name, "week_end_date_key"),
month_key: __sdk::__query_builder::Col::new(table_name, "month_key"),
month_start_date_key: __sdk::__query_builder::Col::new(
table_name,
"month_start_date_key",
),
month_end_date_key: __sdk::__query_builder::Col::new(table_name, "month_end_date_key"),
quarter_key: __sdk::__query_builder::Col::new(table_name, "quarter_key"),
quarter_start_date_key: __sdk::__query_builder::Col::new(
table_name,
"quarter_start_date_key",
),
quarter_end_date_key: __sdk::__query_builder::Col::new(
table_name,
"quarter_end_date_key",
),
year_key: __sdk::__query_builder::Col::new(table_name, "year_key"),
year_start_date_key: __sdk::__query_builder::Col::new(
table_name,
"year_start_date_key",
),
year_end_date_key: __sdk::__query_builder::Col::new(table_name, "year_end_date_key"),
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 `AnalyticsDateDimension`.
///
/// Provides typed access to indexed columns for query building.
pub struct AnalyticsDateDimensionIxCols {
pub date_key: __sdk::__query_builder::IxCol<AnalyticsDateDimension, i64>,
pub iso_week_key: __sdk::__query_builder::IxCol<AnalyticsDateDimension, i32>,
pub month_key: __sdk::__query_builder::IxCol<AnalyticsDateDimension, i32>,
pub quarter_key: __sdk::__query_builder::IxCol<AnalyticsDateDimension, i32>,
pub year_key: __sdk::__query_builder::IxCol<AnalyticsDateDimension, i32>,
}
impl __sdk::__query_builder::HasIxCols for AnalyticsDateDimension {
type IxCols = AnalyticsDateDimensionIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
AnalyticsDateDimensionIxCols {
date_key: __sdk::__query_builder::IxCol::new(table_name, "date_key"),
iso_week_key: __sdk::__query_builder::IxCol::new(table_name, "iso_week_key"),
month_key: __sdk::__query_builder::IxCol::new(table_name, "month_key"),
quarter_key: __sdk::__query_builder::IxCol::new(table_name, "quarter_key"),
year_key: __sdk::__query_builder::IxCol::new(table_name, "year_key"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for AnalyticsDateDimension {}

View File

@@ -0,0 +1,24 @@
// 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 AnalyticsGranularity {
Day,
Week,
Month,
Quarter,
Year,
}
impl __sdk::InModule for AnalyticsGranularity {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,21 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::analytics_granularity_type::AnalyticsGranularity;
use super::runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct AnalyticsMetricQueryInput {
pub event_key: String,
pub scope_kind: RuntimeTrackingScopeKind,
pub scope_id: String,
pub granularity: AnalyticsGranularity,
}
impl __sdk::InModule for AnalyticsMetricQueryInput {
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::analytics_bucket_metric_type::AnalyticsBucketMetric;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct AnalyticsMetricQueryProcedureResult {
pub ok: bool,
pub buckets: Vec<AnalyticsBucketMetric>,
pub error_message: Option<String>,
}
impl __sdk::InModule for AnalyticsMetricQueryProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,161 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::asset_entity_binding_type::AssetEntityBinding;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `asset_entity_binding`.
///
/// Obtain a handle from the [`AssetEntityBindingTableAccess::asset_entity_binding`] method on [`super::RemoteTables`],
/// like `ctx.db.asset_entity_binding()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.asset_entity_binding().on_insert(...)`.
pub struct AssetEntityBindingTableHandle<'ctx> {
imp: __sdk::TableHandle<AssetEntityBinding>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `asset_entity_binding`.
///
/// Implemented for [`super::RemoteTables`].
pub trait AssetEntityBindingTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`AssetEntityBindingTableHandle`], which mediates access to the table `asset_entity_binding`.
fn asset_entity_binding(&self) -> AssetEntityBindingTableHandle<'_>;
}
impl AssetEntityBindingTableAccess for super::RemoteTables {
fn asset_entity_binding(&self) -> AssetEntityBindingTableHandle<'_> {
AssetEntityBindingTableHandle {
imp: self
.imp
.get_table::<AssetEntityBinding>("asset_entity_binding"),
ctx: std::marker::PhantomData,
}
}
}
pub struct AssetEntityBindingInsertCallbackId(__sdk::CallbackId);
pub struct AssetEntityBindingDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for AssetEntityBindingTableHandle<'ctx> {
type Row = AssetEntityBinding;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = AssetEntityBinding> + '_ {
self.imp.iter()
}
type InsertCallbackId = AssetEntityBindingInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> AssetEntityBindingInsertCallbackId {
AssetEntityBindingInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: AssetEntityBindingInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = AssetEntityBindingDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> AssetEntityBindingDeleteCallbackId {
AssetEntityBindingDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: AssetEntityBindingDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct AssetEntityBindingUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for AssetEntityBindingTableHandle<'ctx> {
type UpdateCallbackId = AssetEntityBindingUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> AssetEntityBindingUpdateCallbackId {
AssetEntityBindingUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: AssetEntityBindingUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `binding_id` unique index on the table `asset_entity_binding`,
/// which allows point queries on the field of the same name
/// via the [`AssetEntityBindingBindingIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.asset_entity_binding().binding_id().find(...)`.
pub struct AssetEntityBindingBindingIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<AssetEntityBinding, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> AssetEntityBindingTableHandle<'ctx> {
/// Get a handle on the `binding_id` unique index on the table `asset_entity_binding`.
pub fn binding_id(&self) -> AssetEntityBindingBindingIdUnique<'ctx> {
AssetEntityBindingBindingIdUnique {
imp: self.imp.get_unique_constraint::<String>("binding_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> AssetEntityBindingBindingIdUnique<'ctx> {
/// Find the subscribed row whose `binding_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<AssetEntityBinding> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<AssetEntityBinding>("asset_entity_binding");
_table.add_unique_constraint::<String>("binding_id", |row| &row.binding_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<AssetEntityBinding>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<AssetEntityBinding>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `AssetEntityBinding`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait asset_entity_bindingQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `AssetEntityBinding`.
fn asset_entity_binding(&self) -> __sdk::__query_builder::Table<AssetEntityBinding>;
}
impl asset_entity_bindingQueryTableAccess for __sdk::QueryTableAccessor {
fn asset_entity_binding(&self) -> __sdk::__query_builder::Table<AssetEntityBinding> {
__sdk::__query_builder::Table::new("asset_entity_binding")
}
}

View File

@@ -0,0 +1,160 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::asset_object_access_policy_type::AssetObjectAccessPolicy;
use super::asset_object_type::AssetObject;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `asset_object`.
///
/// Obtain a handle from the [`AssetObjectTableAccess::asset_object`] method on [`super::RemoteTables`],
/// like `ctx.db.asset_object()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.asset_object().on_insert(...)`.
pub struct AssetObjectTableHandle<'ctx> {
imp: __sdk::TableHandle<AssetObject>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `asset_object`.
///
/// Implemented for [`super::RemoteTables`].
pub trait AssetObjectTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`AssetObjectTableHandle`], which mediates access to the table `asset_object`.
fn asset_object(&self) -> AssetObjectTableHandle<'_>;
}
impl AssetObjectTableAccess for super::RemoteTables {
fn asset_object(&self) -> AssetObjectTableHandle<'_> {
AssetObjectTableHandle {
imp: self.imp.get_table::<AssetObject>("asset_object"),
ctx: std::marker::PhantomData,
}
}
}
pub struct AssetObjectInsertCallbackId(__sdk::CallbackId);
pub struct AssetObjectDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for AssetObjectTableHandle<'ctx> {
type Row = AssetObject;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = AssetObject> + '_ {
self.imp.iter()
}
type InsertCallbackId = AssetObjectInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> AssetObjectInsertCallbackId {
AssetObjectInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: AssetObjectInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = AssetObjectDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> AssetObjectDeleteCallbackId {
AssetObjectDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: AssetObjectDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct AssetObjectUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for AssetObjectTableHandle<'ctx> {
type UpdateCallbackId = AssetObjectUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> AssetObjectUpdateCallbackId {
AssetObjectUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: AssetObjectUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `asset_object_id` unique index on the table `asset_object`,
/// which allows point queries on the field of the same name
/// via the [`AssetObjectAssetObjectIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.asset_object().asset_object_id().find(...)`.
pub struct AssetObjectAssetObjectIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<AssetObject, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> AssetObjectTableHandle<'ctx> {
/// Get a handle on the `asset_object_id` unique index on the table `asset_object`.
pub fn asset_object_id(&self) -> AssetObjectAssetObjectIdUnique<'ctx> {
AssetObjectAssetObjectIdUnique {
imp: self.imp.get_unique_constraint::<String>("asset_object_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> AssetObjectAssetObjectIdUnique<'ctx> {
/// Find the subscribed row whose `asset_object_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<AssetObject> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<AssetObject>("asset_object");
_table.add_unique_constraint::<String>("asset_object_id", |row| &row.asset_object_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<AssetObject>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<AssetObject>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `AssetObject`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait asset_objectQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `AssetObject`.
fn asset_object(&self) -> __sdk::__query_builder::Table<AssetObject>;
}
impl asset_objectQueryTableAccess for __sdk::QueryTableAccessor {
fn asset_object(&self) -> __sdk::__query_builder::Table<AssetObject> {
__sdk::__query_builder::Table::new("asset_object")
}
}

View File

@@ -0,0 +1,159 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::auth_identity_type::AuthIdentity;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `auth_identity`.
///
/// Obtain a handle from the [`AuthIdentityTableAccess::auth_identity`] method on [`super::RemoteTables`],
/// like `ctx.db.auth_identity()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.auth_identity().on_insert(...)`.
pub struct AuthIdentityTableHandle<'ctx> {
imp: __sdk::TableHandle<AuthIdentity>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `auth_identity`.
///
/// Implemented for [`super::RemoteTables`].
pub trait AuthIdentityTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`AuthIdentityTableHandle`], which mediates access to the table `auth_identity`.
fn auth_identity(&self) -> AuthIdentityTableHandle<'_>;
}
impl AuthIdentityTableAccess for super::RemoteTables {
fn auth_identity(&self) -> AuthIdentityTableHandle<'_> {
AuthIdentityTableHandle {
imp: self.imp.get_table::<AuthIdentity>("auth_identity"),
ctx: std::marker::PhantomData,
}
}
}
pub struct AuthIdentityInsertCallbackId(__sdk::CallbackId);
pub struct AuthIdentityDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for AuthIdentityTableHandle<'ctx> {
type Row = AuthIdentity;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = AuthIdentity> + '_ {
self.imp.iter()
}
type InsertCallbackId = AuthIdentityInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> AuthIdentityInsertCallbackId {
AuthIdentityInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: AuthIdentityInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = AuthIdentityDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> AuthIdentityDeleteCallbackId {
AuthIdentityDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: AuthIdentityDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct AuthIdentityUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for AuthIdentityTableHandle<'ctx> {
type UpdateCallbackId = AuthIdentityUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> AuthIdentityUpdateCallbackId {
AuthIdentityUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: AuthIdentityUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `identity_id` unique index on the table `auth_identity`,
/// which allows point queries on the field of the same name
/// via the [`AuthIdentityIdentityIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.auth_identity().identity_id().find(...)`.
pub struct AuthIdentityIdentityIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<AuthIdentity, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> AuthIdentityTableHandle<'ctx> {
/// Get a handle on the `identity_id` unique index on the table `auth_identity`.
pub fn identity_id(&self) -> AuthIdentityIdentityIdUnique<'ctx> {
AuthIdentityIdentityIdUnique {
imp: self.imp.get_unique_constraint::<String>("identity_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> AuthIdentityIdentityIdUnique<'ctx> {
/// Find the subscribed row whose `identity_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<AuthIdentity> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<AuthIdentity>("auth_identity");
_table.add_unique_constraint::<String>("identity_id", |row| &row.identity_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<AuthIdentity>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<AuthIdentity>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `AuthIdentity`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait auth_identityQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `AuthIdentity`.
fn auth_identity(&self) -> __sdk::__query_builder::Table<AuthIdentity>;
}
impl auth_identityQueryTableAccess for __sdk::QueryTableAccessor {
fn auth_identity(&self) -> __sdk::__query_builder::Table<AuthIdentity> {
__sdk::__query_builder::Table::new("auth_identity")
}
}

View File

@@ -0,0 +1,161 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::auth_store_snapshot_type::AuthStoreSnapshot;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `auth_store_snapshot`.
///
/// Obtain a handle from the [`AuthStoreSnapshotTableAccess::auth_store_snapshot`] method on [`super::RemoteTables`],
/// like `ctx.db.auth_store_snapshot()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.auth_store_snapshot().on_insert(...)`.
pub struct AuthStoreSnapshotTableHandle<'ctx> {
imp: __sdk::TableHandle<AuthStoreSnapshot>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `auth_store_snapshot`.
///
/// Implemented for [`super::RemoteTables`].
pub trait AuthStoreSnapshotTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`AuthStoreSnapshotTableHandle`], which mediates access to the table `auth_store_snapshot`.
fn auth_store_snapshot(&self) -> AuthStoreSnapshotTableHandle<'_>;
}
impl AuthStoreSnapshotTableAccess for super::RemoteTables {
fn auth_store_snapshot(&self) -> AuthStoreSnapshotTableHandle<'_> {
AuthStoreSnapshotTableHandle {
imp: self
.imp
.get_table::<AuthStoreSnapshot>("auth_store_snapshot"),
ctx: std::marker::PhantomData,
}
}
}
pub struct AuthStoreSnapshotInsertCallbackId(__sdk::CallbackId);
pub struct AuthStoreSnapshotDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for AuthStoreSnapshotTableHandle<'ctx> {
type Row = AuthStoreSnapshot;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = AuthStoreSnapshot> + '_ {
self.imp.iter()
}
type InsertCallbackId = AuthStoreSnapshotInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> AuthStoreSnapshotInsertCallbackId {
AuthStoreSnapshotInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: AuthStoreSnapshotInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = AuthStoreSnapshotDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> AuthStoreSnapshotDeleteCallbackId {
AuthStoreSnapshotDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: AuthStoreSnapshotDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct AuthStoreSnapshotUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for AuthStoreSnapshotTableHandle<'ctx> {
type UpdateCallbackId = AuthStoreSnapshotUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> AuthStoreSnapshotUpdateCallbackId {
AuthStoreSnapshotUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: AuthStoreSnapshotUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `snapshot_id` unique index on the table `auth_store_snapshot`,
/// which allows point queries on the field of the same name
/// via the [`AuthStoreSnapshotSnapshotIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.auth_store_snapshot().snapshot_id().find(...)`.
pub struct AuthStoreSnapshotSnapshotIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<AuthStoreSnapshot, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> AuthStoreSnapshotTableHandle<'ctx> {
/// Get a handle on the `snapshot_id` unique index on the table `auth_store_snapshot`.
pub fn snapshot_id(&self) -> AuthStoreSnapshotSnapshotIdUnique<'ctx> {
AuthStoreSnapshotSnapshotIdUnique {
imp: self.imp.get_unique_constraint::<String>("snapshot_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> AuthStoreSnapshotSnapshotIdUnique<'ctx> {
/// Find the subscribed row whose `snapshot_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<AuthStoreSnapshot> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<AuthStoreSnapshot>("auth_store_snapshot");
_table.add_unique_constraint::<String>("snapshot_id", |row| &row.snapshot_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<AuthStoreSnapshot>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<AuthStoreSnapshot>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `AuthStoreSnapshot`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait auth_store_snapshotQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `AuthStoreSnapshot`.
fn auth_store_snapshot(&self) -> __sdk::__query_builder::Table<AuthStoreSnapshot>;
}
impl auth_store_snapshotQueryTableAccess for __sdk::QueryTableAccessor {
fn auth_store_snapshot(&self) -> __sdk::__query_builder::Table<AuthStoreSnapshot> {
__sdk::__query_builder::Table::new("auth_store_snapshot")
}
}

View File

@@ -0,0 +1,163 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::battle_mode_type::BattleMode;
use super::battle_state_type::BattleState;
use super::battle_status_type::BattleStatus;
use super::combat_outcome_type::CombatOutcome;
use super::runtime_item_reward_item_snapshot_type::RuntimeItemRewardItemSnapshot;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `battle_state`.
///
/// Obtain a handle from the [`BattleStateTableAccess::battle_state`] method on [`super::RemoteTables`],
/// like `ctx.db.battle_state()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.battle_state().on_insert(...)`.
pub struct BattleStateTableHandle<'ctx> {
imp: __sdk::TableHandle<BattleState>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `battle_state`.
///
/// Implemented for [`super::RemoteTables`].
pub trait BattleStateTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`BattleStateTableHandle`], which mediates access to the table `battle_state`.
fn battle_state(&self) -> BattleStateTableHandle<'_>;
}
impl BattleStateTableAccess for super::RemoteTables {
fn battle_state(&self) -> BattleStateTableHandle<'_> {
BattleStateTableHandle {
imp: self.imp.get_table::<BattleState>("battle_state"),
ctx: std::marker::PhantomData,
}
}
}
pub struct BattleStateInsertCallbackId(__sdk::CallbackId);
pub struct BattleStateDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for BattleStateTableHandle<'ctx> {
type Row = BattleState;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = BattleState> + '_ {
self.imp.iter()
}
type InsertCallbackId = BattleStateInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> BattleStateInsertCallbackId {
BattleStateInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: BattleStateInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = BattleStateDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> BattleStateDeleteCallbackId {
BattleStateDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: BattleStateDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct BattleStateUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for BattleStateTableHandle<'ctx> {
type UpdateCallbackId = BattleStateUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> BattleStateUpdateCallbackId {
BattleStateUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: BattleStateUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `battle_state_id` unique index on the table `battle_state`,
/// which allows point queries on the field of the same name
/// via the [`BattleStateBattleStateIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.battle_state().battle_state_id().find(...)`.
pub struct BattleStateBattleStateIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<BattleState, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> BattleStateTableHandle<'ctx> {
/// Get a handle on the `battle_state_id` unique index on the table `battle_state`.
pub fn battle_state_id(&self) -> BattleStateBattleStateIdUnique<'ctx> {
BattleStateBattleStateIdUnique {
imp: self.imp.get_unique_constraint::<String>("battle_state_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> BattleStateBattleStateIdUnique<'ctx> {
/// Find the subscribed row whose `battle_state_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<BattleState> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<BattleState>("battle_state");
_table.add_unique_constraint::<String>("battle_state_id", |row| &row.battle_state_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<BattleState>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<BattleState>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `BattleState`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait battle_stateQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `BattleState`.
fn battle_state(&self) -> __sdk::__query_builder::Table<BattleState>;
}
impl battle_stateQueryTableAccess for __sdk::QueryTableAccessor {
fn battle_state(&self) -> __sdk::__query_builder::Table<BattleState> {
__sdk::__query_builder::Table::new("battle_state")
}
}

View File

@@ -0,0 +1,163 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::big_fish_agent_message_kind_type::BigFishAgentMessageKind;
use super::big_fish_agent_message_role_type::BigFishAgentMessageRole;
use super::big_fish_agent_message_type::BigFishAgentMessage;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `big_fish_agent_message`.
///
/// Obtain a handle from the [`BigFishAgentMessageTableAccess::big_fish_agent_message`] method on [`super::RemoteTables`],
/// like `ctx.db.big_fish_agent_message()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.big_fish_agent_message().on_insert(...)`.
pub struct BigFishAgentMessageTableHandle<'ctx> {
imp: __sdk::TableHandle<BigFishAgentMessage>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `big_fish_agent_message`.
///
/// Implemented for [`super::RemoteTables`].
pub trait BigFishAgentMessageTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`BigFishAgentMessageTableHandle`], which mediates access to the table `big_fish_agent_message`.
fn big_fish_agent_message(&self) -> BigFishAgentMessageTableHandle<'_>;
}
impl BigFishAgentMessageTableAccess for super::RemoteTables {
fn big_fish_agent_message(&self) -> BigFishAgentMessageTableHandle<'_> {
BigFishAgentMessageTableHandle {
imp: self
.imp
.get_table::<BigFishAgentMessage>("big_fish_agent_message"),
ctx: std::marker::PhantomData,
}
}
}
pub struct BigFishAgentMessageInsertCallbackId(__sdk::CallbackId);
pub struct BigFishAgentMessageDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for BigFishAgentMessageTableHandle<'ctx> {
type Row = BigFishAgentMessage;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = BigFishAgentMessage> + '_ {
self.imp.iter()
}
type InsertCallbackId = BigFishAgentMessageInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> BigFishAgentMessageInsertCallbackId {
BigFishAgentMessageInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: BigFishAgentMessageInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = BigFishAgentMessageDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> BigFishAgentMessageDeleteCallbackId {
BigFishAgentMessageDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: BigFishAgentMessageDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct BigFishAgentMessageUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for BigFishAgentMessageTableHandle<'ctx> {
type UpdateCallbackId = BigFishAgentMessageUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> BigFishAgentMessageUpdateCallbackId {
BigFishAgentMessageUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: BigFishAgentMessageUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `message_id` unique index on the table `big_fish_agent_message`,
/// which allows point queries on the field of the same name
/// via the [`BigFishAgentMessageMessageIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.big_fish_agent_message().message_id().find(...)`.
pub struct BigFishAgentMessageMessageIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<BigFishAgentMessage, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> BigFishAgentMessageTableHandle<'ctx> {
/// Get a handle on the `message_id` unique index on the table `big_fish_agent_message`.
pub fn message_id(&self) -> BigFishAgentMessageMessageIdUnique<'ctx> {
BigFishAgentMessageMessageIdUnique {
imp: self.imp.get_unique_constraint::<String>("message_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> BigFishAgentMessageMessageIdUnique<'ctx> {
/// Find the subscribed row whose `message_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<BigFishAgentMessage> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<BigFishAgentMessage>("big_fish_agent_message");
_table.add_unique_constraint::<String>("message_id", |row| &row.message_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<BigFishAgentMessage>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<BigFishAgentMessage>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `BigFishAgentMessage`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait big_fish_agent_messageQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `BigFishAgentMessage`.
fn big_fish_agent_message(&self) -> __sdk::__query_builder::Table<BigFishAgentMessage>;
}
impl big_fish_agent_messageQueryTableAccess for __sdk::QueryTableAccessor {
fn big_fish_agent_message(&self) -> __sdk::__query_builder::Table<BigFishAgentMessage> {
__sdk::__query_builder::Table::new("big_fish_agent_message")
}
}

View File

@@ -0,0 +1,163 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::big_fish_asset_kind_type::BigFishAssetKind;
use super::big_fish_asset_slot_type::BigFishAssetSlot;
use super::big_fish_asset_status_type::BigFishAssetStatus;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `big_fish_asset_slot`.
///
/// Obtain a handle from the [`BigFishAssetSlotTableAccess::big_fish_asset_slot`] method on [`super::RemoteTables`],
/// like `ctx.db.big_fish_asset_slot()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.big_fish_asset_slot().on_insert(...)`.
pub struct BigFishAssetSlotTableHandle<'ctx> {
imp: __sdk::TableHandle<BigFishAssetSlot>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `big_fish_asset_slot`.
///
/// Implemented for [`super::RemoteTables`].
pub trait BigFishAssetSlotTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`BigFishAssetSlotTableHandle`], which mediates access to the table `big_fish_asset_slot`.
fn big_fish_asset_slot(&self) -> BigFishAssetSlotTableHandle<'_>;
}
impl BigFishAssetSlotTableAccess for super::RemoteTables {
fn big_fish_asset_slot(&self) -> BigFishAssetSlotTableHandle<'_> {
BigFishAssetSlotTableHandle {
imp: self
.imp
.get_table::<BigFishAssetSlot>("big_fish_asset_slot"),
ctx: std::marker::PhantomData,
}
}
}
pub struct BigFishAssetSlotInsertCallbackId(__sdk::CallbackId);
pub struct BigFishAssetSlotDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for BigFishAssetSlotTableHandle<'ctx> {
type Row = BigFishAssetSlot;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = BigFishAssetSlot> + '_ {
self.imp.iter()
}
type InsertCallbackId = BigFishAssetSlotInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> BigFishAssetSlotInsertCallbackId {
BigFishAssetSlotInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: BigFishAssetSlotInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = BigFishAssetSlotDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> BigFishAssetSlotDeleteCallbackId {
BigFishAssetSlotDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: BigFishAssetSlotDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct BigFishAssetSlotUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for BigFishAssetSlotTableHandle<'ctx> {
type UpdateCallbackId = BigFishAssetSlotUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> BigFishAssetSlotUpdateCallbackId {
BigFishAssetSlotUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: BigFishAssetSlotUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `slot_id` unique index on the table `big_fish_asset_slot`,
/// which allows point queries on the field of the same name
/// via the [`BigFishAssetSlotSlotIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.big_fish_asset_slot().slot_id().find(...)`.
pub struct BigFishAssetSlotSlotIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<BigFishAssetSlot, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> BigFishAssetSlotTableHandle<'ctx> {
/// Get a handle on the `slot_id` unique index on the table `big_fish_asset_slot`.
pub fn slot_id(&self) -> BigFishAssetSlotSlotIdUnique<'ctx> {
BigFishAssetSlotSlotIdUnique {
imp: self.imp.get_unique_constraint::<String>("slot_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> BigFishAssetSlotSlotIdUnique<'ctx> {
/// Find the subscribed row whose `slot_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<BigFishAssetSlot> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<BigFishAssetSlot>("big_fish_asset_slot");
_table.add_unique_constraint::<String>("slot_id", |row| &row.slot_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<BigFishAssetSlot>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<BigFishAssetSlot>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `BigFishAssetSlot`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait big_fish_asset_slotQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `BigFishAssetSlot`.
fn big_fish_asset_slot(&self) -> __sdk::__query_builder::Table<BigFishAssetSlot>;
}
impl big_fish_asset_slotQueryTableAccess for __sdk::QueryTableAccessor {
fn big_fish_asset_slot(&self) -> __sdk::__query_builder::Table<BigFishAssetSlot> {
__sdk::__query_builder::Table::new("big_fish_asset_slot")
}
}

View File

@@ -0,0 +1,163 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::big_fish_creation_session_type::BigFishCreationSession;
use super::big_fish_creation_stage_type::BigFishCreationStage;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `big_fish_creation_session`.
///
/// Obtain a handle from the [`BigFishCreationSessionTableAccess::big_fish_creation_session`] method on [`super::RemoteTables`],
/// like `ctx.db.big_fish_creation_session()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.big_fish_creation_session().on_insert(...)`.
pub struct BigFishCreationSessionTableHandle<'ctx> {
imp: __sdk::TableHandle<BigFishCreationSession>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `big_fish_creation_session`.
///
/// Implemented for [`super::RemoteTables`].
pub trait BigFishCreationSessionTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`BigFishCreationSessionTableHandle`], which mediates access to the table `big_fish_creation_session`.
fn big_fish_creation_session(&self) -> BigFishCreationSessionTableHandle<'_>;
}
impl BigFishCreationSessionTableAccess for super::RemoteTables {
fn big_fish_creation_session(&self) -> BigFishCreationSessionTableHandle<'_> {
BigFishCreationSessionTableHandle {
imp: self
.imp
.get_table::<BigFishCreationSession>("big_fish_creation_session"),
ctx: std::marker::PhantomData,
}
}
}
pub struct BigFishCreationSessionInsertCallbackId(__sdk::CallbackId);
pub struct BigFishCreationSessionDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for BigFishCreationSessionTableHandle<'ctx> {
type Row = BigFishCreationSession;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = BigFishCreationSession> + '_ {
self.imp.iter()
}
type InsertCallbackId = BigFishCreationSessionInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> BigFishCreationSessionInsertCallbackId {
BigFishCreationSessionInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: BigFishCreationSessionInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = BigFishCreationSessionDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> BigFishCreationSessionDeleteCallbackId {
BigFishCreationSessionDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: BigFishCreationSessionDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct BigFishCreationSessionUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for BigFishCreationSessionTableHandle<'ctx> {
type UpdateCallbackId = BigFishCreationSessionUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> BigFishCreationSessionUpdateCallbackId {
BigFishCreationSessionUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: BigFishCreationSessionUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `session_id` unique index on the table `big_fish_creation_session`,
/// which allows point queries on the field of the same name
/// via the [`BigFishCreationSessionSessionIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.big_fish_creation_session().session_id().find(...)`.
pub struct BigFishCreationSessionSessionIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<BigFishCreationSession, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> BigFishCreationSessionTableHandle<'ctx> {
/// Get a handle on the `session_id` unique index on the table `big_fish_creation_session`.
pub fn session_id(&self) -> BigFishCreationSessionSessionIdUnique<'ctx> {
BigFishCreationSessionSessionIdUnique {
imp: self.imp.get_unique_constraint::<String>("session_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> BigFishCreationSessionSessionIdUnique<'ctx> {
/// Find the subscribed row whose `session_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<BigFishCreationSession> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table =
client_cache.get_or_make_table::<BigFishCreationSession>("big_fish_creation_session");
_table.add_unique_constraint::<String>("session_id", |row| &row.session_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<BigFishCreationSession>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<BigFishCreationSession>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `BigFishCreationSession`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait big_fish_creation_sessionQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `BigFishCreationSession`.
fn big_fish_creation_session(&self) -> __sdk::__query_builder::Table<BigFishCreationSession>;
}
impl big_fish_creation_sessionQueryTableAccess for __sdk::QueryTableAccessor {
fn big_fish_creation_session(&self) -> __sdk::__query_builder::Table<BigFishCreationSession> {
__sdk::__query_builder::Table::new("big_fish_creation_session")
}
}

View File

@@ -0,0 +1,162 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::big_fish_run_status_type::BigFishRunStatus;
use super::big_fish_runtime_run_type::BigFishRuntimeRun;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `big_fish_runtime_run`.
///
/// Obtain a handle from the [`BigFishRuntimeRunTableAccess::big_fish_runtime_run`] method on [`super::RemoteTables`],
/// like `ctx.db.big_fish_runtime_run()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.big_fish_runtime_run().on_insert(...)`.
pub struct BigFishRuntimeRunTableHandle<'ctx> {
imp: __sdk::TableHandle<BigFishRuntimeRun>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `big_fish_runtime_run`.
///
/// Implemented for [`super::RemoteTables`].
pub trait BigFishRuntimeRunTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`BigFishRuntimeRunTableHandle`], which mediates access to the table `big_fish_runtime_run`.
fn big_fish_runtime_run(&self) -> BigFishRuntimeRunTableHandle<'_>;
}
impl BigFishRuntimeRunTableAccess for super::RemoteTables {
fn big_fish_runtime_run(&self) -> BigFishRuntimeRunTableHandle<'_> {
BigFishRuntimeRunTableHandle {
imp: self
.imp
.get_table::<BigFishRuntimeRun>("big_fish_runtime_run"),
ctx: std::marker::PhantomData,
}
}
}
pub struct BigFishRuntimeRunInsertCallbackId(__sdk::CallbackId);
pub struct BigFishRuntimeRunDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for BigFishRuntimeRunTableHandle<'ctx> {
type Row = BigFishRuntimeRun;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = BigFishRuntimeRun> + '_ {
self.imp.iter()
}
type InsertCallbackId = BigFishRuntimeRunInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> BigFishRuntimeRunInsertCallbackId {
BigFishRuntimeRunInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: BigFishRuntimeRunInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = BigFishRuntimeRunDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> BigFishRuntimeRunDeleteCallbackId {
BigFishRuntimeRunDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: BigFishRuntimeRunDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct BigFishRuntimeRunUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for BigFishRuntimeRunTableHandle<'ctx> {
type UpdateCallbackId = BigFishRuntimeRunUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> BigFishRuntimeRunUpdateCallbackId {
BigFishRuntimeRunUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: BigFishRuntimeRunUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `run_id` unique index on the table `big_fish_runtime_run`,
/// which allows point queries on the field of the same name
/// via the [`BigFishRuntimeRunRunIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.big_fish_runtime_run().run_id().find(...)`.
pub struct BigFishRuntimeRunRunIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<BigFishRuntimeRun, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> BigFishRuntimeRunTableHandle<'ctx> {
/// Get a handle on the `run_id` unique index on the table `big_fish_runtime_run`.
pub fn run_id(&self) -> BigFishRuntimeRunRunIdUnique<'ctx> {
BigFishRuntimeRunRunIdUnique {
imp: self.imp.get_unique_constraint::<String>("run_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> BigFishRuntimeRunRunIdUnique<'ctx> {
/// Find the subscribed row whose `run_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<BigFishRuntimeRun> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<BigFishRuntimeRun>("big_fish_runtime_run");
_table.add_unique_constraint::<String>("run_id", |row| &row.run_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<BigFishRuntimeRun>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<BigFishRuntimeRun>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `BigFishRuntimeRun`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait big_fish_runtime_runQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `BigFishRuntimeRun`.
fn big_fish_runtime_run(&self) -> __sdk::__query_builder::Table<BigFishRuntimeRun>;
}
impl big_fish_runtime_runQueryTableAccess for __sdk::QueryTableAccessor {
fn big_fish_runtime_run(&self) -> __sdk::__query_builder::Table<BigFishRuntimeRun> {
__sdk::__query_builder::Table::new("big_fish_runtime_run")
}
}

View File

@@ -0,0 +1,166 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::chapter_pace_band_type::ChapterPaceBand;
use super::chapter_progression_type::ChapterProgression;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `chapter_progression`.
///
/// Obtain a handle from the [`ChapterProgressionTableAccess::chapter_progression`] method on [`super::RemoteTables`],
/// like `ctx.db.chapter_progression()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.chapter_progression().on_insert(...)`.
pub struct ChapterProgressionTableHandle<'ctx> {
imp: __sdk::TableHandle<ChapterProgression>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `chapter_progression`.
///
/// Implemented for [`super::RemoteTables`].
pub trait ChapterProgressionTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`ChapterProgressionTableHandle`], which mediates access to the table `chapter_progression`.
fn chapter_progression(&self) -> ChapterProgressionTableHandle<'_>;
}
impl ChapterProgressionTableAccess for super::RemoteTables {
fn chapter_progression(&self) -> ChapterProgressionTableHandle<'_> {
ChapterProgressionTableHandle {
imp: self
.imp
.get_table::<ChapterProgression>("chapter_progression"),
ctx: std::marker::PhantomData,
}
}
}
pub struct ChapterProgressionInsertCallbackId(__sdk::CallbackId);
pub struct ChapterProgressionDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for ChapterProgressionTableHandle<'ctx> {
type Row = ChapterProgression;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = ChapterProgression> + '_ {
self.imp.iter()
}
type InsertCallbackId = ChapterProgressionInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> ChapterProgressionInsertCallbackId {
ChapterProgressionInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: ChapterProgressionInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = ChapterProgressionDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> ChapterProgressionDeleteCallbackId {
ChapterProgressionDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: ChapterProgressionDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct ChapterProgressionUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for ChapterProgressionTableHandle<'ctx> {
type UpdateCallbackId = ChapterProgressionUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> ChapterProgressionUpdateCallbackId {
ChapterProgressionUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: ChapterProgressionUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `chapter_progression_id` unique index on the table `chapter_progression`,
/// which allows point queries on the field of the same name
/// via the [`ChapterProgressionChapterProgressionIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.chapter_progression().chapter_progression_id().find(...)`.
pub struct ChapterProgressionChapterProgressionIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<ChapterProgression, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> ChapterProgressionTableHandle<'ctx> {
/// Get a handle on the `chapter_progression_id` unique index on the table `chapter_progression`.
pub fn chapter_progression_id(&self) -> ChapterProgressionChapterProgressionIdUnique<'ctx> {
ChapterProgressionChapterProgressionIdUnique {
imp: self
.imp
.get_unique_constraint::<String>("chapter_progression_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> ChapterProgressionChapterProgressionIdUnique<'ctx> {
/// Find the subscribed row whose `chapter_progression_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<ChapterProgression> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<ChapterProgression>("chapter_progression");
_table.add_unique_constraint::<String>("chapter_progression_id", |row| {
&row.chapter_progression_id
});
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<ChapterProgression>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<ChapterProgression>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `ChapterProgression`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait chapter_progressionQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `ChapterProgression`.
fn chapter_progression(&self) -> __sdk::__query_builder::Table<ChapterProgression>;
}
impl chapter_progressionQueryTableAccess for __sdk::QueryTableAccessor {
fn chapter_progression(&self) -> __sdk::__query_builder::Table<ChapterProgression> {
__sdk::__query_builder::Table::new("chapter_progression")
}
}

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::square_hole_agent_session_procedure_result_type::SquareHoleAgentSessionProcedureResult;
use super::square_hole_draft_compile_input_type::SquareHoleDraftCompileInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct CompileSquareHoleDraftArgs {
pub input: SquareHoleDraftCompileInput,
}
impl __sdk::InModule for CompileSquareHoleDraftArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `compile_square_hole_draft`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait compile_square_hole_draft {
fn compile_square_hole_draft(&self, input: SquareHoleDraftCompileInput) {
self.compile_square_hole_draft_then(input, |_, _| {});
}
fn compile_square_hole_draft_then(
&self,
input: SquareHoleDraftCompileInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<SquareHoleAgentSessionProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl compile_square_hole_draft for super::RemoteProcedures {
fn compile_square_hole_draft_then(
&self,
input: SquareHoleDraftCompileInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<SquareHoleAgentSessionProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, SquareHoleAgentSessionProcedureResult>(
"compile_square_hole_draft",
CompileSquareHoleDraftArgs { 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::square_hole_agent_session_create_input_type::SquareHoleAgentSessionCreateInput;
use super::square_hole_agent_session_procedure_result_type::SquareHoleAgentSessionProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct CreateSquareHoleAgentSessionArgs {
pub input: SquareHoleAgentSessionCreateInput,
}
impl __sdk::InModule for CreateSquareHoleAgentSessionArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `create_square_hole_agent_session`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait create_square_hole_agent_session {
fn create_square_hole_agent_session(&self, input: SquareHoleAgentSessionCreateInput) {
self.create_square_hole_agent_session_then(input, |_, _| {});
}
fn create_square_hole_agent_session_then(
&self,
input: SquareHoleAgentSessionCreateInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<SquareHoleAgentSessionProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl create_square_hole_agent_session for super::RemoteProcedures {
fn create_square_hole_agent_session_then(
&self,
input: SquareHoleAgentSessionCreateInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<SquareHoleAgentSessionProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, SquareHoleAgentSessionProcedureResult>(
"create_square_hole_agent_session",
CreateSquareHoleAgentSessionArgs { input },
__callback,
);
}
}

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